diff --git a/CONTENT_CODEC_SPEC.md b/CONTENT_CODEC_SPEC.md index c909e154c..a50a13103 100644 --- a/CONTENT_CODEC_SPEC.md +++ b/CONTENT_CODEC_SPEC.md @@ -174,7 +174,11 @@ S.base64.with(S.to, S.string); // identity — a string is NOT bytes | `S.base64` | bytes | sync | sync | | `S.base64url` | bytes | sync | sync | | `S.jsonString` (future: toon, env) | a JSON value | sync | sync | -| future: `S.formData`, protobuf | a record / a message | sync | sync | +| future: protobuf | a message | sync | sync | + +`S.formData` is not a carrier on this axis: a form has no JSON document form +and no format opens into one, so a link to it has one reading or none, and a +`FormData` in a JSON position has no document, the way a `Blob` has none. Packing bytes into a JSON position always produces base64. Packing a `File` loses its name — the reverse builds `new File([content], "")`; a name option diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5248e18c..e06d8b9c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,15 +335,12 @@ case the harness *should* have caught or guided better — a missing check, a we error message, a strictness gap that let a bad spec through — add a bullet here instead of silently working around it. -- An operation whose output holds a `Blob` or `File` (`S.blob`/`S.file` - decoding, or the reverse of any conversion into them) can't be specced: the - golden writer raises "cannot represent a Blob instance as spec source code", - and an op has no way to opt out. `Uint8Array` is written as a constructor - call, but a binary container's bytes are only readable asynchronously, so the - writer would have to await the example before rendering it. It costs a whole - direction of the content axis: the `codec-*` specs for `S.blob` and `S.file` - carry codegen and error cases only, and `tests/content_test.ts` holds the - values instead. +- A `Blob`, `File` or `FormData` in an example's output renders without its + `lastModified`: that field defaults to the moment the value was built, so + recording it would rewrite the golden on every run. Two values that differ + only there therefore write the same golden. Everything else round-trips — + bytes as the text that produced them where that is printable, and as a + `Uint8Array` otherwise. - An example's `error` is matched verbatim, so one raised by the *platform* rather than by Sury pins that engine's wording: `new Blob([Symbol()])` says "Cannot convert a Symbol value to a string" on Node 22 and "The argument diff --git a/IDEAS.md b/IDEAS.md index 21594eff7..590301f2b 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -161,22 +161,44 @@ of a form-data story. What they were built to make cheap, roughly in order: fail with `invalid_operation`. `advanced/uint8Array.ts` is the shape to copy. The payoff is `S.file.with(S.to, S.jsonString.with(S.to, configSchema))` — parse an upload into a typed value, and reverse it to *build* the upload. -- **`S.formData` as a codec, not a preprocessor.** A `FormData` field is - `string | File`, so the per-field work is the existing string coercions plus - `.get`/`.getAll` extraction; the object rebuild in `advanced/json.ts` - (`jsonDecoderFn`, via `makeObjectVal`/`B_addObjectField`) is the pattern. - Reversing it emits `new FormData()` + `append` per field, which is what makes - this different from VineJS and every other form validator: one schema serves - the request handler *and* the `fetch` body. `S.urlSearchParams` is the same - code minus files, and `S.queryString` is to it what `S.jsonString` is to - `S.json`. -- **The three HTML-form quirks**, once `S.formData` exists: a checkbox is absent - when unchecked and `"on"` when checked (VineJS spells this `vine.accepted()`), - an empty text input submits `""` rather than nothing, and repeated keys are - how arrays arrive. The first wants a named `S.accepted`; the second belongs to - the codec rather than a global flag, since it's a wire quirk; the third is - `.getAll`. Bracket notation (`user[name]`) is deliberately out — VineJS leans - on `qs` for it too. +- **`S.urlSearchParams` and `S.queryString`**, now that `S.formData` has shipped. + The codec only calls `get`/`getAll`/`append`, all of which `URLSearchParams` + has, so the first is the same code minus files and the second is to it what + `S.jsonString` is to `S.json`. +- **A `S.record` target for the same readers**, for a form whose keys aren't + known ahead of time. `S.formData.with(S.to, S.record(S.string))` is rejected + today: the codec takes the object path only when `additionalItems` is + `"strip"`/`"strict"`, and a record's is the value schema, so the pair falls + through to `Can't decode FormData to { [key: string]: string; }`. Two things + to settle before it can be written: + - **What a repeated key becomes.** The declared path answers `getAll` for a + `S.array` field and `get` for every other, which a record has no field to + ask. `Object.fromEntries(fd)` keeps the last value and loses the rest; + `S.record(S.array(V))` keeps them but wraps the common case in a + one-element array. A third reading — `getAll` where the value type is an + array and `get` otherwise — matches the declared path exactly and is + probably the one. + - **Which value types can work.** Only ones a text wire can discriminate: + `S.record(S.string)` and `S.record(S.number)` are fine, and + `S.record(S.union([S.string, S.number]))` can't be — the union rules reject + `string -> string | number` before the codec is consulted, since every + entry satisfies the string arm. +- **`string -> string | undefined` is still rejected by the union rules**, so + the env pattern can't read an optional string field — where the form codec + converts the present arm itself. Pinned by + `specs/dict-to-object-optional-string`. +- **Nested keys for `S.formData`, with no API to turn them on.** Nesting the + schema is the switch: `S.schema({ user: S.schema({ city }) })` rejects the + pair today, and instead should read `user[city]`. Brackets only — PHP + invented the spelling, Rails, `qs` and Express read it, and a plain `
` + can produce it without JS. Dot notation stays out: it is the newer JS-side + convention, and accepting both means two `get` calls per leaf for a spelling + no browser emits on its own. The point of driving the key off the schema is + that none of `qs`'s hazards arrive with it — no depth or parameter limit, no + `__proto__` filtering, no array-vs-object heuristic — because the shape is + known before a document is read, and a flat schema never probes at all. If a + second spelling is ever wanted on the wire out, it is a second constant + (`S.formDataNested`), not a config object on the first. - **`S.mime`** for uploads, next to the size bounds. Wants a JSON Schema emit (`contentMediaType`, and `format: "binary"` for the instances) — which is the point at which `minSize`/`maxSize` should be revisited, since neither has a diff --git a/docs/js-usage.md b/docs/js-usage.md index 2e2e08744..5dfb56933 100644 --- a/docs/js-usage.md +++ b/docs/js-usage.md @@ -42,6 +42,10 @@ - [Instance](#instance) - [Blob](#blob) - [File](#file) +- [FormData](#formdata) + - [Checkboxes](#checkboxes) + - [Blank inputs](#blank-inputs) + - [Not supported](#not-supported) - [Content](#content) - [Meta](#meta) - [Brand](#brand) @@ -1167,6 +1171,122 @@ its own: const upload = (f: S.File) => S.parser(S.file)(f); ``` +## FormData + +`S.formData` validates a `FormData`. Convert it with `S.to` and one schema +serves both the request handler and the `fetch` body: + +```ts +const signup = S.formData.with( + S.to, + S.schema({ + email: S.email, + age: S.number, // "42" -> 42 + agree: true, // a checkbox that has to be ticked + avatar: S.file, + }), +); + +S.decoder(signup)(await request.formData()); +// => { email: "a@b.co", age: 42, agree: true, avatar: File } +S.encoder(signup)(user); +// => a FormData, ready for fetch(url, { body }) +``` + +A field reads its entry as text through the same coercions +[`S.record(S.string)`](#records) gets; `S.file` and `S.blob` take the entry as +it is, and `S.array` reads every entry of the key — `S.array(S.file)` included, +for a multi-file input. A `S.tuple` is the fixed-length version of the same +read, so `S.tuple([S.string, S.number])` takes two entries of that key and +reports a form that sent a different number of them. + +A key the schema declares once but the form sent twice is reported rather than +resolved — `get` would answer the first and say nothing, and which one that is +depends on submission order: + +```ts +S.schema({ name: S.string.with(S.nonEmpty) }); +// name=first&name=second +// => Failed at name: Expected string.length >= 1, received ["first", "second"] +``` + +### Checkboxes + +A boolean field is a checkbox, since nothing else a browser sends is one, and +it stays one however you wrap it: + +```ts +S.schema({ + agree: S.boolean, // "on"/"true"/"1" -> true, "false"/"0" or absent -> false + terms: true, // must be ticked: absent -> Expected true, received false + spam: false, // must stay clear: "on" -> Expected false, received true + notify: S.optional(S.boolean), // tri-state: absent -> undefined + seen: S.nullable(S.boolean), // absent -> null +}); +``` + +Encoding omits an unchecked box, exactly as a browser does. `S.optional(S.boolean)` +is the exception — absent and unchecked are the same wire, so its `false` is +written out to keep the third state apart, and `S.optional(S.boolean, true)` +therefore cannot round-trip. + +Any other `value` is a string the schema should name (`S.union(["yes", "no"])`), +and a list of booleans is rejected: a checkbox group submits the value of each +checked box, so `S.array(S.string)` is what one decodes to. + +A boolean arm of a union reads the same way, because the rule belongs to the +entry rather than to the field: + +```ts +S.union([S.boolean, S.number]); // "on" -> true, "false" -> false, "42" -> 42 +``` + +### Blank inputs + +An empty text input submits `""`, and a required string field has to say what +that means: + +```ts +S.formData.with(S.to, S.schema({ name: S.string })); +// throws at S.decoder: Ambiguous at name: say what "" means with +// S.nonEmpty, S.minLength(0), S.optional or S.nullable +``` + +```ts +S.schema({ + name: S.string.with(S.nonEmpty), // "" -> Expected string.length >= 1 + bio: S.string.with(S.minLength, 0), // "" -> "", a value + nick: S.optional(S.string), // "" -> undefined + note: S.nullable(S.string), // "" -> null + tier: S.optional(S.number, 1), // "" -> 1, the default + age: S.number, // "" -> Expected number +}); +``` + +Only a required, non-nullable string has to choose — every other target answers +for itself, `S.minLength(0)` being the way to say "the empty string is a value" +without adding a check. + +### Not supported + +`S.strict` fails at operation creation: a browser adds entries no schema +declared, so "no entries but these" is not something a form can promise. +Objects strip by default; keep it that way. + +Nested objects have no wire form here — send them as a +[`S.jsonString`](#advanced-schemas) field: + +```ts +S.schema({ prefs: S.jsonString.with(S.to, S.schema({ theme: S.string })) }); +``` + +A file input with nothing chosen still submits an empty, unnamed `File`; that +sentinel reads as absent, so a required `S.file` reports a missing file. + +Both directions are sync — nothing reads a file's bytes. `S.FormData` is +exported as a type for projects with neither `lib.dom` nor `@types/node`, like +[`S.File`](#file). + ## Content Bytes in JSON become base64. They are not mangled as UTF-8. diff --git a/docs/rescript-usage.md b/docs/rescript-usage.md index 0c55446da..8b6debe61 100644 --- a/docs/rescript-usage.md +++ b/docs/rescript-usage.md @@ -49,6 +49,7 @@ - [`instance`](#instance) - [`blob`](#blob) - [`file`](#file) + - [`formData`](#formdata) - [`json`](#json) - [`jsonString`](#jsonstring) - [Content](#content) @@ -1265,6 +1266,34 @@ let schema = S.file->S.maxSize(1_000_000) A `File` is a `Blob`, so it also satisfies [`S.blob`](#blob) — not the other way round. It takes the same size bounds. +### **`formData`** + +`S.t` + +```rescript +let schema = S.formData->S.to( + S.schema(s => { + name: s.field("name", S.string->S.nonEmpty), + age: s.field("age", S.int), // "42" -> 42 + agree: s.field("agree", S.literal(true)), // a checkbox that has to be ticked + notify: s.field("notify", S.bool), // "on" -> true, absent -> false + tags: s.field("tags", S.array(S.string)), // every "tags" entry + avatar: s.field("avatar", S.file), + }), +) + +%raw(`new FormData()`)->S.parseOrThrow(~to=schema) // throws - Failed at name: Expected string, received undefined +value->S.reverseConvertOrThrow(~from=schema) // a FormData with one append per field +``` + +A field reads its entry as text through the same coercions `S.dict(S.string)` +gets; `S.file` and `S.blob` take the entry as it is, and an encode omits an +unchecked box the way a browser does. A required, non-nullable string must say +what a blank entry means — `S.string->S.nonEmpty`, `S.string->S.minLength(0)`, +`S.option` or `S.null` — or the operation fails to build. The type is abstract, +since the stdlib has no `FormData` module; a value from a fetch binding is cast +to it. + ### **`json`** `S.t` diff --git a/packages/spec/harness.ts b/packages/spec/harness.ts index 4e6a4c94c..80f48eaba 100644 --- a/packages/spec/harness.ts +++ b/packages/spec/harness.ts @@ -117,6 +117,68 @@ export const lintSkips = (obj: unknown, path: string, out: string[]): void => { for (const [k, v] of Object.entries(obj)) lintSkips(v, path ? `${path}.${k}` : k, out); }; +// Every name a compiled operation binds, so an assignment to anything else can +// be spotted. Scans the golden text rather than parsing it: the shapes Sury +// emits are `let a,b=…`, `for(let i=…`, `for(let k in o)` and `catch(x)`, and +// each one names its bindings up to the first `;`, `)` or `in`/`of` at depth 0. +const boundNames = (code: string): Set => { + // `i` is the operation's argument and `e` its embed array — the only two + // free names generated code is allowed to read. + const out = new Set(["i", "e"]); + const heads = /\b(?:let|const|var)\s+|\bcatch\(/g; + let head: RegExpExecArray | null; + while ((head = heads.exec(code))) { + let at = heads.lastIndex; + for (;;) { + let name = ""; + while (/\s/.test(code[at]!)) at++; + while (/[\w$]/.test(code[at] ?? "")) name += code[at++]; + if (name) out.add(name); + if (head[0] === "catch(") break; + let depth = 0; + for (;;) { + const c = code[at]; + if (c === undefined) return out; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { + if (depth === 0) break; + depth--; + } else if (depth === 0 && (c === ";" || c === ",")) break; + at++; + } + if (code[at] !== ",") break; + at++; + } + } + return out; +}; + +// An operation that assigns a name it never bound writes a *global*: Sury +// builds its functions with `new Function`, whose body is sloppy mode, so +// nothing reports it and two operations end up sharing the slot. The goldens +// are the only place the generated code is written down, so this is where it +// gets caught. +export const undeclaredAssignments = (spec: Spec, out: string[]): void => { + const ops = spec.operations as Partial> | undefined; + if (ops == null) return; + for (const opName of OP_ORDER) { + const op = ops[opName]; + if (op == null || typeof op === "string" || isCreationError(op) || isSkip(op)) continue; + const code = op.expression; + if (typeof code !== "string") continue; + const bound = boundNames(code); + const leaked = new Set(); + for (const [, name] of code.matchAll(/[({,;&|?:!= ]([A-Za-z_$][\w$]*)\s*=(?![=>])/g)) { + if (!bound.has(name!)) leaked.add(name!); + } + if (leaked.size) + out.push( + `operations.${opName}: assigns ${[...leaked].join(", ")} without declaring ` + + "it — generated code runs in sloppy mode, so that lands on globalThis", + ); + } +}; + // A full op block is chosen over `identity`/`eq-to-parse` precisely because it // has real codegen — and nothing ever runs that codegen until an example does, // so an empty map snapshots an expression no test executes. @@ -712,7 +774,65 @@ const keyToCode = (k: string): string => // throw rather than emit: a cyclic value would recurse forever, and a class // instance would silently flatten to a plain-object literal — each of those // would record a golden that looks fine but doesn't equal the real output. -const valueToCode = (v: unknown, seen: WeakSet = new WeakSet()): string => { +// A binary container's bytes are only readable asynchronously, so they are +// collected in one pass before rendering and handed to `valueToCode` through +// this map. Everything the writer walks is walked here too, in the same order. +type Bytes = WeakMap; +const readBytes = async (v: unknown, out: Bytes): Promise => { + if (v === null || typeof v !== "object") return; + if (isBlob(v)) { + out.set(v, new Uint8Array(await (v as Blob).arrayBuffer())); + return; + } + if (isFormData(v)) { + for (const [, entry] of (v as FormData).entries()) await readBytes(entry, out); + return; + } + if (v instanceof Map) for (const pair of v) await readBytes(pair, out); + else if (v instanceof Set || Array.isArray(v)) for (const item of v as Iterable) await readBytes(item, out); + else if (Object.getPrototypeOf(v) === Object.prototype) for (const item of Object.values(v)) await readBytes(item, out); +}; + +const globalClass = (name: string): Function | undefined => + (globalThis as Record)[name] as Function | undefined; +const isBlob = (v: object): boolean => { + const c = globalClass("Blob"); + return c !== undefined && v instanceof c; +}; +const isFile = (v: object): boolean => { + const c = globalClass("File"); + return c !== undefined && v instanceof c; +}; +const isFormData = (v: object): boolean => { + const c = globalClass("FormData"); + return c !== undefined && v instanceof c; +}; + +// Bytes read best as the text that produced them, which is what a spec author +// writes; anything else (and anything with a control byte, which YAML would +// have to escape) falls back to the byte array. +const TEXT_SAFE = /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]*$/; +const bytesToCode = (bytes: Uint8Array): string => { + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + return TEXT_SAFE.test(text) && String(new TextEncoder().encode(text)) === String(bytes) + ? escapeC1(JSON.stringify(text)) + : `new Uint8Array([${[...bytes].join(", ")}])`; +}; + +// `type` is rendered only when set, and `lastModified` never: it defaults to +// the moment the file is built, so recording it would rewrite the golden on +// every run. +const blobToCode = (v: object, bytes: Bytes): string => { + const own = bytes.get(v); + if (own === undefined) throw new Error("cannot represent a Blob whose bytes were not read"); + const type = (v as Blob).type; + const options = type ? `, { type: ${JSON.stringify(type)} }` : ""; + return isFile(v) + ? `new File([${bytesToCode(own)}], ${JSON.stringify((v as File).name)}${options})` + : `new Blob([${bytesToCode(own)}]${options})`; +}; + +const valueToCode = (v: unknown, seen: WeakSet = new WeakSet(), bytes: Bytes = new WeakMap()): string => { if (v === undefined) return "undefined"; if (typeof v === "bigint") return `${v}n`; if (typeof v === "number") return Object.is(v, -0) ? "-0" : String(v); @@ -735,9 +855,20 @@ const valueToCode = (v: unknown, seen: WeakSet = new WeakSet()): string // stays unrepresentable rather than silently narrowed. if (Object.getPrototypeOf(v) === Uint8Array.prototype) return `new Uint8Array([${[...(v as Uint8Array)].join(", ")}])`; - if (v instanceof Map) return `new Map(${valueToCode([...v], seen)})`; - if (v instanceof Set) return `new Set(${valueToCode([...v], seen)})`; - if (Array.isArray(v)) return `[${v.map((x) => valueToCode(x, seen)).join(", ")}]`; + if (isBlob(v)) return blobToCode(v, bytes); + // The same idiom a spec author writes for an input: `append` returns + // nothing, so the comma expression hands the FormData back. + if (isFormData(v)) { + const appends = [...(v as FormData).entries()] + .map(([k, entry]) => `f.append(${JSON.stringify(k)}, ${valueToCode(entry, seen, bytes)})`) + .join(", "); + return appends + ? `((f) => (${appends}, f))(new FormData())` + : "new FormData()"; + } + if (v instanceof Map) return `new Map(${valueToCode([...v], seen, bytes)})`; + if (v instanceof Set) return `new Set(${valueToCode([...v], seen, bytes)})`; + if (Array.isArray(v)) return `[${v.map((x) => valueToCode(x, seen, bytes)).join(", ")}]`; const proto = Object.getPrototypeOf(v); if (proto !== Object.prototype && proto !== null) throw new Error( @@ -745,10 +876,10 @@ const valueToCode = (v: unknown, seen: WeakSet = new WeakSet()): string ); // Symbol keys ride along as computed keys — only registry symbols, for the // same reason as symbol values above. Object.entries would drop them. - const parts = Object.entries(v).map(([k, val]) => `${keyToCode(k)}: ${valueToCode(val, seen)}`); + const parts = Object.entries(v).map(([k, val]) => `${keyToCode(k)}: ${valueToCode(val, seen, bytes)}`); for (const sym of Object.getOwnPropertySymbols(v)) { if (!Object.getOwnPropertyDescriptor(v, sym)!.enumerable) continue; - parts.push(`[${valueToCode(sym, seen)}]: ${valueToCode((v as Record)[sym], seen)}`); + parts.push(`[${valueToCode(sym, seen, bytes)}]: ${valueToCode((v as Record)[sym], seen, bytes)}`); } if (parts.length === 0) return "{}"; return `{ ${parts.join(", ")} }`; @@ -842,6 +973,10 @@ export const recomputeGoldens = async (obj: Spec): Promise => { // same catch handles — a rejection and a synchronous throw are one // outcome to the author. const out = await fn(value); + // Binary containers only yield their bytes asynchronously, so they are + // read here, before the sync writer runs. + const bytes: Bytes = new WeakMap(); + await readBytes(out, bytes); // An operation that hands its input straight back records the input's // own source rather than a re-derived spelling of the same value. It // reads better, and it's the only way a value the serializer can't @@ -850,7 +985,7 @@ export const recomputeGoldens = async (obj: Spec): Promise => { // could be *run* but never written down as a result. op.examples[name] = clean({ input: ex.input, - output: out === value ? ex.input : valueToCode(out), + output: out === value ? ex.input : valueToCode(out, new WeakSet(), bytes), }); } catch (e) { if (e instanceof Error && e.message.startsWith("cannot represent ")) throw e; @@ -1131,6 +1266,7 @@ export const checkSpec = async ( lintSkips(spec, "", errs); lintExamples(spec, errs); + undeclaredAssignments(spec, errs); // Collected before the canonical form is built (rather than dropped) so a // disallowed comment is reported as itself, not as a "not canonical" diff — diff --git a/packages/sury/README.md b/packages/sury/README.md index 8b8649884..8f4384ba4 100644 --- a/packages/sury/README.md +++ b/packages/sury/README.md @@ -173,7 +173,32 @@ S.encoder(rows)([{ id: 1n, city: "Tbilisi" }, { id: 2n, city: "Batumi" }]); // => [["1", "2"], ["Tbilisi", "Batumi"]] ``` -Wires today: `S.json`, `S.jsonString`, `S.base64`, `S.base64url`, `S.uint8Array`, `S.file` and `S.blob`. Coming next: env, `FormData` and protobuf. +A form submission is strings, files and missing checkboxes. `S.formData` reads it as what you declared, and builds the body you post back: + +```ts +const signup = S.formData.with( + S.to, + S.schema({ + name: S.string.with(S.nonEmpty), + age: S.number, // "42" -> 42 + agree: true, // a checkbox that has to be ticked + newsletter: S.optional(S.boolean), // tri-state: absent -> undefined + role: S.union(["admin", "user"]), + tags: S.array(S.string), // every "tags" entry + avatar: S.file, + prefs: S.jsonString.with(S.to, S.schema({ theme: S.string })), + }), +); + +S.decoder(signup)(await request.formData()); +// => { name: "Ann", age: 42, agree: true, newsletter: undefined, role: "user", +// tags: ["a", "b"], avatar: File, prefs: { theme: "dark" } } + +S.encoder(signup)(value); +// => a FormData with one append per field, ready for fetch(url, { body }) +``` + +Wires today: `S.json`, `S.jsonString`, `S.formData`, `S.base64`, `S.base64url`, `S.uint8Array`, `S.file` and `S.blob`. Coming next: env and protobuf. ### The code a schema turns into diff --git a/packages/sury/index.d.ts b/packages/sury/index.d.ts index 2df8164cc..537c86b0e 100644 --- a/packages/sury/index.d.ts +++ b/packages/sury/index.d.ts @@ -277,6 +277,8 @@ export type Error = }) | (BaseError & { readonly code: "invalid_operation"; + /** Leads `message` in place of `"Failed"`, when the operation is rejected rather than failed. */ + readonly verb?: string; }) | (BaseError & { readonly code: "unsupported_decode"; @@ -488,6 +490,28 @@ export const blob: Schema; export const file: Schema; +/** The runtime's `FormData`, or a structural stand-in. See {@link Blob}. */ +export type FormData = typeof globalThis extends { + FormData: abstract new (...args: never) => infer T; +} + ? T + : { + append(name: string, value: string | Blob): void; + get(name: string): string | File | null; + getAll(name: string): (string | File)[]; + }; + +/** + * A form submission, converted to and from an object schema with `S.to`. A + * field reads its entry as text (`"42"` -> `S.number`), a boolean is a + * checkbox, `S.array` reads every entry of the key, and `S.file` takes the + * entry as it is. A required, non-nullable string must say what a blank input + * means — `S.nonEmpty`, `S.minLength(0)`, `S.optional` or `S.nullable` — or + * the operation fails to build. + * @example S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty), agree: true, avatar: S.file })) + */ +export const formData: Schema; + /** * RFC 3339 timestamp — the JSON Schema `date-time` format exactly: `Z` or an * offset like `+02:00`. Calendar-aware: month, day, hour, minute and leap diff --git a/packages/sury/scripts/unionFuzz/catalog.ts b/packages/sury/scripts/unionFuzz/catalog.ts index dab925559..1d1c0ad10 100644 --- a/packages/sury/scripts/unionFuzz/catalog.ts +++ b/packages/sury/scripts/unionFuzz/catalog.ts @@ -80,6 +80,7 @@ export const FUZZ_EXPORTS: Record = { enum: build(), extendJSONSchema: skip("JSON Schema document helper, not a schema factory"), file: schema((S) => S.file), + formData: schema((S) => S.formData), fromJSONSchema: skip("JSON Schema import, not a generation primitive"), global: skip("mutates global config"), gt: modify(["number", "bigint"], (S, schema) => diff --git a/packages/sury/specs/bundleSize.yaml b/packages/sury/specs/bundleSize.yaml index 04ef82146..99438d426 100644 --- a/packages/sury/specs/bundleSize.yaml +++ b/packages/sury/specs/bundleSize.yaml @@ -1,145 +1,146 @@ # Minified+gzipped bytes per public export of index.mjs, plus `total` for the whole entry. # Generated by `pnpm spec check --write` — every row is measured, so never hand-write one. -total: 36857 +total: 38538 exports: - $Metadata_Id_make: 4316 - $Metadata_get: 4310 - $Metadata_set: 4326 - $Option_getOr: 10392 - $Option_getOrWith: 10393 - $nullAsOption: 12402 - $nullAsUnit: 4588 - $nullableAsOption: 9843 - $option: 12380 - $safe: 4349 - $safeAsync: 4365 - $schema: 7673 - $setExnId: 4309 - $unit: 4558 - Error: 4308 - any: 4305 - anyOf: 12770 - array: 7667 - assertInput: 4602 - assertOutput: 4606 - asyncAssertInput: 4603 - asyncAssertOutput: 4607 - asyncDecoder: 4315 - asyncEncoder: 4322 - asyncInputConstructor: 4594 - asyncOutputConstructor: 4597 - asyncParser: 4317 - base64: 5301 - base64url: 5406 - bigint: 4534 - blob: 5744 - boolean: 4510 - brand: 4315 - cidrv4: 4771 - cidrv6: 4838 - compactColumns: 8623 - cuid: 4695 - cuid2: 4692 - date: 4773 - decoder: 4306 - deepStrict: 4467 - deepStrip: 4464 - duration: 4696 - e164: 4695 - email: 4703 - enableStandardJSONSchema: 6115 - encoder: 4321 - enum: 12777 - extendJSONSchema: 4388 - file: 5747 - fromJSONSchema: 26562 - global: 4351 - gt: 5731 - gte: 5733 - hex: 4690 - hostname: 4697 - httpUrl: 4995 - idnEmail: 4671 - idnHostname: 4724 - inputConstructor: 4588 - inputExpression: 4306 - inputJSONSchema: 6074 - inputValidator: 4612 - instance: 4395 - int32: 4727 - integer: 4710 - ipv4: 4721 - ipv6: 4818 - iri: 5037 - iriReference: 5045 - isoDate: 4791 - isoDateTime: 4992 - isoTime: 4871 - json: 14214 - jsonPointer: 4674 - jsonString: 16771 - jsonStringWithSpace: 16786 - ksuid: 4689 - length: 5678 - list: 8247 - literal: 7665 - lt: 5734 - lte: 5735 - mac: 4739 - maxLength: 5685 - maxSize: 5665 - merge: 7002 - meta: 4474 - minLength: 5685 - minSize: 5665 - multipleOf: 5686 - nan: 4559 - nanoid: 4689 - never: 4364 - noValidation: 4316 - nonEmpty: 5692 - nullable: 13296 - nullish: 12770 - number: 4700 - object: 9407 - optional: 13272 - outputConstructor: 4590 - outputExpression: 4311 - outputJSONSchema: 6085 - outputValidator: 4614 - parser: 4316 - pathToText: 4307 - pattern: 4502 - port: 4799 - record: 7667 - recursive: 5014 - refine: 4415 - relativeJsonPointer: 4699 - reverse: 4305 - safe: 4349 - safeAsync: 4364 - schema: 7665 - shape: 8632 - size: 5661 - strict: 4466 - string: 4489 - strip: 4462 - symbol: 4428 - to: 5375 - trim: 5051 - tuple: 8750 - uint8Array: 5390 - ulid: 4720 - union: 12770 - unknown: 4305 - uri: 4951 - uriReference: 4959 - uriTemplate: 4775 - url: 5077 - utcDateTime: 4877 - uuid: 4674 - uuidv4: 4732 - uuidv6: 4734 - uuidv7: 4734 - void: 4562 - xid: 4693 + $Metadata_Id_make: 4837 + $Metadata_get: 4831 + $Metadata_set: 4844 + $Option_getOr: 10694 + $Option_getOrWith: 10695 + $nullAsOption: 12712 + $nullAsUnit: 5071 + $nullableAsOption: 10181 + $option: 12690 + $safe: 4867 + $safeAsync: 4883 + $schema: 8104 + $setExnId: 4830 + $unit: 5038 + Error: 4825 + any: 4821 + anyOf: 13080 + array: 8093 + assertInput: 5088 + assertOutput: 5091 + asyncAssertInput: 5089 + asyncAssertOutput: 5092 + asyncDecoder: 4837 + asyncEncoder: 4842 + asyncInputConstructor: 5078 + asyncOutputConstructor: 5079 + asyncParser: 4838 + base64: 5616 + base64url: 5717 + bigint: 4928 + blob: 6012 + boolean: 4903 + brand: 4834 + cidrv4: 5098 + cidrv6: 5167 + compactColumns: 9053 + cuid: 5021 + cuid2: 5020 + date: 5035 + decoder: 4819 + deepStrict: 4981 + deepStrip: 4978 + duration: 5023 + e164: 5022 + email: 5030 + enableStandardJSONSchema: 6650 + encoder: 4840 + enum: 13088 + extendJSONSchema: 4909 + file: 6015 + formData: 9390 + fromJSONSchema: 26752 + global: 4871 + gt: 6253 + gte: 6255 + hex: 5018 + hostname: 5024 + httpUrl: 5335 + idnEmail: 4997 + idnHostname: 5051 + inputConstructor: 5072 + inputExpression: 4822 + inputJSONSchema: 6604 + inputValidator: 5096 + instance: 4841 + int32: 5140 + integer: 5125 + ipv4: 5047 + ipv6: 5146 + iri: 5370 + iriReference: 5377 + isoDate: 5118 + isoDateTime: 5320 + isoTime: 5196 + json: 14391 + jsonPointer: 5000 + jsonString: 16953 + jsonStringWithSpace: 16964 + ksuid: 5017 + length: 6196 + list: 8691 + literal: 8094 + lt: 6255 + lte: 6257 + mac: 5067 + maxLength: 6201 + maxSize: 6183 + merge: 7416 + meta: 4991 + minLength: 6219 + minSize: 6184 + multipleOf: 6215 + nan: 5042 + nanoid: 5016 + never: 4851 + noValidation: 4835 + nonEmpty: 6224 + nullable: 13584 + nullish: 13084 + number: 5115 + object: 9831 + optional: 13562 + outputConstructor: 5073 + outputExpression: 4831 + outputJSONSchema: 6614 + outputValidator: 5096 + parser: 4836 + pathToText: 4818 + pattern: 5019 + port: 5205 + record: 8096 + recursive: 5536 + refine: 4931 + relativeJsonPointer: 5026 + reverse: 4822 + safe: 4869 + safeAsync: 4883 + schema: 8094 + shape: 9075 + size: 6174 + strict: 4980 + string: 4819 + strip: 4977 + symbol: 4840 + to: 5915 + trim: 5395 + tuple: 9192 + uint8Array: 5649 + ulid: 5047 + union: 13080 + unknown: 4821 + uri: 5292 + uriReference: 5300 + uriTemplate: 5102 + url: 5356 + utcDateTime: 5205 + uuid: 5001 + uuidv4: 5059 + uuidv6: 5061 + uuidv7: 5062 + void: 5044 + xid: 5021 diff --git a/packages/sury/specs/codec-base64-file.yaml b/packages/sury/specs/codec-base64-file.yaml index 0c9a2d7a1..6ca6b0023 100644 --- a/packages/sury/specs/codec-base64-file.yaml +++ b/packages/sury/specs/codec-base64-file.yaml @@ -16,12 +16,18 @@ operations: parse: expression: i=>{typeof i==="string"||e[4](i);e[2](i)||e[3](i);return new e[1]([e[0](i)],"")} examples: + the-bytes: + input: '"aGk="' + output: new File(["hi"], "") not-base64: input: '"!!"' error: Expected base64, received "!!" decode: expression: i=>{e[2](i)||e[3](i);return new e[1]([e[0](i)],"")} examples: + the-bytes: + input: '"aGk="' + output: new File(["hi"], "") not-base64: input: '"!!"' error: Expected base64, received "!!" diff --git a/packages/sury/specs/codec-formdata-nested-unsupported.yaml b/packages/sury/specs/codec-formdata-nested-unsupported.yaml new file mode 100644 index 000000000..84114b527 --- /dev/null +++ b/packages/sury/specs/codec-formdata-nested-unsupported.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ user: S.schema({ name: S.string }) }))" + input: FormData + output: "{ user: { name: string; }; }" + instantiations: 6364 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { user: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } }, required: ["user"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + creationError: "SuryError: Failed at user: Can't decode form field to { name: string; }. Use S.to to define a custom decoder" + decode: eq-to-parse + encode: + creationError: "SuryError: Failed at user: Can't decode { name: string; } to FormData. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-formdata-object-accepted.yaml b/packages/sury/specs/codec-formdata-object-accepted.yaml new file mode 100644 index 000000000..f52a7eb3b --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-accepted.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ terms: S.schema(true), picks: S.array(S.string).with(S.nonEmpty), maybe: S.nullable(S.boolean) }))" + input: FormData + output: "{ terms: true; picks: [string, ...string[]]; maybe: boolean | null; }" + instantiations: 6895 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { terms: { type: "boolean", const: true }, picks: { items: { type: "string" }, type: "array", minItems: 1 }, maybe: { anyOf: [{ type: "boolean" }, { type: "null" }] } }, required: ["terms", "picks", "maybe"] }' + fromOutputType: "{ terms: true; picks: string[]; maybe: boolean | null; }" + openapi-3.0: + output: '{ type: "object", properties: { terms: { type: "boolean", enum: [true] }, picks: { items: { type: "string" }, type: "array", minItems: 1 }, maybe: { type: "boolean", nullable: true } }, required: ["terms", "picks", "maybe"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[7]||e[8](i);let v0=e[0](i),v1=v0.get("terms"),v3=e[3](v0.get("picks")),v7=v0.get("maybe");let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);v2===true||e[2](v2);for(let v4=0;v40||e[5](v3);let v8;if(v7){(v8=v7==="on"||v7==="true"||v7==="1")||v7==="false"||v7==="0"||e[6](v7);}else{v8=null}return {terms:v2,picks:v3,maybe:v8}} + examples: + checked: + input: ((f) => (f.append("terms", "on"), f.append("picks", "a"), f.append("picks", "b"), f.append("maybe", "on"), f))(new FormData()) + output: '{ terms: true, picks: ["a", "b"], maybe: true }' + unchecked-terms: + input: ((f) => (f.append("picks", "a"), f))(new FormData()) + error: "Failed at terms: Expected true, received false" + absent-nullable-checkbox: + input: ((f) => (f.append("terms", "1"), f.append("picks", "a"), f))(new FormData()) + output: '{ terms: true, picks: ["a"], maybe: null }' + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("terms"),v3=e[3](v0.get("picks")),v7=v0.get("maybe");let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);v2===true||e[2](v2);for(let v4=0;v40||e[5](v3);let v8;if(v7){(v8=v7==="on"||v7==="true"||v7==="1")||v7==="false"||v7==="0"||e[6](v7);}else{v8=null}return {terms:v2,picks:v3,maybe:v8}} + examples: + checked: + input: ((f) => (f.append("terms", "on"), f.append("picks", "a"), f))(new FormData()) + output: '{ terms: true, picks: ["a"], maybe: null }' + encode: + expression: i=>{let v0=i["picks"],v4=i["maybe"];v0.length>0||e[0](v0);let v2=new e[1]();v2.append("terms","on");for(let v3=0;v3 (f.append("terms", "on"), f.append("picks", "a"), f.append("picks", "b"), f))(new FormData()) + no-picks: + input: "{ terms: true, picks: [], maybe: null }" + error: "Failed at picks: Expected string[].length >= 1, received []" diff --git a/packages/sury/specs/codec-formdata-object-array.yaml b/packages/sury/specs/codec-formdata-object-array.yaml new file mode 100644 index 000000000..4a67850f4 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-array.yaml @@ -0,0 +1,55 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: 'S.formData.with(S.to, S.schema({ tags: S.array(S.string.with(S.nonEmpty)), ids: S.array(S.number), picks: S.array(S.union(["a", "b"])), extra: S.optional(S.array(S.string)), sizes: S.optional(S.array(S.string), ["m"]) }))' + input: FormData + output: '{ tags: string[]; ids: number[]; picks: ("a" | "b")[]; sizes: string[]; extra?: string[] | undefined; }' + instantiations: 9070 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { tags: { items: { type: "string", minLength: 1 }, type: "array" }, ids: { items: { type: "number" }, type: "array" }, picks: { items: { enum: ["a", "b"] }, type: "array" }, extra: { items: { type: "string" }, type: "array" }, sizes: { items: { type: "string" }, type: "array" } }, required: ["tags", "ids", "picks", "sizes"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[15]||e[16](i);let v0=e[0](i),v1=e[1](v0.get("tags")),v5=e[4](v0.get("ids")),v11=e[7](v0.get("picks")),v15=e[10](v0.get("extra")),v19=e[12](v0.get("sizes"));for(let v2=0;v20||e[2](v3);}catch(v4){v4.path=["tags",v2,...v4.path];throw v4}}let v10=new Array(v5.length);for(let v6=0;v6 (f.append("tags", "a"), f.append("tags", "b"), f.append("picks", "a"), f.append("ids", "1"), f.append("ids", "2"), f.append("extra", "x"), f))(new FormData()) + output: '{ tags: ["a", "b"], ids: [1, 2], picks: ["a"], extra: ["x"], sizes: ["m"] }' + no-entries: + input: new FormData() + output: '{ tags: [], ids: [], picks: [], extra: undefined, sizes: ["m"] }' + defaulted-list-absent: + input: ((f) => (f.append("tags", "a"), f))(new FormData()) + output: '{ tags: ["a"], ids: [], picks: [], extra: undefined, sizes: ["m"] }' + defaulted-list-present: + input: ((f) => (f.append("tags", "a"), f.append("sizes", "s"), f.append("sizes", "l"), f))(new FormData()) + output: '{ tags: ["a"], ids: [], picks: [], extra: undefined, sizes: ["s", "l"] }' + not-a-number: + input: ((f) => (f.append("ids", "1"), f.append("ids", "x"), f))(new FormData()) + error: 'Failed at ids[1]: Expected number, received "x"' + empty-item-stays: + input: ((f) => (f.append("extra", ""), f))(new FormData()) + output: '{ tags: [], ids: [], picks: [], extra: [""], sizes: ["m"] }' + empty-item-refined: + input: ((f) => (f.append("tags", ""), f))(new FormData()) + error: 'Failed at tags[0]: Expected string.length >= 1, received ""' + file-where-text: + input: ((f) => (f.append("tags", new File(["a"], "a.txt")), f))(new FormData()) + error: "Failed at tags[0]: Expected string.length >= 1, received File" + decode: + expression: i=>{let v0=e[0](i),v1=e[1](v0.get("tags")),v5=e[4](v0.get("ids")),v11=e[7](v0.get("picks")),v15=e[10](v0.get("extra")),v19=e[12](v0.get("sizes"));for(let v2=0;v20||e[2](v3);}catch(v4){v4.path=["tags",v2,...v4.path];throw v4}}let v10=new Array(v5.length);for(let v6=0;v6 (f.append("tags", "a"), f.append("tags", "b"), f.append("picks", "a"), f.append("ids", "1"), f.append("ids", "2"), f.append("extra", "x"), f))(new FormData()) + output: '{ tags: ["a", "b"], ids: [1, 2], picks: ["a"], extra: ["x"], sizes: ["m"] }' + encode: + expression: i=>{let v0=i["tags"],v4=i["ids"],v6=i["picks"],v8=i["sizes"],v18=i["extra"];for(let v1=0;v10||e[0](v2);}catch(v3){v3.path=["tags",v1,...v3.path];throw v3}}if(Array.isArray(v8)){for(let v9=0;v9 (f.append("tags", "a"), f.append("tags", "b"), f.append("ids", "1"), f.append("ids", "2"), f.append("picks", "a"), f.append("sizes", "m"), f))(new FormData()) + empty-item-refined: + input: '{ tags: [""], ids: [] }' + error: 'Failed at tags[0]: Expected string.length >= 1, received ""' diff --git a/packages/sury/specs/codec-formdata-object-blank-ambiguous.yaml b/packages/sury/specs/codec-formdata-object-blank-ambiguous.yaml new file mode 100644 index 000000000..db0367722 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-blank-ambiguous.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ a: S.string, n: S.number.with(S.gte, 18) }))" + input: FormData + output: "{ a: string; n: number; }" + instantiations: 6128 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { a: { type: "string" }, n: { type: "number", minimum: 18 } }, required: ["a", "n"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + creationError: 'SuryError: Ambiguous at a: say what "" means with S.nonEmpty, S.minLength(0), S.optional or S.nullable' + decode: eq-to-parse + encode: + expression: i=>{let v0=i["n"];v0>=18||e[0](v0);let v1=new e[1]();v1.append("a",i["a"]);v1.append("n",""+v0);return v1} + examples: + built: + input: '{ a: "x", n: 42 }' + output: ((f) => (f.append("a", "x"), f.append("n", "42"), f))(new FormData()) + too-young: + input: '{ a: "x", n: 7 }' + error: "Failed at n: Expected number >= 18, received 7" diff --git a/packages/sury/specs/codec-formdata-object-boolean-union.yaml b/packages/sury/specs/codec-formdata-object-boolean-union.yaml new file mode 100644 index 000000000..79f7ddb96 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-boolean-union.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ a: S.union([S.boolean, S.number]) }))" + input: FormData + output: "{ a: number | boolean; }" + instantiations: 6208 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { a: { anyOf: [{ type: "boolean" }, { type: "number" }] } }, required: ["a"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[5]||e[6](i);let v0=e[0](i),v1=v0.get("a");for(;;){let r;try{let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);v1=v2;break}catch(x){(r||(r=[])).push(e[3](x))}if(typeof v1==="string"){try{let v3=+v1;v3===v3&&(v3||v1.trim())||e[2](v1);v1=v3;break}catch(x){(r||(r=[])).push(e[3](x))}}e[4](v1,...(r||[]))}return {a:v1}} + examples: + checked: + input: ((f) => (f.append("a", "on"), f))(new FormData()) + output: "{ a: true }" + the-number: + input: ((f) => (f.append("a", "42"), f))(new FormData()) + output: "{ a: 42 }" + unchecked: + input: ((f) => (f.append("a", "false"), f))(new FormData()) + output: "{ a: false }" + neither: + input: ((f) => (f.append("a", "x"), f))(new FormData()) + error: |- + Failed at a: Expected boolean | number, received "x" + - At a: Expected boolean, received "x" + - At a: Expected number, received "x" + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("a");for(;;){let r;try{let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);v1=v2;break}catch(x){(r||(r=[])).push(e[3](x))}if(typeof v1==="string"){try{let v3=+v1;v3===v3&&(v3||v1.trim())||e[2](v1);v1=v3;break}catch(x){(r||(r=[])).push(e[3](x))}}e[4](v1,...(r||[]))}return {a:v1}} + examples: + checked: + input: ((f) => (f.append("a", "on"), f))(new FormData()) + output: "{ a: true }" + encode: + expression: i=>{let v1=i["a"];let v0=new e[0]();for(;;){if(typeof v1==="boolean"){v1=""+i["a"];break}if(typeof v1==="number"&&v1===v1){v1=""+v1;break}e[1](v1)}v0.append("a",v1);return v0} + examples: + built: + input: "{ a: true }" + output: ((f) => (f.append("a", "true"), f))(new FormData()) diff --git a/packages/sury/specs/codec-formdata-object-checkbox.yaml b/packages/sury/specs/codec-formdata-object-checkbox.yaml new file mode 100644 index 000000000..a264c793d --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-checkbox.yaml @@ -0,0 +1,85 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ agree: S.boolean, notify: S.optional(S.boolean), seen: S.optional(S.boolean, false), asText: S.boolean.with(S.to, S.string), age: S.number.with(S.gte, 18) }))" + input: FormData + output: "{ agree: boolean; seen: boolean; asText: string; age: number; notify?: boolean | undefined; }" + instantiations: 8240 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { agree: { type: "boolean" }, notify: { type: "boolean" }, seen: { type: "boolean" }, asText: { type: "string" }, age: { type: "number", minimum: 18 } }, required: ["agree", "seen", "asText", "age"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[8]||e[9](i);let v0=e[0](i),v1=v0.get("agree"),v3=v0.get("notify"),v5=v0.get("seen"),v7=v0.get("asText"),v9=v0.get("age");let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);let v4;if(v3){(v4=v3==="on"||v3==="true"||v3==="1")||v3==="false"||v3==="0"||e[2](v3);}let v6;if(v5){(v6=v5==="on"||v5==="true"||v5==="1")||v5==="false"||v5==="0"||e[3](v5);}else{v6=false;}let v8;(v8=v7==="on"||v7==="true"||v7==="1")||v7==="false"||v7==="0"||!v7||e[4](v7);typeof v9==="string"||e[7](v9);let v10=+v9;v10===v10&&(v10||v9.trim())||e[6](v9);v10>=18||e[5](v10);return {agree:v2,notify:v4,seen:v6,asText:""+v8,age:v10}} + examples: + checked: + input: ((f) => (f.append("agree", "on"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: true, notify: undefined, seen: false, asText: "false", age: 42 }' + defaulted-absent: + input: ((f) => (f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + defaulted-on: + input: ((f) => (f.append("seen", "on"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: true, asText: "false", age: 42 }' + one-is-checked: + input: ((f) => (f.append("agree", "1"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: true, notify: undefined, seen: false, asText: "false", age: 42 }' + zero-is-unchecked: + input: ((f) => (f.append("agree", "0"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + unchecked: + input: ((f) => (f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + empty-value: + input: ((f) => (f.append("agree", ""), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + hidden-true: + input: ((f) => (f.append("agree", "true"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: true, notify: undefined, seen: false, asText: "false", age: 42 }' + hidden-false: + input: ((f) => (f.append("agree", "false"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + not-a-boolean: + input: ((f) => (f.append("agree", "yes"), f.append("age", "42"), f))(new FormData()) + error: 'Failed at agree: Expected boolean, received "yes"' + file-where-checkbox: + input: ((f) => (f.append("agree", new File(["on"], "a.txt")), f.append("age", "42"), f))(new FormData()) + error: "Failed at agree: Expected boolean, received File" + tri-state-absent: + input: ((f) => (f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + tri-state-false: + input: ((f) => (f.append("notify", "false"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: false, seen: false, asText: "false", age: 42 }' + tri-state-on-is-not-a-boolean: + input: ((f) => (f.append("notify", "on"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: true, seen: false, asText: "false", age: 42 }' + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("agree"),v3=v0.get("notify"),v5=v0.get("seen"),v7=v0.get("asText"),v9=v0.get("age");let v2;(v2=v1==="on"||v1==="true"||v1==="1")||v1==="false"||v1==="0"||!v1||e[1](v1);let v4;if(v3){(v4=v3==="on"||v3==="true"||v3==="1")||v3==="false"||v3==="0"||e[2](v3);}let v6;if(v5){(v6=v5==="on"||v5==="true"||v5==="1")||v5==="false"||v5==="0"||e[3](v5);}else{v6=false;}let v8;(v8=v7==="on"||v7==="true"||v7==="1")||v7==="false"||v7==="0"||!v7||e[4](v7);typeof v9==="string"||e[7](v9);let v10=+v9;v10===v10&&(v10||v9.trim())||e[6](v9);v10>=18||e[5](v10);return {agree:v2,notify:v4,seen:v6,asText:""+v8,age:v10}} + examples: + checked: + input: ((f) => (f.append("agree", "on"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: true, notify: undefined, seen: false, asText: "false", age: 42 }' + defaulted-absent: + input: ((f) => (f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + defaulted-on: + input: ((f) => (f.append("seen", "on"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: true, asText: "false", age: 42 }' + one-is-checked: + input: ((f) => (f.append("agree", "1"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: true, notify: undefined, seen: false, asText: "false", age: 42 }' + zero-is-unchecked: + input: ((f) => (f.append("agree", "0"), f.append("age", "42"), f))(new FormData()) + output: '{ agree: false, notify: undefined, seen: false, asText: "false", age: 42 }' + encode: + expression: i=>{let v0=i["seen"],v2=i["asText"],v3=i["age"];typeof v0==="boolean"||e[0](v0);let v1;(v1=v2==="true")||v2==="false"||e[1](v2);v3>=18||e[2](v3);let v4=new e[3]();if(i["agree"]){v4.append("agree","on")}if(i["notify"]!==void 0){v4.append("notify",i["notify"]?"on":"false")}if(v0){v4.append("seen","on")}if(v1){v4.append("asText","on")}v4.append("age",""+v3);return v4} + examples: + built: + input: '{ agree: true, notify: false, seen: false, asText: "true", age: 42 }' + output: ((f) => (f.append("agree", "on"), f.append("notify", "false"), f.append("asText", "on"), f.append("age", "42"), f))(new FormData()) + too-young: + input: "{ agree: true, age: 7 }" + error: "Failed at seen: Expected boolean, received undefined" diff --git a/packages/sury/specs/codec-formdata-object-enum.yaml b/packages/sury/specs/codec-formdata-object-enum.yaml new file mode 100644 index 000000000..3631c2083 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-enum.yaml @@ -0,0 +1,46 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: 'S.formData.with(S.to, S.schema({ role: S.union(["admin", "user"]), plan: S.optional(S.union(["free", "pro"])) }))' + input: FormData + output: '{ role: "admin" | "user"; plan?: "free" | "pro" | undefined; }' + instantiations: 7202 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { role: { enum: ["admin", "user"] }, plan: { enum: ["free", "pro"] } }, required: ["role"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[5]||e[6](i);let v0=e[0](i),v1=v0.get("role"),v2=v0.get("plan")||void 0;typeof v1==="string"||e[2](v1);(v1==="admin"||v1==="user")||e[1](v1);if(v2!==void 0){typeof v2==="string"||e[4](v2);(v2==="free"||v2==="pro")||e[3](v2);}return {role:v1,plan:v2}} + examples: + members: + input: ((f) => (f.append("role", "admin"), f.append("plan", "pro"), f))(new FormData()) + output: '{ role: "admin", plan: "pro" }' + optional-absent: + input: ((f) => (f.append("role", "user"), f))(new FormData()) + output: '{ role: "user", plan: undefined }' + optional-empty: + input: ((f) => (f.append("role", "user"), f.append("plan", ""), f))(new FormData()) + output: '{ role: "user", plan: undefined }' + no-member: + input: ((f) => (f.append("role", "guest"), f))(new FormData()) + error: 'Failed at role: Expected "admin" | "user", received "guest"' + optional-no-member: + input: ((f) => (f.append("role", "user"), f.append("plan", "trial"), f))(new FormData()) + error: 'Failed at plan: Expected "free" | "pro", received "trial"' + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("role"),v2=v0.get("plan")||void 0;typeof v1==="string"||e[2](v1);(v1==="admin"||v1==="user")||e[1](v1);if(v2!==void 0){typeof v2==="string"||e[4](v2);(v2==="free"||v2==="pro")||e[3](v2);}return {role:v1,plan:v2}} + examples: + members: + input: ((f) => (f.append("role", "admin"), f.append("plan", "pro"), f))(new FormData()) + output: '{ role: "admin", plan: "pro" }' + encode: + expression: i=>{let v1=i["role"],v2=i["plan"];let v0=new e[0]();typeof v1==="string"&&(v1==="admin"||v1==="user")||e[1](v1);v0.append("role",v1);if(v2!=null){typeof v2==="string"&&(v2==="free"||v2==="pro")||e[2](v2);v0.append("plan",v2);}return v0} + examples: + built: + input: '{ role: "admin", plan: "pro" }' + output: ((f) => (f.append("role", "admin"), f.append("plan", "pro"), f))(new FormData()) + no-member: + input: '{ role: "guest" }' + error: 'Failed at role: Expected "admin" | "user", received "guest"' diff --git a/packages/sury/specs/codec-formdata-object-file.yaml b/packages/sury/specs/codec-formdata-object-file.yaml new file mode 100644 index 000000000..4a517cd97 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-file.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ avatar: S.file.with(S.maxSize, 4), cover: S.optional(S.file), raw: S.blob }))" + input: FormData + output: "{ avatar: File; raw: Blob; cover?: File | undefined; }" + instantiations: 7268 +jsonSchema: + input: Expected JSON, received FormData + output: "Failed at avatar: Expected JSON, received File.size <= 4" +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[8]||e[9](i);let v0=e[0](i),v2,v1=(v2=v0.get("avatar"))&&v2.name===""&&!v2.size?void 0:v2,v4,v3=(v4=v0.get("cover"))&&v4.name===""&&!v4.size?void 0:v4,v6,v5=(v6=v0.get("raw"))&&v6.name===""&&!v6.size?void 0:v6;v1 instanceof e[2]||e[3](v1);v1.size<5||e[1](v1);(v3 instanceof e[4]||v3===void 0)||e[5](v3);v5 instanceof e[6]||e[7](v5);return {avatar:v1,cover:v3,raw:v5}} + examples: + text-where-file: + input: ((f) => (f.append("avatar", "x"), f.append("raw", new File(["r"], "r.bin")), f))(new FormData()) + error: 'Failed at avatar: Expected File.size <= 4, received "x"' + missing-file: + input: ((f) => (f.append("raw", new File(["r"], "r.bin")), f))(new FormData()) + error: "Failed at avatar: Expected File.size <= 4, received undefined" + too-large: + input: ((f) => (f.append("avatar", new File(["12345"], "a.png")), f.append("raw", new File(["r"], "r.bin")), f))(new FormData()) + error: "Failed at avatar: Expected File.size <= 4, received File" + text-where-optional-file: + input: ((f) => (f.append("avatar", new File(["a"], "a.png")), f.append("cover", "x"), f.append("raw", new File(["r"], "r.bin")), f))(new FormData()) + error: 'Failed at cover: Expected File | undefined, received "x"' + decode: + expression: i=>{let v0=e[0](i),v2,v1=(v2=v0.get("avatar"))&&v2.name===""&&!v2.size?void 0:v2,v4,v3=(v4=v0.get("cover"))&&v4.name===""&&!v4.size?void 0:v4,v6,v5=(v6=v0.get("raw"))&&v6.name===""&&!v6.size?void 0:v6;v1 instanceof e[2]||e[3](v1);v1.size<5||e[1](v1);(v3 instanceof e[4]||v3===void 0)||e[5](v3);v5 instanceof e[6]||e[7](v5);return {avatar:v1,cover:v3,raw:v5}} + examples: + text-where-file: + input: ((f) => (f.append("avatar", "x"), f.append("raw", new File(["r"], "r.bin")), f))(new FormData()) + error: 'Failed at avatar: Expected File.size <= 4, received "x"' + encode: + expression: i=>{let v0=i["avatar"],v2=i["cover"];v0.size<5||e[0](v0);let v1=new e[1]();v1.append("avatar",v0);if(v2!=null){v1.append("cover",v2);}v1.append("raw",i["raw"]);return v1} + examples: + built: + input: '{ avatar: new File(["ok"], "a.png", { type: "image/png" }), cover: new File(["c"], "c.png"), raw: new Blob(["r"]) }' + output: '((f) => (f.append("avatar", new File(["ok"], "a.png", { type: "image/png" })), f.append("cover", new File(["c"], "c.png")), f.append("raw", new File(["r"], "blob")), f))(new FormData())' + too-large: + input: '{ avatar: new File(["12345"], "a.png"), raw: new Blob(["r"]) }' + error: "Failed at avatar: Expected File.size <= 4, received File" diff --git a/packages/sury/specs/codec-formdata-object-jsonstring.yaml b/packages/sury/specs/codec-formdata-object-jsonstring.yaml new file mode 100644 index 000000000..1671b78b3 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-jsonstring.yaml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ meta: S.jsonString.with(S.to, S.schema({ a: S.number })) }))" + input: FormData + output: "{ meta: { a: number; }; }" + instantiations: 6508 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { meta: { type: "object", properties: { a: { type: "number" } }, required: ["a"] } }, required: ["meta"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[5]||e[6](i);let v0=e[0](i),v1=v0.get("meta");typeof v1==="string"||e[4](v1);let v2;try{v2=JSON.parse(v1)}catch(t){e[1](v1)}typeof v2==="object"&&v2&&!Array.isArray(v2)||e[3](v2);let v3=v2["a"];typeof v3==="number"&&v3===v3||e[2](v3);return {meta:{a:v3}}} + examples: + nested-document: + input: ((f) => (f.append("meta", "{\"a\":1}"), f))(new FormData()) + output: "{ meta: { a: 1 } }" + not-json: + input: ((f) => (f.append("meta", "nope"), f))(new FormData()) + error: 'Failed at meta: Expected JSON string, received "nope"' + wrong-inner-type: + input: ((f) => (f.append("meta", "{\"a\":\"1\"}"), f))(new FormData()) + error: 'Failed at meta.a: Expected number, received "1"' + missing: + input: new FormData() + error: "Failed at meta: Expected JSON string, received undefined" + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("meta");typeof v1==="string"||e[4](v1);let v2;try{v2=JSON.parse(v1)}catch(t){e[1](v1)}typeof v2==="object"&&v2&&!Array.isArray(v2)||e[3](v2);let v3=v2["a"];typeof v3==="number"&&v3===v3||e[2](v3);return {meta:{a:v3}}} + examples: + nested-document: + input: ((f) => (f.append("meta", "{\"a\":1}"), f))(new FormData()) + output: "{ meta: { a: 1 } }" + encode: + expression: i=>{let v0=i["meta"];let v1=v0["a"];let v2=new e[1]();v2.append("meta","{\"a\":"+(Number.isFinite(v1)?v1:e[0](v1))+"}");return v2} + examples: + non-finite: + input: "{ meta: { a: Infinity } }" + error: "Failed at meta.a: Expected JSON, received Infinity" diff --git a/packages/sury/specs/codec-formdata-object-nullable.yaml b/packages/sury/specs/codec-formdata-object-nullable.yaml new file mode 100644 index 000000000..5372efd42 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-nullable.yaml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: 'S.formData.with(S.to, S.schema({ s: S.nullable(S.string), n: S.nullable(S.number), tag: S.nullable(S.union(["yes", "no"])) }))' + input: FormData + output: '{ s: string | null; n: number | null; tag: "yes" | "no" | null; }' + instantiations: 6876 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { s: { anyOf: [{ type: "string" }, { type: "null" }] }, n: { anyOf: [{ type: "number" }, { type: "null" }] }, tag: { enum: ["yes", "no", null] } }, required: ["s", "n", "tag"] }' + openapi-3.0: + output: '{ type: "object", properties: { s: { type: "string", nullable: true }, n: { type: "number", nullable: true }, tag: { enum: ["yes", "no", null] } }, required: ["s", "n", "tag"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[6]||e[7](i);let v0=e[0](i),v1=v0.get("s")||void 0,v2=v0.get("n")||void 0,v4=v0.get("tag")||void 0;if(v1!==void 0){typeof v1==="string"||e[1](v1);}else{v1=null}if(v2!==void 0){typeof v2==="string"||e[3](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[2](v2);v2=v3;}else{v2=null}if(v4!==void 0){typeof v4==="string"||e[5](v4);(v4==="yes"||v4==="no")||e[4](v4);}else{v4=null}return {s:v1,n:v2,tag:v4}} + examples: + number: + input: ((f) => (f.append("s", "x"), f.append("n", "1"), f.append("tag", "yes"), f))(new FormData()) + output: '{ s: "x", n: 1, tag: "yes" }' + blank-is-null: + input: ((f) => (f.append("s", ""), f.append("n", ""), f.append("tag", ""), f))(new FormData()) + output: "{ s: null, n: null, tag: null }" + absent-is-null: + input: new FormData() + output: "{ s: null, n: null, tag: null }" + the-text-null: + input: ((f) => (f.append("n", "null"), f))(new FormData()) + error: 'Failed at n: Expected number, received "null"' + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("s")||void 0,v2=v0.get("n")||void 0,v4=v0.get("tag")||void 0;if(v1!==void 0){typeof v1==="string"||e[1](v1);}else{v1=null}if(v2!==void 0){typeof v2==="string"||e[3](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[2](v2);v2=v3;}else{v2=null}if(v4!==void 0){typeof v4==="string"||e[5](v4);(v4==="yes"||v4==="no")||e[4](v4);}else{v4=null}return {s:v1,n:v2,tag:v4}} + examples: + blank-is-null: + input: ((f) => (f.append("s", ""), f.append("n", ""), f.append("tag", ""), f))(new FormData()) + output: "{ s: null, n: null, tag: null }" + encode: + expression: i=>{let v1=i["s"],v2=i["n"],v3=i["tag"];let v0=new e[0]();if(v1!=null){v0.append("s",v1);}if(v2!=null){v0.append("n",""+v2);}if(v3!=null){typeof v3==="string"&&(v3==="yes"||v3==="no")||e[1](v3);v0.append("tag",v3);}return v0} + examples: + built: + input: '{ s: "x", n: 1, tag: "yes" }' + output: ((f) => (f.append("s", "x"), f.append("n", "1"), f.append("tag", "yes"), f))(new FormData()) + not-a-member: + input: '{ s: null, n: null, tag: "maybe" }' + error: 'Failed at tag: Expected "yes" | "no", received "maybe"' diff --git a/packages/sury/specs/codec-formdata-object-optional.yaml b/packages/sury/specs/codec-formdata-object-optional.yaml new file mode 100644 index 000000000..ad3773be3 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-optional.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ nick: S.optional(S.string.with(S.maxLength, 3)), age: S.optional(S.number, 18), bio: S.string.with(S.minLength, 0), note: S.optional(S.string.with(S.minLength, 0)) }))" + input: FormData + output: "{ age: number; bio: string; nick?: string | undefined; note?: string | undefined; }" + instantiations: 8029 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { nick: { type: "string", maxLength: 3 }, age: { type: "number" }, bio: { type: "string" }, note: { type: "string" } }, required: ["age", "bio"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[8]||e[9](i);let v0=e[0](i),v1=v0.get("nick")||void 0,v2=v0.get("age")||void 0,v4=v0.get("bio"),v5=v0.get("note")||void 0;if(v1!==void 0){typeof v1==="string"||e[3](v1);v1.length<4||e[1](v1)<4||e[2](v1);}if(v2!==void 0){typeof v2==="string"||e[5](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[4](v2);v2=v3;}else{v2=18;}typeof v4==="string"||e[6](v4);if(v5!==void 0){typeof v5==="string"||e[7](v5);}return {nick:v1,age:v2,bio:v4,note:v5}} + examples: + all-present: + input: ((f) => (f.append("nick", "nn"), f.append("age", "42"), f.append("bio", "hi"), f.append("note", "n"), f))(new FormData()) + output: '{ nick: "nn", age: 42, bio: "hi", note: "n" }' + all-empty: + input: ((f) => (f.append("nick", ""), f.append("age", ""), f.append("bio", ""), f.append("note", ""), f))(new FormData()) + output: '{ nick: undefined, age: 18, bio: "", note: undefined }' + all-absent: + input: ((f) => (f.append("bio", "hi"), f))(new FormData()) + output: '{ nick: undefined, age: 18, bio: "hi", note: undefined }' + empty-required: + input: ((f) => (f.append("nick", "nn"), f.append("age", "42"), f.append("note", "n"), f))(new FormData()) + error: "Failed at bio: Expected string, received undefined" + not-a-number: + input: ((f) => (f.append("age", "x"), f.append("bio", "hi"), f))(new FormData()) + error: 'Failed at age: Expected number, received "x"' + too-long: + input: ((f) => (f.append("nick", "long"), f.append("bio", "hi"), f))(new FormData()) + error: 'Failed at nick: Expected string.length <= 3, received "long"' + file-where-text: + input: ((f) => (f.append("nick", new File(["nn"], "a.txt")), f.append("bio", "hi"), f))(new FormData()) + error: "Failed at nick: Expected string.length <= 3, received File" + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("nick")||void 0,v2=v0.get("age")||void 0,v4=v0.get("bio"),v5=v0.get("note")||void 0;if(v1!==void 0){typeof v1==="string"||e[3](v1);v1.length<4||e[1](v1)<4||e[2](v1);}if(v2!==void 0){typeof v2==="string"||e[5](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[4](v2);v2=v3;}else{v2=18;}typeof v4==="string"||e[6](v4);if(v5!==void 0){typeof v5==="string"||e[7](v5);}return {nick:v1,age:v2,bio:v4,note:v5}} + examples: + all-empty: + input: ((f) => (f.append("nick", ""), f.append("age", ""), f.append("bio", ""), f.append("note", ""), f))(new FormData()) + output: '{ nick: undefined, age: 18, bio: "", note: undefined }' + encode: + expression: i=>{let v0=i["age"],v2=i["nick"],v3=i["note"];typeof v0==="number"&&v0===v0||e[0](v0);let v1=new e[1]();if(v2!=null){v1.append("nick",v2);}v1.append("age",""+v0);v1.append("bio",i["bio"]);if(v3!=null){v1.append("note",v3);}return v1} + examples: + missing-defaulted: + input: '{ nick: "n", bio: "hi" }' + error: "Failed at age: Expected number, received undefined" diff --git a/packages/sury/specs/codec-formdata-object-strict.yaml b/packages/sury/specs/codec-formdata-object-strict.yaml new file mode 100644 index 000000000..e5279dc09 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object-strict.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.schema({ a: S.string }).with(S.strict))" + input: FormData + output: "{ a: string; }" + instantiations: 5671 +jsonSchema: + input: Expected JSON, received FormData + output: '{ type: "object", properties: { a: { type: "string" } }, additionalProperties: false, required: ["a"] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + creationError: "SuryError: S.strict is not supported by S.formData. Use S.strip" + decode: eq-to-parse + encode: eq-to-parse diff --git a/packages/sury/specs/codec-formdata-object.yaml b/packages/sury/specs/codec-formdata-object.yaml new file mode 100644 index 000000000..cebc10ac3 --- /dev/null +++ b/packages/sury/specs/codec-formdata-object.yaml @@ -0,0 +1,61 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: 'S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty), age: S.number.with(S.gte, 18), agree: S.boolean, kind: "signup", since: S.date }))' + input: FormData + output: '{ name: string; age: number; agree: boolean; kind: "signup"; since: Date; }' + instantiations: 7235 +jsonSchema: + input: Expected JSON, received FormData + output: "Failed at since: Expected JSON, received Date" +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[10]||e[11](i);let v0=e[0](i),v1=v0.get("name"),v2=v0.get("age"),v4=v0.get("agree"),v6=v0.get("kind"),v7=v0.get("since");typeof v1==="string"||e[2](v1);v1.length>0||e[1](v1);typeof v2==="string"||e[5](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[4](v2);v3>=18||e[3](v3);let v5;(v5=v4==="on"||v4==="true"||v4==="1")||v4==="false"||v4==="0"||!v4||e[6](v4);v6==="signup"||e[7](v6);typeof v7==="string"||e[9](v7);let v8=new Date(v7);!Number.isNaN(v8.getTime())||e[8](v8);return {name:v1,age:v3,agree:v5,kind:v6,since:v8}} + examples: + valid: + input: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + output: '{ name: "Ann", age: 42, agree: true, kind: "signup", since: new Date("2024-01-01T00:00:00.000Z") }' + blank-name-rejected: + input: ((f) => (f.append("name", ""), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: 'Failed at name: Expected string.length >= 1, received ""' + missing-field: + input: ((f) => (f.append("name", "Ann"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: "Failed at age: Expected string, received undefined" + not-a-number: + input: ((f) => (f.append("name", "Ann"), f.append("age", "x"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: 'Failed at age: Expected number >= 18, received "x"' + too-young: + input: ((f) => (f.append("name", "Ann"), f.append("age", "7"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: "Failed at age: Expected number >= 18, received 7" + not-a-boolean: + input: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "yes"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: 'Failed at agree: Expected boolean, received "yes"' + wrong-literal: + input: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "login"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: 'Failed at kind: Expected "signup", received "login"' + not-a-date: + input: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "nope"), f))(new FormData()) + error: "Failed at since: Expected Date, received invalid Date" + not-a-form: + input: '{ name: "Ann" }' + error: 'Expected FormData, received { name: "Ann"; }' + decode: + expression: i=>{let v0=e[0](i),v1=v0.get("name"),v2=v0.get("age"),v4=v0.get("agree"),v6=v0.get("kind"),v7=v0.get("since");typeof v1==="string"||e[2](v1);v1.length>0||e[1](v1);typeof v2==="string"||e[5](v2);let v3=+v2;v3===v3&&(v3||v2.trim())||e[4](v2);v3>=18||e[3](v3);let v5;(v5=v4==="on"||v4==="true"||v4==="1")||v4==="false"||v4==="0"||!v4||e[6](v4);v6==="signup"||e[7](v6);typeof v7==="string"||e[9](v7);let v8=new Date(v7);!Number.isNaN(v8.getTime())||e[8](v8);return {name:v1,age:v3,agree:v5,kind:v6,since:v8}} + examples: + valid: + input: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + output: '{ name: "Ann", age: 42, agree: true, kind: "signup", since: new Date("2024-01-01T00:00:00.000Z") }' + blank-name-rejected: + input: ((f) => (f.append("name", ""), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + error: 'Failed at name: Expected string.length >= 1, received ""' + encode: + expression: i=>{let v0=i["name"],v1=i["age"],v4=i["since"];v0.length>0||e[0](v0);v1>=18||e[1](v1);let v2=new e[2]();v2.append("name",v0);v2.append("age",""+v1);if(i["agree"]){v2.append("agree","on")}v2.append("kind","signup");let v3;try{v3=v4.toISOString()}catch(_){e[3](v4)}v2.append("since",v3);return v2} + examples: + built: + input: '{ name: "Ann", age: 42, agree: true, kind: "signup", since: new Date("2024-01-01T00:00:00.000Z") }' + output: ((f) => (f.append("name", "Ann"), f.append("age", "42"), f.append("agree", "on"), f.append("kind", "signup"), f.append("since", "2024-01-01T00:00:00.000Z"), f))(new FormData()) + too-young: + input: '{ name: "Ann", age: 7, agree: true, kind: "signup", since: new Date("2024-01-01T00:00:00.000Z") }' + error: "Failed at age: Expected number >= 18, received 7" diff --git a/packages/sury/specs/codec-formdata-union-unsupported.yaml b/packages/sury/specs/codec-formdata-union-unsupported.yaml new file mode 100644 index 000000000..1578e2c4d --- /dev/null +++ b/packages/sury/specs/codec-formdata-union-unsupported.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.formData.with(S.to, S.union([S.schema({ a: S.string }), S.schema({ b: S.string })]))" + input: FormData + output: "{ a: string; } | { b: string; }" + instantiations: 7722 +jsonSchema: + input: Expected JSON, received FormData + output: '{ anyOf: [{ type: "object", properties: { a: { type: "string" } }, required: ["a"] }, { type: "object", properties: { b: { type: "string" } }, required: ["b"] }] }' +vs: + zod: + _skip: not-applicable +operations: + parse: + creationError: "SuryError: Can't decode FormData to { a: string; } | { b: string; }. Use S.to to define a custom decoder" + decode: eq-to-parse + encode: + expression: i=>{for(;;){let r;if(typeof i==="object"&&i&&!Array.isArray(i)){try{let v0=i["a"];typeof v0==="string"||e[0](v0);let v1=new e[1]();v1.append("a",v0);i=v1;break}catch(x){(r||(r=[])).push(e[4](x))}try{let v2=i["b"];typeof v2==="string"||e[2](v2);let v3=new e[3]();v3.append("b",v2);i=v3;break}catch(x){(r||(r=[])).push(e[4](x))}}e[5](i,...(r||[]))}return i} + examples: + no-variant-matches: + input: "{ c: 1 }" + error: |- + Expected { a: string; } | { b: string; }, received { c: 1; } + - At a: Expected string, received undefined + - At b: Expected string, received undefined diff --git a/packages/sury/specs/codec-jsonstring-file-slots.yaml b/packages/sury/specs/codec-jsonstring-file-slots.yaml index 02412f8e6..718d4e101 100644 --- a/packages/sury/specs/codec-jsonstring-file-slots.yaml +++ b/packages/sury/specs/codec-jsonstring-file-slots.yaml @@ -22,6 +22,9 @@ operations: decode: expression: i=>{let v0;try{v0=JSON.parse(i)}catch(t){e[0](i)}typeof v0==="string"||e[5](v0);e[3](v0)||e[4](v0);return new e[2]([e[1](v0)],"")} examples: + the-payload: + input: '"\"aGk=\""' + output: new File(["hi"], "") not-a-document: input: '"aGk="' error: Expected JSON string, received "aGk=" diff --git a/packages/sury/specs/codec-jsonstring-object-file.yaml b/packages/sury/specs/codec-jsonstring-object-file.yaml index b85d2a9e0..ad56194d5 100644 --- a/packages/sury/specs/codec-jsonstring-object-file.yaml +++ b/packages/sury/specs/codec-jsonstring-object-file.yaml @@ -16,6 +16,9 @@ operations: parse: expression: i=>{typeof i==="string"||e[7](i);let v0;try{v0=JSON.parse(i)}catch(t){e[0](i)}typeof v0==="object"&&v0&&!Array.isArray(v0)||e[6](v0);let v1=v0["avatar"];typeof v1==="string"||e[5](v1);e[3](v1)||e[4](v1);return {avatar:new e[2]([e[1](v1)],"")}} examples: + the-bytes: + input: '"{\"avatar\":\"aGk=\"}"' + output: '{ avatar: new File(["hi"], "") }' not-a-document: input: '"nope"' error: Expected JSON string, received "nope" @@ -25,6 +28,9 @@ operations: decode: expression: i=>{let v0;try{v0=JSON.parse(i)}catch(t){e[0](i)}typeof v0==="object"&&v0&&!Array.isArray(v0)||e[6](v0);let v1=v0["avatar"];typeof v1==="string"||e[5](v1);e[3](v1)||e[4](v1);return {avatar:new e[2]([e[1](v1)],"")}} examples: + the-bytes: + input: '"{\"avatar\":\"aGk=\"}"' + output: '{ avatar: new File(["hi"], "") }' not-base64: input: '"{\"avatar\":\"!!\"}"' error: 'Failed at avatar: Expected base64, received "!!"' diff --git a/packages/sury/specs/codec-nan-union2-exact.yaml b/packages/sury/specs/codec-nan-union2-exact.yaml index 9bb650374..594de4792 100644 --- a/packages/sury/specs/codec-nan-union2-exact.yaml +++ b/packages/sury/specs/codec-nan-union2-exact.yaml @@ -22,4 +22,4 @@ operations: error: Expected NaN, received 1 decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert NaN | string to NaN — NaN has the same type as the target and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from NaN | string to NaN. Use S.to(from, to, {decode, encode}), or S.never on an arm" diff --git a/packages/sury/specs/codec-optional-nullable-partial.yaml b/packages/sury/specs/codec-optional-nullable-partial.yaml index 4708da423..17736ec8e 100644 --- a/packages/sury/specs/codec-optional-nullable-partial.yaml +++ b/packages/sury/specs/codec-optional-nullable-partial.yaml @@ -14,7 +14,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert string | undefined to boolean | null — string has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode string | undefined to boolean | null. Use S.to to define a custom decoder" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert boolean | null to string | undefined — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode boolean | null to string | undefined. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-string-optional-partial.yaml b/packages/sury/specs/codec-string-optional-partial.yaml index dfe8b71fc..3efcc1169 100644 --- a/packages/sury/specs/codec-string-optional-partial.yaml +++ b/packages/sury/specs/codec-string-optional-partial.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert string to string | undefined — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from string to string | undefined. Use S.to(from, to, {decode, encode}), or S.never on an arm" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert string | undefined to string — string has the same type as the target and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from string | undefined to string. Use S.to(from, to, {decode, encode}), or S.never on an arm" diff --git a/packages/sury/specs/codec-string-union2-partial.yaml b/packages/sury/specs/codec-string-union2-partial.yaml index ed0832353..cfedeeae9 100644 --- a/packages/sury/specs/codec-string-union2-partial.yaml +++ b/packages/sury/specs/codec-string-union2-partial.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert string to number | string — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from string to number | string. Use S.to(from, to, {decode, encode}), or S.never on an arm" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert number | string to string — string has the same type as the target and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from number | string to string. Use S.to(from, to, {decode, encode}), or S.never on an arm" diff --git a/packages/sury/specs/codec-union-unreachable-nullish-bridge.yaml b/packages/sury/specs/codec-union-unreachable-nullish-bridge.yaml index 23520d096..cabec0de5 100644 --- a/packages/sury/specs/codec-union-unreachable-nullish-bridge.yaml +++ b/packages/sury/specs/codec-union-unreachable-nullish-bridge.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert never | string to undefined | string — undefined has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode never | string to undefined | string. Use S.to to define a custom decoder" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert undefined | string to null | string — undefined has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode undefined | string to null | string. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-union2-string-partial.yaml b/packages/sury/specs/codec-union2-string-partial.yaml index af7049216..1b61bd042 100644 --- a/packages/sury/specs/codec-union2-string-partial.yaml +++ b/packages/sury/specs/codec-union2-string-partial.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert number | string to string — string has the same type as the target and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from number | string to string. Use S.to(from, to, {decode, encode}), or S.never on an arm" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert string to number | string — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from string to number | string. Use S.to(from, to, {decode, encode}), or S.never on an arm" diff --git a/packages/sury/specs/codec-union2-union2-reject.yaml b/packages/sury/specs/codec-union2-union2-reject.yaml index 820da6b8c..5fd50ef73 100644 --- a/packages/sury/specs/codec-union2-union2-reject.yaml +++ b/packages/sury/specs/codec-union2-union2-reject.yaml @@ -22,4 +22,4 @@ operations: output: '"ok"' decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert never | string to number | string — number has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode never | string to number | string. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-union2-union3-extra-target.yaml b/packages/sury/specs/codec-union2-union3-extra-target.yaml index 82caadfa6..11d752471 100644 --- a/packages/sury/specs/codec-union2-union3-extra-target.yaml +++ b/packages/sury/specs/codec-union2-union3-extra-target.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert string | number to number | string | boolean — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode string | number to number | string | boolean. Use S.to to define a custom decoder" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert number | string | boolean to string | number — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode number | string | boolean to string | number. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-union3-union2-extra-source.yaml b/packages/sury/specs/codec-union3-union2-extra-source.yaml index ba11bdb7b..979efb419 100644 --- a/packages/sury/specs/codec-union3-union2-extra-source.yaml +++ b/packages/sury/specs/codec-union3-union2-extra-source.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert string | number | boolean to number | string — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode string | number | boolean to number | string. Use S.to to define a custom decoder" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert number | string to string | number | boolean — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode number | string to string | number | boolean. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/codec-union3-union2-json.yaml b/packages/sury/specs/codec-union3-union2-json.yaml index cd89a3d3b..c461b3c2c 100644 --- a/packages/sury/specs/codec-union3-union2-json.yaml +++ b/packages/sury/specs/codec-union3-union2-json.yaml @@ -12,7 +12,7 @@ vs: _skip: not-applicable operations: parse: - creationError: "SuryError: Invalid operation: can't convert bigint to JSON | bigint — bigint has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Ambiguous conversion from bigint to JSON | bigint. Use S.to(from, to, {decode, encode}), or S.never on an arm" decode: eq-to-parse encode: - creationError: "SuryError: Invalid operation: can't convert JSON | bigint to string | number | bigint — JSON has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable" + creationError: "SuryError: Can't decode JSON | bigint to string | number | bigint. Use S.to to define a custom decoder" diff --git a/packages/sury/specs/dict-to-object-optional-string.yaml b/packages/sury/specs/dict-to-object-optional-string.yaml new file mode 100644 index 000000000..3b2835120 --- /dev/null +++ b/packages/sury/specs/dict-to-object-optional-string.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: "S.record(S.string).with(S.to, S.schema({ a: S.optional(S.string) }))" + input: "{ [x: string]: string; }" + output: "{ a?: string | undefined; }" + instantiations: 6342 +jsonSchema: + input: '{ type: "object", additionalProperties: { type: "string" } }' + output: '{ type: "object", properties: { a: { type: "string" } } }' +vs: + zod: + _skip: not-applicable +operations: + parse: + creationError: "SuryError: Failed at a: Ambiguous conversion from string to string | undefined. Use S.to(from, to, {decode, encode}), or S.never on an arm" + decode: eq-to-parse + encode: + creationError: "SuryError: Failed at a: Ambiguous conversion from string | undefined to string. Use S.to(from, to, {decode, encode}), or S.never on an arm" diff --git a/packages/sury/specs/formdata.yaml b/packages/sury/specs/formdata.yaml new file mode 100644 index 000000000..6254796f1 --- /dev/null +++ b/packages/sury/specs/formdata.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=./spec.schema.json +ts: + schema: S.formData + input: FormData + output: FormData + instantiations: 265 +jsonSchema: + input: Expected JSON, received FormData + output: Expected JSON, received FormData +vs: + zod: + _skip: not-applicable +operations: + parse: + expression: i=>{i instanceof e[0]||e[1](i);return i} + examples: + valid: + input: new FormData() + output: new FormData() + plain-object: + input: "{}" + error: Expected FormData, received {} + string: + input: '"a=1"' + error: Expected FormData, received "a=1" + null-value: + input: "null" + error: Expected FormData, received null + decode: identity + encode: identity diff --git a/packages/sury/specs/string-to-blob.yaml b/packages/sury/specs/string-to-blob.yaml index 22feae188..e66f6fa06 100644 --- a/packages/sury/specs/string-to-blob.yaml +++ b/packages/sury/specs/string-to-blob.yaml @@ -16,12 +16,18 @@ operations: parse: expression: i=>{typeof i==="string"||e[1](i);return new e[0]([i])} examples: + the-text: + input: '"hi"' + output: new Blob(["hi"]) not-a-string: input: "42" error: Expected string, received 42 decode: expression: i=>{return new e[0]([i])} examples: + the-text: + input: '"hi"' + output: new Blob(["hi"]) not-a-part: input: '{ toString: () => { throw new Error("boom") } }' error: boom diff --git a/packages/sury/src/S.res b/packages/sury/src/S.res index bf1dcf019..e00d9db61 100644 --- a/packages/sury/src/S.res +++ b/packages/sury/src/S.res @@ -469,6 +469,11 @@ type blob = Js.Blob.t @module("sury") external blob: t = "blob" type file = Js.File.t @module("sury") external file: t = "file" +// The stdlib has no FormData module, so the type is declared here, abstract: +// a value from `%raw`, a fetch binding or a form event unifies with it only +// through a cast, the way an external's own abstract types do. +type formData +@module("sury") external formData: t = "formData" @unboxed type isoDateTime = IsoDateTime(string) @module("sury") external isoDateTime: t = "isoDateTime" @unboxed type utcDateTime = UtcDateTime(string) diff --git a/packages/sury/src/advanced/file.ts b/packages/sury/src/advanced/file.ts index f349c0251..71353c558 100644 --- a/packages/sury/src/advanced/file.ts +++ b/packages/sury/src/advanced/file.ts @@ -11,7 +11,6 @@ import { instanceTag, type Internal, openApi30, - panic, setContent, tagFlags, U, @@ -30,7 +29,8 @@ import { } from "../builder"; import type { JSONSchemaT } from "../jsonschema"; import { - instanceDecoder + instanceDecoder, + unsupportedInstance } from "../parse"; import { openedText, @@ -41,25 +41,6 @@ import { bytesTarget } from "../refinements"; -// On a runtime that has no such global there is no schema to be had, so `class` -// reports that instead of sitting there as `undefined` for its readers to -// dereference. Every route into the schema goes through `class` — the decoder's -// `instanceof`, the rendering and the JSON Schema emit via `.name`, and -// `copySchema`'s `Object.assign` for `.with(…)` and `reverse` — so all of them -// answer with this one sentence rather than a TypeError, or worse, a schema -// that builds and fails later — converting a schema that only decodes to one -// included, since the encode-reverse copies the target to get there. -// -// Enumerable, so the `Object.assign` copy is one of the routes it covers. -// `console.log` still works: `util.inspect` shows an accessor rather than -// invoking it. -const unsupported = (s: Internal, name: string): void => { - Object.defineProperty(s, "class", { - enumerable: true, - get: () => panic(`S.${name} is not supported in this runtime`), - }); -}; - // No `type`: octets have none, so the carrier that decodes to a blob is the // side with a type to give and this only says what it carries. `minSize` and // `maxSize` stay off — neither dialect bounds a byte count, and `minLength` @@ -156,7 +137,7 @@ const binarySchema = (name: string, global: string, nameArg: string): Internal = setContent(s, base64Content); s.jsonSchema = binaryJSONSchema; if (s.class === U) { - unsupported(s, name); + unsupportedInstance(s, name); } s.encoder = (input, target) => { diff --git a/packages/sury/src/advanced/formData.ts b/packages/sury/src/advanced/formData.ts new file mode 100644 index 000000000..9df0d5e67 --- /dev/null +++ b/packages/sury/src/advanced/formData.ts @@ -0,0 +1,669 @@ +// `S.formData` — a form submission as a browser or `Request.formData()` hands +// it over, and the body a `fetch` call sends. An entry is a string or a +// `File`, so an object schema reads its fields through the string coercions +// the env pattern already compiles (`"42"` -> 42), a file field takes the entry +// as it is, and a repeated key is an array. Nothing reads file bytes, so both +// directions are sync. +// +// Not on the content axis (CONTENT_CODEC_SPEC.md): a form has no JSON document +// form and no format opens into one, so a link to it never has two readings, +// and a `FormData` in a JSON position has no document, the way `S.blob` has +// none. Bracket notation (`user[name]`) is deliberately out — a nested value +// travels as a `S.jsonString.with(S.to, …)` field. + +import { + anyOfTag, + arrayTag, + copySchema, + inlinedValueFromString, + instanceTag, + initSchema, + type Internal, + isOptional, + nullTag, + pathConcat, + setHas, + tagFlags, + type Tag, + U, + undefinedTag, + unknown, + unknownTag, + type Val +} from "../base"; +import { + _var, + B_addObjectField, + B_dynamicScope, + B_embed, + B_embedInvalidInput, + B_failWithArg, + B_hoistDecl, + B_invalidInputBuilder, + B_markOutput, + B_refine, + B_merge, + B_mergeWithPathPrepend, + B_invalidOperation, + B_next, + B_scope, + B_unsupportedDecode, + B_varWithoutAllocation +} from "../builder"; +import { + arrayFactory, + completeObjectVal, + makeObjectVal, + valGet +} from "../composites"; +import { + instanceDecoder, + parse, + unsupportedInstance +} from "../parse"; +import { + string +} from "../primitives"; + +const isBlobClass = (class_: unknown): boolean => { + const blobClass = (globalThis as { Blob?: unknown }).Blob as + | (abstract new () => unknown) + | undefined; + return ( + blobClass !== U && + class_ !== U && + (class_ === blobClass || (class_ as { prototype?: unknown }).prototype instanceof blobClass) + ); +}; + +// What a supplied entry converts to: the union's arms minus the two a blank +// field produces. Rebuilt from the union's own pieces rather than through +// unionFactory, so `S.formData` doesn't carry the union compiler for a form +// that never has an optional field. +const presentArm = (schema: Internal): Internal => { + if (schema.type !== anyOfTag) { + return schema; + } + const present: Internal[] = []; + const has: Partial> = {}; + for (const variant of schema.anyOf!) { + if (variant.type !== undefinedTag && variant.type !== nullTag) { + present.push(variant); + setHas(has, variant.type); + } + } + if (present.length === 1) { + return present[0]!; + } + const mut = copySchema(schema); + mut.anyOf = present; + mut.has = has; + return mut; +}; + +// A boolean field can only be a checkbox — nothing else a browser sends is one +// — so it reads the way a checkbox submits: absent is unchecked, and a present +// entry is `"on"`, or the `"true"`/`"false"` a hidden input carries. True of a +// boolean however it is wrapped: `S.optional(S.boolean, false)` is the natural +// spelling of "checkbox, default unchecked", and its entry is still `"on"`. +// A boolean literal is one too — `S.schema(true)` is the terms-and-conditions +// box, which submits `"on"` like any other and must be checked. +const isCheckbox = (schema: Internal): boolean => + schema.type === anyOfTag + ? schema.anyOf!.every((variant) => variant.type === undefinedTag || isCheckbox(variant)) + : (tagFlags[schema.type]! & 8) !== 0; + +// Whether the schema states what a blank entry means. A form always submits a +// text input, so `""` is what a user leaving one alone sends — and a bare +// `S.string` is silent about whether that is a value or a missing field. These +// are the ways a schema answers: a lower length bound (`S.nonEmpty` rejects it, +// `S.minLength(0)` admits it), a literal, a named format — 30 of the 36 reject +// `""` and the rest, like `S.jsonPointer`, admit it deliberately — a pattern +// that rejects it, or a conversion whose far end decides (`S.to(S.date)`). +const decidesBlank = (schema: Internal): boolean => + schema.minLength !== U || + schema.const !== U || + schema.format !== U || + schema.to !== U || + (schema.pattern !== U && !schema.pattern.test("")); + +// A blob takes the entry as it is, and so does `unknown`. Everything else on +// the wire is text, so it reads through a `string` stage: the entry is checked +// to be one, and the target's own decoder coerces from there, exactly as it +// does from `S.record(S.string)`. +const takesEntry = (schema: Internal): boolean => + schema.type === unknownTag || + (schema.type === instanceTag && isBlobClass(schema.class)); + +// What a field's own `.to` converts from, so a reader that assembles the value +// itself can hand the parse loop something still owing that conversion. +const beforeTo = (schema: Internal): Internal => { + if (schema.to === U) { + return schema; + } + const mut = copySchema(schema); + // `delete`, not `= U`: `unionIsTransparent` counts a schema's keys, and a + // key left present with an undefined value stops every union flattening. + delete mut.to; + return mut; +}; + +// The entry list as a lookup, in one pass. A key that repeats holds an array, +// which is what lets a field declared as one value report the two it got +// (`Expected string, received ["a", "b"]`) instead of silently taking the +// first — `get` answers the first and says nothing. A `Map`, not an object: +// the keys are whatever the client sent, and `__proto__` is one of them. +// +// Measured against `get` per field on node 22: 0.30µs vs 0.22µs at 3 fields, +// level at 15, and 1.5x faster at 30 — `get` and `getAll` each scan the whole +// list, so per-field reads go quadratic while this stays linear. +// +// A `Object.create(null)` lookup was measured too, and is faster only for a +// large form of one type (2.1µs vs 2.6µs at 40 string fields). It loses where +// forms actually sit: 1.5x slower on six mixed fields, and 1.5x on a form with +// a repeated key, since overwriting a slot with the array changes the object's +// shape. A `Map` has no shape to change. +const readEntries = (formData: FormData): Map => { + const entries = new Map(); + for (const [key, value] of formData as unknown as Iterable<[string, unknown]>) { + const prev = entries.get(key); + prev === U + ? entries.set(key, value) + : Array.isArray(prev) + ? prev.push(value) + : entries.set(key, [prev, value]); + } + return entries; +}; + +// A key's entries as a list. One entry is a one-item list and none is an empty +// one, since a form has no other way to send either. `asOptionalList` is the +// same read for a field that can be absent, which is the only way it has to +// say "no list at all". +const asList = (value: unknown): unknown[] => + value === U ? [] : Array.isArray(value) ? value : [value]; +const asOptionalList = (value: unknown): unknown[] | undefined => + value === U ? U : Array.isArray(value) ? value : [value]; + +// One entry of the list — a string or a `File` — as a schema, so the rules for +// reading one live on it rather than in a per-field inspection. `parse` +// consults a source's encoder hook once per arm of a union target, so a +// `S.union([S.boolean, S.number])` field gets the checkbox reading on its +// boolean arm and the text coercion on its number arm, which a rule applied at +// field level could never reach. +// +// Named, and instance-tagged so no text target shares its type: a same-typed +// arm would be taken as a pass-through and the hook never consulted. +const formDataField: Internal = /* @__PURE__ */ initSchema(instanceTag, instanceDecoder, (s) => { + s.name = "form field"; +}); +formDataField.encoder = (input: Val, target: Internal): Val => { + const flag = tagFlags[target.type]!; + if (flag & 8) { + return readCheckbox(input, target); + } + if (flag & 256) { + // A union of text arms checks the entry once and dispatches on the value, + // which is what a bare enum wants and what a per-arm check would repeat. + // Anything else declines, so the compiler dispatches and calls this hook + // once per arm. + return target.anyOf!.every((variant) => tagFlags[variant.type]! & 2) + ? asText(input, target) + : input; + } + if (flag & (64 | 128)) { + // An entry is one value, so no structure fits in it. Reported from here, + // where the pair still names the form field — the text stage below would + // otherwise report a `string` the schema never mentioned. + return B_unsupportedDecode(input, formDataField, target); + } + // A blob takes the entry as it is, and `undefined`/`null` are the sentinels a + // union carries for an absent one. A string-tagged target checks the entry + // itself, and reads it as its own document where it is a format — a `string` + // stage in front would escape it into a JSON string value instead. In all + // three `unknown` is the source that leaves the target's own check the one + // that runs. + return takesEntry(target) || (flag & (2 | 16 | 32)) + ? B_refine(input, unknown, U, target) + : asText(input, target); +}; + +// The entry checked to be a string, with the target's own decoder reading it +// from there. +const asText = (input: Val, target: Internal): Val => + B_refine(parse(B_refine(input, unknown, U, string)), string, U, target); + +// A repeated key is how a form carries an array, and `getAll` is its read. +// A repeated key is positional, so every array-tagged target reads the same +// way — a tuple is the fixed-length case, and its own checks report a list of +// the wrong length. +const isList = (schema: Internal): boolean => schema.type === arrayTag; + +// Every schema a list's items can take: the rest item, the fixed slots, or both. +const listItems = (schema: Internal): Internal[] => { + const rest = schema.additionalItems; + return schema.items!.concat(typeof rest === "object" ? [rest] : []); +}; + +// Every field decision, taken once off the target: `present` is what a supplied +// entry converts to, and the rest say how the entry is read. They are read +// together because they interact — a `S.array(S.file)` is a list whose *item* +// takes the entry, which is not the same question as the field taking one. +type Field = { + optional: boolean; + // A `null` arm makes a blank entry `null`, the way an `undefined` one makes + // it absent — both are a schema saying what an empty input means, so both + // answer the blank question and neither is ambiguous. + nullable: boolean; + present: Internal; + checkbox: boolean; +}; + +const classify = (schema: Internal): Field => { + const present = presentArm(schema); + return { + optional: isOptional(schema), + nullable: schema.type === anyOfTag && !!schema.has![nullTag], + present, + checkbox: isCheckbox(present), + }; +}; + +// The value the parse loop continues from, for a reader that assembled it +// rather than compiling one: `s` still owes the field's own `.to`, so the loop +// runs it instead of dropping it. +const assembled = (item: Val, schema: Internal, code: string, resultVar: string): Val => { + const output = B_next(item, resultVar, beforeTo(schema), schema); + output.v = _var; + output.io = true; + output.cp = code; + return parse(B_markOutput(output, item)); +}; + +// One arm of a possibly-absent entry, compiled on a scope of the field's own +// val and written back into `into` — the reader's result var, which is not +// always the val's own: a checkbox assembles its boolean elsewhere. +const armCode = (item: Val, source: Internal, target: Internal, into: string): string => { + const armIn = B_scope(item); + armIn.io = false; + armIn.s = source; + armIn.e = target; + const armOut = parse(armIn); + return B_merge(armOut) + (armOut.i === into ? "" : `${into}=${armOut.i};`); +}; + +// What a blank entry becomes. A `null` arm makes it `null`; an `undefined` one +// leaves the var alone, which is already absent, and runs that arm's own chain +// — where `S.optional(x, default)` keeps its default. A field with both takes +// the optional reading, since absence is the weaker claim. +const absentCode = (item: Val, field: Field, schema: Internal, into: string): string => { + if (!field.optional) { + return field.nullable ? `else{${into}=null}` : ""; + } + const absent = schema.anyOf!.find((variant) => variant.type === undefinedTag)!; + return absent.to === U ? "" : `else{${armCode(item, absent, absent, into)}}`; +}; + +// A possibly-absent entry, each arm converted on its own. The present one +// converts to the field's present arm rather than to the whole optional: a +// string reaching `X | undefined` would be routed through the union rules, +// which reject `string | undefined` outright and otherwise dispatch on the text +// `"undefined"`. +const readOptional = ( + item: Val, + field: Field, + schema: Internal, + source: Internal, + target: Internal, +): Val => + assembled( + item, + schema, + `if(${item.i}!==void 0){${armCode(item, source, target, item.i)}}${absentCode( + item, + field, + schema, + item.i, + )}`, + item.i, + ); + +// One entry read as a checkbox: `"on"` is what a checked box with no `value` +// attribute submits, the rest are the hidden-input spellings, and anything +// falsy (absent, `null`, the `""` of a box carrying an empty value) is an +// unchecked box. +const readCheckbox = (input: Val, target: Internal): Val => { + const v = input.i; + const outputVar = B_varWithoutAllocation(input.g); + const output = B_next(input, outputVar, target, target); + output.v = _var; + output.io = true; + output.cp = `let ${outputVar};(${outputVar}=${v}==="on"||${v}==="true"||${v}==="1")||${v}==="false"||${v}==="0"||!${v}||${B_embedInvalidInput(input, target)};`; + return B_markOutput(output, input); +}; + +const readCheckboxField = (item: Val, field: Field, schema: Internal): Val => { + const v = item.i; + const outputVar = B_varWithoutAllocation(item.g); + // `"on"` is the entry a checked box with no `value` attribute submits; the + // rest are the hidden-input spellings, and match what VineJS accepts. A + // checkbox carrying any other `value` is not a boolean — the schema names + // that value instead of the codec guessing at it. + const read = `(${outputVar}=${v}==="on"||${v}==="true"||${v}==="1")||${v}==="false"||${v}==="0"||`; + const fail = B_embedInvalidInput(item, schema); + // A literal arm is narrowed against the boolean the read produced, not + // against the entry: "must be checked" reports the box it got, not the text + // a browser did or didn't send. `assembled` can't emit it — its source is + // the field's own schema, so there is nothing left for it to check. + const narrow = + field.present.const === U + ? "" + : `${outputVar}===${field.present.const}||${B_failWithArg( + item, + B_invalidInputBuilder(field.present)(item), + outputVar, + )};`; + return assembled( + item, + schema, + field.optional || field.nullable + ? // Absent leaves the var undefined, which is the tri-state's third value + // and what a default, or a `null` arm, converts from. Without one it + // would be unreachable: nothing a form submits reads as `null`. + `let ${outputVar};if(${v}){${read}${fail};${narrow}}${absentCode( + item, + field, + schema, + outputVar, + )}` + : // An unchecked box sends nothing, so absent is `false` — which is what + // the comparisons already assigned by the time the guard admits it. + `let ${outputVar};${read}!${v}||${fail};${narrow}`, + outputVar, + ); +}; + +// `append` takes a string or a blob as it is; every other entry is the string +// the value converts to, through the same encoders a JSON document uses. +const appendValue = (val: Val, fdVar: string, keyText: string, inList?: boolean): string => { + const schema = val.s; + const tagFlag = tagFlags[schema.type]!; + // Only a field is a checkbox. A repeated key is a list, and a list of + // booleans is positional — dropping the false ones would lose the indices + // the decoder reads back. (A checkbox *group* submits the values of the + // checked boxes, which is `S.array(S.string)`.) + if (!inList && isCheckbox(schema)) { + // A literal settles the entry at compile time — `S.schema(true)` always + // submits, `S.schema(false)` never does — so neither needs a guard. + if (schema.const !== U) { + return schema.const ? `${fdVar}.append(${keyText},"on");` : ""; + } + // An unchecked box sends nothing, which is the whole of what the entry + // list says about `false`, so that is what is written. A tri-state is the + // one case the platform cannot express — absent and unchecked are the same + // wire — so there `false` is spelled out to keep the third value apart. + // `S.optional(S.boolean, true)` therefore cannot round-trip: its `false` + // omits, and an absent entry is its default. That default contradicts the + // wire, where a missing checkbox means unchecked. + return (tagFlag & 256) && schema.has![undefinedTag] + ? `if(${val.i}!==void 0){${fdVar}.append(${keyText},${val.i}?"on":"false")}` + : `if(${val.i}){${fdVar}.append(${keyText},"on")}`; + } + if (isList(schema)) { + const slots = schema.items!; + if (slots.length) { + // A tuple's slots each have their own schema, so there is no one item a + // loop could convert — one append per slot, in order. + let code = ""; + for (let idx = 0; idx < slots.length; idx++) { + const slot = valGet(val, `${idx}`); + code += B_mergeWithPathPrepend(slot, val, U, () => + appendValue(B_scope(slot), fdVar, keyText, true), + ); + } + return code; + } + const arrayVar = val.v(); + const iterVar = B_varWithoutAllocation(val.g); + const raiseCountBefore = val.g.t; + // B_dynamicScope reads the item off `e`; the recursive call picks the + // item's own target. + val.e = schema; + const itemVal = B_dynamicScope(val, iterVar); + // Built before the merge, not inside its callback: `B_mergeWithCatch` runs + // the merge first, so a var this materializes on the item afterwards would + // have its `let` dropped and the loop body would read an undeclared name. + // On a scope of the item, not the item: the conversion merges its own + // chain, and `B_mergeWithPathPrepend` below merges the item — the same val + // in both would emit a union's dispatch `let` twice. + const appendCode = appendValue(B_scope(itemVal), fdVar, keyText, true); + const itemCode = B_mergeWithPathPrepend( + itemVal, + val, + iterVar, + () => appendCode, + raiseCountBefore, + ); + return `for(let ${iterVar}=0;${iterVar}<${arrayVar}.length;++${iterVar}){${itemCode}}`; + } + if ((tagFlag & 256) && (schema.has![undefinedTag] || schema.has![nullTag])) { + // Neither absent nor null is an entry, so the whole append sits behind one + // loose guard — `!= null` is both sentinels and shorter than testing them + // apart. + // Compiled on a chain detached from the field val, the way json.ts's + // guardedJsonPiece does, so the conversion's own code lands inside it. + const inputVar = val.v(); + const presentSchema = presentArm(schema); + const detached = B_next(val, inputVar, presentSchema, presentSchema); + detached.v = _var; + detached.prev = U; + return `if(${inputVar}!=null){${appendValue(detached, fdVar, keyText, inList)}}`; + } + if ((tagFlag & 2) || ((tagFlag & 8192) && isBlobClass(schema.class))) { + return `${fdVar}.append(${keyText},${val.i});`; + } + if (!(tagFlag & ((4 | 8) | (32 | 1024) | (2048 | 8192) | 256))) { + return B_unsupportedDecode(val, schema, formData); + } + val.io = false; + val.e = string; + const converted = parse(val); + return B_merge(converted) + `${fdVar}.append(${keyText},${converted.i});`; +}; + +// `S.strict` means "no entries but these", which a form submission cannot +// honour: a browser appends entries of its own that no schema declared — +// `_charset_` for a hidden input of that name, one per `dirname` attribute, +// and an image button's `name.x`/`name.y`. Rejected where the pair is written +// rather than silently read as `S.strip`. +const assertNotStrict = (input: Val, schema: Internal): void => { + // `seq` is what separates a schema someone declared from the object shape a + // val builds as it assembles fields (`makeObjectVal`), which is always + // `"strict"` and is not a statement about the wire. Only the declaration is + // rejected. + if (schema.additionalItems === "strict" && schema.seq !== U) { + B_invalidOperation( + input, + `S.strict is not supported by S.formData. Use S.strip`, + ); + } +}; + +const objectToFormData = (input: Val): Val => { + assertNotStrict(input, input.s); + const fdVar = B_varWithoutAllocation(input.g); + const properties = input.s.properties!; + let code = `let ${fdVar}=new ${B_embed(input, input.e.class)}();`; + for (const key in properties) { + const field = valGet(input, key); + code += appendValue(field, fdVar, inlinedValueFromString(key)); + } + const output = B_next(input, fdVar, input.e); + output.v = _var; + output.cp = code; + return output; +}; + +const formDataToObject = (input: Val, target: Internal): Val => { + assertNotStrict(input, target); + const objectVal = makeObjectVal(input, target); + const entriesVar = B_varWithoutAllocation(input.g); + B_hoistDecl(input, `${entriesVar}=${B_embed(input, readEntries)}(${input.v()})`); + const properties = target.properties!; + for (const key in properties) { + const schema = properties[key]!; + const keyText = inlinedValueFromString(key); + const field = classify(schema); + const list = isList(field.present); + // Both say a blank entry carries no value, so both read it away and both + // compile their arms on their own. + const absent = field.optional || field.nullable; + const entry = takesEntry(field.present); + + // An empty text input submits `""`, and only an optional field reads it as + // absent — that is the one case where the entry carries no value, and it is + // what makes a default apply. A required field is handed `""` unchanged, so + // the target answers for itself: `S.string` accepts it, `S.nonEmpty` and + // `S.number` reject it in their own words. A checkbox is the exception + // either way: a box carries no text, so an empty value is an unchecked box + // rather than a value to report on. + // + // A list is `getAll`, which answers `[]` rather than `undefined`; an + // optional one folds that empty read into absent, since a form has no other + // way to submit an empty list. + const readVar = B_varWithoutAllocation(input.g); + const slot = `${entriesVar}.get(${keyText})`; + if (list) { + B_hoistDecl( + input, + `${readVar}=${B_embed(input, absent ? asOptionalList : asList)}(${slot})`, + ); + } else if (entry) { + // A file input with nothing chosen still submits: the HTML Standard's + // entry list gets "a new File object with an empty name, + // application/octet-stream as type, and an empty body". That sentinel is + // not an upload, so it reads as absent — a required field then reports a + // missing file rather than accepting an empty one. A string entry falls + // through the guard untouched (`"".name` is undefined). + // Declared on its own: the sentinel is read three times, and an + // assignment inside the initializer would otherwise be an implicit + // global — `new Function` is sloppy mode, so nothing would say so. + const entryVar = B_varWithoutAllocation(input.g); + B_hoistDecl(input, entryVar); + B_hoistDecl( + input, + `${readVar}=(${entryVar}=${slot})&&${entryVar}.name===""&&!${entryVar}.size?void 0:${entryVar}`, + ); + } else { + // A checkbox tests its entry for truth, which already covers `null` and + // the `""` of a box carrying an empty value — so it is the one read that + // needs no sentinel of its own. + B_hoistDecl( + input, + `${readVar}=${slot}${field.checkbox || !absent ? "" : "||void 0"}`, + ); + } + + // A field val the way valGet builds one: hung off the parent rather than + // chained through `prev`, so each field's merge emits its own read and not + // the parent's code again. Absent reads as `undefined`, the way a missing + // object key does, so the error and the optional handling match an object's. + // Canonical Val field order (see B_operationArg in builder.ts). + const item: Val = { + b: U, + p: input, + v: _var, + i: readVar, + s: list && !field.optional ? arrayFactory(unknown) : formDataField, + io: U, + e: schema, + prev: U, + f: 0, + d: U, + fv: U, + cp: "", + hd: "", + fz: U, + vc: U, + u: U, + t: true, + path: pathConcat(input.path, [key]), + g: input.g, + o: U, + }; + + + // A blank text input submits `""`, so a required string field that says + // nothing about it has two equally good readings and the codec picks + // neither. + if (!absent && (tagFlags[field.present.type]! & 2) && !decidesBlank(field.present)) { + B_invalidOperation( + item, + `say what "" means with S.nonEmpty, S.minLength(0), S.optional or S.nullable`, + "Ambiguous", + ); + } + + let output: Val; + if (field.checkbox) { + output = readCheckboxField(item, field, schema); + } else if (list) { + // A boolean item would take the checkbox reading, and a checkbox is a + // whole field: a group of them submits the *value* of each checked box, + // never `"on"` per position, so a list of booleans is not something a + // form can send. + for (const listItem of listItems(field.present)) { + if (isCheckbox(listItem)) { + B_invalidOperation( + item, + `A list of booleans is not supported by S.formData: a checkbox group submits the value of each checked box. Use S.array(S.string)`, + ); + } + } + // A list of entries: each item reads by the same rules a field does, + // through the same hook. + output = absent + ? readOptional(item, field, schema, arrayFactory(formDataField), field.present) + : ((item.s = arrayFactory(formDataField)), parse(item)); + } else if (entry || !absent) { + // The field schema's hook takes it from here: it is consulted once per + // union arm, so each arm reads by its own rule. + output = parse(item); + } else { + // What "no entry" means is the reader's to say — the union rules have no + // conversion into `undefined` or `null` to dispatch on. + output = readOptional(item, field, schema, formDataField, field.present); + } + B_addObjectField(objectVal, key, output); + } + + return B_markOutput(completeObjectVal(objectVal), input); +}; + +export const formData: Internal = /* @__PURE__ */ initSchema( + instanceTag, + (input: Val): Val => + (tagFlags[input.s.type]! & 64) && typeof input.s.additionalItems === "string" + ? objectToFormData(input) + : instanceDecoder(input), + (s) => { + // Read inside the initializer, for the reason file.ts gives: a module-scope + // member read is not something esbuild drops, and `FormData` landed in + // Node 18. + s.class = (globalThis as unknown as Record)["FormData"]; + if (s.class === U) { + unsupportedInstance(s, "formData"); + } + s.encoder = (input, target) => { + const targetTagFlag = tagFlags[target.type]!; + return (targetTagFlag & 64) && typeof target.additionalItems === "string" + ? formDataToObject(input, target) + : // A union picks its variant by narrowing the form to an object it + // isn't, so the dispatch never reaches the codec — say so here, where + // the pair is still named. + (targetTagFlag & 256) + ? B_unsupportedDecode(input, input.s, target) + : input; + }; + }, +); diff --git a/packages/sury/src/base.ts b/packages/sury/src/base.ts index fbd271758..ebd225ff1 100644 --- a/packages/sury/src/base.ts +++ b/packages/sury/src/base.ts @@ -229,6 +229,10 @@ export type InvalidOperationDetails = { code: "invalid_operation"; path: Path; reason: string; + // Leads the message in place of "Failed", for an operation that is rejected + // rather than failed. Read by formatErrorMessage, so a custom + // messageFormatter is free to ignore it. + verb?: string; } export type UnsupportedDecodeDetails = { code: "unsupported_decode"; @@ -802,7 +806,7 @@ export const panic = (message: string): never => { } const formatErrorMessage = (error: SuryErrorRecord): string => - `${error.path.length ? `Failed at ${pathToText(error.path)}: ` : ""}${error.reason}`; + `${error.path.length ? `${(error as { verb?: string }).verb || "Failed"} at ${pathToText(error.path)}: ` : ""}${error.reason}`; export const errorClass: unknown = SuryError; diff --git a/packages/sury/src/builder.ts b/packages/sury/src/builder.ts index 1f5721fdf..7cc053f8e 100644 --- a/packages/sury/src/builder.ts +++ b/packages/sury/src/builder.ts @@ -839,8 +839,8 @@ export const B_contentDiffers = (from?: Internal, to?: Internal): boolean => export const B_readsPayload = (target: Internal): boolean => target.opens ?? target.to !== U; -export const B_invalidOperation = (val: Val, description: string): never => - B_throw({ code: "invalid_operation", reason: description, path: val.path }); +export const B_invalidOperation = (val: Val, description: string, verb?: string): never => + B_throw({ code: "invalid_operation", reason: description, path: val.path, verb }); const B_mergeWithCatch = ( val: Val, diff --git a/packages/sury/src/entry.ts b/packages/sury/src/entry.ts index 7eba6def5..12a902bd2 100644 --- a/packages/sury/src/entry.ts +++ b/packages/sury/src/entry.ts @@ -101,6 +101,7 @@ export { uint8Array } from "./advanced/uint8Array"; export { date } from "./advanced/date"; export { url } from "./advanced/url"; export { blob, file } from "./advanced/file"; +export { formData } from "./advanced/formData"; export { isoDateTime, utcDateTime, diff --git a/packages/sury/src/jsonschema.ts b/packages/sury/src/jsonschema.ts index e974f730e..16cffdb3f 100644 --- a/packages/sury/src/jsonschema.ts +++ b/packages/sury/src/jsonschema.ts @@ -486,7 +486,11 @@ const internalToJSONSchemaBase = ( const name = alias === U ? format : alias; if (name) jsonSchema.format = name; } - if (schema.minLength !== U) jsonSchema.minLength = schema.minLength; + // `bounds`, not the field: `S.minLength(0)` records a zero without the bit + // (see refinements.ts) because it states what the empty string means to a + // text wire rather than constraining the value, and `minLength: 0` is the + // keyword's own default anyway. + if ((schema.bounds ?? 0) & 1) jsonSchema.minLength = schema.minLength; if (schema.maxLength !== U) jsonSchema.maxLength = schema.maxLength; if (schema.pattern !== U) jsonSchema.pattern = schema.pattern.source; if (const_ !== U) setConstOrEnum(const_); diff --git a/packages/sury/src/parse.ts b/packages/sury/src/parse.ts index 0eaf0524d..a3470b5a5 100644 --- a/packages/sury/src/parse.ts +++ b/packages/sury/src/parse.ts @@ -445,6 +445,25 @@ export const instanceDecoder: Builder = (input: Val) => { : B_unsupportedDecode(input, input.s, input.e); }; +// On a runtime that has no such global there is no schema to be had, so `class` +// reports that instead of sitting there as `undefined` for its readers to +// dereference. Every route into the schema goes through `class` — the decoder's +// `instanceof`, the rendering and the JSON Schema emit via `.name`, and +// `copySchema`'s `Object.assign` for `.with(…)` and `reverse` — so all of them +// answer with this one sentence rather than a TypeError, or worse, a schema +// that builds and fails later — converting a schema that only decodes to one +// included, since the encode-reverse copies the target to get there. +// +// Enumerable, so the `Object.assign` copy is one of the routes it covers. +// `console.log` still works: `util.inspect` shows an accessor rather than +// invoking it. +export const unsupportedInstance = (s: Internal, name: string): void => { + Object.defineProperty(s, "class", { + enumerable: true, + get: () => panic(`S.${name} is not supported in this runtime`), + }); +}; + // @__NO_SIDE_EFFECTS__ export const instance = (class_: unknown): Internal => { const mut = baseSchema(instanceTag, true, instanceDecoder); diff --git a/packages/sury/src/refinements.ts b/packages/sury/src/refinements.ts index 83e503465..a7aa55c59 100644 --- a/packages/sury/src/refinements.ts +++ b/packages/sury/src/refinements.ts @@ -695,6 +695,17 @@ export const minLength = (root: Internal, length: number, maybeMessage?: string) assertSize(schema, length, false); const key = sizeKey(schema, false); if (!narrowsSize(schema[key], length, false)) { + // A zero lower bound checks nothing — every length is already >= 0 — but on + // a string it is how a schema states that the empty string is a value it + // admits, which the text wires read (`decidesBlank` in + // advanced/formData.ts). Recorded WITHOUT the `bounds` bit, so no check, + // no rendering and no JSON Schema keyword follow it: the bit is what those + // three read. + if (length === 0 && schema.type === stringTag && schema[key] === U) { + return updateOutput(root, (mut: Internal) => { + mut[key] = 0; + }); + } return carryMessage(root, (schema.bounds ?? 0) & 1 ? key : U, maybeMessage); } return updateBounds(root, (mut: Internal) => { diff --git a/packages/sury/src/union.ts b/packages/sury/src/union.ts index 5a2fcc480..b2bb37d56 100644 --- a/packages/sury/src/union.ts +++ b/packages/sury/src/union.ts @@ -50,6 +50,7 @@ import { B_embed, B_inlineConst, B_invalidOperation, + B_unsupportedDecode, B_neverSlot, B_makeInvalidInputDetails, B_markOutput, @@ -597,30 +598,22 @@ const unionCheckPartial = ( input, source, target, - `${inputExpression(matched!)} has the same type as the ${outputSide ? "target" : "source"} and the others don't` + `. Use S.to(from, to, {decode, encode}), or S.never on an arm` ); } }; -const unionUncovered = ( - input: Val, - source: Internal, - target: Internal, - variant: Internal -): never => - unionInvalid( - input, - source, - target, - `${inputExpression(variant)} has no same-type variant on the other side` - ); +// An arm with no counterpart is not ambiguous, it is a pair with no reading — +// which is what every other unconvertible pair reports, in the same words. +const unionUncovered = (input: Val, source: Internal, target: Internal): never => + B_unsupportedDecode(input, source, target); +// The pair, then the spelling that resolves it — the shape `S.to` already uses +// for a content pair with two readings. const unionInvalid = (input: Val, from: Internal, to: Internal, why: string): never => B_invalidOperation( input, - `Invalid operation: can't convert ${inputExpression(from)} to ${inputExpression( - to - )} — ${why}. Use S.to to say what you mean, or S.never to mark a variant unreachable` + `Ambiguous conversion from ${inputExpression(from)} to ${inputExpression(to)}${why}` ); // ── Normalize → Analyze → Plan → Emit ──────────────────────────────────────── @@ -1585,7 +1578,7 @@ const unionResolveToUnion = ( ); } if (matches[s] === U) { - unionUncovered(input, source, target, sourceOut); + unionUncovered(input, source, target); } } for (let t = 0; t < targets.length; t++) { @@ -1601,7 +1594,7 @@ const unionResolveToUnion = ( unionOutput(targetVariant).type === neverTag || !(sourceNullish & tagFlags[opposite]!)) ) { - unionUncovered(input, source, target, targetVariant); + unionUncovered(input, source, target); } } diff --git a/packages/sury/tests/S_to_test.res b/packages/sury/tests/S_to_test.res index 639d11d2b..52cbdc2df 100644 --- a/packages/sury/tests/S_to_test.res +++ b/packages/sury/tests/S_to_test.res @@ -843,7 +843,7 @@ test("Rejects widening a union into one with an uncovered member", t => { t->U.assertThrowsMessage( () => "123"->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert string | number to string | number | boolean — boolean has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Can't decode string | number to string | number | boolean. Use S.to to define a custom decoder`, ) // S.never marks the extra member unreachable, and the rest passes through. @@ -879,7 +879,7 @@ test("Fails to transform union to union to string", t => { // others, which is the ambiguity rule 2 rejects. t->U.assertThrowsMessage( () => true->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert string to string | number | boolean — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Ambiguous conversion from string to string | number | boolean. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) }) @@ -909,7 +909,7 @@ test("Rejects a source matching some but not all target members", t => { t->U.assertThrowsMessage( () => "true"->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert string to boolean | string — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Ambiguous conversion from string to boolean | string. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) // Pass strings through, never producing a boolean: @@ -1003,7 +1003,7 @@ test("Instance source matching one of two instance members is ambiguous", t => { t->U.assertThrowsMessage( () => %raw(`new Set(["a"])`)->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert Set to Map | Set — Set has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Ambiguous conversion from Set to Map | Set. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) let explicit = @@ -1038,7 +1038,7 @@ test("S.date -> S.union([S.string, S.date]) is an ambiguous widening", t => { t->U.assertThrowsMessage( () => d->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert Date to string | Date — Date has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Ambiguous conversion from Date to string | Date. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) let explicit = @@ -1108,7 +1108,7 @@ test("Refined+converted target union is still an ambiguous widening", t => { t->U.assertThrowsMessage( () => "123"->S.parseOrThrow(~to=schema), - `Invalid operation: can't convert string to string | number | boolean — string has the same type as the source and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Ambiguous conversion from string to string | number | boolean. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) // Narrow the target to the reachable member and both the refinement and the @@ -1203,7 +1203,7 @@ test("Rejects a nested union whose member has no same-type target member", t => t->U.assertThrowsMessage( () => {"f": %raw(`123n`)}->S.parseOrThrow(~to=schema), - `Failed at f: Invalid operation: can't convert bigint | null to string | undefined — bigint has no same-type variant on the other side. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Failed at f: Can't decode bigint | null to string | undefined. Use S.to to define a custom decoder`, ) }) @@ -1229,7 +1229,7 @@ test("Rejects a nested union where only some members match the single target", t t->U.assertThrowsMessage( () => {"f": %raw(`123`)}->S.parseOrThrow(~to=schema), - `Failed at f: Invalid operation: can't convert string | number to string — string has the same type as the target and the others don't. Use S.to to say what you mean, or S.never to mark a variant unreachable`, + `Failed at f: Ambiguous conversion from string | number to string. Use S.to(from, to, {decode, encode}), or S.never on an arm`, ) }) diff --git a/packages/sury/tests/file_test.ts b/packages/sury/tests/file_test.ts index 4b2f63522..e9a5f8d6b 100644 --- a/packages/sury/tests/file_test.ts +++ b/packages/sury/tests/file_test.ts @@ -1,23 +1,6 @@ -import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import * as S from "sury"; - -// `S.blob`/`S.file` bind their class at import, so the runtime-missing case -// can only be observed in a process that never had the global — which is why -// this is a test and not a spec. -const withoutGlobal = (name: string, body: string): string => - execFileSync( - process.execPath, - [ - "--input-type=module", - "-e", - `delete globalThis.${name}; - const S = await import(${JSON.stringify(fileURLToPath(new URL("../index.mjs", import.meta.url)))}); - ${body}`, - ], - { encoding: "utf8" } - ).trim(); +import { withoutGlobal } from "./withoutGlobal"; test("every route into a schema the runtime can't support says so", () => { // `class` is the one thing all of them read — the decoder's `instanceof`, diff --git a/packages/sury/tests/formDataBrowser_test.ts b/packages/sury/tests/formDataBrowser_test.ts new file mode 100644 index 000000000..ea619442e --- /dev/null +++ b/packages/sury/tests/formDataBrowser_test.ts @@ -0,0 +1,215 @@ +import { expect, test } from "vitest"; +import * as S from "sury"; + +// What a real browser puts in a `FormData`, captured rather than imagined. +// +// SUBMISSION is the entry list headless Chromium 141 built from the form in +// FORM_HTML below, read back as `[...new FormData(form).entries()]`. It is +// frozen here because the assertions are about `S.formData`, not about the +// browser — but every row is reproducible: load FORM_HTML in any engine and +// read the entries. Each one is also a step of the HTML Standard's +// "constructing the entry list" algorithm (§4.10.22.4), noted per row. +const FORM_HTML = ` + + + + + + + + + + + + + + + + + + + + + + +`; + +type Captured = [string, string | { file: true; name: string; type: string }]; + +const SUBMISSION: Captured[] = [ + ["name", "Ann"], + // An empty text input is an entry with an empty value, not an absent one. + ["blank", ""], + // No trimming anywhere in the algorithm. + ["padded", " spaced "], + // LF stays LF in the entry list; CRLF normalization belongs to the + // urlencoded/text-plain serializers, not to `FormData`. + ["bio", "line1\nline2"], + ["age", "42"], + ["blankNumber", ""], + // A checked box with no `value` attribute submits the string "on". + ["agree", "on"], + // `unchecked` contributes nothing: the algorithm skips an unchecked box. + // A checkbox's value is whatever the attribute says… + ["valued", "yes"], + // …including the empty string, which is why "" cannot mean "unchecked". + ["emptyValued", ""], + // Only the checked radio of a group. + ["plan", "pro"], + // One entry per selected option; `none` selects nothing and contributes none. + ["tags", "a"], + ["tags", "b"], + ["single", "x"], + // A file input with nothing chosen still submits — an empty, unnamed file. + ["avatar", { file: true, name: "", type: "application/octet-stream" }], + // The browser fills `_charset_` itself; the schema never declared it. + ["_charset_", "UTF-8"], + // `disabledField` contributes nothing. + ["dated", "2024-01-01"], + // No seconds and no zone, so `new Date` reads it as local time. + ["dt", "2024-01-01T10:30"], + ["rng", "7"], + ["colr", "#ff0000"], +]; + +const submitted = (): FormData => { + const fd = new FormData(); + for (const [key, value] of SUBMISSION) { + fd.append( + key, + typeof value === "string" ? value : new File([], value.name, { type: value.type }), + ); + } + return fd; +}; + +test("the captured submission is what the algorithm describes", () => { + const keys = SUBMISSION.map(([key]) => key); + expect(FORM_HTML).toContain(`name="unchecked"`); + expect(keys).not.toContain("unchecked"); + expect(keys).not.toContain("disabledField"); + expect(keys).not.toContain("none"); + expect(keys.filter((key) => key === "tags")).toHaveLength(2); +}); + +test("a browser submission decodes field by field", () => { + const schema = S.formData.with( + S.to, + S.schema({ + name: S.string.with(S.nonEmpty), + blank: S.string.with(S.minLength, 0), + padded: S.string.with(S.minLength, 0), + bio: S.string.with(S.minLength, 0), + age: S.number, + agree: S.boolean, + unchecked: S.boolean, + plan: S.union(["free", "pro"]), + tags: S.array(S.string), + single: S.string.with(S.nonEmpty), + none: S.array(S.string), + dated: S.string.with(S.to, S.date), + rng: S.number, + colr: S.string.with(S.pattern, /^#[0-9a-f]{6}$/), + }), + ); + expect(S.decoder(schema)(submitted())).toEqual({ + name: "Ann", + // `S.minLength(0)` is how the schema says the empty entry is a value. + blank: "", + // Never trimmed — `S.trim` is the opt-in. + padded: " spaced ", + bio: "line1\nline2", + age: 42, + agree: true, + // An unchecked box sends nothing, and nothing is `false`. + unchecked: false, + plan: "pro", + tags: ["a", "b"], + single: "x", + none: [], + dated: new Date("2024-01-01"), + rng: 7, + colr: "#ff0000", + }); +}); + +test("a blank entry is absent for an optional field, and its own value otherwise", () => { + const optional = S.formData.with( + S.to, + S.schema({ blank: S.optional(S.string), blankNumber: S.optional(S.number, 7) }), + ); + expect(S.decoder(optional)(submitted())).toEqual({ blank: undefined, blankNumber: 7 }); + + // A required string must say which it means, and each spelling then answers + // for itself. + expect(() => S.decoder(S.formData.with(S.to, S.schema({ blank: S.string })))).toThrow( + 'Ambiguous at blank: say what "" means', + ); + expect( + S.decoder(S.formData.with(S.to, S.schema({ blank: S.string.with(S.minLength, 0) })))( + submitted(), + ), + ).toEqual({ blank: "" }); + expect(() => + S.decoder(S.formData.with(S.to, S.schema({ blank: S.string.with(S.nonEmpty) })))(submitted()), + ).toThrow('Failed at blank: Expected string.length >= 1, received ""'); + expect(() => + S.decoder(S.formData.with(S.to, S.schema({ blankNumber: S.number })))(submitted()), + ).toThrow('Failed at blankNumber: Expected number, received ""'); +}); + +test("a file input with nothing chosen reads as absent, not as an empty file", () => { + // The algorithm still appends an entry: a `File` with an empty name, + // `application/octet-stream`, and no bytes. Handing that to a schema as a + // real upload is what every form library treats as a bug. + const optional = S.formData.with(S.to, S.schema({ avatar: S.optional(S.file) })); + expect(S.decoder(optional)(submitted())).toEqual({ avatar: undefined }); + + const required = S.formData.with(S.to, S.schema({ avatar: S.file })); + expect(() => S.decoder(required)(submitted())).toThrow( + "Failed at avatar: Expected File, received undefined", + ); + + // A real upload still arrives as itself. + const chosen = new FormData(); + const picked = new File(["hi"], "a.txt", { type: "text/plain" }); + chosen.append("avatar", picked); + expect(S.decoder(required)(chosen)).toEqual({ avatar: picked }); +}); + +test("a checkbox with a value attribute is not a boolean", () => { + // "yes" is a legal checkbox value, and the boolean read does not guess at it + // — the schema says what the value is. + expect(() => + S.decoder(S.formData.with(S.to, S.schema({ valued: S.boolean })))(submitted()), + ).toThrow('Failed at valued: Expected boolean, received "yes"'); + expect( + S.decoder(S.formData.with(S.to, S.schema({ valued: S.union(["yes"]) })))(submitted()), + ).toEqual({ valued: "yes" }); + + // A checked box whose value is "" is indistinguishable from an unchecked one + // on this wire, and reads as unchecked. + expect( + S.decoder(S.formData.with(S.to, S.schema({ emptyValued: S.boolean })))(submitted()), + ).toEqual({ emptyValued: false }); +}); + +test("a browser adds entries the schema never declared, so S.strict cannot hold", () => { + // `_charset_` is filled in by the browser; a `dirname` attribute and an image + // button's `name.x`/`name.y` arrive the same way. None of them is in any + // schema, so "no entries but these" is not a thing a form can promise. + expect(SUBMISSION.map(([key]) => key)).toContain("_charset_"); + expect(() => + S.decoder( + S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty) }).with(S.strict)), + ), + ).toThrow("S.strict is not supported by S.formData"); + // Stripping is the supported mode, and it reads the same submission fine. + expect( + S.decoder(S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty) })))(submitted()), + ).toEqual({ name: "Ann" }); +}); diff --git a/packages/sury/tests/formData_test.ts b/packages/sury/tests/formData_test.ts new file mode 100644 index 000000000..af363f7ff --- /dev/null +++ b/packages/sury/tests/formData_test.ts @@ -0,0 +1,445 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import * as S from "sury"; +import { withoutGlobalRoutes } from "./withoutGlobal"; + +// The value side of `S.formData`, for what the spec format can't write down: a +// golden can't hold a `FormData` (see CONTRIBUTING.md's Spec Harness +// Suggestions), so every `codec-formdata-*` encode block carries only its +// failures, and the entries an encode produces are checked here. Codegen and +// the decode direction stay in the specs. + +const form = (...entries: [string, string | Blob][]): FormData => { + const f = new FormData(); + for (const [key, value] of entries) f.append(key, value); + return f; +}; + +const entries = (f: FormData): [string, string | File][] => [...f.entries()]; + +test("an encode appends one entry per field, in field order, as text", () => { + const schema = S.formData.with( + S.to, + S.schema({ + name: S.string.with(S.nonEmpty), + age: S.number, + agree: S.boolean, + kind: "signup", + since: S.date, + id: S.bigint, + site: S.url, + }), + ); + const encoded = S.encoder(schema)({ + name: "Ann", + age: 42, + agree: true, + kind: "signup", + since: new Date("2024-01-01T00:00:00.000Z"), + id: 7n, + site: new URL("https://sury.dev/"), + }); + expect(encoded).toBeInstanceOf(FormData); + expect(entries(encoded)).toEqual([ + ["name", "Ann"], + ["age", "42"], + ["agree", "on"], + ["kind", "signup"], + ["since", "2024-01-01T00:00:00.000Z"], + ["id", "7"], + ["site", "https://sury.dev/"], + ]); + // The same schema reads the entries back, coercions included. + expect(S.decoder(schema)(encoded)).toEqual({ + name: "Ann", + age: 42, + agree: true, + kind: "signup", + since: new Date("2024-01-01T00:00:00.000Z"), + id: 7n, + site: new URL("https://sury.dev/"), + }); +}); + +test("an absent optional is no entry, and a default fills the absent one back", () => { + const schema = S.formData.with( + S.to, + S.schema({ nick: S.optional(S.string), age: S.optional(S.number, 18) }), + ); + expect(entries(S.encoder(schema)({ age: 18 }))).toEqual([["age", "18"]]); + expect(entries(S.encoder(schema)({ nick: "nn", age: 42 }))).toEqual([ + ["nick", "nn"], + ["age", "42"], + ]); + expect(S.decoder(schema)(new FormData())).toEqual({ nick: undefined, age: 18 }); + // The empty text input is the absent one. + expect(S.decoder(schema)(form(["nick", ""], ["age", ""]))).toEqual({ nick: undefined, age: 18 }); +}); + +test("a boolean is a checkbox: on when set, nothing when not", () => { + const schema = S.formData.with(S.to, S.schema({ agree: S.boolean, notify: S.optional(S.boolean) })); + // An unchecked box sends nothing, which is all the entry list says about + // `false` — so that is what an encode writes. + expect(entries(S.encoder(schema)({ agree: false }))).toEqual([]); + expect(entries(S.encoder(schema)({ agree: true, notify: false }))).toEqual([ + ["agree", "on"], + // A tri-state is the one thing the wire cannot express, so `false` is + // spelled out there to keep it apart from absent. + ["notify", "false"], + ]); + expect(S.decoder(schema)(S.encoder(schema)({ agree: false, notify: true }))).toEqual({ + agree: false, + notify: true, + }); + expect(S.decoder(schema)(form(["agree", "on"]))).toEqual({ agree: true, notify: undefined }); + expect(S.decoder(schema)(new FormData())).toEqual({ agree: false, notify: undefined }); +}); + +test("a checkbox reads the entries a form can carry, and only those", () => { + const schema = S.formData.with(S.to, S.schema({ a: S.boolean })); + // "on" is what a checked box submits; the rest are the hidden-input + // spellings, matching VineJS's accepted set. + for (const [entry, value] of [ + ["on", true], + ["true", true], + ["1", true], + ["false", false], + ["0", false], + // A checked box whose value is "" is indistinguishable from an unchecked + // one on this wire. + ["", false], + ] as const) { + expect(S.decoder(schema)(form(["a", entry])), entry).toEqual({ a: value }); + } + // Anything else is a checkbox with a `value` attribute, which is a string + // the schema should name rather than a boolean the codec guesses at. + expect(() => S.decoder(schema)(form(["a", "yes"]))).toThrow( + 'Failed at a: Expected boolean, received "yes"', + ); +}); + +test("a nullable field reads a blank entry as null, and omits null on the way out", () => { + const schema = S.formData.with( + S.to, + S.schema({ nick: S.nullable(S.string), age: S.nullable(S.number) }), + ); + expect(S.decoder(schema)(form(["nick", ""], ["age", ""]))).toEqual({ nick: null, age: null }); + expect(S.decoder(schema)(new FormData())).toEqual({ nick: null, age: null }); + expect(S.decoder(schema)(form(["nick", "nn"], ["age", "42"]))).toEqual({ nick: "nn", age: 42 }); + // `null` is not an entry, so it is omitted — and reads back as null. + expect(entries(S.encoder(schema)({ nick: null, age: null }))).toEqual([]); + expect(entries(S.encoder(schema)({ nick: "nn", age: 42 }))).toEqual([ + ["nick", "nn"], + ["age", "42"], + ]); + expect(S.decoder(schema)(S.encoder(schema)({ nick: null, age: 7 }))).toEqual({ + nick: null, + age: 7, + }); + // The literal text "null" is a string, not the null a blank field means. + expect(S.decoder(schema)(form(["nick", "null"]))).toEqual({ nick: "null", age: null }); +}); + +test("a blank required string must say what it means", () => { + const ambiguous = ["Ambiguous at f:", "S.nonEmpty", "S.minLength(0)", "S.optional", "S.nullable"]; + for (const schema of [S.string, S.string.with(S.maxLength, 100)]) { + for (const fragment of ambiguous) { + expect(() => S.decoder(S.formData.with(S.to, S.schema({ f: schema })))).toThrow(fragment); + } + } + // Every spelling the message names, plus the ones that answer on their own. + for (const [name, schema] of [ + ["nonEmpty", S.string.with(S.nonEmpty)], + ["minLength(0)", S.string.with(S.minLength, 0)], + ["optional", S.optional(S.string)], + ["nullable", S.nullable(S.string)], + ["a format", S.email], + ["a literal", S.schema("x")], + ["a pattern rejecting blank", S.string.with(S.pattern, /^\d+$/)], + ["a conversion", S.string.with(S.to, S.date)], + ] as const) { + expect(() => S.decoder(S.formData.with(S.to, S.schema({ f: schema }))), name).not.toThrow(); + } + // A pattern that matches "" says nothing about it, so it stays ambiguous. + expect(() => + S.decoder(S.formData.with(S.to, S.schema({ f: S.string.with(S.pattern, /^\d*$/) }))), + ).toThrow("Ambiguous at f:"); + // Encoding never reads a blank entry, so it has nothing to be ambiguous about. + expect(() => S.encoder(S.formData.with(S.to, S.schema({ f: S.string })))).not.toThrow(); +}); + +test("an array is a repeated key, and an empty array is no entry", () => { + const schema = S.formData.with(S.to, S.schema({ tags: S.array(S.string), ids: S.array(S.number) })); + expect(entries(S.encoder(schema)({ tags: ["a", "b"], ids: [1, 2] }))).toEqual([ + ["tags", "a"], + ["tags", "b"], + ["ids", "1"], + ["ids", "2"], + ]); + expect(entries(S.encoder(schema)({ tags: [], ids: [] }))).toEqual([]); + expect(S.decoder(schema)(form(["ids", "1"], ["tags", "x"], ["ids", "2"]))).toEqual({ + tags: ["x"], + ids: [1, 2], + }); +}); + +test("a file travels as itself, name included, and a blob becomes a file", async () => { + const schema = S.formData.with( + S.to, + S.schema({ avatar: S.file, cover: S.optional(S.file), raw: S.blob }), + ); + const avatar = new File(["a"], "a.png", { type: "image/png" }); + const encoded = S.encoder(schema)({ avatar, raw: new Blob(["r"]) }); + const sent = entries(encoded) as [string, File][]; + const sentAvatar = sent[0]![1]; + const sentRaw = sent[1]![1]; + expect(sentAvatar.name).toBe("a.png"); + expect(sentAvatar.type).toBe("image/png"); + expect(await sentAvatar.text()).toBe("a"); + // `append` wraps a bare blob in a File, which is what `S.blob` still accepts. + expect(sentRaw).toBeInstanceOf(File); + expect(await sentRaw.text()).toBe("r"); + const decoded = S.decoder(schema)(encoded); + expect(decoded.avatar).toBe(sentAvatar); + expect(decoded.cover).toBe(undefined); + expect(decoded.raw).toBe(sentRaw); +}); + +test("a multi-file input is an array of entries, both ways", () => { + const schema = S.formData.with(S.to, S.schema({ files: S.array(S.file) })); + const a = new File(["a"], "a.png"); + const b = new File(["b"], "b.png"); + expect(entries(S.encoder(schema)({ files: [a, b] }))).toEqual([ + ["files", a], + ["files", b], + ]); + expect(S.decoder(schema)(form(["files", a], ["files", b]))).toEqual({ files: [a, b] }); + expect(S.decoder(schema)(new FormData())).toEqual({ files: [] }); + expect(() => S.decoder(schema)(form(["files", "x"]))).toThrow( + "Failed at files[0]: Expected File, received \"x\"", + ); +}); + +test("an array of optional items encodes without leaking a declaration", () => { + // The item's own `let` used to land after the loop body that reads it, so + // the compiled encoder threw `ReferenceError` on its first item. + const schema = S.formData.with(S.to, S.schema({ m: S.array(S.optional(S.string)) })); + expect(entries(S.encoder(schema)({ m: ["a", undefined, "b"] }))).toEqual([ + ["m", "a"], + ["m", "b"], + ]); + const nested = S.formData.with(S.to, S.schema({ n: S.array(S.array(S.string)) })); + expect(entries(S.encoder(nested)({ n: [["a", "b"], ["c"]] }))).toEqual([ + ["n", "a"], + ["n", "b"], + ["n", "c"], + ]); +}); + +test("a checkbox round-trips however the field is wrapped", () => { + for (const [name, schema] of [ + ["required", S.boolean], + ["optional", S.optional(S.boolean)], + ["defaulted false", S.optional(S.boolean, false)], + ] as const) { + const s = S.formData.with(S.to, S.schema({ a: schema })); + for (const value of [true, false]) { + expect(S.decoder(s)(S.encoder(s)({ a: value })), `${name} ${value}`).toEqual({ a: value }); + } + // What a browser actually submits for a checked and an unchecked box. + expect(S.decoder(s)(form(["a", "on"])), name).toEqual({ a: true }); + expect(S.decoder(s)(new FormData()), name).toEqual({ a: name === "optional" ? undefined : false }); + } +}); + +test("a boolean literal is the must-be-checked box", () => { + // The terms-and-conditions checkbox, which submits "on" like any other and + // reports the box rather than the entry when it is missing. + const schema = S.formData.with(S.to, S.schema({ terms: S.schema(true) })); + expect(S.decoder(schema)(form(["terms", "on"]))).toEqual({ terms: true }); + expect(entries(S.encoder(schema)({ terms: true }))).toEqual([["terms", "on"]]); + for (const fd of [new FormData(), form(["terms", "false"]), form(["terms", "0"])]) { + expect(() => S.decoder(schema)(fd)).toThrow("Failed at terms: Expected true, received false"); + } + // And its mirror, for a box that must stay clear. + const clear = S.formData.with(S.to, S.schema({ spam: S.schema(false) })); + expect(S.decoder(clear)(new FormData())).toEqual({ spam: false }); + expect(entries(S.encoder(clear)({ spam: false }))).toEqual([]); + expect(() => S.decoder(clear)(form(["spam", "on"]))).toThrow( + "Failed at spam: Expected false, received true", + ); +}); + +test("a union arm reads by its own rule, not the field's", () => { + // The reading belongs to the entry, so a boolean beside a number still gets + // the checkbox spellings — the hook is consulted once per arm. + const schema = S.formData.with(S.to, S.schema({ a: S.union([S.boolean, S.number]) })); + const d = S.decoder(schema); + expect(d(form(["a", "on"]))).toEqual({ a: true }); + expect(d(form(["a", "1"]))).toEqual({ a: true }); + expect(d(form(["a", "false"]))).toEqual({ a: false }); + expect(d(form(["a", "42"]))).toEqual({ a: 42 }); + expect(() => d(form(["a", "x"]))).toThrow("Expected boolean | number"); +}); + +test("a list of booleans is not something a form can send", () => { + // A checkbox is a whole field, and a group of them submits the *value* of + // each checked box — never `"on"` per position. Nothing a browser produces + // reads as a boolean list, so it is refused rather than given a reading of + // its own. + for (const schema of [S.array(S.boolean), S.tuple([S.string, S.boolean])]) { + expect(() => S.decoder(S.formData.with(S.to, S.schema({ flags: schema })))).toThrow( + "Failed at flags: A list of booleans is not supported by S.formData", + ); + } + // The group a browser does send is a list of the checked values. + const group = S.formData.with(S.to, S.schema({ tags: S.array(S.string) })); + expect(S.decoder(group)(form(["tags", "ts"], ["tags", "go"]))).toEqual({ + tags: ["ts", "go"], + }); +}); + +test("a repeated key fills a tuple, one entry per slot", () => { + // A tuple is the fixed-length case of the same positional read, and its own + // length check reports a form that sent the wrong number of them. + const schema = S.formData.with(S.to, S.schema({ at: S.tuple([S.string.with(S.nonEmpty), S.number]) })); + expect(entries(S.encoder(schema)({ at: ["x", 42] }))).toEqual([ + ["at", "x"], + ["at", "42"], + ]); + expect(S.decoder(schema)(form(["at", "x"], ["at", "42"]))).toEqual({ at: ["x", 42] }); + expect(() => S.decoder(schema)(form(["at", "x"]))).toThrow( + 'Failed at at: Expected [string.length >= 1, number], received ["x"]', + ); +}); + +test("a repeated key of a union item encodes once per item", () => { + // The conversion merges its own chain and the loop merges the item, so + // compiling both on the same val emitted the union's dispatch `let` twice + // and the operation failed to build at all. + const schema = S.formData.with(S.to, S.schema({ picks: S.array(S.union(["a", "b"])) })); + expect(entries(S.encoder(schema)({ picks: ["a", "b", "a"] }))).toEqual([ + ["picks", "a"], + ["picks", "b"], + ["picks", "a"], + ]); + expect(S.decoder(schema)(form(["picks", "a"], ["picks", "b"]))).toEqual({ picks: ["a", "b"] }); +}); + +test("a nullable checkbox reads an absent box as null", () => { + // Without it the `null` arm would be unreachable: nothing a form submits + // reads as null, so absence is the only thing left to carry it. + const schema = S.formData.with(S.to, S.schema({ a: S.nullable(S.boolean) })); + expect(S.decoder(schema)(new FormData())).toEqual({ a: null }); + expect(S.decoder(schema)(form(["a", "on"]))).toEqual({ a: true }); + expect(S.decoder(schema)(form(["a", "false"]))).toEqual({ a: false }); + expect(entries(S.encoder(schema)({ a: null }))).toEqual([]); + expect(entries(S.encoder(schema)({ a: false }))).toEqual([]); +}); + +test("a checkbox defaulting to true cannot round-trip, because the wire disagrees", () => { + // An absent checkbox entry means unchecked, so a default of `true` states + // something the wire never says. The encode omits `false` like a browser + // does, and the decode then applies that default. Documented rather than + // worked around: the schema is what contradicts the medium. + const schema = S.formData.with(S.to, S.schema({ a: S.optional(S.boolean, true) })); + expect(entries(S.encoder(schema)({ a: false }))).toEqual([]); + expect(S.decoder(schema)(S.encoder(schema)({ a: false }))).toEqual({ a: true }); +}); + +test("FIXME: a refinement inside S.optional is not checked on encode", () => { + // Not this codec's doing — the union encode path trusts its typed input, and + // a plain object target has the same hole. Pinned so the fix shows up here. + const schema = S.formData.with( + S.to, + S.schema({ nick: S.optional(S.string.with(S.maxLength, 3)) }), + ); + const encoded = S.encoder(schema)({ nick: "long" }); + expect(entries(encoded)).toEqual([["nick", "long"]]); + expect(() => S.decoder(schema)(encoded)).toThrow( + 'Failed at nick: Expected string.length <= 3, received "long"', + ); + expect(() => S.encoder(S.schema({ nick: S.optional(S.string.with(S.maxLength, 3)) }))({ nick: "long" })) + .not.toThrow(); +}); + +test("a repeated key reaching a field declared once is reported, not resolved", () => { + // Parameter pollution: a client can send a key twice for a field the schema + // says holds one value. `get` would answer the first and say nothing, and + // which one that is depends on submission order — so the pair is handed over + // and the field's own check reports it. + const schema = S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty) })); + expect(() => S.decoder(schema)(form(["name", "first"], ["name", "second"]))).toThrow( + 'Failed at name: Expected string.length >= 1, received ["first", "second"]', + ); + // One entry is still one value, and a list takes it as a one-item list. + expect(S.decoder(schema)(form(["name", "only"]))).toEqual({ name: "only" }); +}); + +test("a file entry in a text field is reported as the file it is", () => { + const schema = S.formData.with(S.to, S.schema({ name: S.string.with(S.nonEmpty) })); + expect(() => S.decoder(schema)(form(["name", new File(["x"], "a.txt")]))).toThrow( + "Failed at name: Expected string.length >= 1, received File", + ); +}); + +test("what a text input reaching S.number is read as", () => { + // A form has no number type, so every one of these is a string a user can + // type into a field the schema calls a number. `+text` is the reading, which + // is broader than most people expect at both ends. + const schema = S.formData.with(S.to, S.schema({ n: S.number })); + for (const [text, value] of [ + ["42", 42], + [" 42 ", 42], + ["42.00", 42], + ["+42", 42], + [".5", 0.5], + ["1e5", 100000], + ["0x10", 16], + ["Infinity", Infinity], + ] as const) { + expect(S.decoder(schema)(form(["n", text])), text).toEqual({ n: value }); + } + for (const text of ["42abc", "1_000", "NaN", "", " "]) { + expect(() => S.decoder(schema)(form(["n", text])), text).toThrow("Expected number"); + } +}); + +test("a nested document is a JSON text field, both ways", () => { + const schema = S.formData.with( + S.to, + S.schema({ prefs: S.jsonString.with(S.to, S.schema({ theme: S.string, size: S.number })) }), + ); + const encoded = S.encoder(schema)({ prefs: { theme: "dark", size: 2 } }); + expect(entries(encoded)).toEqual([["prefs", `{"theme":"dark","size":2}`]]); + expect(S.decoder(schema)(encoded)).toEqual({ prefs: { theme: "dark", size: 2 } }); +}); + +test("the reverse is spelled the same as jsonString's", () => { + const user = S.schema({ name: S.string.with(S.nonEmpty), age: S.number }); + const value = { name: "Ann", age: 42 }; + expect(entries(S.encoder(user, S.formData)(value))).toEqual([ + ["name", "Ann"], + ["age", "42"], + ]); + expect(S.decoder(S.formData, user)(S.encoder(user, S.formData)(value))).toEqual(value); + expect(entries(S.parser(user.with(S.to, S.formData))(value))).toEqual([ + ["name", "Ann"], + ["age", "42"], + ]); +}); + +test("a runtime without FormData says so on every route into the schema", () => { + const message = "[Sury] S.formData is not supported in this runtime"; + expect( + withoutGlobalRoutes("FormData", [ + `S.parser(S.formData)`, + `S.inputExpression(S.formData)`, + `S.encoder(S.schema({ a: S.string }), S.formData)`, + `S.decoder(S.formData, S.schema({ a: S.string }))`, + // And the sibling the runtime does have is untouched. + `typeof S.parser(S.file)`, + ]), + ).toEqual([message, message, message, message, "ok:function"]); +}); diff --git a/packages/sury/tests/spec_test.ts b/packages/sury/tests/spec_test.ts index c6f229b83..e40fd4e11 100644 --- a/packages/sury/tests/spec_test.ts +++ b/packages/sury/tests/spec_test.ts @@ -20,6 +20,7 @@ import { lintComments, lintExamples, lintSkips, + undeclaredAssignments, lintSpecsDir, checkBundleSize, checkScenarios, @@ -260,6 +261,12 @@ describe.each(specs)("spec: $id", ({ file }) => { expect(errs, errs.join("\n")).toEqual([]); }); + test("no compiled op assigns an undeclared var (run `pnpm spec check`)", () => { + const errs: string[] = []; + undeclaredAssignments(readSpec(file), errs); + expect(errs).toEqual([]); + }); + test("every compiled op block has examples (run `pnpm spec check`)", () => { const errs: string[] = []; lintExamples(spec, errs); diff --git a/packages/sury/tests/union_planner_regression_test.ts b/packages/sury/tests/union_planner_regression_test.ts index 4fcaeda66..b3c9e4dd6 100644 --- a/packages/sury/tests/union_planner_regression_test.ts +++ b/packages/sury/tests/union_planner_regression_test.ts @@ -316,7 +316,7 @@ test("reachable rejection and unreachable conversion stay distinct", (t) => { S.number, ]).with(S.to, S.union([S.string, S.number])); t.expect(() => S.parser(uncoveredTarget)).toThrow( - /string has no same-type variant on the other side/, + /Can.t decode/, ); const chainedUncoveredTarget = S.union([ @@ -324,7 +324,7 @@ test("reachable rejection and unreachable conversion stay distinct", (t) => { S.number, ]).with(S.to, S.union([S.string, S.number])); t.expect(() => S.parser(chainedUncoveredTarget)).toThrow( - /string has no same-type variant on the other side/, + /Can.t decode/, ); }); diff --git a/packages/sury/tests/withoutGlobal.ts b/packages/sury/tests/withoutGlobal.ts new file mode 100644 index 000000000..fefdef2ca --- /dev/null +++ b/packages/sury/tests/withoutGlobal.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// `S.blob`, `S.file` and `S.formData` bind their class at import, so the +// runtime-missing case can only be observed in a process that never had the +// global — which is why those are tests and not specs. Booting Node and +// importing the bundle is ~60ms, so `routes` runs every route in one child +// rather than one child each. +const run = (name: string, body: string): string => + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + // `File`, `Blob` and `FormData` are one internal undici module, lazily + // loaded on first touch since node 24, and its initializer reads the + // global `File`. Deleting one and then touching another loads undici + // with that binding already gone (`ReferenceError: File is not + // defined`). Reading all three first finishes the load while they are + // all still there, so the delete takes only what Sury reads. + `void [globalThis.File, globalThis.Blob, globalThis.FormData]; + delete globalThis.${name}; + const S = await import(${JSON.stringify(fileURLToPath(new URL("../index.mjs", import.meta.url)))}); + ${body}`, + ], + { encoding: "utf8" }, + ).trim(); + +export const withoutGlobal = run; + +// Each route's own line: the message it reported, or `ok:` when it +// didn't throw. A route that prints nothing would collapse two lines into one, +// so every branch prints. +export const withoutGlobalRoutes = (name: string, routes: string[]): string[] => + run( + name, + routes + .map( + (route) => + `try { console.log("ok:" + (${route})) } catch (e) { console.log(e.message) }`, + ) + .join("\n"), + ).split("\n");