Skip to content

Commit ae9c45a

Browse files
committed
fix(zod): add dates.offset option
1 parent b85f406 commit ae9c45a

File tree

8 files changed

+187
-23
lines changed

8 files changed

+187
-23
lines changed

.changeset/wicked-tables-shout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hey-api/openapi-ts': patch
3+
---
4+
5+
fix(zod): add `dates.offset` option

packages/openapi-ts-tests/test/3.1.x.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,25 @@ describe(`OpenAPI ${version}`, () => {
764764
}),
765765
description: 'generates validator schemas',
766766
},
767+
{
768+
config: createConfig({
769+
input: 'validators.yaml',
770+
output: 'validators-dates',
771+
plugins: [
772+
// Valibot doesn't allow configuring offset
773+
// {
774+
// name: 'valibot',
775+
// },
776+
{
777+
dates: {
778+
offset: true,
779+
},
780+
name: 'zod',
781+
},
782+
],
783+
}),
784+
description: 'generates validator schemas with any offset',
785+
},
767786
{
768787
config: createConfig({
769788
input: 'validators.yaml',
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import { z } from 'zod';
4+
5+
/**
6+
* This is Bar schema.
7+
*/
8+
export const zBar: z.AnyZodObject = z.object({
9+
foo: z.lazy(() => {
10+
return zFoo;
11+
}).optional()
12+
});
13+
14+
/**
15+
* This is Foo schema.
16+
*/
17+
export const zFoo: z.ZodTypeAny = z.union([
18+
z.object({
19+
foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).optional(),
20+
bar: zBar.optional(),
21+
baz: z.array(z.lazy(() => {
22+
return zFoo;
23+
})).optional(),
24+
qux: z.number().int().gt(0).optional().default(0)
25+
}),
26+
z.null()
27+
]).default(null);
28+
29+
export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz');
30+
31+
export const zQux = z.record(z.object({
32+
qux: z.string().optional()
33+
}));
34+
35+
/**
36+
* This is Foo parameter.
37+
*/
38+
export const zFoo2 = z.string();
39+
40+
export const zFoo3 = z.object({
41+
foo: zBar.optional()
42+
});
43+
44+
export const zPatchFooData = z.object({
45+
body: z.object({
46+
foo: z.string().optional()
47+
}),
48+
path: z.never().optional(),
49+
query: z.object({
50+
foo: z.string().optional(),
51+
bar: zBar.optional(),
52+
baz: z.object({
53+
baz: z.string().optional()
54+
}).optional(),
55+
qux: z.string().date().optional(),
56+
quux: z.string().datetime({
57+
offset: true
58+
}).optional()
59+
}).optional()
60+
});
61+
62+
export const zPostFooData = z.object({
63+
body: zFoo3,
64+
path: z.never().optional(),
65+
query: z.never().optional()
66+
});

packages/openapi-ts-tests/test/openapi-ts.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export default defineConfig(() => {
3737
// 'invalid',
3838
// 'servers-entry.yaml',
3939
// ),
40-
path: path.resolve(__dirname, 'spec', '3.1.x', 'full.yaml'),
40+
path: path.resolve(__dirname, 'spec', '3.1.x', 'validators.yaml'),
4141
// path: path.resolve(__dirname, 'spec', 'v3-transforms.json'),
4242
// path: path.resolve(__dirname, 'spec', 'v3.json'),
4343
// path: 'http://localhost:4000/',
@@ -206,6 +206,9 @@ export default defineConfig(() => {
206206
{
207207
// case: 'snake_case',
208208
// comments: false,
209+
// dates: {
210+
// // offset: false,
211+
// },
209212
definitions: 'z{{name}}Definition',
210213
// exportFromIndex: true,
211214
// metadata: true,

packages/openapi-ts/src/config/utils.ts

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,37 @@ type ObjectType<T> =
33
? Record<string, any>
44
: Extract<T, Record<string, any>>;
55

6+
type MappersType<T> = {
7+
boolean: T extends boolean
8+
? (value: boolean) => Partial<ObjectType<T>>
9+
: never;
10+
number: T extends number ? (value: number) => Partial<ObjectType<T>> : never;
11+
object?: (value: Partial<ObjectType<T>>) => Partial<ObjectType<T>>;
12+
string: T extends string ? (value: string) => Partial<ObjectType<T>> : never;
13+
} extends infer U
14+
? { [K in keyof U as U[K] extends never ? never : K]: U[K] }
15+
: never;
16+
17+
type IsObjectOnly<T> = T extends Record<string, any> | undefined
18+
? Extract<T, string | boolean | number> extends never
19+
? true
20+
: false
21+
: false;
22+
623
export type ValueToObject = <
724
T extends undefined | string | boolean | number | Record<string, any>,
8-
>(args: {
9-
defaultValue: ObjectType<T>;
10-
mappers: {
11-
boolean: T extends boolean
12-
? (value: boolean) => Partial<ObjectType<T>>
13-
: never;
14-
number: T extends number
15-
? (value: number) => Partial<ObjectType<T>>
16-
: never;
17-
object?: (value: Partial<ObjectType<T>>) => Partial<ObjectType<T>>;
18-
string: T extends string
19-
? (value: string) => Partial<ObjectType<T>>
20-
: never;
21-
} extends infer U
22-
? { [K in keyof U as U[K] extends never ? never : K]: U[K] }
23-
: never;
24-
value: T;
25-
}) => ObjectType<T>;
25+
>(
26+
args: {
27+
defaultValue: ObjectType<T>;
28+
value: T;
29+
} & (IsObjectOnly<T> extends true
30+
? {
31+
mappers?: MappersType<T>;
32+
}
33+
: {
34+
mappers: MappersType<T>;
35+
}),
36+
) => ObjectType<T>;
2637

2738
const mergeResult = <T>(
2839
result: ObjectType<T>,
@@ -45,28 +56,32 @@ export const valueToObject: ValueToObject = ({
4556

4657
switch (typeof value) {
4758
case 'boolean':
48-
if ('boolean' in mappers) {
59+
if (mappers && 'boolean' in mappers) {
4960
const mapper = mappers.boolean as (
5061
value: boolean,
5162
) => Record<string, any>;
5263
result = mergeResult(result, mapper(value));
5364
}
5465
break;
5566
case 'number':
56-
if ('number' in mappers) {
67+
if (mappers && 'number' in mappers) {
5768
const mapper = mappers.number as (value: number) => Record<string, any>;
5869
result = mergeResult(result, mapper(value));
5970
}
6071
break;
6172
case 'string':
62-
if ('string' in mappers) {
73+
if (mappers && 'string' in mappers) {
6374
const mapper = mappers.string as (value: string) => Record<string, any>;
6475
result = mergeResult(result, mapper(value));
6576
}
6677
break;
6778
case 'object':
6879
if (value !== null) {
69-
if ('object' in mappers && typeof mappers.object === 'function') {
80+
if (
81+
mappers &&
82+
'object' in mappers &&
83+
typeof mappers.object === 'function'
84+
) {
7085
const mapper = mappers.object as (
7186
value: Record<string, any>,
7287
) => Partial<ObjectType<any>>;

packages/openapi-ts/src/plugins/zod/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export const defaultConfig: ZodPlugin['Config'] = {
1515
name: 'zod',
1616
output: 'zod',
1717
resolveConfig: (plugin, context) => {
18+
plugin.config.dates = context.valueToObject({
19+
defaultValue: {
20+
offset: false,
21+
},
22+
value: plugin.config.dates,
23+
});
24+
1825
plugin.config.definitions = context.valueToObject({
1926
defaultValue: {
2027
case: plugin.config.case ?? 'camelCase',

packages/openapi-ts/src/plugins/zod/plugin.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,10 @@ const objectTypeToZodSchema = ({
455455
};
456456

457457
const stringTypeToZodSchema = ({
458+
plugin,
458459
schema,
459460
}: {
461+
plugin: ZodPlugin['Instance'];
460462
schema: SchemaWithType<'string'>;
461463
}) => {
462464
if (typeof schema.const === 'string') {
@@ -485,6 +487,18 @@ const stringTypeToZodSchema = ({
485487
expression: stringExpression,
486488
name: compiler.identifier({ text: 'datetime' }),
487489
}),
490+
parameters: plugin.config.dates.offset
491+
? [
492+
compiler.objectExpression({
493+
obj: [
494+
{
495+
key: 'offset',
496+
value: true,
497+
},
498+
],
499+
}),
500+
]
501+
: [],
488502
});
489503
break;
490504
case 'ipv4':
@@ -718,6 +732,7 @@ const schemaTypeToZodSchema = ({
718732
case 'string':
719733
return {
720734
expression: stringTypeToZodSchema({
735+
plugin,
721736
schema: schema as SchemaWithType<'string'>,
722737
}),
723738
};

packages/openapi-ts/src/plugins/zod/types.d.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ export type Config = Plugin.Name<'zod'> & {
1515
* @default true
1616
*/
1717
comments?: boolean;
18+
/**
19+
* Configuration for date handling in generated Zod schemas.
20+
*
21+
* Controls how date values are processed and validated using Zod's
22+
* date validation features.
23+
*/
24+
dates?: {
25+
/**
26+
* Whether to include timezone offset information when handling dates.
27+
*
28+
* When enabled, date strings will preserve timezone information.
29+
* When disabled, dates will be treated as local time.
30+
*
31+
* @default false
32+
*/
33+
offset?: boolean;
34+
};
1835
/**
1936
* Configuration for reusable schema definitions.
2037
*
@@ -156,6 +173,23 @@ export type ResolvedConfig = Plugin.Name<'zod'> & {
156173
* @default true
157174
*/
158175
comments: boolean;
176+
/**
177+
* Configuration for date handling in generated Zod schemas.
178+
*
179+
* Controls how date values are processed and validated using Zod's
180+
* date validation features.
181+
*/
182+
dates: {
183+
/**
184+
* Whether to include timezone offset information when handling dates.
185+
*
186+
* When enabled, date strings will preserve timezone information.
187+
* When disabled, dates will be treated as local time.
188+
*
189+
* @default false
190+
*/
191+
offset: boolean;
192+
};
159193
/**
160194
* Configuration for reusable schema definitions.
161195
*

0 commit comments

Comments
 (0)