diff --git a/.changeset/brave-apes-accept.md b/.changeset/brave-apes-accept.md new file mode 100644 index 00000000000..92c2bc67af6 --- /dev/null +++ b/.changeset/brave-apes-accept.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added encodeUriComponent/decodeUriComponent for both Encoding and Schema diff --git a/packages/effect/src/Encoding.ts b/packages/effect/src/Encoding.ts index e0e8b61947f..9c2f4cbe03c 100644 --- a/packages/effect/src/Encoding.ts +++ b/packages/effect/src/Encoding.ts @@ -88,6 +88,30 @@ export const decodeHex = (str: string): Either.Either Either.map(decodeHex(str), (_) => Common.decoder.decode(_)) +/** + * Encodes a UTF-8 `string` into a URI component `string`. + * + * @category encoding + * @since 3.12.0 + */ +export const encodeUriComponent = (str: string): Either.Either => + Either.try({ + try: () => encodeURIComponent(str), + catch: (e) => EncodeException(str, e instanceof Error ? e.message : "Invalid input") + }) + +/** + * Decodes a URI component `string` into a UTF-8 `string`. + * + * @category decoding + * @since 3.12.0 + */ +export const decodeUriComponent = (str: string): Either.Either => + Either.try({ + try: () => decodeURIComponent(str), + catch: (e) => DecodeException(str, e instanceof Error ? e.message : "Invalid input") + }) + /** * @since 2.0.0 * @category symbols @@ -128,3 +152,44 @@ export const DecodeException: (input: string, message?: string) => DecodeExcepti * @category refinements */ export const isDecodeException: (u: unknown) => u is DecodeException = Common.isDecodeException + +/** + * @since 3.12.0 + * @category symbols + */ +export const EncodeExceptionTypeId: unique symbol = Common.EncodeExceptionTypeId + +/** + * @since 3.12.0 + * @category symbols + */ +export type EncodeExceptionTypeId = typeof EncodeExceptionTypeId + +/** + * Represents a checked exception which occurs when encoding fails. + * + * @since 3.12.0 + * @category models + */ +export interface EncodeException { + readonly _tag: "EncodeException" + readonly [EncodeExceptionTypeId]: EncodeExceptionTypeId + readonly input: string + readonly message?: string +} + +/** + * Creates a checked exception which occurs when encoding fails. + * + * @since 3.12.0 + * @category errors + */ +export const EncodeException: (input: string, message?: string) => EncodeException = Common.EncodeException + +/** + * Returns `true` if the specified value is an `Exception`, `false` otherwise. + * + * @since 3.12.0 + * @category refinements + */ +export const isEncodeException: (u: unknown) => u is EncodeException = Common.isEncodeException diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 461c68f4944..6a1bf221687 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -5917,6 +5917,48 @@ export const StringFromHex: Schema = makeEncodingTransformation( Encoding.encodeHex ) +/** + * Decodes a URI component encoded string into a UTF-8 string. + * Can be used to store data in a URL. + * + * @example + * ```ts + * import { Schema } from "effect" + * + * const PaginationSchema = Schema.Struct({ + * maxItemPerPage: Schema.Number, + * page: Schema.Number + * }) + * + * const UrlSchema = Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(PaginationSchema)) + * + * console.log(Schema.encodeSync(UrlSchema)({ maxItemPerPage: 10, page: 1 })) + * // Output: %7B%22maxItemPerPage%22%3A10%2C%22page%22%3A1%7D + * ``` + * + * @category string transformations + * @since 3.12.0 + */ +export const StringFromUriComponent = transformOrFail( + String$.annotations({ + description: `A string that is interpreted as being UriComponent-encoded and will be decoded into a UTF-8 string` + }), + String$, + { + strict: true, + decode: (s, _, ast) => + either_.mapLeft( + Encoding.decodeUriComponent(s), + (decodeException) => new ParseResult.Type(ast, s, decodeException.message) + ), + encode: (u, _, ast) => + either_.mapLeft( + Encoding.encodeUriComponent(u), + (encodeException) => new ParseResult.Type(ast, u, encodeException.message) + ) + } +).annotations({ identifier: `StringFromUriComponent` }) + /** * @category schema id * @since 3.10.0 diff --git a/packages/effect/src/internal/encoding/common.ts b/packages/effect/src/internal/encoding/common.ts index 60244c27b5a..580cd6ffae0 100644 --- a/packages/effect/src/internal/encoding/common.ts +++ b/packages/effect/src/internal/encoding/common.ts @@ -23,6 +23,27 @@ export const DecodeException = (input: string, message?: string): Encoding.Decod /** @internal */ export const isDecodeException = (u: unknown): u is Encoding.DecodeException => hasProperty(u, DecodeExceptionTypeId) +/** @internal */ +export const EncodeExceptionTypeId: Encoding.EncodeExceptionTypeId = Symbol.for( + "effect/Encoding/errors/Encode" +) as Encoding.EncodeExceptionTypeId + +/** @internal */ +export const EncodeException = (input: string, message?: string): Encoding.EncodeException => { + const out: Mutable = { + _tag: "EncodeException", + [EncodeExceptionTypeId]: EncodeExceptionTypeId, + input + } + if (isString(message)) { + out.message = message + } + return out +} + +/** @internal */ +export const isEncodeException = (u: unknown): u is Encoding.EncodeException => hasProperty(u, EncodeExceptionTypeId) + /** @interal */ export const encoder = new TextEncoder() diff --git a/packages/effect/test/Encoding.test.ts b/packages/effect/test/Encoding.test.ts index e0bd0107756..8c477c8d6f4 100644 --- a/packages/effect/test/Encoding.test.ts +++ b/packages/effect/test/Encoding.test.ts @@ -161,3 +161,47 @@ describe("Hex", () => { assert(Encoding.isDecodeException(result.left)) }) }) + +describe("UriComponent", () => { + const valid: Array<[uri: string, raw: string]> = [ + ["", ""], + ["hello", "hello"], + ["hello%20world", "hello world"], + ["hello%20world%2F", "hello world/"], + ["%20", " "], + ["%2F", "/"] + ] + + const invalidDecode: Array = [ + "hello%2world" + ] + + const invalidEncode: Array = [ + "\uD800", + "\uDFFF" + ] + + it.each(valid)(`should decode %j => %j`, (uri: string, raw: string) => { + const decoded = Encoding.decodeUriComponent(uri) + assert(Either.isRight(decoded)) + deepStrictEqual(decoded.right, raw) + }) + + it.each(valid)(`should encode %j => %j`, (uri: string, raw: string) => { + const encoded = Encoding.encodeUriComponent(raw) + assert(Either.isRight(encoded)) + deepStrictEqual(encoded.right, uri) + }) + + it.each(invalidDecode)(`should refuse to decode %j`, (uri: string) => { + const result = Encoding.decodeUriComponent(uri) + assert(Either.isLeft(result)) + assert(Encoding.isDecodeException(result.left)) + }) + + it.each(invalidEncode)(`should refuse to encode %j`, (raw: string) => { + const result = Encoding.encodeUriComponent(raw) + assert(Either.isLeft(result)) + assert(Encoding.isEncodeException(result.left)) + }) +}) diff --git a/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts b/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts new file mode 100644 index 00000000000..5b5bd2d9d94 --- /dev/null +++ b/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts @@ -0,0 +1,32 @@ +import * as S from "effect/Schema" +import * as Util from "effect/test/Schema/TestUtils" +import { describe, it } from "vitest" + +describe("StringFromUriComponent", () => { + const schema = S.StringFromUriComponent + + it("encoding", async () => { + await Util.expectEncodeSuccess(schema, "шеллы", "%D1%88%D0%B5%D0%BB%D0%BB%D1%8B") + await Util.expectEncodeFailure( + schema, + "Hello\uD800", + `StringFromUriComponent +└─ Transformation process failure + └─ URI malformed` + ) + }) + + it("decoding", async () => { + await Util.expectDecodeUnknownSuccess(schema, "%D1%88%D0%B5%D0%BB%D0%BB%D1%8B", "шеллы") + await Util.expectDecodeUnknownSuccess(schema, "hello", "hello") + await Util.expectDecodeUnknownSuccess(schema, "hello%20world", "hello world") + + await Util.expectDecodeUnknownFailure( + schema, + "Hello%2world", + `StringFromUriComponent +└─ Transformation process failure + └─ URI malformed` + ) + }) +})