diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index 042069a..fec83a2 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -1,6 +1,99 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`const values 1`] = ` +"export type StringConst = "hello"; +export type NumberConst = 42; +export type BooleanConst = true; +export type NullConst = null; +" +`; + +exports[`const values 2`] = ` +"import * as v from "valibot"; + +export const stringConstSchema = v.literal("hello"); +export const numberConstSchema = v.literal(42); +export const booleanConstSchema = v.literal(true); +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; " `; + +exports[`top-level type array with null 1`] = ` +"export type NullableString = string | null; +export type NullableStringEnum = "active" | "inactive" | null; +export type NullableInteger = number | null; +export type MultiType = string | number; +" +`; + +exports[`top-level type array with null 2`] = ` +"import * as v from "valibot"; + +export const nullableStringSchema = v.nullable(v.string()); +export const nullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"])); +export const nullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer())); +export const multiTypeSchema = v.union([v.string(), v.number()]); +" +`; + +exports[`top-level type array with null 3`] = ` +"export const nullableStringEnum = ["active", "inactive"] as const; +" +`; diff --git a/__tests__/fixtures/test1.json b/__tests__/fixtures/test1.json index 3eb96d0..b524135 100644 --- a/__tests__/fixtures/test1.json +++ b/__tests__/fixtures/test1.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.3", + "openapi": "3.1.0", "info": { "title": "Billing Service REST API", "version": "1.0.0" @@ -58,6 +58,33 @@ "maxLength": 256, "pattern": "/^[A-Z0-9]$/" }, + "ApiVersion": { + "type": "string", + "const": "2024-01-01", + "description": "The API version" + }, + "MaxRetries": { + "type": "integer", + "const": 3 + }, + "DefaultEnabled": { + "const": true + }, + "NullableNotes": { + "type": ["string", "null"], + "description": "Optional notes field" + }, + "NullableDiscount": { + "type": ["number", "null"] + }, + "NullableStatus": { + "type": ["string", "null"], + "enum": ["active", "paused", "cancelled"] + }, + "AccountTier": { + "type": "string", + "enum": ["free", "pro", "enterprise"] + }, "LongRunningOperationIndeterminate": { "type": "object", "additionalProperties": false, @@ -778,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", diff --git a/__tests__/fixtures/test1/commands.ts b/__tests__/fixtures/test1/commands.ts index 94014a5..35b6bf0 100644 --- a/__tests__/fixtures/test1/commands.ts +++ b/__tests__/fixtures/test1/commands.ts @@ -3,7 +3,7 @@ * * WARN: Do not edit directly. * - * Generated on 2026-03-17T13:15:38.356Z + * Generated on 2026-04-02T04:52:00.193Z * */ /** eslint-disable max-classes */ @@ -40,7 +40,8 @@ import type { } from "./types.js"; /** - * Tagged template literal that applies encodeURIComponent to all interpolated values, protecting path integrity from characters like `/` and `#`. + * 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( diff --git a/__tests__/fixtures/test1/enums.ts b/__tests__/fixtures/test1/enums.ts new file mode 100644 index 0000000..1cfa772 --- /dev/null +++ b/__tests__/fixtures/test1/enums.ts @@ -0,0 +1,34 @@ +/** + * This file was auto generated by @block65/openapi-codegen + * + * WARN: Do not edit directly. + * + * Generated on 2026-04-02T04:52:00.193Z + * + */ +export const nullableStatus = ["active", "paused", "cancelled"] as const; +export const accountTier = ["free", "pro", "enterprise"] as const; +export const billingSubscriptionStatus = ["active", "inactive"] as const; +export const billingSubscriptionInterval = ["monthly", "yearly"] as const; +export const planSku = [ + "donotuse", + "plasku1", + "plasku2", + "plasku3", + "plasku4", +] as const; +export const paymentMethodBrand = [ + "amex", + "diners", + "discover", + "jcb", + "mastercard", + "unionpay", + "visa", + "unknown", +] as const; +export const billingLocale = ["en"] as const; +export const billingAccountType = ["standard", "agency", "reseller"] as const; +export const currency = ["usd", "aud", "sgd", "myr", "gbp"] as const; +export const billingAccountStatus = ["nominal", "delinquent"] as const; +export const billingCountry = ["us", "au", "sg", "my", "gb"] as const; diff --git a/__tests__/fixtures/test1/hono-valibot.ts b/__tests__/fixtures/test1/hono-valibot.ts index 361d1ef..8aff2db 100644 --- a/__tests__/fixtures/test1/hono-valibot.ts +++ b/__tests__/fixtures/test1/hono-valibot.ts @@ -3,7 +3,7 @@ * * WARN: Do not edit directly. * - * Generated on 2026-03-17T13:15:38.356Z + * Generated on 2026-04-02T04:52:00.193Z * */ diff --git a/__tests__/fixtures/test1/main.ts b/__tests__/fixtures/test1/main.ts index 188cb45..eb502f6 100644 --- a/__tests__/fixtures/test1/main.ts +++ b/__tests__/fixtures/test1/main.ts @@ -3,7 +3,7 @@ * * WARN: Do not edit directly. * - * Generated on 2026-03-17T13:15:38.356Z + * Generated on 2026-04-02T04:52:00.193Z * */ import { diff --git a/__tests__/fixtures/test1/types.ts b/__tests__/fixtures/test1/types.ts index c066d16..c67f235 100644 --- a/__tests__/fixtures/test1/types.ts +++ b/__tests__/fixtures/test1/types.ts @@ -3,12 +3,21 @@ * * WARN: Do not edit directly. * - * Generated on 2026-03-17T13:15:38.356Z + * Generated on 2026-04-02T04:52:00.193Z * */ import type { Jsonifiable, Jsonify } from "type-fest"; export type PromoCode = string; +/** The API version */ +export type ApiVersion = "2024-01-01"; +export type MaxRetries = 3; +export type DefaultEnabled = true; +/** Optional notes field */ +export type NullableNotes = string | null; +export type NullableDiscount = number | null; +export type NullableStatus = "active" | "paused" | "cancelled" | null; +export type AccountTier = "free" | "pro" | "enterprise"; export type StripeId = string; export type DateTime = Jsonify; export type BillingSubscriptionStatus = "active" | "inactive"; diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index bd98e02..6ad3b08 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -3,7 +3,7 @@ * * WARN: Do not edit directly. * - * Generated on 2026-03-17T13:15:38.356Z + * Generated on 2026-04-02T04:52:00.193Z * */ import * as v from "valibot"; @@ -14,6 +14,17 @@ export const promoCodeSchema = v.pipe( v.maxLength(256), v.regex(/\/^[A-Z0-9]$\//), ); +/** The API version */ +export const apiVersionSchema = v.literal("2024-01-01"); +export const maxRetriesSchema = v.literal(3); +export const defaultEnabledSchema = v.literal(true); +/** Optional notes field */ +export const nullableNotesSchema = v.nullable(v.string()); +export const nullableDiscountSchema = v.nullable(v.number()); +export const nullableStatusSchema = v.nullable( + v.picklist(["active", "paused", "cancelled"]), +); +export const accountTierSchema = v.picklist(["free", "pro", "enterprise"]); export const stripeIdSchema = v.pipe( v.string(), v.minLength(11), diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index fc4dc97..7d26dd3 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -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 () => { @@ -29,3 +30,160 @@ test("nullables", async () => { expect(result.typesFile.getText()).toMatchSnapshot(); }); + +test("top-level type array with null", async () => { + const result = await processOpenApiDocument( + "/tmp/like-you-know-whatever", + { + openapi: "3.1.0", + info: { + title: "Test", + version: "1.0.0", + }, + paths: {}, + components: { + schemas: { + NullableString: { + type: ["string", "null"], + }, + NullableStringEnum: { + type: ["string", "null"], + enum: ["active", "inactive"], + }, + NullableInteger: { + type: ["integer", "null"], + }, + MultiType: { + type: ["string", "number"], + }, + }, + }, + }, + [], + ); + + expect(result.typesFile.getText()).toMatchSnapshot(); + expect(result.valibotFile?.getText()).toMatchSnapshot(); + expect(result.enumsFile.getText()).toMatchSnapshot(); +}); + +test("const values", async () => { + const result = await processOpenApiDocument( + "/tmp/like-you-know-whatever", + { + openapi: "3.1.0", + info: { + title: "Test", + version: "1.0.0", + }, + paths: {}, + components: { + schemas: { + StringConst: { + type: "string", + const: "hello", + }, + NumberConst: { + type: "integer", + const: 42, + }, + BooleanConst: { + const: true, + }, + NullConst: { + const: null, + }, + }, + }, + }, + [], + ); + + 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(); +}); diff --git a/__tests__/openai.test.ts b/__tests__/openai.test.ts index 30e0def..55fe3ef 100644 --- a/__tests__/openai.test.ts +++ b/__tests__/openai.test.ts @@ -1,15 +1,48 @@ -import { expect, test } from "vitest"; +import { MockAgent, setGlobalDispatcher } from "undici"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { CreateModerationCommand } from "./fixtures/openai/commands.ts"; import { OpenAiApiRestClient } from "./fixtures/openai/main.ts"; -test("OpenAI CreateModeration", async () => { - const openAiClient = new OpenAiApiRestClient(new URL("http://invalid")); +const mockAgent = new MockAgent(); +setGlobalDispatcher(mockAgent); - const command = new CreateModerationCommand({ - input: "This is a test", - }); +beforeAll(() => { + mockAgent.activate(); + mockAgent.disableNetConnect(); +}); + +afterAll(() => { + mockAgent.assertNoPendingInterceptors(); +}); + +const apiUrl = "http://192.2.0.1"; - const result = await openAiClient.json(command).catch((err) => err); +describe("OpenAI", () => { + const pool = mockAgent.get(apiUrl); - expect(result).toBeTruthy(); + pool + .intercept({ + path: "/moderations", + method: "POST", + }) + .reply(200, { + id: "modr-123", + model: "text-moderation-latest", + results: [], + }) + .times(1); + + test("CreateModeration", async () => { + const openAiClient = new OpenAiApiRestClient(apiUrl, { + logger: console.debug, + }); + + const command = new CreateModerationCommand({ + input: "This is a test", + }); + + const result = await openAiClient.json(command); + + expect(result).toBeTruthy(); + }); }); diff --git a/lib/build.ts b/lib/build.ts index 76b7865..77d689d 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -21,8 +21,14 @@ export async function build( * */`.trim(); - const { commandsFile, typesFile, mainFile, valibotFile, honoValibotFile } = - await processOpenApiDocument(outputDir, apischema.default, tags); + const { + commandsFile, + typesFile, + mainFile, + valibotFile, + honoValibotFile, + enumsFile, + } = await processOpenApiDocument(outputDir, apischema.default, tags); const files = [ commandsFile, @@ -30,6 +36,7 @@ export async function build( mainFile, valibotFile, honoValibotFile, + ...(enumsFile.getStatements().length > 0 ? [enumsFile] : []), ]; commandsFile.insertStatements(0, "/** eslint-disable max-classes */"); diff --git a/lib/hono-valibot.ts b/lib/hono-valibot.ts index 3dd9f65..cdecdb9 100644 --- a/lib/hono-valibot.ts +++ b/lib/hono-valibot.ts @@ -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, @@ -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) => {`, ); diff --git a/lib/process-document.ts b/lib/process-document.ts index 650592e..4e028ae 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -25,6 +25,7 @@ import { } from "./hono-valibot.ts"; import { registerTypesFromSchema, schemaToType } from "./process-schema.ts"; import { + camelCase, castToValidJsIdentifier, getDependents, iife, @@ -39,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; @@ -108,6 +109,13 @@ export async function processOpenApiDocument( overwrite: true, }); + // Enums file for runtime enum values + const enumsFile = project.createSourceFile( + join(outputDir, "enums.ts"), + "", + { overwrite: true }, + ); + // Validators file for Valibot schemas const valibotFile = createValibotFile(project, outputDir); @@ -243,6 +251,52 @@ export async function processOpenApiDocument( schemaName, schemaObject, ); + + // Add enum values to enums file + if ( + !("$ref" in schemaObject) && + "enum" in schemaObject && + Array.isArray(schemaObject.enum) + ) { + const values = schemaObject.enum.filter( + (v): v is string => v !== null, + ); + + if (values.length > 0) { + enumsFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + docs: schemaObject.description + ? [ + { + description: wordWrap(schemaObject.description), + tags: [ + ...(schemaObject.deprecated + ? [{ tagName: "deprecated" }] + : []), + ].filter(Boolean), + }, + ] + : [], + declarations: [ + { + name: camelCase(schemaName), + initializer: Writers.assertion( + (writer) => { + writer.write("["); + values.forEach((value, index) => { + writer.write(JSON.stringify(value)); + if (index < values.length - 1) writer.write(", "); + }); + writer.write("]"); + }, + "const", + ), + }, + ], + }); + } + } } for (const [path, pathItemObject] of Object.entries( @@ -329,6 +383,7 @@ export async function processOpenApiDocument( : undefined; const queryParameters: oas30.ParameterObject[] = []; + const headerParameters: oas30.ParameterObject[] = []; for (const parameter of [ ...(operationObject.parameters || []), @@ -355,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 @@ -430,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"]; @@ -646,6 +763,7 @@ export async function processOpenApiDocument( }), params: pathParameters, query: queryParameters, + header: headerParameters, }, ); @@ -1073,5 +1191,6 @@ export async function processOpenApiDocument( mainFile, valibotFile, honoValibotFile, + enumsFile, }; } diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 2afc2a3..c5ce331 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -568,6 +568,50 @@ export function registerTypesFromSchema( typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); } + // deal with type arrays (OpenAPI 3.1: type: ["string", "null"]) + else if (Array.isArray(schemaObject.type)) { + const prop = schemaToType( + typesAndInterfaces, + {}, + schemaName, + schemaObject, + ); + + const typeAlias = typesFile.addTypeAlias({ + name: pascalCase(schemaName), + isExported: true, + type: prop.type || "unknown", + }); + + if (schemaObject.description) { + typeAlias.addJsDoc({ + description: wordWrap(schemaObject.description), + }); + } + + typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + } + + // deal with const values + else if ("const" in schemaObject) { + const constDeclaration = typesFile.addTypeAlias({ + isExported: true, + name: pascalCase(schemaName), + type: JSON.stringify(schemaObject.const), + }); + + if (schemaObject.description) { + constDeclaration.addJsDoc({ + description: wordWrap(schemaObject.description), + }); + } + + typesAndInterfaces.set( + `#/components/schemas/${schemaName}`, + constDeclaration, + ); + } + // deal with objects else if (!schemaObject.type || schemaObject.type === "object") { const newIf = typesFile.addTypeAlias({ @@ -643,26 +687,6 @@ export function registerTypesFromSchema( // ); } - // deal with string consts - else if (schemaObject.type === "string" && "const" in schemaObject) { - const constDeclaration = typesFile.addTypeAlias({ - isExported: true, - name: pascalCase(schemaName), - type: JSON.stringify(schemaObject.const), - }); - - if (schemaObject.description) { - constDeclaration.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set( - `#/components/schemas/${schemaName}`, - constDeclaration, - ); - } - // deal with non-enum strings else if (schemaObject.type === "string" && !schemaObject.enum) { const typeAlias = typesFile.addTypeAlias({ diff --git a/lib/utils.ts b/lib/utils.ts index 792459e..cea22b0 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -69,6 +69,10 @@ export function refToName(ref: string): string { return name; } +export function camelCase(...str: string[]): string { + return camelcase(str.flatMap((s) => s.split("/"))); +} + export function pascalCase(...str: string[]): string { return camelcase( str.flatMap((s) => s.split("/")), diff --git a/lib/valibot.ts b/lib/valibot.ts index 667bf56..c8002bb 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -93,6 +93,43 @@ export function schemaToValidator( ? `v.custom<${typescriptHint}>(() => true)` : undefined; + // Handle const values (OpenAPI 3.1: const: "value") + if ("const" in schema) { + return schema.const === null + ? vcall("null") + : maybeNullable( + vcall("literal", JSON.stringify(schema.const)), + isNullable, + ); + } + + // Handle type arrays (OpenAPI 3.1: type: ["string", "null"]) + if (Array.isArray(schema.type)) { + const nonNullTypes = schema.type.filter((t) => t !== "null"); + const [singleType] = nonNullTypes; + + if (nonNullTypes.length === 1 && singleType) { + return maybeNullable( + schemaToValidator(validators, { + ...schema, + type: singleType, + } satisfies typeof schema), + isNullable, + ); + } + + const variants = nonNullTypes.map((t) => + schemaToValidator(validators, { + ...schema, + type: t, + } satisfies typeof schema), + ); + return maybeNullable( + variants.length > 0 ? vcall("union", variants) : vcall("unknown"), + isNullable, + ); + } + if (schema.type === "string") { if (schema.enum) { return maybeNullable( @@ -331,9 +368,15 @@ export function createValidatorForOperationInput( body?: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject; params: oas30.ParameterObject[]; query: oas30.ParameterObject[]; + header: oas30.ParameterObject[]; }, -): { json?: string; param?: string; query?: string } { - const schemas: { json?: string; param?: string; query?: string } = {}; +): { json?: string; param?: string; query?: string; header?: string } { + const schemas: { + json?: string; + param?: string; + query?: string; + header?: string; + } = {}; // 1. Generate the JSON Body Schema if (input.body) { @@ -390,5 +433,37 @@ export function createValidatorForOperationInput( addParams("params", input.params); addParams("query", input.query); + // 3. Header schema (non-strict to allow extra HTTP headers) + if (input.header.length > 0) { + const name = camelcase([commandName, "header", "schema"]); + schemas.header = name; + + const propertyMap = Object.fromEntries( + input.header.map((p) => [ + JSON.stringify(p.name.toLowerCase()), + p.required + ? schemaToValidator(validatorSchemas, p.schema ?? { type: "string" }) + : vcall( + "exactOptional", + schemaToValidator( + validatorSchemas, + p.schema ?? { type: "string" }, + ), + ), + ]), + ); + + valibotFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name, + initializer: vcall("object", Writers.object(propertyMap)), + }, + ], + }); + } + return schemas; }