Skip to content

Commit 0dda464

Browse files
maxholmanclaude
andcommitted
feat: split commands into lean + validated files for tree-shaking
Generates two parallel command files: a lean commands.ts with zero schema imports (just HTTP plumbing + types), and commands-validated.ts whose subclasses attach static responseSchema. Consumers swap the import path (or use a bundler alias dev → validated) to opt into runtime response validation without paying for it in prod. valibot lazy-loads in rest-client, so prod imports that skip the validated file never pull valibot at all. Also fixes a longstanding bug: inline 2xx response bodies with nested \$refs (e.g. \`{ user: { \$ref: "..." } }\`) now correctly generate a per-command response schema. Previously the codegen silently fell through, producing commands with no static responseSchema and a silent validation gap. Dead-weight removal: static bodySchema, paramsSchema, querySchema are no longer emitted on Command classes. They were never consumed by rest-client, and server middleware imports schemas directly from valibot.ts. Uses namespace imports (\`import * as commands\` / \`import * as schemas\`) in the validated file to keep generated output compact and stable across regenerations — adding/removing a single command no longer churns the import list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6104a04 commit 0dda464

7 files changed

Lines changed: 223 additions & 98 deletions

File tree

__tests__/__snapshots__/nullables.test.ts.snap

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ export type UploadDataCommandInput = UploadDataCommandParams;
3939
exports[`header parameters 2`] = `
4040
"import { Command } from "@block65/rest-client";
4141
import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js";
42-
import { uploadDataCommandParamsSchema, uploadStatusSchema } from "./valibot.js";
4342
4443
/**
4544
* Tagged template literal that applies encodeURIComponent to all interpolated
@@ -56,8 +55,6 @@ function encodePath(strings: TemplateStringsArray, ...values: string[]): string
5655
*/
5756
export class UploadDataCommand extends Command<UploadDataCommandInput, UploadStatus, never, UploadDataCommandHeader> {
5857
public override method = "post" as const;
59-
static paramsSchema = uploadDataCommandParamsSchema;
60-
static responseSchema = uploadStatusSchema;
6158
6259
constructor(input: UploadDataCommandInput, headers: UploadDataCommandHeader) {
6360
const {uploadId } = input;
@@ -68,10 +65,22 @@ export class UploadDataCommand extends Command<UploadDataCommandInput, UploadSta
6865
`;
6966
7067
exports[`header parameters 3`] = `
68+
"import * as commands from "./commands.js";
69+
import * as schemas from "./valibot.js";
70+
71+
export class UploadDataCommand extends commands.UploadDataCommand {
72+
static responseSchema = schemas.uploadDataCommandResponseSchema;
73+
}
74+
"
75+
`;
76+
77+
exports[`header parameters 4`] = `
7178
"import * as v from "valibot";
7279
7380
export const exactUploadStatusSchema = v.picklist(["pending", "complete"]);
7481
export const uploadStatusSchema = exactUploadStatusSchema;
82+
export const exactUploadDataCommandResponseSchema = exactUploadStatusSchema;
83+
export const uploadDataCommandResponseSchema = uploadStatusSchema;
7584
export const exactUploadDataCommandParamsSchema = v.strictObject({
7685
"uploadId": v.string()
7786
});
@@ -91,7 +100,7 @@ export const uploadDataCommandHeaderSchema = v.object({
91100
"
92101
`;
93102
94-
exports[`header parameters 4`] = `
103+
exports[`header parameters 5`] = `
95104
"import { validator } from "hono/validator";
96105
import * as v from "valibot";
97106
import { PublicValibotHonoError } from "@block65/rest-client";
@@ -148,6 +157,8 @@ export const exactDummySchema = v.string();
148157
export const dummySchema = v.pipe(v.string(), v.trim());
149158
export const exactExpireTimeSchema = v.pipe(v.bigint(), v.minValue(BigInt(0)));
150159
export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.bigint(), v.minValue(BigInt(0)))]);
160+
export const exactListFilesCommandResponseSchema = exactDummySchema;
161+
export const listFilesCommandResponseSchema = dummySchema;
151162
export const exactListFilesCommandQuerySchema = v.strictObject({
152163
"exp": v.pipe(v.bigint(), v.minValue(BigInt(0))),
153164
"limit": v.exactOptional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)))

__tests__/fixtures/petstore.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,52 @@
184184
}
185185
}
186186
}
187+
},
188+
"/pets/{id}/wrapped": {
189+
"get": {
190+
"description": "Returns a pet inside an envelope — exercises inline 2xx response shape with nested $ref",
191+
"operationId": "findPetWrapped",
192+
"parameters": [
193+
{
194+
"name": "id",
195+
"in": "path",
196+
"description": "ID of pet to fetch",
197+
"required": true,
198+
"schema": {
199+
"type": "integer",
200+
"format": "int64"
201+
}
202+
}
203+
],
204+
"responses": {
205+
"200": {
206+
"description": "wrapped pet response",
207+
"content": {
208+
"application/json": {
209+
"schema": {
210+
"type": "object",
211+
"required": ["pet"],
212+
"properties": {
213+
"pet": {
214+
"$ref": "#/components/schemas/Pet"
215+
}
216+
}
217+
}
218+
}
219+
}
220+
},
221+
"default": {
222+
"description": "unexpected error",
223+
"content": {
224+
"application/json": {
225+
"schema": {
226+
"$ref": "#/components/schemas/Error"
227+
}
228+
}
229+
}
230+
}
231+
}
232+
}
187233
}
188234
},
189235
"components": {

__tests__/nullables.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ test("header parameters", async () => {
274274

275275
expect(result.typesFile.getText()).toMatchSnapshot();
276276
expect(result.commandsFile.getText()).toMatchSnapshot();
277+
expect(result.commandsValidatedFile.getText()).toMatchSnapshot();
277278
expect(result.valibotFile.getText()).toMatchSnapshot();
278279
expect(result.honoValibotFile.getText()).toMatchSnapshot();
279280
});

lib/build.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export async function build(
2727

2828
const {
2929
commandsFile,
30+
commandsValidatedFile,
3031
typesFile,
3132
mainFile,
3233
valibotFile,
@@ -40,10 +41,14 @@ export async function build(
4041
mainFile,
4142
valibotFile,
4243
honoValibotFile,
44+
...(commandsValidatedFile.getStatements().length > 0
45+
? [commandsValidatedFile]
46+
: []),
4347
...(enumsFile.getStatements().length > 0 ? [enumsFile] : []),
4448
];
4549

4650
commandsFile.insertStatements(0, "/** eslint-disable max-classes */");
51+
commandsValidatedFile.insertStatements(0, "/** eslint-disable max-classes */");
4752

4853
// eslint-disable-next-line no-restricted-syntax
4954
for await (const file of files) {

lib/hono-valibot.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ export function createHonoValibotFile(
4646
export function createHonoValibotMiddleware(
4747
honoValibotFile: SourceFile,
4848
exportName: string,
49-
schemas: { json?: string; param?: string; query?: string; header?: string },
49+
schemas: {
50+
json?: string;
51+
response?: string;
52+
param?: string;
53+
query?: string;
54+
header?: string;
55+
},
5056
): void {
5157
honoValibotFile.addVariableStatement({
5258
isExported: true,
@@ -57,8 +63,11 @@ export function createHonoValibotMiddleware(
5763
initializer: (writer) => {
5864
writer.write("[");
5965
writer.indent(() => {
66+
// Hono validators only run on inbound request data; response
67+
// schemas are emitted for client-side consumption only, and
68+
// `header` is intentionally skipped (extra HTTP headers ok).
6069
for (const [target, schemaName] of Object.entries(schemas).filter(
61-
([t]) => t !== "header",
70+
([t]) => t !== "header" && t !== "response",
6271
)) {
6372
writer.writeLine(
6473
`validator(${JSON.stringify(target)}, (value) => {`,

0 commit comments

Comments
 (0)