Skip to content

Commit

Permalink
Bugs/typed tuple on zod (orval-labs#1593)
Browse files Browse the repository at this point in the history
* Generate zod schema for typed tuples, too

* Add a path spec for zod

* WIP generate zod tuple and rest

* All tests working

* Organize imports

* Remove debug logging

* Create explicit specification for typed arrays tuples tests

* added more tests

* revert means REVERT!

* I wish husky would do his job correctly *sigh*

---------

Co-authored-by: Hendrik Lücke-Tieke <[email protected]>
  • Loading branch information
helt and Hendrik Lücke-Tieke authored Aug 30, 2024
1 parent 46641aa commit b0f6cf6
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 4 deletions.
79 changes: 77 additions & 2 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ResponseObject,
SchemaObject,
} from 'openapi3-ts/oas30';
import { SchemaObject as SchemaObject31 } from 'openapi3-ts/oas31';

const ZOD_DEPENDENCIES: GeneratorDependency[] = [
{
Expand All @@ -43,6 +44,9 @@ const ZOD_DEPENDENCIES: GeneratorDependency[] = [

export const getZodDependencies = () => ZOD_DEPENDENCIES;

/**
* values that may appear in "type". Equals SchemaObjectType
*/
const possibleSchemaTypes = [
'integer',
'number',
Expand All @@ -53,11 +57,17 @@ const possibleSchemaTypes = [
'array',
];

const resolveZodType = (schemaTypeValue: SchemaObject['type']) => {
const resolveZodType = (schema: SchemaObject) => {
const schemaTypeValue = schema.type;
const type = Array.isArray(schemaTypeValue)
? schemaTypeValue.find((t) => possibleSchemaTypes.includes(t))
: schemaTypeValue;

// TODO: if "prefixItems" exists and type is "array", then generate a "tuple"
if (schema.type === 'array' && 'prefixItems' in schema) {
return 'tuple';
}

switch (type) {
case 'integer':
return 'number';
Expand Down Expand Up @@ -102,7 +112,7 @@ export const generateZodValidationSchemaDefinition = (
constsUniqueCounter[name] = constsCounter;

const functions: [string, any][] = [];
const type = resolveZodType(schema.type);
const type = resolveZodType(schema);
const required =
schema.default !== undefined ? false : rules?.required ?? false;
const nullable =
Expand All @@ -113,6 +123,63 @@ export const generateZodValidationSchemaDefinition = (
const matches = schema.pattern ?? undefined;

switch (type) {
case 'tuple':
/**
*
* > 10.3.1.1. prefixItems
* > The value of "prefixItems" MUST be a non-empty array of valid JSON Schemas.
* >
* > Validation succeeds if each element of the instance validates against the schema at the same position, if any.
* > This keyword does not constrain the length of the array. If the array is longer than this keyword's value,
* > this keyword validates only the prefix of matching length.
* >
* > This keyword produces an annotation value which is the largest index to which this keyword applied a subschema.
* > The value MAY be a boolean true if a subschema was applied to every index of the instance, such as is produced by the "items" keyword.
* > This annotation affects the behavior of "items" and "unevaluatedItems".
* >
* > Omitting this keyword has the same assertion behavior as an empty array.
*/
if ('prefixItems' in schema) {
const schema31 = schema as SchemaObject31;

if (schema31.prefixItems && schema31.prefixItems.length > 0) {
functions.push([
'tuple',
schema31.prefixItems.map((item, idx) =>
generateZodValidationSchemaDefinition(
deference(item as SchemaObject | ReferenceObject, context),
context,
camel(`${name}-${idx}-item`),
strict,
{
required: true,
},
),
),
]);

if (schema.items) {
if (
(max || Number.POSITIVE_INFINITY) > schema31.prefixItems.length
) {
// only add zod.rest() if number of tuple elements can exceed provided prefixItems:
functions.push([
'rest',
generateZodValidationSchemaDefinition(
schema.items as SchemaObject | undefined,
context,
camel(`${name}-item`),
strict,
{
required: true,
},
),
]);
}
}
}
}
break;
case 'array':
const items = schema.items as SchemaObject | undefined;
functions.push([
Expand Down Expand Up @@ -388,6 +455,14 @@ ${Object.entries(args)
return '.strict()';
}

if (fn === 'tuple') {
return `zod.tuple([${(args as ZodValidationSchemaDefinition[])
.map((x) => 'zod' + x.functions.map(parseProperty).join(','))
.join(',\n')}])`;
}
if (fn === 'rest') {
return `.rest(zod${(args as ZodValidationSchemaDefinition).functions.map(parseProperty)})`;
}
const shouldCoerceType =
coerceTypes &&
(Array.isArray(coerceTypes)
Expand Down
101 changes: 99 additions & 2 deletions packages/zod/src/zod.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { SchemaObject } from 'openapi3-ts/oas30';
import { SchemaObject as SchemaObject31 } from 'openapi3-ts/oas31';
import { describe, expect, it } from 'vitest';
import {
generateZod,
generateZodValidationSchemaDefinition,
parseZodValidationSchemaDefinition,
type ZodValidationSchemaDefinition,
} from '.';
import { SchemaObject } from 'openapi3-ts/oas30';

import { ContextSpecs } from '@orval/core';
import * as fs from 'node:fs';

const queryParams: ZodValidationSchemaDefinition = {
functions: [
Expand Down Expand Up @@ -666,3 +667,99 @@ describe('generatePartOfSchemaGenerateZod', () => {
);
});
});

describe('parsePrefixItemsArrayAsTupleZod', () => {
it('generates correctly', async () => {
const arrayWithPrefixItemsSchema: SchemaObject31 = {
type: 'array',
prefixItems: [{ type: 'string' }, {}],
items: { type: 'string' },
};
const result = generateZodValidationSchemaDefinition(
arrayWithPrefixItemsSchema as SchemaObject,
{
output: {
override: {
zod: {},
},
},
} as ContextSpecs,
'example_tuple',
true,
{
required: true,
},
);

expect(result).toEqual({
consts: [],
functions: [
[
'tuple',
[
{
consts: [],
functions: [['string', undefined]],
},
{
consts: [],
functions: [['any', undefined]],
},
],
],
[
'rest',
{
consts: [],
functions: [['string', undefined]],
},
],
],
});
});
});

describe('parsePrefixItemsArrayAsTupleZod', () => {
it('correctly omits rest', async () => {
const arrayWithPrefixItemsSchema: SchemaObject31 = {
type: 'array',
prefixItems: [{ type: 'string' }, {}],
items: { type: 'string' },
maxItems: 2,
};
const result = generateZodValidationSchemaDefinition(
arrayWithPrefixItemsSchema as SchemaObject,
{
output: {
override: {
zod: {},
},
},
} as ContextSpecs,
'example_tuple',
true,
{
required: true,
},
);

expect(result).toEqual({
consts: [],
functions: [
[
'tuple',
[
{
consts: [],
functions: [['string', undefined]],
},
{
consts: [],
functions: [['any', undefined]],
},
],
],
],
});
});
});
9 changes: 9 additions & 0 deletions tests/configs/zod.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,13 @@ export default defineConfig({
target: '../specifications/additional-properties.yaml',
},
},
typedArraysTuplesV31: {
output: {
target: '../generated/zod/typed-arrays-tuples-v3-1.ts',
client: 'zod',
},
input: {
target: '../specifications/typed-arrays-tuples-v3-1.yaml',
},
},
});
48 changes: 48 additions & 0 deletions tests/specifications/typed-arrays-tuples-v3-1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
openapi: 3.1.0
info:
title: Nullables
description: 'OpenAPI 3.1 examples'
version: 1.0.0
paths:
/api/sample:
post:
summary: sample
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Test'

components:
schemas:
Test:
properties:
example_tuple:
maxItems: 2
minItems: 2
prefixItems:
- type: string
- {}
type: array
title: Example tuple
example_tuple_additional:
maxItems: 2
minItems: 2
prefixItems:
- type: string
- {}
items:
type: string
type: array
title: Example tuple
example_const:
const: this_is_a_const
example_enum:
type: string
enum:
- enum1
- enum2
title: Test
type: object

0 comments on commit b0f6cf6

Please sign in to comment.