From f515f32f0156e03b13660ec113741c396af8dcc6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 22 Sep 2026 20:33:21 +0800 Subject: [PATCH 1/5] build(deps): take @block65/rest-client 15.0.0 15.0.0 exports one plain serializer per OAS query style, and a command names the one its document states through the `querySerializer` property. The generated commands import those by name, so the peer range moves with the dev dependency. Co-Authored-By: LLM --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 8fefcb5..255c663 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@block65/custom-error": "^14.1.0", - "@block65/rest-client": "^14.2.0", + "@block65/rest-client": "^15.0.0", "@block65/shared-config": "^0.4.0", "@block65/tsconfig": "^0.3.1", "@hono/standard-validator": "^0.4.0", @@ -53,7 +53,7 @@ "vitest": "^5.0.1" }, "peerDependencies": { - "@block65/rest-client": "^14.2.0", + "@block65/rest-client": "^15.0.0", "@hono/standard-validator": "^0.4.0", "hono": "^4.13.8", "type-fest": "^5.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c6499b..7795d20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,8 +193,8 @@ importers: specifier: ^14.1.0 version: 14.1.0 '@block65/rest-client': - specifier: ^14.2.0 - version: 14.2.0(valibot@1.5.0(typescript@7.0.2)) + specifier: ^15.0.0 + version: 15.0.0(valibot@1.5.0(typescript@7.0.2)) '@block65/shared-config': specifier: ^0.4.0 version: 0.4.0(@block65/oxlint-tsgolint@7.0.200200)(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)(oxfmt@0.68.0) @@ -308,8 +308,8 @@ packages: vite-plus: optional: true - '@block65/rest-client@14.2.0': - resolution: {integrity: sha512-dh6wcX8N4bocowE23VwNAWCRyLiyJGoS7A79iJR6RAjVjKCM7dbBIwlFWtUDuOWZkRLVgDIN5oE8Oa/fC8y5lA==} + '@block65/rest-client@15.0.0': + resolution: {integrity: sha512-ol95B6hendclv9l3epvcKmtXrJI3/UgxY25rIm65+6d0HU6x6MBKjTD9MR7Oh1P9eXOq2AJh0JeUuESZtlDb0Q==} peerDependencies: valibot: ^1.5.0 peerDependenciesMeta: @@ -1657,7 +1657,7 @@ snapshots: '@block65/oxlint-binding-linux-x64-gnu': 1.83.0 oxlint-tsgolint: '@block65/oxlint-tsgolint@7.0.200200' - '@block65/rest-client@14.2.0(valibot@1.5.0(typescript@7.0.2))': + '@block65/rest-client@15.0.0(valibot@1.5.0(typescript@7.0.2))': dependencies: '@block65/custom-error': 14.1.0 '@standard-schema/spec': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6ba1058..3451a80 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ minimumReleaseAgeExclude: - "@block65/oxlint-tsgolint@7.0.200200" - "@block65/oxlint@1.83.0" - "@block65/oxlint-plugin@0.10.0" - - "@block65/rest-client@14.2.0" + - "@block65/rest-client@15.0.0" - "@block65/shared-config@0.4.0" overrides: From 9223faae9bfb8b26583fef9aed85416a1bb5488d Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 22 Sep 2026 20:33:21 +0800 Subject: [PATCH 2/5] feat(query): emit the document's query serializer on each command A command whose query parameters state a style other than form with explode overrides `querySerializer` with the rest-client serializer for that style, imported by name. A command that states none inherits `formExplodeSerializer`, the OpenAPI default. One serializer covers a whole operation, so an operation whose parameters need two of them stops generation with the conflicting pair named. The roundtrip test moves to a serializer test that drives the emitted command through rest-client and reads the query back from the URL. Co-Authored-By: LLM --- README.md | 7 +- .../query-serializer.test.ts.snap | 36 ++++ __tests__/generate.ts | 49 +++++ __tests__/query-roundtrip.test.ts | 86 +------- __tests__/query-serializer.test.ts | 183 ++++++++++++++++++ lib/process-document.ts | 128 +++++++++--- 6 files changed, 374 insertions(+), 115 deletions(-) create mode 100644 __tests__/__snapshots__/query-serializer.test.ts.snap create mode 100644 __tests__/generate.ts create mode 100644 __tests__/query-serializer.test.ts diff --git a/README.md b/README.md index 46f54b7..188be4d 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,11 @@ carrying the `style` and `explode` each parameter declares: import { listPetsQueryParams } from "./generated/hono.ts"; ``` -The client half is already handled: `@block65/rest-client` encodes each -parameter from the `queryStyles` the generated command carries. +The client half is already handled: a generated command names the +`@block65/rest-client` serializer for the style its document states, and +inherits `formExplodeSerializer` — form with explode, the OpenAPI default — +when it names none. One serializer covers a whole operation, so an operation +whose query parameters need two of them stops generation. ## Linting generated output diff --git a/__tests__/__snapshots__/query-serializer.test.ts.snap b/__tests__/__snapshots__/query-serializer.test.ts.snap new file mode 100644 index 0000000..13bb741 --- /dev/null +++ b/__tests__/__snapshots__/query-serializer.test.ts.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`sortQuery orders the generated query in the URL > findPets 1`] = `"https://example.com/api/pets?limit=10&tags=cat&tags=dog"`; + +exports[`sortQuery orders the generated query in the URL > imageCreate 1`] = `"https://example.com/v1.43/images/create?changes=ENV%20a%3D1,ENV%20b%3D2&fromImage=alpine&platform=linux%2Famd64&tag=latest"`; + +exports[`the generated query reaches the URL in document order > findPets 1`] = `"https://example.com/api/pets?tags=cat&tags=dog&limit=10"`; + +exports[`the generated query reaches the URL in document order > imageCreate 1`] = `"https://example.com/v1.43/images/create?fromImage=alpine&tag=latest&changes=ENV%20a%3D1,ENV%20b%3D2&platform=linux%2Famd64"`; + +exports[`the source of a command that names a serializer 1`] = ` +"import { Command, stripUndefined, deepObjectSerializer } from "@block65/rest-client"; + +export class ListThingsCommand extends Command, ListThingsCommandOutput, ListThingsCommandQuery> { + public override method = "get" as const; + public override querySerializer = deepObjectSerializer; + + constructor(input?: UndefinedOnPartialDeep) { + const {limit, filter } = input ?? {}; + super("/things", undefined, stripUndefined({limit, filter})); + } +}" +`; + +exports[`the source of a command that names none 1`] = ` +"import { Command, stripUndefined } from "@block65/rest-client"; + +export class ListThingsCommand extends Command, ListThingsCommandOutput, ListThingsCommandQuery> { + public override method = "get" as const; + + constructor(input?: UndefinedOnPartialDeep) { + const {limit, tags } = input ?? {}; + super("/things", undefined, stripUndefined({limit, tags})); + } +}" +`; diff --git a/__tests__/generate.ts b/__tests__/generate.ts new file mode 100644 index 0000000..bc7b76c --- /dev/null +++ b/__tests__/generate.ts @@ -0,0 +1,49 @@ +import path from "node:path"; +import type { oas31 } from "openapi3-ts"; +import { processOpenApiDocument } from "../lib/process-document.ts"; + +// OAS 3.2 added `in: "querystring"`, which the 3.1 types predate +export type TestParameter = + | oas31.ParameterObject + | { + name: string; + in: "querystring"; + content: oas31.ParameterObject["content"]; + }; + +export function documentFor( + parameters: readonly TestParameter[], +): oas31.OpenAPIObject { + return { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/things": { + get: { + operationId: "listThingsCommand", + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TestParameter widens the 3.1 union by the one 3.2 location these tests exercise, and processOpenApiDocument takes a 3.1 document + parameters: parameters as oas31.ParameterObject[], + responses: { + "200": { + description: "OK", + content: { "application/json": { schema: { type: "string" } } }, + }, + }, + }, + }, + }, + }; +} + +export async function generateFor(parameters: readonly TestParameter[]) { + // This path names the emitted files, which stay in memory + const outputDir = path.join(import.meta.dirname, ".generated"); + + return processOpenApiDocument(outputDir, documentFor(parameters)); +} + +export async function commandsFor(parameters: readonly TestParameter[]) { + const result = await generateFor(parameters); + + return result.commandsFile.getText(); +} diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index bcb3cf4..b17c70d 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -1,11 +1,10 @@ -import path from "node:path"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { oas31 } from "openapi3-ts"; import { expect, test } from "vitest"; -import { processOpenApiDocument } from "../lib/process-document.ts"; import { listauditlogs } from "./fixtures/openai/hono.ts"; import { findPets } from "./fixtures/petstore/hono.ts"; +import { generateFor, type TestParameter } from "./generate.ts"; // Runs a real query string through the generated middleware and back out async function validatedQuery( @@ -79,55 +78,6 @@ function appFor(middleware: readonly MiddlewareHandler[]) { return app; } -// OAS 3.2 added `in: "querystring"`, which the 3.1 types predate -type TestParameter = - | oas31.ParameterObject - | { - name: string; - in: "querystring"; - content: oas31.ParameterObject["content"]; - }; - -async function commandsFor(parameters: readonly TestParameter[]) { - const result = await processOpenApiDocument( - path.join(import.meta.dirname, ".generated"), - documentFor(parameters), - ); - - return result.commandsFile.getText(); -} - -function documentFor( - parameters: readonly TestParameter[], -): oas31.OpenAPIObject { - return { - openapi: "3.1.0", - info: { title: "Test", version: "1.0.0" }, - paths: { - "/things": { - get: { - operationId: "listThingsCommand", - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TestParameter widens the 3.1 union by the one 3.2 location these tests exercise, and processOpenApiDocument takes a 3.1 document - parameters: parameters as oas31.ParameterObject[], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { type: "string" } } }, - }, - }, - }, - }, - }, - }; -} - -async function generateFor(parameters: readonly TestParameter[]) { - // This path names the emitted files, which stay in memory - const outputDir = path.join(import.meta.dirname, ".generated"); - - await processOpenApiDocument(outputDir, documentFor(parameters)); -} - // Collects what the generator says while it walks a document async function warningsFrom(parameters: readonly TestParameter[]) { const warnings: string[] = []; @@ -143,40 +93,6 @@ async function warningsFrom(parameters: readonly TestParameter[]) { return warnings.join("\n"); } -// rest-client's QueryParameterStyle union is these four, and its -// appendSearchParams reads form with explode as the default -test("a departure from the default encoding is listed in queryStyles", async () => { - const commands = await commandsFor([ - { - name: "names", - in: "query", - style: "pipeDelimited", - explode: false, - schema: { type: "array", items: { type: "string" } }, - }, - ]); - - expect(commands).toContain("public override queryStyles"); - expect(commands).toContain( - '"names": { style: "pipeDelimited", explode: false }', - ); -}); - -// An unlisted parameter takes rest-client's default, so listing it is noise -test("the default encoding is left out of queryStyles", async () => { - const commands = await commandsFor([ - { - name: "tags", - in: "query", - style: "form", - explode: true, - schema: { type: "array", items: { type: "string" } }, - }, - ]); - - expect(commands).not.toContain("queryStyles"); -}); - // rest-client encodes only these four test("a style rest-client cannot encode stops generation", async () => { await expect( diff --git a/__tests__/query-serializer.test.ts b/__tests__/query-serializer.test.ts new file mode 100644 index 0000000..5b58f4d --- /dev/null +++ b/__tests__/query-serializer.test.ts @@ -0,0 +1,183 @@ +import { RestServiceClient } from "@block65/rest-client"; +import type { oas31 } from "openapi3-ts"; +import { assert, expect, test, vi } from "vitest"; +import { ImageCreateCommand } from "./fixtures/docker/commands.ts"; +import { FindPetsCommand } from "./fixtures/petstore/commands.ts"; +import { SwaggerPetstoreRestClient } from "./fixtures/petstore/main.ts"; +import { commandsFor, generateFor, type TestParameter } from "./generate.ts"; + +const arrayOfStrings: oas31.SchemaObject = { + type: "array", + items: { type: "string" }, +}; + +const rangeSchema: oas31.SchemaObject = { + type: "object", + properties: { + gt: { type: "integer" }, + lte: { type: "integer" }, + }, +}; + +// What a serializer writes is rest-client's to test, from the spec's own +// examples. What is tested here is which one a command names +test("a departure from the default encoding names a serializer", async () => { + const commands = await commandsFor([ + { + name: "names", + in: "query", + style: "pipeDelimited", + explode: false, + schema: arrayOfStrings, + }, + ]); + + expect(commands).toContain( + "public override querySerializer = pipeDelimitedSerializer;", + ); + expect(commands).toMatch( + /import \{[^}]*pipeDelimitedSerializer[^}]*\} from "@block65\/rest-client"/, + ); +}); + +// An unnamed serializer is form with explode, so naming it would be noise +test("the default encoding names nothing", async () => { + const commands = await commandsFor([ + { + name: "tags", + in: "query", + style: "form", + explode: true, + schema: arrayOfStrings, + }, + ]); + + expect(commands).not.toContain("querySerializer"); +}); + +// One serializer covers the whole query. A scalar reads alike under every +// style, and deepObject writes an array as form with explode, so deepObject +// is the serializer all three parameters share +test("a deepObject parameter sets the serializer for its operation", async () => { + const commands = await commandsFor([ + { name: "limit", in: "query", schema: { type: "integer" } }, + { name: "tags", in: "query", schema: arrayOfStrings }, + { name: "filter", in: "query", style: "deepObject", schema: rangeSchema }, + ]); + + expect(commands).toContain( + "public override querySerializer = deepObjectSerializer;", + ); +}); + +// A command names one serializer, so two parameters that need different +// ones stop generation +test("query parameters needing different serializers stop generation", async () => { + await expect( + generateFor([ + { + name: "tags", + in: "query", + style: "form", + explode: false, + schema: arrayOfStrings, + }, + { name: "filter", in: "query", style: "deepObject", schema: rangeSchema }, + ]), + ).rejects.toThrow("are written by different serializers"); +}); + +// A consumer compiles against the import and the property +async function commandSourceFor(parameters: readonly TestParameter[]) { + const { commandsFile } = await generateFor(parameters); + + const restClientImport = commandsFile.getImportDeclarationOrThrow( + (declaration) => + declaration.getModuleSpecifier().getLiteralValue() === + "@block65/rest-client", + ); + + const [command] = commandsFile.getClasses(); + assert(command); + + return [restClientImport.getText(), command.getText()].join("\n\n"); +} + +test("the source of a command that names a serializer", async () => { + await expect( + commandSourceFor([ + { name: "limit", in: "query", schema: { type: "integer" } }, + { name: "filter", in: "query", style: "deepObject", schema: rangeSchema }, + ]), + ).resolves.toMatchSnapshot(); +}); + +test("the source of a command that names none", async () => { + await expect( + commandSourceFor([ + { name: "limit", in: "query", schema: { type: "integer" } }, + { name: "tags", in: "query", schema: arrayOfStrings }, + ]), + ).resolves.toMatchSnapshot(); +}); + +function fetchStub() { + return vi.fn(async () => Response.json({})); +} + +function urlFrom(fetch: ReturnType) { + expect(fetch).toHaveBeenCalledOnce(); + + const [url] = fetch.mock.calls[0] ?? []; + assert(url instanceof URL); + + return url.href; +} + +// findPets declares tags before limit, which is not alphabetical order +async function findPetsUrl(sortQuery?: true) { + const fetch = fetchStub(); + const client = new SwaggerPetstoreRestClient( + new URL("https://example.com/api/"), + { fetch, sortQuery }, + ); + + await client.json(new FindPetsCommand({ tags: ["cat", "dog"], limit: "10" })); + + return urlFrom(fetch); +} + +// ImageCreate declares seven query parameters and names formJoinSerializer +async function imageCreateUrl(sortQuery?: true) { + const fetch = fetchStub(); + + // ImageCreate leaves its response unspecified, so the base client sends it + const client = new RestServiceClient(new URL("https://example.com/v1.43/"), { + fetch, + sortQuery, + }); + + await client.json( + new ImageCreateCommand({ + body: "", + tag: "latest", + fromImage: "alpine", + changes: ["ENV a=1", "ENV b=2"], + platform: "linux/amd64", + }), + ); + + return urlFrom(fetch); +} + +// A change in how the constructor assembles the query — the destructuring, +// stripUndefined, a spread — moves these keys +test("the generated query reaches the URL in document order", async () => { + await expect(findPetsUrl()).resolves.toMatchSnapshot("findPets"); + await expect(imageCreateUrl()).resolves.toMatchSnapshot("imageCreate"); +}); + +test("sortQuery orders the generated query in the URL", async () => { + await expect(findPetsUrl(true)).resolves.toMatchSnapshot("findPets"); + await expect(imageCreateUrl(true)).resolves.toMatchSnapshot("imageCreate"); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index fbaf0cc..d96474d 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -652,37 +652,104 @@ function collectParameters( return parameters; } -function addQueryStyles( +// rest-client exports one serializer per OAS 3.2 §4.12.6 query style +const defaultSerializer = "formExplodeSerializer"; + +const serializers = { + form: "formJoinSerializer", + spaceDelimited: "spaceDelimitedSerializer", + pipeDelimited: "pipeDelimitedSerializer", + deepObject: "deepObjectSerializer", +} as const satisfies Record; + +function serializerFor(spec: QueryParamSpec) { + return spec.style === "form" && spec.explode + ? defaultSerializer + : serializers[spec.style]; +} + +// deepObject brackets an object and writes an array as form with explode +function serializersWriting(spec: QueryParamSpec) { + const named = serializerFor(spec); + + return named === defaultSerializer && spec.type === "array" + ? [defaultSerializer, serializers.deepObject] + : [named]; +} + +// A command names one serializer for its whole query +function querySerializerFor( + operationId: string, + queryParameters: oas30.ParameterObject[], +) { + // every serializer writes a scalar alike, so arrays and objects choose + const specs = queryParameters + .map((parameter) => queryParameterSpec(parameter)) + .filter((spec) => spec !== undefined); + + let agreed: string[] = [defaultSerializer, ...Object.values(serializers)]; + + for (const spec of specs) { + const writing = serializersWriting(spec); + + agreed = agreed.filter((name) => writing.includes(name)); + } + + if (agreed.length === 0) { + const written = specs + .map((spec) => { + const label = + spec.style === "form" ? `form, explode: ${spec.explode}` : spec.style; + + return `"${spec.name}" (${label})`; + }) + .join(", "); + + throw new Error( + `${operationId}: query parameters ${written} are written by different serializers, and a command names one for its whole query. Declare one style across the operation's query parameters.`, + ); + } + + // a command inherits form with explode, which leaves it to name nothing + return agreed.includes(defaultSerializer) ? undefined : agreed[0]; +} + +function importSerializer(commandsFile: SourceFile, name: string) { + const restClientImport = commandsFile.getImportDeclarationOrThrow( + (declaration) => + declaration.getModuleSpecifier().getLiteralValue() === + "@block65/rest-client", + ); + + const alreadyImported = restClientImport + .getNamedImports() + .some((namedImport) => namedImport.getName() === name); + + if (!alreadyImported) { + restClientImport.addNamedImport(name); + } +} + +function addQuerySerializer( + commandsFile: SourceFile, commandClass: ClassDeclaration, + operationId: string, queryParameters: oas30.ParameterObject[], ) { - // rest-client reads form with explode as the default, so only a - // departure from it is listed - const queryStyleEntries = queryParameters - .map( - (parameter) => - [parameter.name, queryParameterEncoding(parameter)] as const, - ) - .filter(([, encoding]) => encoding.style !== "form" || !encoding.explode); - - if (queryStyleEntries.length > 0) { - commandClass.addProperty({ - name: "queryStyles", - hasOverrideKeyword: true, - scope: Scope.Public, - initializer: (writer) => { - writer.write("{"); - writer.indent(() => { - for (const [name, encoding] of queryStyleEntries) { - writer.writeLine( - `${JSON.stringify(name)}: { style: ${JSON.stringify(encoding.style)}, explode: ${encoding.explode} },`, - ); - } - }); - writer.write("} as const"); - }, - }); + const serializer = querySerializerFor(operationId, queryParameters); + + if (!serializer) { + return; } + + importSerializer(commandsFile, serializer); + + commandClass.addProperty({ + name: "querySerializer", + hasOverrideKeyword: true, + scope: Scope.Public, + initializer: serializer, + }); } function parameterProperty( @@ -1542,7 +1609,12 @@ function processOperation( operationObject, ); - addQueryStyles(command.commandClass, queryParameters); + addQuerySerializer( + documentCtx.commandsFile, + command.commandClass, + operationObject.operationId, + queryParameters, + ); const queryType = addQueryType(documentCtx, command, queryParameters); const headerType = addHeaderType(documentCtx, command, headerParameters); From 3d1d40db29983143557c5dd382c388765252a7cb Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 22 Sep 2026 20:33:21 +0800 Subject: [PATCH 3/5] fix(valibot): emit a one-member combinator as its member A one-member `anyOf` or `oneOf` came out as `v.union([member])`, which only wraps the member's issues, and an empty one as `v.union([])`, which fails every input where the document constrains nothing. The member now stands alone and the empty case is `v.unknown()`. block65/require-union-options rejects both of the old shapes. Co-Authored-By: LLM --- __tests__/codegen-regressions.test.ts | 32 +++++++++++++++++++++++++++ lib/valibot.ts | 12 ++++++++++ 2 files changed, 44 insertions(+) diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index d2bcb11..774b2f7 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -229,6 +229,38 @@ test("additionalProperties chooses the object schema", async () => { } }); +// A one-member `anyOf` or `oneOf` is that member. `v.union` of one option +// only wraps its issues, and the block65 valibot rules reject it +test("a single-member combinator emits the member alone", async () => { + const cases: [oas31.SchemaObject, string][] = [ + [{ anyOf: [{ type: "string" }] }, "v.string()"], + [{ oneOf: [{ type: "string" }] }, "v.string()"], + [ + { oneOf: [{ type: "string" }, { type: "number" }] }, + "v.union([v.string(), v.number()])", + ], + ]; + + const emitted = await Promise.all( + cases.map(async ([schema]) => { + const result = await processOpenApiDocument( + "/tmp/single-member-combinator", + docWithSchema("Only", schema), + ); + + const text = result.valibotFile.getText(); + + return text.slice(text.indexOf("export const inputOnlySchema"), -1); + }), + ); + + for (const [index, [, expected]] of cases.entries()) { + expect(emitted[index]).toContain( + `export const inputOnlySchema = ${expected};`, + ); + } +}); + function docWithSchema(name: string, schema: oas31.SchemaObject) { return { openapi: "3.1.0", diff --git a/lib/valibot.ts b/lib/valibot.ts index d3de707..7a4c53d 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -603,6 +603,18 @@ function combinatorValidator( const variants = combinator.map((s) => schemaToValidator(validators, s, mode), ); + const [only, second] = variants; + + // a one-member combinator is that member. `v.union` of one option only + // wraps its issues, and an empty list would fail every input where the + // document constrains nothing + if (only === undefined) { + return maybeNullable(vcall("unknown"), isNullable); + } + + if (second === undefined) { + return maybeNullable(only, isNullable); + } if (schema.oneOf && schema.discriminator?.propertyName) { return maybeNullable( From f31876323590e772054f8ae0e420ecc1465b12e3 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 22 Sep 2026 20:33:21 +0800 Subject: [PATCH 4/5] build(lint): make the generated modules clean under the valibot group A consumer on shared-config 0.4.0 with the valibot group enabled saw three rules fire on the generated valibot module that it could do nothing about. `defineOverrides` now turns them off with the reason for each: an object schema is open unless the document closes it, input schemas accept an explicit `undefined` for a TS caller, and property spelling is the document's wire contract. The repo's own config enables the group so the fixtures lint the way a consumer's do. Co-Authored-By: LLM --- __tests__/manifest.test.ts | 1 + lib/oxlint.ts | 13 +++++++++++++ oxlint.config.ts | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/__tests__/manifest.test.ts b/__tests__/manifest.test.ts index 3e8f469..505c31b 100644 --- a/__tests__/manifest.test.ts +++ b/__tests__/manifest.test.ts @@ -75,6 +75,7 @@ test("a stale emitter revision rewrites every file", async () => { await writeFile(target, "// edited by hand\n"); await writeFile( manifestPath, + // oxlint-disable-next-line block65/snake-case-wire-keys -- the manifest's own key, read back by this generator JSON.stringify({ ...manifest, "#generator": "0".repeat(32) }), ); diff --git a/lib/oxlint.ts b/lib/oxlint.ts index bc30ef5..a99244b 100644 --- a/lib/oxlint.ts +++ b/lib/oxlint.ts @@ -56,6 +56,19 @@ export function defineOverrides( // contract, so the generated code destructures that name "block65/no-single-character-declaration": "off", "unicorn/max-nested-calls": "off", + + // property spelling is the document's wire contract too + "block65/snake-case-wire-keys": "off", + + // an object schema is open unless the document sets + // `additionalProperties: false`, so `looseObject` accepts the + // unnamed keys a peer may send + "block65/prefer-strict-object": "off", + + // input schemas face TS callers, who may pass an explicit + // `undefined` for an absent member. The wire schemas use + // `exactOptional` + "block65/prefer-exact-optional": "off", }, }, ], diff --git a/oxlint.config.ts b/oxlint.config.ts index c4058df..1baecdd 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from "@block65/shared-config/oxlint"; import * as codegenPlugin from "./lib/oxlint.ts"; export default defineConfig({ - groups: { vitest: "on" }, + // a consumer's config enables the valibot group, so the fixtures lint under it + groups: { vitest: "on", valibot: "on" }, overrides: codegenPlugin.defineOverrides("__tests__/fixtures/*"), }); From b73922e1b7a2b9607857116e114d251b11712963 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 22 Sep 2026 20:33:21 +0800 Subject: [PATCH 5/5] build: regenerate the fixtures The docker fixture names `formJoinSerializer` on its two form-without- explode commands. The openai fixture loses nine one-member unions. The `#generator` stamp moves in every manifest. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 4 +- __tests__/fixtures/docker/commands.ts | 15 ++++---- .../openai/.openapi-codegen-manifest.json | 4 +- __tests__/fixtures/openai/valibot.ts | 37 +++++++------------ .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- 6 files changed, 28 insertions(+), 36 deletions(-) diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index b241c60..89d70c1 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,6 +1,6 @@ { - "#generator": "d42d93cf60354fd3599a5861038e3bec", - "commands.ts": "26c79ac152c76497332be8d70b6d9ef7", + "#generator": "d9c25059b8f56e84cd41d51232262509", + "commands.ts": "20f63fd7ad412eb1f03ae5cfc4b81df9", "types.ts": "72a44fac13cd4872db19c1a64b11a6ea", "main.ts": "0f596fab7f6e9bb140fcd133caccb38d", "valibot.ts": "822492c19be03028e145c1819bc12f83", diff --git a/__tests__/fixtures/docker/commands.ts b/__tests__/fixtures/docker/commands.ts index a552fe9..4c5658c 100644 --- a/__tests__/fixtures/docker/commands.ts +++ b/__tests__/fixtures/docker/commands.ts @@ -4,7 +4,12 @@ * Do not edit directly */ -import { Command, stripUndefined, jsonStringify } from "@block65/rest-client"; +import { + Command, + stripUndefined, + jsonStringify, + formJoinSerializer, +} from "@block65/rest-client"; import type { Except, UndefinedOnPartialDeep } from "type-fest"; import type { ContainerListCommandQuery, @@ -1097,9 +1102,7 @@ export class ImageCreateCommand extends Command< ImageCreateCommandHeader > { public override method = "post" as const; - public override queryStyles = { - changes: { style: "form", explode: false }, - } as const; + public override querySerializer = formJoinSerializer; constructor( input: UndefinedOnPartialDeep> & @@ -1520,9 +1523,7 @@ export class ImageGetAllCommand extends Command< ImageGetAllCommandQuery > { public override method = "get" as const; - public override queryStyles = { - names: { style: "form", explode: false }, - } as const; + public override querySerializer = formJoinSerializer; constructor(input?: UndefinedOnPartialDeep) { const { names } = input ?? {}; diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index d7efbb3..3b3664a 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "d42d93cf60354fd3599a5861038e3bec", + "#generator": "d9c25059b8f56e84cd41d51232262509", "commands.ts": "7d2187eb106fc582735b22176033ea28", "types.ts": "e2ca3f6a2e1e1a382f4d11f187b341df", "main.ts": "5ba91c2efb44e3e5c5bd1e1a1b90bb51", - "valibot.ts": "d43862b947bcf47a57a94d14daa0cc29", + "valibot.ts": "1ce0f05f51117be27a3719d479c82790", "hono.ts": "f5768dde31ada252b89074452fe2f549", "commands-validated.ts": "e4929484f6a64b784ff7b03327f58a3a", "enums.ts": "350bddfda5b5eb357bbf6cff60f1808f" diff --git a/__tests__/fixtures/openai/valibot.ts b/__tests__/fixtures/openai/valibot.ts index 5b85ae9..b1f198f 100644 --- a/__tests__/fixtures/openai/valibot.ts +++ b/__tests__/fixtures/openai/valibot.ts @@ -1376,7 +1376,7 @@ export const inputCreateFineTuningJobRequestSchema = v.looseObject({ * The type of integration to enable. Currently, only "wandb" (Weights and * Biases) is supported. */ - type: v.union([v.picklist(["wandb"])]), + type: v.picklist(["wandb"]), /** * The settings for your integration with Weights and Biases. This payload * specifies the project that @@ -1524,7 +1524,7 @@ export const createFineTuningJobRequestSchema = v.looseObject({ * The type of integration to enable. Currently, only "wandb" (Weights and * Biases) is supported. */ - type: v.union([v.picklist(["wandb"])]), + type: v.picklist(["wandb"]), /** * The settings for your integration with Weights and Biases. This payload * specifies the project that @@ -6739,12 +6739,10 @@ export const threadObjectSchema = v.looseObject({ */ metadata: v.nullable(v.record(v.string(), v.unknown())), }); -export const inputThreadStreamEventSchema = v.union([ - v.looseObject({ - event: v.picklist(["thread.created"]), - data: inputThreadObjectSchema, - }), -]); +export const inputThreadStreamEventSchema = v.looseObject({ + event: v.picklist(["thread.created"]), + data: inputThreadObjectSchema, +}); export const threadStreamEventSchema = inputThreadStreamEventSchema; /** * Represents an event emitted when streaming a Run. @@ -8653,7 +8651,7 @@ export const inputModifyAssistantRequestSchema = v.strictObject({ * models, or see our [Model overview](/docs/models/overview) for descriptions * of them. */ - model: v.optional(v.union([v.string()])), + model: v.optional(v.string()), /** * The name of the assistant. The maximum length is 256 characters. */ @@ -8755,7 +8753,7 @@ export const modifyAssistantRequestSchema = v.strictObject({ * models, or see our [Model overview](/docs/models/overview) for descriptions * of them. */ - model: v.exactOptional(v.union([v.pipe(v.string(), v.trim())])), + model: v.exactOptional(v.pipe(v.string(), v.trim())), /** * The name of the assistant. The maximum length is 256 characters. */ @@ -10080,10 +10078,7 @@ export const inputFineTuningJobSchema = v.looseObject({ */ integrations: v.optional( v.nullable( - v.pipe( - v.array(v.union([inputFineTuningIntegrationSchema])), - v.maxLength(5), - ), + v.pipe(v.array(inputFineTuningIntegrationSchema), v.maxLength(5)), ), ), /** @@ -10204,9 +10199,7 @@ export const fineTuningJobSchema = v.looseObject({ * A list of integrations to enable for this fine-tuning job. */ integrations: v.exactOptional( - v.nullable( - v.pipe(v.array(v.union([fineTuningIntegrationSchema])), v.maxLength(5)), - ), + v.nullable(v.pipe(v.array(fineTuningIntegrationSchema), v.maxLength(5))), ), /** * The seed used for the fine-tuning job. @@ -10683,9 +10676,8 @@ export const chatCompletionRequestMessageContentPartTextSchema = v.looseObject({ */ text: v.pipe(v.string(), v.trim()), }); -export const inputChatCompletionRequestToolMessageContentPartSchema = v.union([ - inputChatCompletionRequestMessageContentPartTextSchema, -]); +export const inputChatCompletionRequestToolMessageContentPartSchema = + inputChatCompletionRequestMessageContentPartTextSchema; export const chatCompletionRequestToolMessageContentPartSchema = inputChatCompletionRequestToolMessageContentPartSchema; export const inputChatCompletionRequestToolMessageSchema = v.looseObject({ @@ -11004,9 +10996,8 @@ export const chatCompletionRequestUserMessageSchema = v.looseObject({ */ name: v.exactOptional(v.pipe(v.string(), v.trim())), }); -export const inputChatCompletionRequestSystemMessageContentPartSchema = v.union( - [inputChatCompletionRequestMessageContentPartTextSchema], -); +export const inputChatCompletionRequestSystemMessageContentPartSchema = + inputChatCompletionRequestMessageContentPartTextSchema; export const chatCompletionRequestSystemMessageContentPartSchema = inputChatCompletionRequestSystemMessageContentPartSchema; export const inputChatCompletionRequestSystemMessageSchema = v.looseObject({ diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index f417a8c..36c5336 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "d42d93cf60354fd3599a5861038e3bec", + "#generator": "d9c25059b8f56e84cd41d51232262509", "commands.ts": "608af748764e3adf1fd212532dfabd10", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index a8f6694..2366456 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "d42d93cf60354fd3599a5861038e3bec", + "#generator": "d9c25059b8f56e84cd41d51232262509", "commands.ts": "19fd590dfc5cc8616a70dcf78326c027", "types.ts": "a5a7fef55f948f68f724f7e50ba282ca", "main.ts": "1e2091a697e1aa9d8b770d777c172bf8",