Skip to content

Commit

Permalink
Add option to enable Zod type coercion for query params (#1045)
Browse files Browse the repository at this point in the history
* feat(zod): add `override.coerceTypes`

- this boolean flag enables type coercion within zod schemas
for `queryParams` *only*

* docs: add documentation to outputs.md for `override.coerceTypes`
  • Loading branch information
solomonhawk authored Nov 14, 2023
1 parent 3ee382b commit e7aad27
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 10 deletions.
24 changes: 24 additions & 0 deletions docs/src/pages/reference/configuration/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,30 @@ module.exports = {
};
```

#### coerceTypes

Type: `Boolean`

Valid values: true or false. Defaults to false.

Use this property to enable [type coercion](https://zod.dev/?id=coercion-for-primitives) for [Zod](https://zod.dev/) schemas (only applies to query parameters schemas).

This is helpful if you want to use the zod schema to coerce (likely string-serialized) query parameters into the correct type before validation.

Example:

```js
module.exports = {
petstore: {
output: {
override: {
coerceTypes: true,
},
},
},
};
```

#### useNamedParameters

Type: `Boolean`.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export type NormalizedOverrideOutput = {
) => string;
requestOptions: Record<string, any> | boolean;
useDates?: boolean;
coerceTypes?: boolean;
useTypeOverInterfaces?: boolean;
useDeprecatedOperations?: boolean;
useBigInt?: boolean;
Expand Down
3 changes: 2 additions & 1 deletion packages/zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"scripts": {
"build": "tsup ./src/index.ts --target node12 --clean --dts --sourcemap",
"dev": "tsup ./src/index.ts --target node12 --clean --watch src",
"lint": "eslint src/**/*.ts"
"lint": "eslint src/**/*.ts",
"test": "vitest --global test.ts"
},
"dependencies": {
"@orval/core": "6.20.0",
Expand Down
33 changes: 24 additions & 9 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const resolveZodType = (schemaTypeValue: SchemaObject['type']) => {
}
};

// /~https://github.com/colinhacks/zod#coercion-for-primitives
const COERCEABLE_TYPES = ['string', 'number', 'boolean', 'bigint', 'date'];

const generateZodValidationSchemaDefinition = (
schema: SchemaObject | undefined,
_required: boolean | undefined,
Expand Down Expand Up @@ -227,8 +230,14 @@ const generateZodValidationSchemaDefinition = (
return { functions, consts: uniq(consts) };
};

const parseZodValidationSchemaDefinition = (
input: Record<string, { functions: [string, any][]; consts: string[] }>,
export type ZodValidationSchemaDefinitionInput = Record<
string,
{ functions: [string, any][]; consts: string[] }
>;

export const parseZodValidationSchemaDefinition = (
input: ZodValidationSchemaDefinitionInput,
coerceTypes = false,
): { zod: string; consts: string } => {
if (!Object.keys(input).length) {
return { zod: '', consts: '' };
Expand Down Expand Up @@ -297,6 +306,11 @@ const parseZodValidationSchemaDefinition = (
}
return `.array(${value.startsWith('.') ? 'zod' : ''}${value})`;
}

if (coerceTypes && COERCEABLE_TYPES.includes(fn)) {
return `.coerce.${fn}(${args})`;
}

return `.${fn}(${args})`;
};

Expand All @@ -305,12 +319,12 @@ const parseZodValidationSchemaDefinition = (
}, '');

const zod = `zod.object({
${Object.entries(input)
.map(([key, schema]) => {
const value = schema.functions.map(parseProperty).join('');
return `"${key}": ${value.startsWith('.') ? 'zod' : ''}${value}`;
})
.join(',')}
${Object.entries(input)
.map(([key, schema]) => {
const value = schema.functions.map(parseProperty).join('');
return ` "${key}": ${value.startsWith('.') ? 'zod' : ''}${value}`;
})
.join(',\n')}
})`;

return { zod, consts };
Expand Down Expand Up @@ -355,7 +369,7 @@ const deference = (

const generateZodRoute = (
{ operationName, body, verb }: GeneratorVerbOptions,
{ pathRoute, context }: GeneratorOptions,
{ pathRoute, context, override }: GeneratorOptions,
) => {
const spec = context.specs[context.specKey].paths[pathRoute] as
| PathItemObject
Expand Down Expand Up @@ -489,6 +503,7 @@ const generateZodRoute = (
);
const inputQueryParams = parseZodValidationSchemaDefinition(
zodDefinitionsParameters.queryParams,
override.coerceTypes,
);
const inputHeaders = parseZodValidationSchemaDefinition(
zodDefinitionsParameters.headers,
Expand Down
53 changes: 53 additions & 0 deletions packages/zod/src/zod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
type ZodValidationSchemaDefinitionInput,
parseZodValidationSchemaDefinition,
} from '.';

const queryParams: ZodValidationSchemaDefinitionInput = {
// limit = non-required integer schema (coerce-able)
limit: {
functions: [
['number', undefined],
['optional', undefined],
],
consts: [],
},

// q = non-required string array schema (not coerce-able)
q: {
functions: [
[
'array',
{
functions: [['string', undefined]],
consts: [],
},
],
['optional', undefined],
],
consts: [],
},
};

describe('parseZodValidationSchemaDefinition', () => {
describe('with `override.coerceTypes = false` (default)', () => {
it('does not emit coerced zod property schemas', () => {
const parseResult = parseZodValidationSchemaDefinition(queryParams);

expect(parseResult.zod).toBe(
'zod.object({\n "limit": zod.number().optional(),\n "q": zod.array(zod.string()).optional()\n})',
);
});
});

describe('with `override.coerceTypes = true`', () => {
it('emits coerced zod property schemas', () => {
const parseResult = parseZodValidationSchemaDefinition(queryParams, true);

expect(parseResult.zod).toBe(
'zod.object({\n "limit": zod.coerce.number().optional(),\n "q": zod.array(zod.coerce.string()).optional()\n})',
);
});
});
});
16 changes: 16 additions & 0 deletions tests/configs/zod-parameters.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from 'orval';

export default defineConfig({
basic: {
output: {
target: '../generated/zod',
client: 'zod',
override: {
coerceTypes: true,
},
},
input: {
target: '../specifications/parameters.yaml',
},
},
});
1 change: 1 addition & 0 deletions tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"generate:swr": "yarn orval --config ./configs/swr.config.ts",
"generate:multi-file": "yarn orval --config ./configs/multi-file.config.ts",
"generate:zod": "yarn orval --config ./configs/zod.config.ts",
"generate:zod-parameters": "yarn orval --config ./configs/zod-parameters.config.ts",
"build": "tsc"
},
"author": "Victor Bury",
Expand Down
44 changes: 44 additions & 0 deletions tests/specifications/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,56 @@ paths:
tags:
- pets
parameters:
- name: q
in: query
description: Filter pets by substring
required: false
schema:
type: array
items:
type: string
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
schema:
type: integer
format: int32
- name: offset
in: query
description: How many items to skip
required: false
schema:
type: integer
format: int32
- name: order
in: query
description: How to order the items
required: false
schema:
type: string
enum: [asc, desc]
- name: sort
in: query
description: |
Which property to sort by?
Example: name sorts ASC while -name sorts DESC.
required: false
schema:
type: string
enum:
- name
- -name
- age
- -age
- name: cnonly
in: query
description: |
Only return pets from China?
Example: true
required: false
schema:
type: boolean
responses:
'200':
description: A paged array of pets
Expand Down

0 comments on commit e7aad27

Please sign in to comment.