Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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>
  • Loading branch information
maxholman and claude committed Apr 9, 2026
commit 92060601b84778e31d627314c55c67b2f6eb02d3
52 changes: 52 additions & 0 deletions __tests__/__snapshots__/nullables.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,58 @@ export const nullConstSchema = v.null();
"
`;

exports[`header parameters 1`] = `
"export type UploadStatus = "pending" | "complete";
export type UploadDataCommandHeader = {
"content-type": "application/json" | "text/csv" | "application/xml";
"content-length": number;
"x-idempotency-key"?: string | undefined;
};
export type UploadDataCommandParams = {
uploadId: string;
};
export type UploadDataCommandInput = UploadDataCommandParams;
"
`;

exports[`header parameters 2`] = `
"import * as v from "valibot";

export const uploadStatusSchema = v.picklist(["pending", "complete"]);
export const uploadDataCommandParamsSchema = v.strictObject({
"uploadId": v.string()
});
export const uploadDataCommandHeaderSchema = v.object({
"content-type": v.picklist(["application/json", "text/csv", "application/xml"]),
"content-length": v.pipe(v.number(), v.integer()),
"x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid()))
});
"
`;

exports[`header parameters 3`] = `
"import { validator } from "hono/validator";
import * as v from "valibot";
import { PublicValibotHonoError } from "@block65/rest-client";
import { uploadDataCommandParamsSchema } from "./valibot.js";

function toPublicValibotHonoError(err: unknown): never {

if (err instanceof v.ValiError) {
throw PublicValibotHonoError.from(err);
}
throw err;

}

export const uploadData = [
validator("param", (value) => {
return v.parseAsync(uploadDataCommandParamsSchema, value).catch(toPublicValibotHonoError);
}),
] as const;
"
`;

exports[`nullables 1`] = `
"export type MySchemaLolOrNullable = "lol" | "kek" | null;
"
Expand Down
68 changes: 68 additions & 0 deletions __tests__/fixtures/test1.json
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,74 @@
}
},
"paths": {
"/billing-accounts/{billingAccountId}/import": {
"post": {
"operationId": "importBillingDataCommand",
"tags": [],
"parameters": [
{
"$ref": "#/components/parameters/BillingAccountIdParameter"
},
{
"name": "Content-Type",
"in": "header",
"required": true,
"description": "The content type of the import data",
"schema": {
"type": "string",
"enum": [
"application/json",
"text/csv",
"application/xml"
]
}
},
{
"name": "Content-Length",
"in": "header",
"required": true,
"description": "The size of the import data in bytes",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "X-Idempotency-Key",
"in": "header",
"required": false,
"description": "Optional idempotency key for safe retries",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"responses": {
"200": {
"description": "Import 200 response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LongRunningOperation"
}
}
}
}
}
}
},
"/operations/{operationId}": {
"get": {
"operationId": "getOperationCommand",
Expand Down
86 changes: 86 additions & 0 deletions __tests__/nullables.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "vitest";
import type { oas31 } from "openapi3-ts";
import { processOpenApiDocument } from "../lib/process-document.ts";

test("nullables", async () => {
Expand Down Expand Up @@ -101,3 +102,88 @@ test("const values", async () => {
expect(result.typesFile.getText()).toMatchSnapshot();
expect(result.valibotFile?.getText()).toMatchSnapshot();
});

test("header parameters", async () => {
const schema: oas31.OpenAPIObject = {
openapi: "3.1.0",
info: {
title: "Test",
version: "1.0.0",
},
components: {
schemas: {
UploadStatus: {
type: "string",
enum: ["pending", "complete"],
},
},
},
paths: {
"/uploads/{uploadId}": {
post: {
operationId: "uploadDataCommand",
parameters: [
{
name: "uploadId",
in: "path",
required: true,
schema: { type: "string" },
},
{
name: "Content-Type",
in: "header",
required: true,
schema: {
type: "string",
enum: [
"application/json",
"text/csv",
"application/xml",
],
},
},
{
name: "Content-Length",
in: "header",
required: true,
schema: {
type: "integer",
format: "int64",
},
},
{
name: "X-Idempotency-Key",
in: "header",
required: false,
schema: {
type: "string",
format: "uuid",
},
},
],
responses: {
"200": {
description: "OK",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/UploadStatus",
},
},
},
},
},
},
},
},
};

const result = await processOpenApiDocument(
"/tmp/like-you-know-whatever",
schema,
);

expect(result.typesFile.getText()).toMatchSnapshot();
expect(result.valibotFile.getText()).toMatchSnapshot();
expect(result.honoValibotFile.getText()).toMatchSnapshot();
});
6 changes: 4 additions & 2 deletions lib/hono-valibot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function createHonoValibotFile(
export function createHonoValibotMiddleware(
honoValibotFile: SourceFile,
exportName: string,
schemas: { json?: string; param?: string; query?: string },
schemas: { json?: string; param?: string; query?: string; header?: string },
): void {
honoValibotFile.addVariableStatement({
isExported: true,
Expand All @@ -57,7 +57,9 @@ export function createHonoValibotMiddleware(
initializer: (writer) => {
writer.write("[");
writer.indent(() => {
for (const [target, schemaName] of Object.entries(schemas)) {
for (const [target, schemaName] of Object.entries(schemas).filter(
([t]) => t !== "header",
)) {
writer.writeLine(
`validator(${JSON.stringify(target)}, (value) => {`,
);
Expand Down
66 changes: 65 additions & 1 deletion lib/process-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {

interface OperationMiddlewareInfo {
exportName: string;
schemas: { json?: string; param?: string; query?: string };
schemas: { json?: string; param?: string; query?: string; header?: string };
}

const neverKeyword = "never" as const;
Expand Down Expand Up @@ -383,6 +383,7 @@ export async function processOpenApiDocument(
: undefined;

const queryParameters: oas30.ParameterObject[] = [];
const headerParameters: oas30.ParameterObject[] = [];

for (const parameter of [
...(operationObject.parameters || []),
Expand All @@ -409,6 +410,10 @@ export async function processOpenApiDocument(
if (resolvedParameter.in === "query") {
queryParameters.push(resolvedParameter);
}

if (resolvedParameter.in === "header") {
headerParameters.push(resolvedParameter);
}
}

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

ensureImport(queryType);

const headerType =
headerParameters.length > 0
? typesFile.addTypeAlias({
name: pascalCase(
commandClassDeclaration.getName() || "INVALID",
"Header",
),
docs: deprecationDocs,
isExported: true,
type: Writers.objectType({
properties: headerParameters.map((hp) => {
const name = hp.name.toLowerCase();

if (!hp.schema) {
return {
name: JSON.stringify(name),
hasQuestionToken: !hp.required,
};
}

const type = schemaToType(
typesAndInterfaces,
hp.required
? {
required: [name],
}
: {},
name,
hp.schema,
{
// headers are strings on the wire but
// valibot parses them to native types
booleanAsStringish: false,
integerAsStringish: false,
},
);

const resolvedType = hp.required
? type.type
: typeof type.type === "function"
? type.type
: type.type
? Writers.unionType(`${type.type}`, "undefined")
: undefined;

return {
...type,
name: JSON.stringify(name),
hasQuestionToken: !hp.required,
...(resolvedType !== undefined && { type: resolvedType }),
};
}),
}),
})
: undefined;

ensureImport(headerType);

const jsonRequestBodyObject =
requestBodyObject?.content["application/json"];

Expand Down Expand Up @@ -700,6 +763,7 @@ export async function processOpenApiDocument(
}),
params: pathParameters,
query: queryParameters,
header: headerParameters,
},
);

Expand Down
Loading
Loading