Skip to content

Commit ef3c73c

Browse files
RobertCraigiestainless-app[bot]
authored andcommitted
fix(helpers/zod): add extract-to-root ref strategy
1 parent e4a247a commit ef3c73c

File tree

7 files changed

+69
-28
lines changed

7 files changed

+69
-28
lines changed

src/_vendor/zod-to-json-schema/Options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const ignoreOverride = Symbol('Let zodToJsonSchema decide on which parser
1010

1111
export type Options<Target extends Targets = 'jsonSchema7'> = {
1212
name: string | undefined;
13-
$refStrategy: 'root' | 'relative' | 'none' | 'seen';
13+
$refStrategy: 'root' | 'relative' | 'none' | 'seen' | 'extract-to-root';
1414
basePath: string[];
1515
effectStrategy: 'input' | 'any';
1616
pipeStrategy: 'input' | 'output' | 'all';
@@ -20,7 +20,7 @@ export type Options<Target extends Targets = 'jsonSchema7'> = {
2020
target: Target;
2121
strictUnions: boolean;
2222
definitionPath: string;
23-
definitions: Record<string, ZodSchema>;
23+
definitions: Record<string, ZodSchema | ZodTypeDef>;
2424
errorMessages: boolean;
2525
markdownDescription: boolean;
2626
patternStrategy: 'escape' | 'preserve';

src/_vendor/zod-to-json-schema/Refs.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ZodTypeDef } from 'zod';
1+
import type { ZodTypeDef } from 'zod';
22
import { getDefaultOptions, Options, Targets } from './Options';
33
import { JsonSchema7Type } from './parseDef';
4+
import { zodDef } from './util';
45

56
export type Refs = {
67
seen: Map<ZodTypeDef, Seen>;
@@ -33,9 +34,9 @@ export const getRefs = (options?: string | Partial<Options<Targets>>): Refs => {
3334
seenRefs: new Set(),
3435
seen: new Map(
3536
Object.entries(_options.definitions).map(([name, def]) => [
36-
def._def,
37+
zodDef(def),
3738
{
38-
def: def._def,
39+
def: zodDef(def),
3940
path: [..._options.basePath, _options.definitionPath, name],
4041
// Resolution of references will be forced even though seen, so it's ok that the schema is undefined here for now.
4142
jsonSchema: undefined,

src/_vendor/zod-to-json-schema/parseDef.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ const get$ref = (
122122
switch (refs.$refStrategy) {
123123
case 'root':
124124
return { $ref: item.path.join('/') };
125+
// this case is needed as OpenAI strict mode doesn't support top-level `$ref`s, i.e.
126+
// the top-level schema *must* be `{"type": "object", "properties": {...}}` but if we ever
127+
// need to define a `$ref`, relative `$ref`s aren't supported, so we need to extract
128+
// the schema to `#/definitions/` and reference that.
129+
//
130+
// e.g. if we need to reference a schema at
131+
// `["#","definitions","contactPerson","properties","person1","properties","name"]`
132+
// then we'll extract it out to `contactPerson_properties_person1_properties_name`
133+
case 'extract-to-root':
134+
const name = item.path.slice(refs.basePath.length + 1).join('_');
135+
136+
// we don't need to extract the root schema in this case, as it's already
137+
// been added to the definitions
138+
if (name !== refs.name && refs.nameStrategy === 'duplicate-ref') {
139+
refs.definitions[name] = item.def;
140+
}
141+
142+
return { $ref: [...refs.basePath, refs.definitionPath, name].join('/') };
125143
case 'relative':
126144
return { $ref: getRelativePath(refs.currentPath, item.path) };
127145
case 'none':
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ZodSchema, ZodTypeDef } from 'zod';
2+
3+
export const zodDef = (zodSchema: ZodSchema | ZodTypeDef): ZodTypeDef => {
4+
return '_def' in zodSchema ? zodSchema._def : zodSchema;
5+
};
6+
7+
export function isEmptyObj(obj: Object | null | undefined): boolean {
8+
if (!obj) return true;
9+
for (const _k in obj) return false;
10+
return true;
11+
}

src/_vendor/zod-to-json-schema/zodToJsonSchema.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ZodSchema } from 'zod';
22
import { Options, Targets } from './Options';
33
import { JsonSchema7Type, parseDef } from './parseDef';
44
import { getRefs } from './Refs';
5+
import { zodDef, isEmptyObj } from './util';
56

67
const zodToJsonSchema = <Target extends Targets = 'jsonSchema7'>(
78
schema: ZodSchema<any>,
@@ -16,25 +17,6 @@ const zodToJsonSchema = <Target extends Targets = 'jsonSchema7'>(
1617
} => {
1718
const refs = getRefs(options);
1819

19-
const definitions =
20-
typeof options === 'object' && options.definitions ?
21-
Object.entries(options.definitions).reduce(
22-
(acc, [name, schema]) => ({
23-
...acc,
24-
[name]:
25-
parseDef(
26-
schema._def,
27-
{
28-
...refs,
29-
currentPath: [...refs.basePath, refs.definitionPath, name],
30-
},
31-
true,
32-
) ?? {},
33-
}),
34-
{},
35-
)
36-
: undefined;
37-
3820
const name =
3921
typeof options === 'string' ? options
4022
: options?.nameStrategy === 'title' ? undefined
@@ -61,6 +43,25 @@ const zodToJsonSchema = <Target extends Targets = 'jsonSchema7'>(
6143
main.title = title;
6244
}
6345

46+
const definitions =
47+
!isEmptyObj(refs.definitions) ?
48+
Object.entries(refs.definitions).reduce(
49+
(acc, [name, schema]) => ({
50+
...acc,
51+
[name]:
52+
parseDef(
53+
zodDef(schema),
54+
{
55+
...refs,
56+
currentPath: [...refs.basePath, refs.definitionPath, name],
57+
},
58+
true,
59+
) ?? {},
60+
}),
61+
{},
62+
)
63+
: undefined;
64+
6465
const combined: ReturnType<typeof zodToJsonSchema<Target>> =
6566
name === undefined ?
6667
definitions ?

src/helpers/zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function zodToJsonSchema(schema: z.ZodType, options: { name: string }): Record<s
1313
openaiStrictMode: true,
1414
name: options.name,
1515
nameStrategy: 'duplicate-ref',
16+
$refStrategy: 'extract-to-root',
1617
});
1718
}
1819

tests/lib/parser.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,10 +352,10 @@ describe('.parse()', () => {
352352
"type": "string",
353353
},
354354
"name": {
355-
"$ref": "#/definitions/contactPerson/properties/person1/properties/name",
355+
"$ref": "#/definitions/contactPerson_properties_person1_properties_name",
356356
},
357357
"phone_number": {
358-
"$ref": "#/definitions/contactPerson/properties/person1/properties/phone_number",
358+
"$ref": "#/definitions/contactPerson_properties_person1_properties_phone_number",
359359
},
360360
},
361361
"required": [
@@ -372,6 +372,15 @@ describe('.parse()', () => {
372372
],
373373
"type": "object",
374374
},
375+
"contactPerson_properties_person1_properties_name": {
376+
"type": "string",
377+
},
378+
"contactPerson_properties_person1_properties_phone_number": {
379+
"type": [
380+
"string",
381+
"null",
382+
],
383+
},
375384
},
376385
"properties": {
377386
"person1": {
@@ -424,10 +433,10 @@ describe('.parse()', () => {
424433
"type": "string",
425434
},
426435
"name": {
427-
"$ref": "#/definitions/contactPerson/properties/person1/properties/name",
436+
"$ref": "#/definitions/contactPerson_properties_person1_properties_name",
428437
},
429438
"phone_number": {
430-
"$ref": "#/definitions/contactPerson/properties/person1/properties/phone_number",
439+
"$ref": "#/definitions/contactPerson_properties_person1_properties_phone_number",
431440
},
432441
},
433442
"required": [

0 commit comments

Comments
 (0)