Skip to content

Commit

Permalink
add UriComponent schemas and encoding (#3982)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian Lorenz <[email protected]>
  • Loading branch information
2 people authored and gcanti committed Dec 13, 2024
1 parent 440621b commit 3fc16d8
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-apes-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Added encodeUriComponent/decodeUriComponent for both Encoding and Schema
65 changes: 65 additions & 0 deletions packages/effect/src/Encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ export const decodeHex = (str: string): Either.Either<Uint8Array, DecodeExceptio
*/
export const decodeHexString = (str: string) => 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<string, EncodeException> =>
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<string, DecodeException> =>
Either.try({
try: () => decodeURIComponent(str),
catch: (e) => DecodeException(str, e instanceof Error ? e.message : "Invalid input")
})

/**
* @since 2.0.0
* @category symbols
Expand Down Expand Up @@ -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
42 changes: 42 additions & 0 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5917,6 +5917,48 @@ export const StringFromHex: Schema<string> = 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
Expand Down
21 changes: 21 additions & 0 deletions packages/effect/src/internal/encoding/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Encoding.EncodeException> = {
_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()

Expand Down
44 changes: 44 additions & 0 deletions packages/effect/test/Encoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [
"hello%2world"
]

const invalidEncode: Array<string> = [
"\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))
})
})
Original file line number Diff line number Diff line change
@@ -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`
)
})
})

0 comments on commit 3fc16d8

Please sign in to comment.