Skip to content

Commit cefc46a

Browse files
authored
Merge pull request #23 from block65/feat/query-serializer-emission
feat(query): emit the document's query serializer on rest-client 15
2 parents 0027bfc + b73922e commit cefc46a

20 files changed

Lines changed: 470 additions & 160 deletions

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ carrying the `style` and `explode` each parameter declares:
5858
import { listPetsQueryParams } from "./generated/hono.ts";
5959
```
6060

61-
The client half is already handled: `@block65/rest-client` encodes each
62-
parameter from the `queryStyles` the generated command carries.
61+
The client half is already handled: a generated command names the
62+
`@block65/rest-client` serializer for the style its document states, and
63+
inherits `formExplodeSerializer` — form with explode, the OpenAPI default —
64+
when it names none. One serializer covers a whole operation, so an operation
65+
whose query parameters need two of them stops generation.
6366

6467
## Linting generated output
6568

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`sortQuery orders the generated query in the URL > findPets 1`] = `"https://example.com/api/pets?limit=10&tags=cat&tags=dog"`;
4+
5+
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"`;
6+
7+
exports[`the generated query reaches the URL in document order > findPets 1`] = `"https://example.com/api/pets?tags=cat&tags=dog&limit=10"`;
8+
9+
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"`;
10+
11+
exports[`the source of a command that names a serializer 1`] = `
12+
"import { Command, stripUndefined, deepObjectSerializer } from "@block65/rest-client";
13+
14+
export class ListThingsCommand extends Command<UndefinedOnPartialDeep<ListThingsCommandInput>, ListThingsCommandOutput, ListThingsCommandQuery> {
15+
public override method = "get" as const;
16+
public override querySerializer = deepObjectSerializer;
17+
18+
constructor(input?: UndefinedOnPartialDeep<ListThingsCommandInput>) {
19+
const {limit, filter } = input ?? {};
20+
super("/things", undefined, stripUndefined({limit, filter}));
21+
}
22+
}"
23+
`;
24+
25+
exports[`the source of a command that names none 1`] = `
26+
"import { Command, stripUndefined } from "@block65/rest-client";
27+
28+
export class ListThingsCommand extends Command<UndefinedOnPartialDeep<ListThingsCommandInput>, ListThingsCommandOutput, ListThingsCommandQuery> {
29+
public override method = "get" as const;
30+
31+
constructor(input?: UndefinedOnPartialDeep<ListThingsCommandInput>) {
32+
const {limit, tags } = input ?? {};
33+
super("/things", undefined, stripUndefined({limit, tags}));
34+
}
35+
}"
36+
`;

‎__tests__/codegen-regressions.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,38 @@ test("additionalProperties chooses the object schema", async () => {
229229
}
230230
});
231231

