Skip to content

Commit d9c8e2b

Browse files
authored
Merge pull request #14 from maxholman/fix/query-header-integer-coercion
fix: coerce integer query and header params from strings
2 parents 03a65a9 + c61c21e commit d9c8e2b

3 files changed

Lines changed: 147 additions & 23 deletions

File tree

__tests__/__snapshots__/nullables.test.ts.snap

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const uploadDataCommandParamsSchema = v.strictObject({
4141
});
4242
export const uploadDataCommandHeaderSchema = v.object({
4343
"content-type": v.picklist(["application/json", "text/csv", "application/xml"]),
44-
"content-length": v.pipe(v.number(), v.integer()),
44+
"content-length": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer()),
4545
"x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid()))
4646
});
4747
"
@@ -75,6 +75,33 @@ exports[`nullables 1`] = `
7575
"
7676
`;
7777

78+
exports[`query and header integer params coerce strings to numbers 1`] = `
79+
"export type Dummy = string;
80+
export type ListFilesCommandQuery = {
81+
exp: \`\${number}\`;
82+
limit?: \`\${number}\` | undefined;
83+
};
84+
export type ListFilesCommandHeader = {
85+
"x-rate-limit": number;
86+
};
87+
export type ListFilesCommandInput = ListFilesCommandQuery;
88+
"
89+
`;
90+
91+
exports[`query and header integer params coerce strings to numbers 2`] = `
92+
"import * as v from "valibot";
93+
94+
export const dummySchema = v.string();
95+
export const listFilesCommandQuerySchema = v.strictObject({
96+
"exp": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer(), v.minValue(0)),
97+
"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)))
98+
});
99+
export const listFilesCommandHeaderSchema = v.object({
100+
"x-rate-limit": v.pipe(v.string(), v.digits(), v.transform((n) => Number.parseInt(n, 10)), v.number(), v.integer(), v.minValue(0))
101+
});
102+
"
103+
`;
104+
78105
exports[`top-level type array with null 1`] = `
79106
"export type NullableString = string | null;
80107
export type NullableStringEnum = "active" | "inactive" | null;

__tests__/nullables.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,79 @@ test("const values", async () => {
103103
expect(result.valibotFile?.getText()).toMatchSnapshot();
104104
});
105105

106+
test("query and header integer params coerce strings to numbers", 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+
Dummy: { type: "string" },
116+
},
117+
},
118+
paths: {
119+
"/files": {
120+
get: {
121+
operationId: "listFilesCommand",
122+
parameters: [
123+
{
124+
name: "exp",
125+
in: "query",
126+
required: true,
127+
schema: {
128+
type: "integer",
129+
format: "int64",
130+
minimum: 0,
131+
},
132+
},
133+
{
134+
name: "limit",
135+
in: "query",
136+
required: false,
137+
schema: {
138+
type: "integer",
139+
minimum: 1,
140+
maximum: 100,
141+
},
142+
},
143+
{
144+
name: "X-Rate-Limit",
145+
in: "header",
146+
required: true,
147+
schema: {
148+
type: "integer",
149+
minimum: 0,
150+
},
151+
},
152+
],
153+
responses: {
154+
"200": {
155+
description: "OK",
156+
content: {
157+
"application/json": {
158+
schema: {
159+
$ref: "#/components/schemas/Dummy",
160+
},
161+
},
162+
},
163+
},
164+
},
165+
},
166+
},
167+
},
168+
};
169+
170+
const result = await processOpenApiDocument(
171+
"/tmp/like-you-know-whatever",
172+
schema,
173+
);
174+
175+
expect(result.typesFile.getText()).toMatchSnapshot();
176+
expect(result.valibotFile.getText()).toMatchSnapshot();
177+
});
178+
106179
test("header parameters", async () => {
107180
const schema: oas31.OpenAPIObject = {
108181
openapi: "3.1.0",

lib/valibot.ts

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,24 @@ export function createValidatorForOperationInput(
394394
});
395395
}
396396

397+
// HTTP params (query, header) arrive as strings. When the schema
398+
// declares type: "integer" or "number", rewrite it to type: "string"
399+
// with an int format so the existing string→number coercion pipeline
400+
// handles parsing and validation.
401+
const asHttpParamSchema = (
402+
schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject,
403+
): oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject => {
404+
if ("$ref" in schema) return schema;
405+
if (schema.type === "integer" || schema.type === "number") {
406+
return {
407+
...schema,
408+
type: "string",
409+
format: schema.type === "integer" ? "int64" : "int64",
410+
};
411+
}
412+
return schema;
413+
};
414+
397415
// 2. Helper for Params/Query (Strict Objects)
398416
const addParams = (
399417
type: "params" | "query",
@@ -403,19 +421,23 @@ export function createValidatorForOperationInput(
403421
const name = camelcase([commandName, type, "schema"]);
404422
schemas[type === "params" ? "param" : "query"] = name;
405423

424+
const coerce = type === "query";
425+
406426
const propertyMap = Object.fromEntries(
407-
list.map((p) => [
408-
JSON.stringify(p.name),
409-
p.required
410-
? schemaToValidator(validatorSchemas, p.schema ?? { type: "string" })
411-
: vcall(
412-
"exactOptional",
413-
schemaToValidator(
414-
validatorSchemas,
415-
p.schema ?? { type: "string" },
427+
list.map((p) => {
428+
const paramSchema = coerce
429+
? asHttpParamSchema(p.schema ?? { type: "string" })
430+
: (p.schema ?? { type: "string" });
431+
return [
432+
JSON.stringify(p.name),
433+
p.required
434+
? schemaToValidator(validatorSchemas, paramSchema)
435+
: vcall(
436+
"exactOptional",
437+
schemaToValidator(validatorSchemas, paramSchema),
416438
),
417-
),
418-
]),
439+
];
440+
}),
419441
);
420442

421443
valibotFile.addVariableStatement({
@@ -439,18 +461,20 @@ export function createValidatorForOperationInput(
439461
schemas.header = name;
440462

441463
const propertyMap = Object.fromEntries(
442-
input.header.map((p) => [
443-
JSON.stringify(p.name.toLowerCase()),
444-
p.required
445-
? schemaToValidator(validatorSchemas, p.schema ?? { type: "string" })
446-
: vcall(
447-
"exactOptional",
448-
schemaToValidator(
449-
validatorSchemas,
450-
p.schema ?? { type: "string" },
464+
input.header.map((p) => {
465+
const paramSchema = asHttpParamSchema(
466+
p.schema ?? { type: "string" },
467+
);
468+
return [
469+
JSON.stringify(p.name.toLowerCase()),
470+
p.required
471+
? schemaToValidator(validatorSchemas, paramSchema)
472+
: vcall(
473+
"exactOptional",
474+
schemaToValidator(validatorSchemas, paramSchema),
451475
),
452-
),
453-
]),
476+
];
477+
}),
454478
);
455479

456480
valibotFile.addVariableStatement({

0 commit comments

Comments
 (0)