From 85cdf49fb203cefe94c3037ad20fd23724f564cd Mon Sep 17 00:00:00 2001 From: Max Holman Date: Thu, 9 Apr 2026 17:44:54 +0700 Subject: [PATCH] feat: wire header params into Command constructor When an operation has header parameters, the generated command gets a separate typed `headers` second constructor arg passed as the 4th arg to super(). Commands without headers are unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__snapshots__/nullables.test.ts.snap | 30 +++++++++++- __tests__/nullables.test.ts | 1 + lib/process-document.ts | 48 ++++++++++++++++--- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index 5baf5e5..18feaa9 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -33,6 +33,34 @@ export type UploadDataCommandInput = UploadDataCommandParams; `; exports[`header parameters 2`] = ` +"import { Command } from "@block65/rest-client"; +import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js"; + +/** + * Tagged template literal that applies encodeURIComponent to all interpolated + * values, protecting path integrity from characters like \`/\` and \`#\`. + * @example encodePath\`/users/\${userId}\` // "/users/foo%2Fbar" + */ +function encodePath(strings: TemplateStringsArray, ...values: string[]): string { + return String.raw({ raw: strings }, ...values.map(encodeURIComponent)); +} + +/** + * UploadDataCommand + * + */ +export class UploadDataCommand extends Command { + public override method = "post" as const; + + constructor(input: UploadDataCommandInput, headers: UploadDataCommandHeader) { + const {uploadId } = input; + super(encodePath\`/uploads/\${uploadId}\`, undefined, undefined, headers); + } +} +" +`; + +exports[`header parameters 3`] = ` "import * as v from "valibot"; export const uploadStatusSchema = v.picklist(["pending", "complete"]); @@ -47,7 +75,7 @@ export const uploadDataCommandHeaderSchema = v.object({ " `; -exports[`header parameters 3`] = ` +exports[`header parameters 4`] = ` "import { validator } from "hono/validator"; import * as v from "valibot"; import { PublicValibotHonoError } from "@block65/rest-client"; diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 265bc8e..e9a13bb 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -280,6 +280,7 @@ test("header parameters", async () => { ); expect(result.typesFile.getText()).toMatchSnapshot(); + expect(result.commandsFile.getText()).toMatchSnapshot(); expect(result.valibotFile.getText()).toMatchSnapshot(); expect(result.honoValibotFile.getText()).toMatchSnapshot(); }); diff --git a/lib/process-document.ts b/lib/process-document.ts index 9d2b19f..55b3946 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -948,6 +948,19 @@ export async function processOpenApiDocument( ?.addTypeArgument(queryType.getName()); } + // headers type argument (4th generic on Command) + if (headerType) { + // fill in query slot if missing + if (!queryType) { + commandClassDeclaration + .getExtends() + ?.addTypeArgument(neverKeyword); + } + commandClassDeclaration + .getExtends() + ?.addTypeArgument(headerType.getName()); + } + const hasPathParams = path.includes("{"); const pathname = hasPathParams ? `encodePath\`${path.replaceAll(/{/g, "${")}\`` @@ -967,7 +980,9 @@ export async function processOpenApiDocument( !isUnspecifiedKeyword(paramsType) && pathParameters.length > 0; - if (hasNonJsonBody || hasJsonBody || hasQuery || hasParams) { + const hasHeaders = !!headerType && headerParameters.length > 0; + + if (hasNonJsonBody || hasJsonBody || hasQuery || hasParams || hasHeaders) { const ctor = commandClassDeclaration.addConstructor(); const queryParameterNames = queryParameters @@ -989,6 +1004,13 @@ export async function processOpenApiDocument( type: inputType.getName(), }); + if (hasHeaders) { + ctor.addParameter({ + name: "headers", + type: headerType.getName(), + }); + } + ctor.addStatements([ { kind: StructureKind.VariableStatement, @@ -1034,6 +1056,8 @@ export async function processOpenApiDocument( SyntaxKind.CallExpression, ); + const headersArg = hasHeaders ? "headers" : undefined; + // type narrowing if (Node.isCallExpression(callExpr)) { if (hasJsonBody) { @@ -1042,7 +1066,10 @@ export async function processOpenApiDocument( `jsonStringify(${inputBodyName})`, ...(hasQuery ? [`stripUndefined({${queryParameterNames.join(", ")}})`] - : []), + : hasHeaders + ? [emptyKeyword] + : []), + ...(headersArg ? [headersArg] : []), ]); } else if (hasNonJsonBody) { callExpr.addArguments([ @@ -1050,15 +1077,24 @@ export async function processOpenApiDocument( nonJsonBodyPropName, ...(hasQuery ? [`stripUndefined({${queryParameterNames.join(", ")}})`] - : []), + : hasHeaders + ? [emptyKeyword] + : []), + ...(headersArg ? [headersArg] : []), ]); } else if (hasQuery) { callExpr.addArguments([ pathname, emptyKeyword, - ...(hasQuery - ? [`stripUndefined({${queryParameterNames.join(", ")}})`] - : []), + `stripUndefined({${queryParameterNames.join(", ")}})`, + ...(headersArg ? [headersArg] : []), + ]); + } else if (hasHeaders && headersArg) { + callExpr.addArguments([ + pathname, + emptyKeyword, + emptyKeyword, + headersArg, ]); } else { callExpr.addArguments([pathname]);