232+
// A one-member `anyOf` or `oneOf` is that member. `v.union` of one option
233+
// only wraps its issues, and the block65 valibot rules reject it
234+
test("a single-member combinator emits the member alone", async () => {
235+
const cases: [oas31.SchemaObject, string][] = [
236+
[{ anyOf: [{ type: "string" }] }, "v.string()"],
237+
[{ oneOf: [{ type: "string" }] }, "v.string()"],
238+
[
239+
{ oneOf: [{ type: "string" }, { type: "number" }] },
240+
"v.union([v.string(), v.number()])",
241+
],
242+
];
243+
244+
const emitted = await Promise.all(
245+
cases.map(async ([schema]) => {
246+
const result = await processOpenApiDocument(
247+
"/tmp/single-member-combinator",
248+
docWithSchema("Only", schema),
249+
);
250+
251+
const text = result.valibotFile.getText();
252+
253+
return text.slice(text.indexOf("export const inputOnlySchema"), -1);
254+
}),
255+
);
256+
257+
for (const [index, [, expected]] of cases.entries()) {
258+
expect(emitted[index]).toContain(
259+
`export const inputOnlySchema = ${expected};`,
260+
);
261+
}
262+
});
263+
232264
function docWithSchema(name: string, schema: oas31.SchemaObject) {
233265
return {
234266
openapi: "3.1.0",

‎__tests__/fixtures/docker/.openapi-codegen-manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"#generator": "d42d93cf60354fd3599a5861038e3bec",
3-
"commands.ts": "26c79ac152c76497332be8d70b6d9ef7",
2+
"#generator": "d9c25059b8f56e84cd41d51232262509",
3+
"commands.ts": "20f63fd7ad412eb1f03ae5cfc4b81df9",
44
"types.ts": "72a44fac13cd4872db19c1a64b11a6ea",
55
"main.ts": "0f596fab7f6e9bb140fcd133caccb38d",
66
"valibot.ts": "822492c19be03028e145c1819bc12f83",

‎__tests__/fixtures/docker/commands.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
* Do not edit directly
55
*/
66

7-
import { Command, stripUndefined, jsonStringify } from "@block65/rest-client";
7+
import {
8+
Command,
9+
stripUndefined,
10+
jsonStringify,
11+
formJoinSerializer,
12+
} from "@block65/rest-client";
813
import type { Except, UndefinedOnPartialDeep } from "type-fest";
914
import type {
1015
ContainerListCommandQuery,
@@ -1097,9 +1102,7 @@ export class ImageCreateCommand extends Command<
10971102
ImageCreateCommandHeader
10981103
> {
10991104
public override method = "post" as const;
1100-
public override queryStyles = {
1101-
changes: { style: "form", explode: false },
1102-
} as const;
1105+
public override querySerializer = formJoinSerializer;
11031106

11041107
constructor(
11051108
input: UndefinedOnPartialDeep<Except<ImageCreateCommandInput, "body">> &
@@ -1520,9 +1523,7 @@ export class ImageGetAllCommand extends Command<
15201523
ImageGetAllCommandQuery
15211524
> {
15221525
public override method = "get" as const;
1523-
public override queryStyles = {
1524-
names: { style: "form", explode: false },
1525-
} as const;
1526+
public override querySerializer = formJoinSerializer;
15261527

15271528
constructor(input?: UndefinedOnPartialDeep<ImageGetAllCommandInput>) {
15281529
const { names } = input ?? {};

‎__tests__/fixtures/openai/.openapi-codegen-manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
2-
"#generator": "d42d93cf60354fd3599a5861038e3bec",
2+
"#generator": "d9c25059b8f56e84cd41d51232262509",
33
"commands.ts": "7d2187eb106fc582735b22176033ea28",
44
"types.ts": "e2ca3f6a2e1e1a382f4d11f187b341df",
55
"main.ts": "5ba91c2efb44e3e5c5bd1e1a1b90bb51",
6-
"valibot.ts": "d43862b947bcf47a57a94d14daa0cc29",
6+
"valibot.ts": "1ce0f05f51117be27a3719d479c82790",
77
"hono.ts": "f5768dde31ada252b89074452fe2f549",
88
"commands-validated.ts": "e4929484f6a64b784ff7b03327f58a3a",
99
"enums.ts": "350bddfda5b5eb357bbf6cff60f1808f"

‎__tests__/fixtures/openai/valibot.ts‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,7 +1376,7 @@ export const inputCreateFineTuningJobRequestSchema = v.looseObject({
13761376
* The type of integration to enable. Currently, only "wandb" (Weights and
13771377
* Biases) is supported.
13781378
*/
1379-
type: v.union([v.picklist(["wandb"])]),
1379+
type: v.picklist(["wandb"]),
13801380
/**
13811381
* The settings for your integration with Weights and Biases. This payload
13821382
* specifies the project that
@@ -1524,7 +1524,7 @@ export const createFineTuningJobRequestSchema = v.looseObject({
15241524
* The type of integration to enable. Currently, only "wandb" (Weights and
15251525
* Biases) is supported.
15261526
*/
1527-
type: v.union([v.picklist(["wandb"])]),
1527+
type: v.picklist(["wandb"]),
15281528
/**
15291529
* The settings for your integration with Weights and Biases. This payload
15301530
* specifies the project that
@@ -6739,12 +6739,10 @@ export const threadObjectSchema = v.looseObject({
67396739
*/
67406740
metadata: v.nullable(v.record(v.string(), v.unknown())),
67416741
});
6742-
export const inputThreadStreamEventSchema = v.union([
6743-
v.looseObject({
6744-
event: v.picklist(["thread.created"]),
6745-
data: inputThreadObjectSchema,
6746-
}),
6747-
]);
6742+
export const inputThreadStreamEventSchema = v.looseObject({
6743+
event: v.picklist(["thread.created"]),
6744+
data: inputThreadObjectSchema,
6745+
});
67486746
export const threadStreamEventSchema = inputThreadStreamEventSchema;
67496747
/**
67506748
* Represents an event emitted when streaming a Run.
@@ -8653,7 +8651,7 @@ export const inputModifyAssistantRequestSchema = v.strictObject({
86538651
* models, or see our [Model overview](/docs/models/overview) for descriptions
86548652
* of them.
86558653
*/
8656-
model: v.optional(v.union([v.string()])),
8654+
model: v.optional(v.string()),
86578655
/**
86588656
* The name of the assistant. The maximum length is 256 characters.
86598657
*/
@@ -8755,7 +8753,7 @@ export const modifyAssistantRequestSchema = v.strictObject({
87558753
* models, or see our [Model overview](/docs/models/overview) for descriptions
87568754
* of them.
87578755
*/
8758-
model: v.exactOptional(v.union([v.pipe(v.string(), v.trim())])),
8756+
model: v.exactOptional(v.pipe(v.string(), v.trim())),
87598757
/**
87608758
* The name of the assistant. The maximum length is 256 characters.
87618759
*/
@@ -10080,10 +10078,7 @@ export const inputFineTuningJobSchema = v.looseObject({
1008010078
*/
1008110079
integrations: v.optional(
1008210080
v.nullable(
10083-
v.pipe(
10084-
v.array(v.union([inputFineTuningIntegrationSchema])),
10085-
v.maxLength(5),
10086-
),
10081+
v.pipe(v.array(inputFineTuningIntegrationSchema), v.maxLength(5)),
1008710082
),
1008810083
),
1008910084
/**
@@ -10204,9 +10199,7 @@ export const fineTuningJobSchema = v.looseObject({
1020410199
* A list of integrations to enable for this fine-tuning job.
1020510200
*/
1020610201
integrations: v.exactOptional(
10207-
v.nullable(
10208-
v.pipe(v.array(v.union([fineTuningIntegrationSchema])), v.maxLength(5)),
10209-
),
10202+
v.nullable(v.pipe(v.array(fineTuningIntegrationSchema), v.maxLength(5))),
1021010203
),
1021110204
/**
1021210205
* The seed used for the fine-tuning job.
@@ -10683,9 +10676,8 @@ export const chatCompletionRequestMessageContentPartTextSchema = v.looseObject({
1068310676
*/
1068410677
text: v.pipe(v.string(), v.trim()),
1068510678
});
10686-
export const inputChatCompletionRequestToolMessageContentPartSchema = v.union([
10687-
inputChatCompletionRequestMessageContentPartTextSchema,
10688-
]);
10679+
export const inputChatCompletionRequestToolMessageContentPartSchema =
10680+
inputChatCompletionRequestMessageContentPartTextSchema;
1068910681
export const chatCompletionRequestToolMessageContentPartSchema =
1069010682
inputChatCompletionRequestToolMessageContentPartSchema;
1069110683
export const inputChatCompletionRequestToolMessageSchema = v.looseObject({
@@ -11004,9 +10996,8 @@ export const chatCompletionRequestUserMessageSchema = v.looseObject({
1100410996
*/
1100510997
name: v.exactOptional(v.pipe(v.string(), v.trim())),
1100610998
});
11007-
export const inputChatCompletionRequestSystemMessageContentPartSchema = v.union(
11008-
[inputChatCompletionRequestMessageContentPartTextSchema],
11009-
);
10999+
export const inputChatCompletionRequestSystemMessageContentPartSchema =
11000+
inputChatCompletionRequestMessageContentPartTextSchema;
1101011001
export const chatCompletionRequestSystemMessageContentPartSchema =
1101111002
inputChatCompletionRequestSystemMessageContentPartSchema;
1101211003
export const inputChatCompletionRequestSystemMessageSchema = v.looseObject({

‎__tests__/fixtures/petstore/.openapi-codegen-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"#generator": "d42d93cf60354fd3599a5861038e3bec",
2+
"#generator": "d9c25059b8f56e84cd41d51232262509",
33
"commands.ts": "608af748764e3adf1fd212532dfabd10",
44
"types.ts": "ea65c3e67352d4e22b97af80085727b4",
55
"main.ts": "64edb526dcbcbd345e631ccff959f11d",

‎__tests__/fixtures/test1/.openapi-codegen-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"#generator": "d42d93cf60354fd3599a5861038e3bec",
2+
"#generator": "d9c25059b8f56e84cd41d51232262509",
33
"commands.ts": "19fd590dfc5cc8616a70dcf78326c027",
44
"types.ts": "a5a7fef55f948f68f724f7e50ba282ca",
55
"main.ts": "1e2091a697e1aa9d8b770d777c172bf8",

‎__tests__/generate.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import path from "node:path";
2+
import type { oas31 } from "openapi3-ts";
3+
import { processOpenApiDocument } from "../lib/process-document.ts";
4+
5+
// OAS 3.2 added `in: "querystring"`, which the 3.1 types predate
6+
export type TestParameter =
7+
| oas31.ParameterObject
8+
| {
9+
name: string;
10+
in: "querystring";
11+
content: oas31.ParameterObject["content"];
12+
};
13+
14+
export function documentFor(
15+
parameters: readonly TestParameter[],
16+
): oas31.OpenAPIObject {
17+
return {
18+
openapi: "3.1.0",
19+
info: { title: "Test", version: "1.0.0" },
20+
paths: {
21+
"/things": {
22+
get: {
23+
operationId: "listThingsCommand",
24+
// 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
25+
parameters: parameters as oas31.ParameterObject[],
26+
responses: {
27+
"200": {
28+
description: "OK",
29+
content: { "application/json": { schema: { type: "string" } } },
30+
},
31+
},
32+
},
33+
},
34+
},
35+
};
36+
}
37+
38+
export async function generateFor(parameters: readonly TestParameter[]) {
39+
// This path names the emitted files, which stay in memory
40+
const outputDir = path.join(import.meta.dirname, ".generated");
41+
42+
return processOpenApiDocument(outputDir, documentFor(parameters));
43+
}
44+
45+
export async function commandsFor(parameters: readonly TestParameter[]) {
46+
const result = await generateFor(parameters);
47+
48+
return result.commandsFile.getText();
49+
}

0 commit comments

Comments
 (0)