@orpc/openapi 0.0.0-unsafe-pr-2-20241118033608
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4379 -0
- package/dist/index.js.map +1 -0
- package/dist/src/generator.d.ts +24 -0
- package/dist/src/generator.d.ts.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/zod-to-json-schema.d.ts +43 -0
- package/dist/src/zod-to-json-schema.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/generator.test.ts +465 -0
- package/src/generator.ts +267 -0
- package/src/index.ts +3 -0
- package/src/zod-to-json-schema.test.ts +391 -0
- package/src/zod-to-json-schema.ts +666 -0
package/src/generator.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { type ContractRouter, eachContractRouterLeaf } from '@orpc/contract'
|
|
2
|
+
import { type Router, toContractRouter } from '@orpc/server'
|
|
3
|
+
import { findDeepMatches, omit } from '@orpc/shared'
|
|
4
|
+
import { preSerialize } from '@orpc/transformer'
|
|
5
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
6
|
+
import {
|
|
7
|
+
type MediaTypeObject,
|
|
8
|
+
type OpenAPIObject,
|
|
9
|
+
OpenApiBuilder,
|
|
10
|
+
type OperationObject,
|
|
11
|
+
type ParameterObject,
|
|
12
|
+
type RequestBodyObject,
|
|
13
|
+
type ResponseObject,
|
|
14
|
+
} from 'openapi3-ts/oas31'
|
|
15
|
+
import { extractJSONSchema, zodToJsonSchema } from './zod-to-json-schema'
|
|
16
|
+
|
|
17
|
+
// Reference: https://spec.openapis.org/oas/v3.1.0.html#style-values
|
|
18
|
+
|
|
19
|
+
export interface GenerateOpenAPIOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Throw error when you missing define tag definition on OpenAPI root tags
|
|
22
|
+
*
|
|
23
|
+
* Example: if procedure has tags ['foo', 'bar'], and OpenAPI root tags is ['foo'], then error will be thrown
|
|
24
|
+
* Because OpenAPI root tags is missing 'bar' tag
|
|
25
|
+
*
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
throwOnMissingTagDefinition?: boolean
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Weather ignore procedures that has no path defined.
|
|
32
|
+
*
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
ignoreUndefinedPathProcedures?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateOpenAPI(
|
|
39
|
+
opts: {
|
|
40
|
+
router: ContractRouter | Router<any>
|
|
41
|
+
} & Omit<OpenAPIObject, 'openapi'>,
|
|
42
|
+
options?: GenerateOpenAPIOptions,
|
|
43
|
+
): OpenAPIObject {
|
|
44
|
+
const throwOnMissingTagDefinition =
|
|
45
|
+
options?.throwOnMissingTagDefinition ?? false
|
|
46
|
+
const ignoreUndefinedPathProcedures =
|
|
47
|
+
options?.ignoreUndefinedPathProcedures ?? false
|
|
48
|
+
|
|
49
|
+
const builder = new OpenApiBuilder({
|
|
50
|
+
...omit(opts, ['router']),
|
|
51
|
+
openapi: '3.1.0',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const rootTags = opts.tags?.map((tag) => tag.name) ?? []
|
|
55
|
+
const router = toContractRouter(opts.router)
|
|
56
|
+
|
|
57
|
+
eachContractRouterLeaf(router, (procedure, path_) => {
|
|
58
|
+
const internal = procedure.zz$cp
|
|
59
|
+
|
|
60
|
+
if (ignoreUndefinedPathProcedures && internal.path === undefined) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const path = internal.path ?? `/${path_.map(encodeURIComponent).join('/')}`
|
|
65
|
+
const method = internal.method ?? 'POST'
|
|
66
|
+
|
|
67
|
+
const inputSchema = internal.InputSchema
|
|
68
|
+
? zodToJsonSchema(internal.InputSchema, { mode: 'input' })
|
|
69
|
+
: {}
|
|
70
|
+
const outputSchema = internal.OutputSchema
|
|
71
|
+
? zodToJsonSchema(internal.OutputSchema, { mode: 'output' })
|
|
72
|
+
: {}
|
|
73
|
+
|
|
74
|
+
const params: ParameterObject[] | undefined = (() => {
|
|
75
|
+
const names = path.match(/{([^}]+)}/g)
|
|
76
|
+
|
|
77
|
+
if (!names || !names.length) {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof inputSchema !== 'object' || inputSchema.type !== 'object') {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`When path has parameters, input schema must be an object [${path_.join('.')}]`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return names
|
|
88
|
+
.map((raw) => raw.slice(1, -1))
|
|
89
|
+
.map((name) => {
|
|
90
|
+
const schema = inputSchema.properties?.[name]
|
|
91
|
+
const required = inputSchema.required?.includes(name)
|
|
92
|
+
|
|
93
|
+
if (!schema) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Parameter ${name} is missing in input schema [${path_.join('.')}]`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!required) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Parameter ${name} must be required in input schema [${path_.join('.')}]`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
name,
|
|
107
|
+
in: 'path',
|
|
108
|
+
required: true,
|
|
109
|
+
schema: schema as any,
|
|
110
|
+
example: internal.inputExample?.[name],
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})()
|
|
114
|
+
|
|
115
|
+
const query: ParameterObject[] | undefined = (() => {
|
|
116
|
+
if (method !== 'GET' || Object.keys(inputSchema).length === 0) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof inputSchema !== 'object' || inputSchema.type !== 'object') {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`When method is GET, input schema must be an object [${path_.join('.')}]`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Object.entries(inputSchema.properties ?? {})
|
|
127
|
+
.filter(([name]) => !params?.find((param) => param.name === name))
|
|
128
|
+
.map(([name, schema]) => {
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
in: 'query',
|
|
132
|
+
style: 'deepObject',
|
|
133
|
+
required: true,
|
|
134
|
+
schema: schema as any,
|
|
135
|
+
example: internal.inputExample?.[name],
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
})()
|
|
139
|
+
|
|
140
|
+
const parameters = [...(params ?? []), ...(query ?? [])]
|
|
141
|
+
|
|
142
|
+
const requestBody: RequestBodyObject | undefined = (() => {
|
|
143
|
+
if (method === 'GET') {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { schema, matches } = extractJSONSchema(inputSchema, isFileSchema)
|
|
148
|
+
|
|
149
|
+
const files = matches as (JSONSchema & {
|
|
150
|
+
type: 'string'
|
|
151
|
+
contentMediaType: string
|
|
152
|
+
})[]
|
|
153
|
+
|
|
154
|
+
const isStillHasFileSchema =
|
|
155
|
+
findDeepMatches(isFileSchema, schema).values.length > 0
|
|
156
|
+
|
|
157
|
+
if (files.length) {
|
|
158
|
+
parameters.push({
|
|
159
|
+
name: 'content-disposition',
|
|
160
|
+
in: 'header',
|
|
161
|
+
required: schema === undefined,
|
|
162
|
+
schema: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
pattern: 'filename',
|
|
165
|
+
example: 'filename="file.png"',
|
|
166
|
+
description:
|
|
167
|
+
'To define the file name. Required when the request body is a file.',
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const content: Record<string, MediaTypeObject> = {}
|
|
173
|
+
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
content[file.contentMediaType] = {
|
|
176
|
+
schema: file as any,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (schema !== undefined) {
|
|
181
|
+
content[
|
|
182
|
+
isStillHasFileSchema ? 'multipart/form-data' : 'application/json'
|
|
183
|
+
] = {
|
|
184
|
+
schema: schema as any,
|
|
185
|
+
example: internal.inputExample,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
required: Boolean(internal.InputSchema?.isOptional()),
|
|
191
|
+
content,
|
|
192
|
+
}
|
|
193
|
+
})()
|
|
194
|
+
|
|
195
|
+
const successResponse: ResponseObject = (() => {
|
|
196
|
+
const { schema, matches } = extractJSONSchema(outputSchema, isFileSchema)
|
|
197
|
+
const files = matches as (JSONSchema & {
|
|
198
|
+
type: 'string'
|
|
199
|
+
contentMediaType: string
|
|
200
|
+
})[]
|
|
201
|
+
|
|
202
|
+
const isStillHasFileSchema =
|
|
203
|
+
findDeepMatches(isFileSchema, schema).values.length > 0
|
|
204
|
+
|
|
205
|
+
const content: Record<string, MediaTypeObject> = {}
|
|
206
|
+
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
content[file.contentMediaType] = {
|
|
209
|
+
schema: file as any,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (schema !== undefined) {
|
|
214
|
+
content[
|
|
215
|
+
isStillHasFileSchema ? 'multipart/form-data' : 'application/json'
|
|
216
|
+
] = {
|
|
217
|
+
schema: schema as any,
|
|
218
|
+
example: internal.outputExample,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
description: 'OK',
|
|
224
|
+
content,
|
|
225
|
+
}
|
|
226
|
+
})()
|
|
227
|
+
|
|
228
|
+
if (throwOnMissingTagDefinition && internal.tags) {
|
|
229
|
+
const missingTag = internal.tags.find((tag) => !rootTags.includes(tag))
|
|
230
|
+
|
|
231
|
+
if (missingTag !== undefined) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Tag "${missingTag}" is missing definition. Please define it in OpenAPI root tags object. [${path_.join('.')}]`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const operation: OperationObject = {
|
|
239
|
+
summary: internal.summary,
|
|
240
|
+
description: internal.description,
|
|
241
|
+
deprecated: internal.deprecated,
|
|
242
|
+
tags: internal.tags,
|
|
243
|
+
operationId: path_.join('.'),
|
|
244
|
+
parameters: parameters.length ? parameters : undefined,
|
|
245
|
+
requestBody,
|
|
246
|
+
responses: {
|
|
247
|
+
'200': successResponse,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
builder.addPath(path, {
|
|
252
|
+
[method.toLocaleLowerCase()]: operation,
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
return preSerialize(builder.getSpec()) as OpenAPIObject
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isFileSchema(schema: unknown) {
|
|
260
|
+
if (typeof schema !== 'object' || schema === null) return false
|
|
261
|
+
return (
|
|
262
|
+
'type' in schema &&
|
|
263
|
+
'contentMediaType' in schema &&
|
|
264
|
+
typeof schema.type === 'string' &&
|
|
265
|
+
typeof schema.contentMediaType === 'string'
|
|
266
|
+
)
|
|
267
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { oz } from '@orpc/zod'
|
|
2
|
+
import { Format } from 'json-schema-typed/draft-2020-12'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { zodToJsonSchema } from './zod-to-json-schema'
|
|
6
|
+
|
|
7
|
+
describe('primitive types', () => {
|
|
8
|
+
it('should convert string schema', () => {
|
|
9
|
+
const schema = z.string()
|
|
10
|
+
expect(zodToJsonSchema(schema)).toEqual({ type: 'string' })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should convert string schema with constraints', () => {
|
|
14
|
+
const schema = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(5)
|
|
17
|
+
.max(10)
|
|
18
|
+
.email()
|
|
19
|
+
.regex(/^[a-z]+$/)
|
|
20
|
+
|
|
21
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
22
|
+
type: 'string',
|
|
23
|
+
minLength: 5,
|
|
24
|
+
maxLength: 10,
|
|
25
|
+
format: Format.Email,
|
|
26
|
+
pattern: '^[a-z]+$',
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should convert number schema', () => {
|
|
31
|
+
const schema = z.number()
|
|
32
|
+
expect(zodToJsonSchema(schema)).toEqual({ type: 'number' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should convert number schema with constraints', () => {
|
|
36
|
+
const schema = z.number().int().min(0).max(100).multipleOf(5)
|
|
37
|
+
|
|
38
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
39
|
+
type: 'integer',
|
|
40
|
+
minimum: 0,
|
|
41
|
+
maximum: 100,
|
|
42
|
+
multipleOf: 5,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should convert boolean schema', () => {
|
|
47
|
+
const schema = z.boolean()
|
|
48
|
+
expect(zodToJsonSchema(schema)).toEqual({ type: 'boolean' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should convert null schema', () => {
|
|
52
|
+
const schema = z.null()
|
|
53
|
+
expect(zodToJsonSchema(schema)).toEqual({ type: 'null' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should convert undefined schema', () => {
|
|
57
|
+
const schema = z.undefined()
|
|
58
|
+
expect(zodToJsonSchema(schema)).toEqual({ const: 'undefined' })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should convert literal schema', () => {
|
|
62
|
+
const schema = z.literal('hello')
|
|
63
|
+
expect(zodToJsonSchema(schema)).toEqual({ const: 'hello' })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('array types', () => {
|
|
68
|
+
it('should convert array schema', () => {
|
|
69
|
+
const schema = z.array(z.string())
|
|
70
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
71
|
+
type: 'array',
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should convert array schema with length constraints', () => {
|
|
76
|
+
const schema = z.array(z.string()).min(1).max(5)
|
|
77
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
78
|
+
type: 'array',
|
|
79
|
+
minItems: 1,
|
|
80
|
+
maxItems: 5,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should convert tuple schema', () => {
|
|
85
|
+
const schema = z.tuple([z.string(), z.number()])
|
|
86
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
87
|
+
type: 'array',
|
|
88
|
+
prefixItems: [{ type: 'string' }, { type: 'number' }],
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('object types', () => {
|
|
94
|
+
it('should convert object schema', () => {
|
|
95
|
+
const schema = z.object({
|
|
96
|
+
name: z.string(),
|
|
97
|
+
age: z.number(),
|
|
98
|
+
email: z.string().email(),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
name: { type: 'string' },
|
|
105
|
+
age: { type: 'number' },
|
|
106
|
+
email: { type: 'string', format: Format.Email },
|
|
107
|
+
},
|
|
108
|
+
required: ['name', 'age', 'email'],
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should handle optional properties', () => {
|
|
113
|
+
const schema = z.object({
|
|
114
|
+
name: z.string(),
|
|
115
|
+
age: z.number().optional(),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
name: { type: 'string' },
|
|
122
|
+
age: { type: 'number' },
|
|
123
|
+
},
|
|
124
|
+
required: ['name'],
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should convert record schema', () => {
|
|
129
|
+
const schema = z.record(z.string(), z.number())
|
|
130
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
131
|
+
type: 'object',
|
|
132
|
+
additionalProperties: { type: 'number' },
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('union and intersection types', () => {
|
|
138
|
+
it('should convert union schema', () => {
|
|
139
|
+
const schema = z.union([z.string(), z.number()])
|
|
140
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
141
|
+
anyOf: [{ type: 'string' }, { type: 'number' }],
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should convert discriminated union schema', () => {
|
|
146
|
+
const schema = z.discriminatedUnion('type', [
|
|
147
|
+
z.object({ type: z.literal('a'), value: z.string() }),
|
|
148
|
+
z.object({ type: z.literal('b'), value: z.number() }),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
152
|
+
anyOf: [
|
|
153
|
+
{
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
type: { const: 'a' },
|
|
157
|
+
value: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
required: ['type', 'value'],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
type: { const: 'b' },
|
|
165
|
+
value: { type: 'number' },
|
|
166
|
+
},
|
|
167
|
+
required: ['type', 'value'],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should convert intersection schema', () => {
|
|
174
|
+
const schema = z.intersection(
|
|
175
|
+
z.object({ name: z.string() }),
|
|
176
|
+
z.object({ age: z.number() }),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
180
|
+
allOf: [
|
|
181
|
+
{
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: { name: { type: 'string' } },
|
|
184
|
+
required: ['name'],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: { age: { type: 'number' } },
|
|
189
|
+
required: ['age'],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('modifiers', () => {
|
|
197
|
+
it('should convert optional schema', () => {
|
|
198
|
+
const schema = z.string().optional()
|
|
199
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
200
|
+
anyOf: [{ const: 'undefined' }, { type: 'string' }],
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should convert nullable schema', () => {
|
|
205
|
+
const schema = z.string().nullable()
|
|
206
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
207
|
+
anyOf: [{ type: 'null' }, { type: 'string' }],
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should convert readonly schema', () => {
|
|
212
|
+
const schema = z.string().readonly()
|
|
213
|
+
expect(zodToJsonSchema(schema)).toEqual({ type: 'string' })
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('special types', () => {
|
|
218
|
+
it('should convert date schema', () => {
|
|
219
|
+
const schema = z.date()
|
|
220
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
221
|
+
type: 'string',
|
|
222
|
+
format: Format.Date,
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should convert enum schema', () => {
|
|
227
|
+
const schema = z.enum(['A', 'B', 'C'])
|
|
228
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
229
|
+
enum: ['A', 'B', 'C'],
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should convert native enum schema', () => {
|
|
234
|
+
enum TestEnum {
|
|
235
|
+
A = 'A',
|
|
236
|
+
B = 'B',
|
|
237
|
+
}
|
|
238
|
+
const schema = z.nativeEnum(TestEnum)
|
|
239
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
240
|
+
enum: ['A', 'B'],
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('transform and effects', () => {
|
|
246
|
+
it('should handle transform effects based on mode', () => {
|
|
247
|
+
const schema = z.string().transform((val) => val.length)
|
|
248
|
+
|
|
249
|
+
expect(zodToJsonSchema(schema, { mode: 'input' })).toEqual({
|
|
250
|
+
type: 'string',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(zodToJsonSchema(schema, { mode: 'output' })).toEqual({})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('lazy types', () => {
|
|
258
|
+
it('should handle lazy types with depth limit', () => {
|
|
259
|
+
type Tree = {
|
|
260
|
+
value: string
|
|
261
|
+
children?: Tree[]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const treeSchema: z.ZodType<Tree> = z.lazy(() =>
|
|
265
|
+
z.object({
|
|
266
|
+
value: z.string(),
|
|
267
|
+
children: z.array(treeSchema).optional(),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(zodToJsonSchema(treeSchema, { maxLazyDepth: 2 })).toEqual({
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
value: { type: 'string' },
|
|
275
|
+
children: {
|
|
276
|
+
type: 'array',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
required: ['value'],
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('with custom json schema', () => {
|
|
285
|
+
const schema = oz.openapi(z.object({}), {
|
|
286
|
+
examples: [{ a: '23' }],
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const schema2 = oz.openapi(
|
|
290
|
+
z.object({}),
|
|
291
|
+
{
|
|
292
|
+
examples: [{ a: '23' }, { b: '23' }],
|
|
293
|
+
},
|
|
294
|
+
{ mode: 'input' },
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const schema3 = oz.openapi(
|
|
298
|
+
z.object({}),
|
|
299
|
+
{
|
|
300
|
+
examples: [{ a: '23' }, { b: '23' }],
|
|
301
|
+
},
|
|
302
|
+
{ mode: 'output' },
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
it('works with input mode', () => {
|
|
306
|
+
expect(zodToJsonSchema(schema, { mode: 'input' })).toEqual({
|
|
307
|
+
type: 'object',
|
|
308
|
+
examples: [{ a: '23' }],
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(zodToJsonSchema(schema2, { mode: 'input' })).toEqual({
|
|
312
|
+
type: 'object',
|
|
313
|
+
examples: [{ a: '23' }, { b: '23' }],
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(zodToJsonSchema(schema3, { mode: 'input' })).toEqual({
|
|
317
|
+
type: 'object',
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('works with output mode', () => {
|
|
322
|
+
expect(zodToJsonSchema(schema, { mode: 'output' })).toEqual({
|
|
323
|
+
type: 'object',
|
|
324
|
+
examples: [{ a: '23' }],
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
expect(zodToJsonSchema(schema2, { mode: 'output' })).toEqual({
|
|
328
|
+
type: 'object',
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(zodToJsonSchema(schema3, { mode: 'output' })).toEqual({
|
|
332
|
+
type: 'object',
|
|
333
|
+
examples: [{ a: '23' }, { b: '23' }],
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('works on complex schema', () => {
|
|
338
|
+
const schema = z.object({
|
|
339
|
+
nested: z.object({
|
|
340
|
+
union: oz.openapi(
|
|
341
|
+
z.union([
|
|
342
|
+
oz.openapi(z.string(), {
|
|
343
|
+
$comment: 'comment for string',
|
|
344
|
+
}),
|
|
345
|
+
z.object({
|
|
346
|
+
url: oz.openapi(oz.url(), {
|
|
347
|
+
$comment: 'comment for url',
|
|
348
|
+
}),
|
|
349
|
+
}),
|
|
350
|
+
]),
|
|
351
|
+
{
|
|
352
|
+
$comment: 'comment for nested',
|
|
353
|
+
},
|
|
354
|
+
),
|
|
355
|
+
}),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
nested: {
|
|
362
|
+
type: 'object',
|
|
363
|
+
properties: {
|
|
364
|
+
union: {
|
|
365
|
+
$comment: 'comment for nested',
|
|
366
|
+
anyOf: [
|
|
367
|
+
{
|
|
368
|
+
type: 'string',
|
|
369
|
+
$comment: 'comment for string',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
type: 'object',
|
|
373
|
+
properties: {
|
|
374
|
+
url: {
|
|
375
|
+
type: 'string',
|
|
376
|
+
format: Format.URI,
|
|
377
|
+
$comment: 'comment for url',
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
required: ['url'],
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
required: ['union'],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
required: ['nested'],
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
})
|