@orpc/openapi 0.0.0

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,666 @@
1
+ import {
2
+ getCustomJSONSchema,
3
+ getCustomZodFileMimeType,
4
+ getCustomZodType,
5
+ } from '@orpc/zod'
6
+ import escapeStringRegexp from 'escape-string-regexp'
7
+ import {
8
+ Format,
9
+ type JSONSchema,
10
+ type keywords,
11
+ } from 'json-schema-typed/draft-2020-12'
12
+ import {
13
+ type EnumLike,
14
+ type KeySchema,
15
+ type ZodAny,
16
+ type ZodArray,
17
+ type ZodBigInt,
18
+ type ZodBoolean,
19
+ type ZodBranded,
20
+ type ZodCatch,
21
+ type ZodDate,
22
+ type ZodDefault,
23
+ type ZodDiscriminatedUnion,
24
+ type ZodEffects,
25
+ type ZodEnum,
26
+ ZodFirstPartyTypeKind,
27
+ type ZodIntersection,
28
+ type ZodLazy,
29
+ type ZodLiteral,
30
+ type ZodMap,
31
+ type ZodNaN,
32
+ type ZodNativeEnum,
33
+ type ZodNullable,
34
+ type ZodNumber,
35
+ type ZodObject,
36
+ type ZodOptional,
37
+ type ZodPipeline,
38
+ type ZodRawShape,
39
+ type ZodReadonly,
40
+ type ZodRecord,
41
+ type ZodSet,
42
+ type ZodString,
43
+ type ZodTuple,
44
+ type ZodTypeAny,
45
+ type ZodUnion,
46
+ type ZodUnionOptions,
47
+ } from 'zod'
48
+
49
+ export const NON_LOGIC_KEYWORDS = [
50
+ // Core Documentation Keywords
51
+ '$anchor',
52
+ '$comment',
53
+ '$defs',
54
+ '$id',
55
+ 'title',
56
+ 'description',
57
+
58
+ // Value Keywords
59
+ 'default',
60
+ 'deprecated',
61
+ 'examples',
62
+
63
+ // Metadata Keywords
64
+ '$schema',
65
+ 'definitions', // Legacy, but still used
66
+ 'readOnly',
67
+ 'writeOnly',
68
+
69
+ // Display and UI Hints
70
+ 'contentMediaType',
71
+ 'contentEncoding',
72
+ 'format',
73
+
74
+ // Custom Extensions
75
+ '$vocabulary',
76
+ '$dynamicAnchor',
77
+ '$dynamicRef',
78
+ ] satisfies (typeof keywords)[number][]
79
+
80
+ export const UNSUPPORTED_JSON_SCHEMA = { not: {} }
81
+ export const UNDEFINED_JSON_SCHEMA = { const: 'undefined' }
82
+
83
+ export interface ZodToJsonSchemaOptions {
84
+ /**
85
+ * Max depth of lazy type, if it exceeds.
86
+ *
87
+ * Used `{}` when reach max depth
88
+ *
89
+ * @default 5
90
+ */
91
+ maxLazyDepth?: number
92
+
93
+ /**
94
+ * The length used to track the depth of lazy type
95
+ *
96
+ * @internal
97
+ */
98
+ lazyDepth?: number
99
+
100
+ /**
101
+ * The expected json schema for input or output zod schema
102
+ *
103
+ * @default input
104
+ */
105
+ mode?: 'input' | 'output'
106
+
107
+ /**
108
+ * Track if current level schema is handled custom json schema to prevent recursive
109
+ *
110
+ * @internal
111
+ */
112
+ isHandledCustomJSONSchema?: boolean
113
+ }
114
+
115
+ export function zodToJsonSchema(
116
+ schema: ZodTypeAny,
117
+ options?: ZodToJsonSchemaOptions,
118
+ ): Exclude<JSONSchema, boolean> {
119
+ if (!options?.isHandledCustomJSONSchema) {
120
+ const customJSONSchema = getCustomJSONSchema(schema._def, options)
121
+
122
+ if (customJSONSchema) {
123
+ const json = zodToJsonSchema(schema, {
124
+ ...options,
125
+ isHandledCustomJSONSchema: true,
126
+ })
127
+
128
+ return {
129
+ ...json,
130
+ ...customJSONSchema,
131
+ }
132
+ }
133
+ }
134
+
135
+ const childOptions = { ...options, isHandledCustomJSONSchema: false }
136
+
137
+ const customType = getCustomZodType(schema._def)
138
+
139
+ switch (customType) {
140
+ case 'Blob': {
141
+ return { type: 'string', contentMediaType: '*/*' }
142
+ }
143
+
144
+ case 'File': {
145
+ const mimeType = getCustomZodFileMimeType(schema._def) ?? '*/*'
146
+
147
+ return { type: 'string', contentMediaType: mimeType }
148
+ }
149
+
150
+ case 'Invalid Date': {
151
+ return { const: 'Invalid Date' }
152
+ }
153
+
154
+ case 'RegExp': {
155
+ return {
156
+ type: 'string',
157
+ pattern: '^\\/(.*)\\/([a-z]*)$',
158
+ }
159
+ }
160
+
161
+ case 'URL': {
162
+ return { type: 'string', format: Format.URI }
163
+ }
164
+ }
165
+
166
+ const _expectedCustomType: undefined = customType
167
+
168
+ const typeName = schema._def.typeName as ZodFirstPartyTypeKind | undefined
169
+
170
+ switch (typeName) {
171
+ case ZodFirstPartyTypeKind.ZodString: {
172
+ const schema_ = schema as ZodString
173
+
174
+ const json: JSONSchema = { type: 'string' }
175
+
176
+ for (const check of schema_._def.checks) {
177
+ switch (check.kind) {
178
+ case 'base64':
179
+ json.contentEncoding = 'base64'
180
+ break
181
+ case 'cuid':
182
+ json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$'
183
+ break
184
+ case 'email':
185
+ json.format = Format.Email
186
+ break
187
+ case 'url':
188
+ json.format = Format.URI
189
+ break
190
+ case 'uuid':
191
+ json.format = Format.UUID
192
+ break
193
+ case 'regex':
194
+ json.pattern = check.regex.source
195
+ break
196
+ case 'min':
197
+ json.minLength = check.value
198
+ break
199
+ case 'max':
200
+ json.maxLength = check.value
201
+ break
202
+ case 'length':
203
+ json.minLength = check.value
204
+ json.maxLength = check.value
205
+ break
206
+ case 'includes':
207
+ json.pattern = escapeStringRegexp(check.value)
208
+ break
209
+ case 'startsWith':
210
+ json.pattern = `^${escapeStringRegexp(check.value)}`
211
+ break
212
+ case 'endsWith':
213
+ json.pattern = `${escapeStringRegexp(check.value)}$`
214
+ break
215
+ case 'emoji':
216
+ json.pattern =
217
+ '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$'
218
+ break
219
+ case 'nanoid':
220
+ json.pattern = '^[a-zA-Z0-9_-]{21}$'
221
+ break
222
+ case 'cuid2':
223
+ json.pattern = '^[0-9a-z]+$'
224
+ break
225
+ case 'ulid':
226
+ json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$'
227
+ break
228
+ case 'datetime':
229
+ json.format = Format.DateTime
230
+ break
231
+ case 'date':
232
+ json.format = Format.Date
233
+ break
234
+ case 'time':
235
+ json.format = Format.Time
236
+ break
237
+ case 'duration':
238
+ json.format = Format.Duration
239
+ break
240
+ case 'ip':
241
+ json.format = Format.IPv4
242
+ break
243
+ default: {
244
+ const _expect: 'toLowerCase' | 'toUpperCase' | 'trim' = check.kind
245
+ }
246
+ }
247
+ }
248
+
249
+ return json
250
+ }
251
+
252
+ case ZodFirstPartyTypeKind.ZodNumber: {
253
+ const schema_ = schema as ZodNumber
254
+
255
+ const json: JSONSchema = { type: 'number' }
256
+
257
+ for (const check of schema_._def.checks) {
258
+ switch (check.kind) {
259
+ case 'int':
260
+ json.type = 'integer'
261
+ break
262
+ case 'min':
263
+ json.minimum = check.value
264
+ break
265
+ case 'max':
266
+ json.maximum = check.value
267
+ break
268
+ case 'multipleOf':
269
+ json.multipleOf = check.value
270
+ break
271
+ default: {
272
+ const _expect: 'finite' = check.kind
273
+ }
274
+ }
275
+ }
276
+
277
+ return json
278
+ }
279
+
280
+ case ZodFirstPartyTypeKind.ZodNaN: {
281
+ const schema_ = schema as ZodNaN
282
+
283
+ return { const: 'NaN' }
284
+ }
285
+
286
+ case ZodFirstPartyTypeKind.ZodBigInt: {
287
+ const schema_ = schema as ZodBigInt
288
+
289
+ const json: JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' }
290
+
291
+ // WARN: ignore checks
292
+
293
+ return json
294
+ }
295
+
296
+ case ZodFirstPartyTypeKind.ZodBoolean: {
297
+ const schema_ = schema as ZodBoolean
298
+
299
+ return { type: 'boolean' }
300
+ }
301
+
302
+ case ZodFirstPartyTypeKind.ZodDate: {
303
+ const schema_ = schema as ZodDate
304
+
305
+ const jsonSchema: JSONSchema = { type: 'string', format: Format.Date }
306
+
307
+ // WARN: ignore checks
308
+
309
+ return jsonSchema
310
+ }
311
+
312
+ case ZodFirstPartyTypeKind.ZodNull: {
313
+ return { type: 'null' }
314
+ }
315
+
316
+ case ZodFirstPartyTypeKind.ZodVoid:
317
+ case ZodFirstPartyTypeKind.ZodUndefined: {
318
+ return UNDEFINED_JSON_SCHEMA
319
+ }
320
+
321
+ case ZodFirstPartyTypeKind.ZodLiteral: {
322
+ const schema_ = schema as ZodLiteral<unknown>
323
+ return { const: schema_._def.value }
324
+ }
325
+
326
+ case ZodFirstPartyTypeKind.ZodEnum: {
327
+ const schema_ = schema as ZodEnum<[string, ...string[]]>
328
+
329
+ return {
330
+ enum: schema_._def.values,
331
+ }
332
+ }
333
+
334
+ case ZodFirstPartyTypeKind.ZodNativeEnum: {
335
+ const schema_ = schema as ZodNativeEnum<EnumLike>
336
+
337
+ return {
338
+ enum: Object.values(schema_._def.values),
339
+ }
340
+ }
341
+
342
+ case ZodFirstPartyTypeKind.ZodArray: {
343
+ const schema_ = schema as ZodArray<ZodTypeAny>
344
+ const def = schema_._def
345
+
346
+ const json: JSONSchema = { type: 'array' }
347
+
348
+ if (def.exactLength) {
349
+ json.maxItems = def.exactLength.value
350
+ json.minItems = def.exactLength.value
351
+ }
352
+
353
+ if (def.minLength) {
354
+ json.minItems = def.minLength.value
355
+ }
356
+
357
+ if (def.maxLength) {
358
+ json.maxItems = def.maxLength.value
359
+ }
360
+
361
+ return json
362
+ }
363
+
364
+ case ZodFirstPartyTypeKind.ZodTuple: {
365
+ const schema_ = schema as ZodTuple<
366
+ [ZodTypeAny, ...ZodTypeAny[]],
367
+ ZodTypeAny | null
368
+ >
369
+
370
+ const prefixItems: JSONSchema[] = []
371
+ const json: JSONSchema = { type: 'array' }
372
+
373
+ for (const item of schema_._def.items) {
374
+ prefixItems.push(zodToJsonSchema(item, childOptions))
375
+ }
376
+
377
+ if (prefixItems?.length) {
378
+ json.prefixItems = prefixItems
379
+ }
380
+
381
+ if (schema_._def.rest) {
382
+ const items = zodToJsonSchema(schema_._def.rest, childOptions)
383
+ if (items) {
384
+ json.items = items
385
+ }
386
+ }
387
+
388
+ return json
389
+ }
390
+
391
+ case ZodFirstPartyTypeKind.ZodObject: {
392
+ const schema_ = schema as ZodObject<ZodRawShape>
393
+
394
+ const json: JSONSchema = { type: 'object' }
395
+ const properties: Record<string, JSONSchema> = {}
396
+ const required: string[] = []
397
+
398
+ for (const [key, value] of Object.entries(schema_.shape)) {
399
+ const { schema, matches } = extractJSONSchema(
400
+ zodToJsonSchema(value, childOptions),
401
+ (schema) => schema === UNDEFINED_JSON_SCHEMA,
402
+ )
403
+
404
+ if (schema) {
405
+ properties[key] = schema
406
+ }
407
+
408
+ if (matches.length === 0) {
409
+ required.push(key)
410
+ }
411
+ }
412
+
413
+ if (Object.keys(properties).length) {
414
+ json.properties = properties
415
+ }
416
+
417
+ if (required.length) {
418
+ json.required = required
419
+ }
420
+
421
+ const additionalProperties = zodToJsonSchema(
422
+ schema_._def.catchall,
423
+ childOptions,
424
+ )
425
+ if (schema_._def.unknownKeys === 'strict') {
426
+ json.additionalProperties =
427
+ additionalProperties === UNSUPPORTED_JSON_SCHEMA
428
+ ? false
429
+ : additionalProperties
430
+ } else {
431
+ if (
432
+ additionalProperties &&
433
+ additionalProperties !== UNSUPPORTED_JSON_SCHEMA
434
+ ) {
435
+ json.additionalProperties = additionalProperties
436
+ }
437
+ }
438
+
439
+ return json
440
+ }
441
+
442
+ case ZodFirstPartyTypeKind.ZodRecord: {
443
+ const schema_ = schema as ZodRecord<KeySchema, ZodAny>
444
+
445
+ const json: JSONSchema = { type: 'object' }
446
+
447
+ json.additionalProperties = zodToJsonSchema(
448
+ schema_._def.valueType,
449
+ childOptions,
450
+ )
451
+
452
+ return json
453
+ }
454
+
455
+ case ZodFirstPartyTypeKind.ZodSet: {
456
+ const schema_ = schema as ZodSet
457
+
458
+ return {
459
+ type: 'array',
460
+ items: zodToJsonSchema(schema_._def.valueType, childOptions),
461
+ }
462
+ }
463
+
464
+ case ZodFirstPartyTypeKind.ZodMap: {
465
+ const schema_ = schema as ZodMap
466
+
467
+ return {
468
+ type: 'array',
469
+ items: {
470
+ type: 'array',
471
+ prefixItems: [
472
+ zodToJsonSchema(schema_._def.keyType, childOptions),
473
+ zodToJsonSchema(schema_._def.valueType, childOptions),
474
+ ],
475
+ maxItems: 2,
476
+ minItems: 2,
477
+ },
478
+ }
479
+ }
480
+
481
+ case ZodFirstPartyTypeKind.ZodUnion:
482
+ case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: {
483
+ const schema_ = schema as
484
+ | ZodUnion<ZodUnionOptions>
485
+ | ZodDiscriminatedUnion<string, [ZodObject<any>, ...ZodObject<any>[]]>
486
+
487
+ const anyOf: JSONSchema[] = []
488
+
489
+ for (const s of schema_._def.options) {
490
+ anyOf.push(zodToJsonSchema(s, childOptions))
491
+ }
492
+
493
+ return { anyOf }
494
+ }
495
+
496
+ case ZodFirstPartyTypeKind.ZodIntersection: {
497
+ const schema_ = schema as ZodIntersection<ZodTypeAny, ZodTypeAny>
498
+
499
+ const allOf: JSONSchema[] = []
500
+
501
+ for (const s of [schema_._def.left, schema_._def.right]) {
502
+ allOf.push(zodToJsonSchema(s, childOptions))
503
+ }
504
+
505
+ return { allOf }
506
+ }
507
+
508
+ case ZodFirstPartyTypeKind.ZodLazy: {
509
+ const schema_ = schema as ZodLazy<ZodTypeAny>
510
+
511
+ const maxLazyDepth = childOptions?.maxLazyDepth ?? 5
512
+ const lazyDepth = childOptions?.lazyDepth ?? 0
513
+
514
+ if (lazyDepth > maxLazyDepth) {
515
+ return {}
516
+ }
517
+
518
+ return zodToJsonSchema(schema_._def.getter(), {
519
+ ...childOptions,
520
+ lazyDepth: lazyDepth + 1,
521
+ })
522
+ }
523
+
524
+ case ZodFirstPartyTypeKind.ZodUnknown:
525
+ case ZodFirstPartyTypeKind.ZodAny:
526
+ case undefined: {
527
+ return {}
528
+ }
529
+
530
+ case ZodFirstPartyTypeKind.ZodOptional: {
531
+ const schema_ = schema as ZodOptional<ZodTypeAny>
532
+
533
+ const inner = zodToJsonSchema(schema_._def.innerType, childOptions)
534
+
535
+ return {
536
+ anyOf: [UNDEFINED_JSON_SCHEMA, inner],
537
+ }
538
+ }
539
+
540
+ case ZodFirstPartyTypeKind.ZodReadonly: {
541
+ const schema_ = schema as ZodReadonly<ZodTypeAny>
542
+ return zodToJsonSchema(schema_._def.innerType, childOptions)
543
+ }
544
+
545
+ case ZodFirstPartyTypeKind.ZodDefault: {
546
+ const schema_ = schema as ZodDefault<ZodTypeAny>
547
+ return zodToJsonSchema(schema_._def.innerType, childOptions)
548
+ }
549
+
550
+ case ZodFirstPartyTypeKind.ZodEffects: {
551
+ const schema_ = schema as ZodEffects<ZodTypeAny>
552
+
553
+ if (
554
+ schema_._def.effect.type === 'transform' &&
555
+ childOptions?.mode === 'output'
556
+ ) {
557
+ return {}
558
+ }
559
+
560
+ return zodToJsonSchema(schema_._def.schema, childOptions)
561
+ }
562
+
563
+ case ZodFirstPartyTypeKind.ZodCatch: {
564
+ const schema_ = schema as ZodCatch<ZodTypeAny>
565
+ return zodToJsonSchema(schema_._def.innerType, childOptions)
566
+ }
567
+
568
+ case ZodFirstPartyTypeKind.ZodBranded: {
569
+ const schema_ = schema as ZodBranded<ZodTypeAny, string | number | symbol>
570
+ return zodToJsonSchema(schema_._def.type, childOptions)
571
+ }
572
+
573
+ case ZodFirstPartyTypeKind.ZodPipeline: {
574
+ const schema_ = schema as ZodPipeline<ZodTypeAny, ZodTypeAny>
575
+ return zodToJsonSchema(
576
+ childOptions?.mode === 'output' ? schema_._def.out : schema_._def.in,
577
+ childOptions,
578
+ )
579
+ }
580
+
581
+ case ZodFirstPartyTypeKind.ZodNullable: {
582
+ const schema_ = schema as ZodNullable<ZodTypeAny>
583
+
584
+ const inner = zodToJsonSchema(schema_._def.innerType, childOptions)
585
+
586
+ return {
587
+ anyOf: [{ type: 'null' }, inner],
588
+ }
589
+ }
590
+ }
591
+
592
+ const _expected:
593
+ | ZodFirstPartyTypeKind.ZodPromise
594
+ | ZodFirstPartyTypeKind.ZodSymbol
595
+ | ZodFirstPartyTypeKind.ZodFunction
596
+ | ZodFirstPartyTypeKind.ZodNever = typeName
597
+
598
+ return UNSUPPORTED_JSON_SCHEMA
599
+ }
600
+
601
+ export function extractJSONSchema(
602
+ schema: JSONSchema,
603
+ check: (schema: JSONSchema) => boolean,
604
+ matches: JSONSchema[] = [],
605
+ ): { schema: JSONSchema | undefined; matches: JSONSchema[] } {
606
+ if (check(schema)) {
607
+ matches.push(schema)
608
+ return { schema: undefined, matches }
609
+ }
610
+
611
+ if (typeof schema === 'boolean') {
612
+ return { schema, matches }
613
+ }
614
+
615
+ // TODO: $ref
616
+
617
+ if (
618
+ schema.anyOf &&
619
+ Object.keys(schema).every(
620
+ (k) => k === 'anyOf' || NON_LOGIC_KEYWORDS.includes(k as any),
621
+ )
622
+ ) {
623
+ const anyOf = schema.anyOf
624
+ .map((s) => extractJSONSchema(s, check, matches).schema)
625
+ .filter((v) => !!v)
626
+
627
+ if (anyOf.length === 1 && typeof anyOf[0] === 'object') {
628
+ return { schema: { ...schema, anyOf: undefined, ...anyOf[0] }, matches }
629
+ }
630
+
631
+ return {
632
+ schema: {
633
+ ...schema,
634
+ anyOf,
635
+ },
636
+ matches,
637
+ }
638
+ }
639
+
640
+ // TODO: $ref
641
+
642
+ if (
643
+ schema.oneOf &&
644
+ Object.keys(schema).every(
645
+ (k) => k === 'oneOf' || NON_LOGIC_KEYWORDS.includes(k as any),
646
+ )
647
+ ) {
648
+ const oneOf = schema.oneOf
649
+ .map((s) => extractJSONSchema(s, check, matches).schema)
650
+ .filter((v) => !!v)
651
+
652
+ if (oneOf.length === 1 && typeof oneOf[0] === 'object') {
653
+ return { schema: { ...schema, oneOf: undefined, ...oneOf[0] }, matches }
654
+ }
655
+
656
+ return {
657
+ schema: {
658
+ ...schema,
659
+ oneOf,
660
+ },
661
+ matches,
662
+ }
663
+ }
664
+
665
+ return { schema, matches }
666
+ }