Skip to content

Commit 9206060

Browse files
maxholmanclaude
andcommitted
feat: generate typed header parameters with valibot validation
Collect header params from operations and generate: - Exported *Header type aliases with lowercase keys (HTTP/2 compliant) - Non-strict v.object() valibot schemas allowing extra HTTP headers - Integer/boolean types as native (not stringish) since valibot parses them Header schemas are excluded from hono middleware (no c.req.valid("header") support) but exported from valibot.ts for manual validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 918ac72 commit 9206060

6 files changed

Lines changed: 315 additions & 5 deletions

File tree

__tests__/__snapshots__/nullables.test.ts.snap

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,58 @@ export const nullConstSchema = v.null();
1818
"
1919
`;
2020

21+
exports[`header parameters 1`] = `
22+
"export type UploadStatus = "pending" | "complete";
23+
export type UploadDataCommandHeader = {
24+
"content-type": "application/json" | "text/csv" | "application/xml";
25+
"content-length": number;
26+
"x-idempotency-key"?: string | undefined;
27+
};
28+
export type UploadDataCommandParams = {
29+
uploadId: string;
30+
};
31+
export type UploadDataCommandInput = UploadDataCommandParams;
32+
"
33+
`;
34+
35+
exports[`header parameters 2`] = `
36+
"import * as v from "valibot";
37+
38+
export const uploadStatusSchema = v.picklist(["pending", "complete"]);
39+
export const uploadDataCommandParamsSchema = v.strictObject({
40+
"uploadId": v.string()
41+
});
42+
export const uploadDataCommandHeaderSchema = v.object({
43+
"content-type": v.picklist(["application/json", "text/csv", "application/xml"]),
44+
"content-length": v.pipe(v.number(), v.integer()),
45+
"x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid()))
46+
});
47+
"
48+
`;
49+
50+
exports[`header parameters 3`] = `
51+
"import { validator } from "hono/validator";
52+
import * as v from "valibot";
53+
import { PublicValibotHonoError } from "@block65/rest-client";
54+
import { uploadDataCommandParamsSchema } from "./valibot.js";
55+
56+
function toPublicValibotHonoError(err: unknown): never {
57+
58+
if (err instanceof v.ValiError) {
59+
throw PublicValibotHonoError.from(err);
60+
}
61+
throw err;
62+
63+
}
64+
65+
export const uploadData = [
66+
validator("param", (value) => {
67+
return v.parseAsync(uploadDataCommandParamsSchema, value).catch(toPublicValibotHonoError);
68+
}),
69+
] as const;
70+
"
71+
`;
72+
2173
exports[`nullables 1`] = `
2274
"export type MySchemaLolOrNullable = "lol" | "kek" | null;
2375
"

