@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.
@@ -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,3 @@
1
+ /** unnoq */
2
+
3
+ export * from './generator'
@@ -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
+ })