diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index fec83a2..1f9427d 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -41,7 +41,7 @@ export const uploadDataCommandParamsSchema = v.strictObject({ }); export const uploadDataCommandHeaderSchema = v.object({ "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), - "content-length": v.pipe(v.number(), v.integer()), + "content-length": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer()), "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())) }); " @@ -75,6 +75,33 @@ exports[`nullables 1`] = ` " `; +exports[`query and header integer params coerce strings to numbers 1`] = ` +"export type Dummy = string; +export type ListFilesCommandQuery = { + exp: \`\${number}\`; + limit?: \`\${number}\` | undefined; + }; +export type ListFilesCommandHeader = { + "x-rate-limit": number; + }; +export type ListFilesCommandInput = ListFilesCommandQuery; +" +`; + +exports[`query and header integer params coerce strings to numbers 2`] = ` +"import * as v from "valibot"; + +export const dummySchema = v.string(); +export const listFilesCommandQuerySchema = v.strictObject({ + "exp": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer(), v.minValue(0)), + "limit": v.exactOptional(v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer(), v.minValue(1), v.maxValue(100))) + }); +export const listFilesCommandHeaderSchema = v.object({ + "x-rate-limit": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer(), v.minValue(0)) + }); +" +`; + exports[`top-level type array with null 1`] = ` "export type NullableString = string | null; export type NullableStringEnum = "active" | "inactive" | null; diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 7d26dd3..8849ece 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -103,6 +103,79 @@ test("const values", async () => { expect(result.valibotFile?.getText()).toMatchSnapshot(); }); +test("query and header integer params coerce strings to numbers", async () => { + const schema: oas31.OpenAPIObject = { + openapi: "3.1.0", + info: { + title: "Test", + version: "1.0.0", + }, + components: { + schemas: { + Dummy: { type: "string" }, + }, + }, + paths: { + "/files": { + get: { + operationId: "listFilesCommand", + parameters: [ + { + name: "exp", + in: "query", + required: true, + schema: { + type: "integer", + format: "int64", + minimum: 0, + }, + }, + { + name: "limit", + in: "query", + required: false, + schema: { + type: "integer", + minimum: 1, + maximum: 100, + }, + }, + { + name: "X-Rate-Limit", + in: "header", + required: true, + schema: { + type: "integer", + minimum: 0, + }, + }, + ], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Dummy", + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument( + "/tmp/like-you-know-whatever", + schema, + ); + + expect(result.typesFile.getText()).toMatchSnapshot(); + expect(result.valibotFile.getText()).toMatchSnapshot(); +}); + test("header parameters", async () => { const schema: oas31.OpenAPIObject = { openapi: "3.1.0", diff --git a/lib/valibot.ts b/lib/valibot.ts index c8002bb..a558305 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -394,6 +394,24 @@ export function createValidatorForOperationInput( }); } + // HTTP params (query, header) arrive as strings. When the schema + // declares type: "integer" or "number", rewrite it to type: "string" + // with an int format so the existing string→number coercion pipeline + // handles parsing and validation. + const asHttpParamSchema = ( + schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, + ): oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject => { + if ("$ref" in schema) return schema; + if (schema.type === "integer" || schema.type === "number") { + return { + ...schema, + type: "string", + format: schema.type === "integer" ? "int64" : "int64", + }; + } + return schema; + }; + // 2. Helper for Params/Query (Strict Objects) const addParams = ( type: "params" | "query", @@ -403,19 +421,23 @@ export function createValidatorForOperationInput( const name = camelcase([commandName, type, "schema"]); schemas[type === "params" ? "param" : "query"] = name; + const coerce = type === "query"; + const propertyMap = Object.fromEntries( - list.map((p) => [ - JSON.stringify(p.name), - p.required - ? schemaToValidator(validatorSchemas, p.schema ?? { type: "string" }) - : vcall( - "exactOptional", - schemaToValidator( - validatorSchemas, - p.schema ?? { type: "string" }, + list.map((p) => { + const paramSchema = coerce + ? asHttpParamSchema(p.schema ?? { type: "string" }) + : (p.schema ?? { type: "string" }); + return [ + JSON.stringify(p.name), + p.required + ? schemaToValidator(validatorSchemas, paramSchema) + : vcall( + "exactOptional", + schemaToValidator(validatorSchemas, paramSchema), ), - ), - ]), + ]; + }), ); valibotFile.addVariableStatement({ @@ -439,18 +461,20 @@ export function createValidatorForOperationInput( 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" }, + input.header.map((p) => { + const paramSchema = asHttpParamSchema( + p.schema ?? { type: "string" }, + ); + return [ + JSON.stringify(p.name.toLowerCase()), + p.required + ? schemaToValidator(validatorSchemas, paramSchema) + : vcall( + "exactOptional", + schemaToValidator(validatorSchemas, paramSchema), ), - ), - ]), + ]; + }), ); valibotFile.addVariableStatement({