__tests__/fixtures/test1.json

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,74 @@
805805
}
806806
},
807807
"paths": {
808+
"/billing-accounts/{billingAccountId}/import": {
809+
"post": {
810+
"operationId": "importBillingDataCommand",
811+
"tags": [],
812+
"parameters": [
813+
{
814+
"$ref": "#/components/parameters/BillingAccountIdParameter"
815+
},
816+
{
817+
"name": "Content-Type",
818+
"in": "header",
819+
"required": true,
820+
"description": "The content type of the import data",
821+
"schema": {
822+
"type": "string",
823+
"enum": [
824+
"application/json",
825+
"text/csv",
826+
"application/xml"
827+
]
828+
}
829+
},
830+
{
831+
"name": "Content-Length",
832+
"in": "header",
833+
"required": true,
834+
"description": "The size of the import data in bytes",
835+
"schema": {
836+
"type": "integer",
837+
"format": "int64"
838+
}
839+
},
840+
{
841+
"name": "X-Idempotency-Key",
842+
"in": "header",
843+
"required": false,
844+
"description": "Optional idempotency key for safe retries",
845+
"schema": {
846+
"type": "string",
847+
"format": "uuid"
848+
}
849+
}
850+
],
851+
"requestBody": {
852+
"required": true,
853+
"content": {
854+
"application/octet-stream": {
855+
"schema": {
856+
"type": "string",
857+
"format": "binary"
858+
}
859+
}
860+
}
861+
},
862+
"responses": {
863+
"200": {
864+
"description": "Import 200 response",
865+
"content": {
866+
"application/json": {
867+
"schema": {
868+
"$ref": "#/components/schemas/LongRunningOperation"
869+
}
870+
}
871+
}
872+
}
873+
}
874+
}
875+
},
808876
"/operations/{operationId}": {
809877
"get": {
810878
"operationId": "getOperationCommand",

__tests__/nullables.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test } from "vitest";
2+
import type { oas31 } from "openapi3-ts";
23
import { processOpenApiDocument } from "../lib/process-document.ts";
34

45
test("nullables", async () => {
@@ -101,3 +102,88 @@ test("const values", async () => {
101102
expect(result.typesFile.getText()).toMatchSnapshot();
102103
expect(result.valibotFile?.getText()).toMatchSnapshot();
103104
});
105+
106+
test("header parameters", async () => {
107+
const schema: oas31.OpenAPIObject = {
108+
openapi: "3.1.0",
109+
info: {
110+
title: "Test",
111+
version: "1.0.0",
112+
},
113+
components: {
114+
schemas: {
115+
UploadStatus: {
116+
type: "string",
117+
enum: ["pending", "complete"],
118+
},
119+
},
120+
},
121+
paths: {
122+
"/uploads/{uploadId}": {
123+
post: {
124+
operationId: "uploadDataCommand",
125+
parameters: [
126+
{
127+
name: "uploadId",
128+
in: "path",
129+
required: true,
130+
schema: { type: "string" },
131+
},
132+
{
133+
name: "Content-Type",
134+
in: "header",
135+
required: true,
136+
schema: {
137+
type: "string",
138+
enum: [
139+
"application/json",
140+
"text/csv",
141+
"application/xml",
142+
],
143+
},
144+
},
145+
{
146+
name: "Content-Length",
147+
in: "header",
148+
required: true,
149+
schema: {
150+
type: "integer",
151+
format: "int64",
152+
},
153+
},
154+
{
155+
name: "X-Idempotency-Key",
156+
in: "header",
157+
required: false,
158+
schema: {
159+
type: "string",
160+
format: "uuid",
161+
},
162+
},
163+
],
164+
responses: {
165+
"200": {
166+
description: "OK",
167+
content: {
168+
"application/json": {
169+
schema: {
170+
$ref: "#/components/schemas/UploadStatus",
171+
},
172+
},
173+
},
174+
},
175+
},
176+
},
177+
},
178+
},
179+
};
180+
181+
const result = await processOpenApiDocument(
182+
"/tmp/like-you-know-whatever",
183+
schema,
184+
);
185+
186+
expect(result.typesFile.getText()).toMatchSnapshot();
187+
expect(result.valibotFile.getText()).toMatchSnapshot();
188+
expect(result.honoValibotFile.getText()).toMatchSnapshot();
189+
});

lib/hono-valibot.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function createHonoValibotFile(
4646
export function createHonoValibotMiddleware(
4747
honoValibotFile: SourceFile,
4848
exportName: string,
49-
schemas: { json?: string; param?: string; query?: string },
49+
schemas: { json?: string; param?: string; query?: string; header?: string },
5050
): void {
5151
honoValibotFile.addVariableStatement({
5252
isExported: true,
@@ -57,7 +57,9 @@ export function createHonoValibotMiddleware(
5757
initializer: (writer) => {
5858
writer.write("[");
5959
writer.indent(() => {
60-
for (const [target, schemaName] of Object.entries(schemas)) {
60+
for (const [target, schemaName] of Object.entries(schemas).filter(
61+
([t]) => t !== "header",
62+
)) {
6163
writer.writeLine(
6264
`validator(${JSON.stringify(target)}, (value) => {`,
6365
);

lib/process-document.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040

4141
interface OperationMiddlewareInfo {
4242
exportName: string;
43-
schemas: { json?: string; param?: string; query?: string };
43+
schemas: { json?: string; param?: string; query?: string; header?: string };
4444
}
4545

4646
const neverKeyword = "never" as const;
@@ -383,6 +383,7 @@ export async function processOpenApiDocument(
383383
: undefined;
384384

385385
const queryParameters: oas30.ParameterObject[] = [];
386+
const headerParameters: oas30.ParameterObject[] = [];
386387

387388
for (const parameter of [
388389
...(operationObject.parameters || []),
@@ -409,6 +410,10 @@ export async function processOpenApiDocument(
409410
if (resolvedParameter.in === "query") {
410411
queryParameters.push(resolvedParameter);
411412
}
413+
414+
if (resolvedParameter.in === "header") {
415+
headerParameters.push(resolvedParameter);
416+
}
412417
}
413418

414419
// Extract path parameters from URL pattern that weren't declared this
@@ -484,6 +489,64 @@ export async function processOpenApiDocument(
484489

485490
ensureImport(queryType);
486491

492+
const headerType =
493+
headerParameters.length > 0
494+
? typesFile.addTypeAlias({
495+
name: pascalCase(
496+
commandClassDeclaration.getName() || "INVALID",
497+
"Header",
498+
),
499+
docs: deprecationDocs,
500+
isExported: true,
501+
type: Writers.objectType({
502+
properties: headerParameters.map((hp) => {
503+
const name = hp.name.toLowerCase();
504+
505+
if (!hp.schema) {
506+
return {
507+
name: JSON.stringify(name),
508+
hasQuestionToken: !hp.required,
509+
};
510+
}
511+
512+
const type = schemaToType(
513+
typesAndInterfaces,
514+
hp.required
515+
? {
516+
required: [name],
517+
}
518+
: {},
519+
name,
520+
hp.schema,
521+
{
522+
// headers are strings on the wire but
523+
// valibot parses them to native types
524+
booleanAsStringish: false,
525+
integerAsStringish: false,
526+
},
527+
);
528+
529+
const resolvedType = hp.required
530+
? type.type
531+
: typeof type.type === "function"
532+
? type.type
533+
: type.type
534+
? Writers.unionType(`${type.type}`, "undefined")
535+
: undefined;
536+
537+
return {
538+
...type,
539+
name: JSON.stringify(name),
540+
hasQuestionToken: !hp.required,
541+
...(resolvedType !== undefined && { type: resolvedType }),
542+
};
543+
}),
544+
}),
545+
})
546+
: undefined;
547+
548+
ensureImport(headerType);
549+
487550
const jsonRequestBodyObject =
488551
requestBodyObject?.content["application/json"];
489552

@@ -700,6 +763,7 @@ export async function processOpenApiDocument(
700763
}),
701764
params: pathParameters,
702765
query: queryParameters,
766+
header: headerParameters,
703767
},
704768
);
705769

0 commit comments

Comments
 (0)