@orpc/openapi 0.0.3 → 0.0.5

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.
@@ -1,6 +1,6 @@
1
+ import type { OpenAPIObject } from 'openapi3-ts/oas31'
1
2
  import { oc } from '@orpc/contract'
2
3
  import { oz } from '@orpc/zod'
3
- import type { OpenAPIObject } from 'openapi3-ts/oas31'
4
4
  import { z } from 'zod'
5
5
  import { generateOpenAPI } from './generator'
6
6
 
@@ -36,7 +36,7 @@ it('works', () => {
36
36
  '/ping': {
37
37
  post: {
38
38
  responses: {
39
- '200': {
39
+ 200: {
40
40
  description: 'OK',
41
41
  content: {
42
42
  'application/json': {
@@ -63,7 +63,7 @@ it('works', () => {
63
63
  },
64
64
  ],
65
65
  responses: {
66
- '200': {
66
+ 200: {
67
67
  description: 'OK',
68
68
  content: {
69
69
  'application/json': {
@@ -133,7 +133,7 @@ it('throwOnMissingTagDefinition option', () => {
133
133
  '/ping': {
134
134
  post: {
135
135
  responses: {
136
- '200': {
136
+ 200: {
137
137
  description: 'OK',
138
138
  content: {
139
139
  'application/json': {
@@ -160,7 +160,7 @@ it('throwOnMissingTagDefinition option', () => {
160
160
  },
161
161
  ],
162
162
  responses: {
163
- '200': {
163
+ 200: {
164
164
  description: 'OK',
165
165
  content: {
166
166
  'application/json': {
@@ -237,7 +237,7 @@ it('support single file upload', () => {
237
237
  },
238
238
  },
239
239
  responses: {
240
- '200': {
240
+ 200: {
241
241
  content: {
242
242
  'image/jpg': {
243
243
  schema: {
@@ -310,7 +310,7 @@ it('support multipart/form-data', () => {
310
310
  },
311
311
  },
312
312
  responses: {
313
- '200': {
313
+ 200: {
314
314
  content: {
315
315
  'image/*': {
316
316
  schema: {
@@ -415,7 +415,7 @@ it('work with example', () => {
415
415
  },
416
416
  },
417
417
  responses: {
418
- '200': {
418
+ 200: {
419
419
  content: {
420
420
  'application/json': {
421
421
  schema: {
@@ -463,3 +463,181 @@ it('work with example', () => {
463
463
  },
464
464
  })
465
465
  })
466
+
467
+ it('should remove params on body', () => {
468
+ const router = oc.router({
469
+ upload: oc.route({ method: 'POST', path: '/upload/{id}' }).input(
470
+ oz.openapi(
471
+ z.object({
472
+ id: z.number(),
473
+ file: z.string().url(),
474
+ }),
475
+ {
476
+ examples: [
477
+ {
478
+ id: 123,
479
+ file: 'https://example.com/file.png',
480
+ },
481
+ ],
482
+ },
483
+ ),
484
+ ),
485
+ })
486
+
487
+ const spec = generateOpenAPI({
488
+ router,
489
+ info: {
490
+ title: 'test',
491
+ version: '1.0.0',
492
+ },
493
+ })
494
+
495
+ expect(spec).toEqual({
496
+ info: { title: 'test', version: '1.0.0' },
497
+ openapi: '3.1.0',
498
+ paths: {
499
+ '/upload/{id}': {
500
+ post: {
501
+ summary: undefined,
502
+ description: undefined,
503
+ deprecated: undefined,
504
+ tags: undefined,
505
+ operationId: 'upload',
506
+ parameters: [
507
+ {
508
+ name: 'id',
509
+ in: 'path',
510
+ required: true,
511
+ schema: { examples: [123], type: 'number' },
512
+ example: undefined,
513
+ },
514
+ ],
515
+ requestBody: {
516
+ required: false,
517
+ content: {
518
+ 'application/json': {
519
+ schema: {
520
+ type: 'object',
521
+ properties: { file: { type: 'string', format: 'uri' } },
522
+ required: ['file'],
523
+ examples: [{ file: 'https://example.com/file.png' }],
524
+ },
525
+ example: undefined,
526
+ },
527
+ },
528
+ },
529
+ responses: {
530
+ 200: {
531
+ description: 'OK',
532
+ content: {
533
+ 'application/json': { schema: {}, example: undefined },
534
+ },
535
+ },
536
+ },
537
+ },
538
+ },
539
+ },
540
+ })
541
+ })
542
+
543
+ it('should remove params on query', () => {
544
+ const router = oc.router({
545
+ upload: oc.route({ method: 'GET', path: '/upload/{id}' }).input(
546
+ oz.openapi(
547
+ z.object({
548
+ id: z.number(),
549
+ file: z.string().url(),
550
+ object: z
551
+ .object({
552
+ name: z.string(),
553
+ })
554
+ .optional(),
555
+ }),
556
+ {
557
+ examples: [
558
+ {
559
+ id: 123,
560
+ file: 'https://example.com/file.png',
561
+ object: { name: 'test' },
562
+ },
563
+ {
564
+ id: 456,
565
+ file: 'https://example.com/file2.png',
566
+ },
567
+ ],
568
+ },
569
+ ),
570
+ ),
571
+ })
572
+
573
+ const spec = generateOpenAPI({
574
+ router,
575
+ info: {
576
+ title: 'test',
577
+ version: '1.0.0',
578
+ },
579
+ })
580
+
581
+ expect(spec).toEqual({
582
+ info: { title: 'test', version: '1.0.0' },
583
+ openapi: '3.1.0',
584
+ paths: {
585
+ '/upload/{id}': {
586
+ get: {
587
+ summary: undefined,
588
+ description: undefined,
589
+ deprecated: undefined,
590
+ tags: undefined,
591
+ operationId: 'upload',
592
+ parameters: [
593
+ {
594
+ name: 'id',
595
+ in: 'path',
596
+ required: true,
597
+ schema: { examples: [123, 456], type: 'number' },
598
+ example: undefined,
599
+ },
600
+ {
601
+ name: 'file',
602
+ in: 'query',
603
+ style: 'deepObject',
604
+ required: true,
605
+ schema: {
606
+ examples: [
607
+ 'https://example.com/file.png',
608
+ 'https://example.com/file2.png',
609
+ ],
610
+ type: 'string',
611
+ format: 'uri',
612
+ },
613
+ example: undefined,
614
+ },
615
+ {
616
+ name: 'object',
617
+ in: 'query',
618
+ style: 'deepObject',
619
+ required: false,
620
+ schema: {
621
+ examples: [{ name: 'test' }],
622
+ anyOf: undefined,
623
+ type: 'object',
624
+ properties: { name: { type: 'string' } },
625
+ required: ['name'],
626
+ },
627
+ example: undefined,
628
+ },
629
+ ],
630
+ requestBody: undefined,
631
+ responses: {
632
+ 200: {
633
+ description: 'OK',
634
+ content: {
635
+ 'application/json': { schema: {}, example: undefined },
636
+ },
637
+ },
638
+ },
639
+ },
640
+ },
641
+ },
642
+ })
643
+ })
package/src/generator.ts CHANGED
@@ -1,18 +1,22 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
1
2
  import { type ContractRouter, eachContractRouterLeaf } from '@orpc/contract'
2
3
  import { type Router, toContractRouter } from '@orpc/server'
3
- import { findDeepMatches, omit } from '@orpc/shared'
4
+ import { findDeepMatches, isPlainObject, omit } from '@orpc/shared'
4
5
  import { preSerialize } from '@orpc/transformer'
5
- import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
6
6
  import {
7
7
  type MediaTypeObject,
8
- type OpenAPIObject,
9
8
  OpenApiBuilder,
9
+ type OpenAPIObject,
10
10
  type OperationObject,
11
11
  type ParameterObject,
12
12
  type RequestBodyObject,
13
13
  type ResponseObject,
14
14
  } from 'openapi3-ts/oas31'
15
- import { extractJSONSchema, zodToJsonSchema } from './zod-to-json-schema'
15
+ import {
16
+ extractJSONSchema,
17
+ UNSUPPORTED_JSON_SCHEMA,
18
+ zodToJsonSchema,
19
+ } from './zod-to-json-schema'
16
20
 
17
21
  // Reference: https://spec.openapis.org/oas/v3.1.0.html#style-values
18
22
 
@@ -41,17 +45,17 @@ export function generateOpenAPI(
41
45
  } & Omit<OpenAPIObject, 'openapi'>,
42
46
  options?: GenerateOpenAPIOptions,
43
47
  ): OpenAPIObject {
44
- const throwOnMissingTagDefinition =
45
- options?.throwOnMissingTagDefinition ?? false
46
- const ignoreUndefinedPathProcedures =
47
- options?.ignoreUndefinedPathProcedures ?? false
48
+ const throwOnMissingTagDefinition
49
+ = options?.throwOnMissingTagDefinition ?? false
50
+ const ignoreUndefinedPathProcedures
51
+ = options?.ignoreUndefinedPathProcedures ?? false
48
52
 
49
53
  const builder = new OpenApiBuilder({
50
54
  ...omit(opts, ['router']),
51
55
  openapi: '3.1.0',
52
56
  })
53
57
 
54
- const rootTags = opts.tags?.map((tag) => tag.name) ?? []
58
+ const rootTags = opts.tags?.map(tag => tag.name) ?? []
55
59
  const router = toContractRouter(opts.router)
56
60
 
57
61
  eachContractRouterLeaf(router, (procedure, path_) => {
@@ -64,7 +68,7 @@ export function generateOpenAPI(
64
68
  const path = internal.path ?? `/${path_.map(encodeURIComponent).join('/')}`
65
69
  const method = internal.method ?? 'POST'
66
70
 
67
- const inputSchema = internal.InputSchema
71
+ let inputSchema = internal.InputSchema
68
72
  ? zodToJsonSchema(internal.InputSchema, { mode: 'input' })
69
73
  : {}
70
74
  const outputSchema = internal.OutputSchema
@@ -72,7 +76,7 @@ export function generateOpenAPI(
72
76
  : {}
73
77
 
74
78
  const params: ParameterObject[] | undefined = (() => {
75
- const names = path.match(/{([^}]+)}/g)
79
+ const names = path.match(/\{([^}]+)\}/g)
76
80
 
77
81
  if (!names || !names.length) {
78
82
  return undefined
@@ -85,12 +89,12 @@ export function generateOpenAPI(
85
89
  }
86
90
 
87
91
  return names
88
- .map((raw) => raw.slice(1, -1))
92
+ .map(raw => raw.slice(1, -1))
89
93
  .map((name) => {
90
- const schema = inputSchema.properties?.[name]
94
+ let schema = inputSchema.properties?.[name]
91
95
  const required = inputSchema.required?.includes(name)
92
96
 
93
- if (!schema) {
97
+ if (schema === undefined) {
94
98
  throw new Error(
95
99
  `Parameter ${name} is missing in input schema [${path_.join('.')}]`,
96
100
  )
@@ -102,6 +106,55 @@ export function generateOpenAPI(
102
106
  )
103
107
  }
104
108
 
109
+ const examples = inputSchema.examples
110
+ ?.filter((example) => {
111
+ return isPlainObject(example) && name in example
112
+ })
113
+ .map((example) => {
114
+ return example[name]
115
+ })
116
+
117
+ schema = {
118
+ examples: examples?.length ? examples : undefined,
119
+ ...(schema === true
120
+ ? {}
121
+ : schema === false
122
+ ? UNSUPPORTED_JSON_SCHEMA
123
+ : schema),
124
+ }
125
+
126
+ inputSchema = {
127
+ ...inputSchema,
128
+ properties: inputSchema.properties
129
+ ? Object.entries(inputSchema.properties).reduce(
130
+ (acc, [key, value]) => {
131
+ if (key !== name) {
132
+ acc[key] = value
133
+ }
134
+
135
+ return acc
136
+ },
137
+ {} as Record<string, JSONSchema>,
138
+ )
139
+ : undefined,
140
+ required: inputSchema.required?.filter(v => v !== name),
141
+ examples: inputSchema.examples?.map((example) => {
142
+ if (!isPlainObject(example))
143
+ return example
144
+
145
+ return Object.entries(example).reduce(
146
+ (acc, [key, value]) => {
147
+ if (key !== name) {
148
+ acc[key] = value
149
+ }
150
+
151
+ return acc
152
+ },
153
+ {} as Record<string, unknown>,
154
+ )
155
+ }),
156
+ }
157
+
105
158
  return {
106
159
  name,
107
160
  in: 'path',
@@ -123,18 +176,35 @@ export function generateOpenAPI(
123
176
  )
124
177
  }
125
178
 
126
- return Object.entries(inputSchema.properties ?? {})
127
- .filter(([name]) => !params?.find((param) => param.name === name))
128
- .map(([name, schema]) => {
179
+ return Object.entries(inputSchema.properties ?? {}).map(
180
+ ([name, schema]) => {
181
+ const examples = inputSchema.examples
182
+ ?.filter((example) => {
183
+ return isPlainObject(example) && name in example
184
+ })
185
+ .map((example) => {
186
+ return example[name]
187
+ })
188
+
189
+ const schema_ = {
190
+ examples: examples?.length ? examples : undefined,
191
+ ...(schema === true
192
+ ? {}
193
+ : schema === false
194
+ ? UNSUPPORTED_JSON_SCHEMA
195
+ : schema),
196
+ }
197
+
129
198
  return {
130
199
  name,
131
200
  in: 'query',
132
201
  style: 'deepObject',
133
- required: true,
134
- schema: schema as any,
202
+ required: inputSchema?.required?.includes(name) ?? false,
203
+ schema: schema_ as any,
135
204
  example: internal.inputExample?.[name],
136
205
  }
137
- })
206
+ },
207
+ )
138
208
  })()
139
209
 
140
210
  const parameters = [...(params ?? []), ...(query ?? [])]
@@ -151,8 +221,8 @@ export function generateOpenAPI(
151
221
  contentMediaType: string
152
222
  })[]
153
223
 
154
- const isStillHasFileSchema =
155
- findDeepMatches(isFileSchema, schema).values.length > 0
224
+ const isStillHasFileSchema
225
+ = findDeepMatches(isFileSchema, schema).values.length > 0
156
226
 
157
227
  if (files.length) {
158
228
  parameters.push({
@@ -199,8 +269,8 @@ export function generateOpenAPI(
199
269
  contentMediaType: string
200
270
  })[]
201
271
 
202
- const isStillHasFileSchema =
203
- findDeepMatches(isFileSchema, schema).values.length > 0
272
+ const isStillHasFileSchema
273
+ = findDeepMatches(isFileSchema, schema).values.length > 0
204
274
 
205
275
  const content: Record<string, MediaTypeObject> = {}
206
276
 
@@ -226,7 +296,7 @@ export function generateOpenAPI(
226
296
  })()
227
297
 
228
298
  if (throwOnMissingTagDefinition && internal.tags) {
229
- const missingTag = internal.tags.find((tag) => !rootTags.includes(tag))
299
+ const missingTag = internal.tags.find(tag => !rootTags.includes(tag))
230
300
 
231
301
  if (missingTag !== undefined) {
232
302
  throw new Error(
@@ -244,7 +314,7 @@ export function generateOpenAPI(
244
314
  parameters: parameters.length ? parameters : undefined,
245
315
  requestBody,
246
316
  responses: {
247
- '200': successResponse,
317
+ 200: successResponse,
248
318
  },
249
319
  }
250
320
 
@@ -257,11 +327,12 @@ export function generateOpenAPI(
257
327
  }
258
328
 
259
329
  function isFileSchema(schema: unknown) {
260
- if (typeof schema !== 'object' || schema === null) return false
330
+ if (typeof schema !== 'object' || schema === null)
331
+ return false
261
332
  return (
262
- 'type' in schema &&
263
- 'contentMediaType' in schema &&
264
- typeof schema.type === 'string' &&
265
- typeof schema.contentMediaType === 'string'
333
+ 'type' in schema
334
+ && 'contentMediaType' in schema
335
+ && typeof schema.type === 'string'
336
+ && typeof schema.contentMediaType === 'string'
266
337
  )
267
338
  }
@@ -244,7 +244,7 @@ describe('special types', () => {
244
244
 
245
245
  describe('transform and effects', () => {
246
246
  it('should handle transform effects based on mode', () => {
247
- const schema = z.string().transform((val) => val.length)
247
+ const schema = z.string().transform(val => val.length)
248
248
 
249
249
  expect(zodToJsonSchema(schema, { mode: 'input' })).toEqual({
250
250
  type: 'string',
@@ -14,11 +14,8 @@ import {
14
14
  type KeySchema,
15
15
  type ZodAny,
16
16
  type ZodArray,
17
- type ZodBigInt,
18
- type ZodBoolean,
19
17
  type ZodBranded,
20
18
  type ZodCatch,
21
- type ZodDate,
22
19
  type ZodDefault,
23
20
  type ZodDiscriminatedUnion,
24
21
  type ZodEffects,
@@ -28,7 +25,6 @@ import {
28
25
  type ZodLazy,
29
26
  type ZodLiteral,
30
27
  type ZodMap,
31
- type ZodNaN,
32
28
  type ZodNativeEnum,
33
29
  type ZodNullable,
34
30
  type ZodNumber,
@@ -213,8 +209,8 @@ export function zodToJsonSchema(
213
209
  json.pattern = `${escapeStringRegexp(check.value)}$`
214
210
  break
215
211
  case 'emoji':
216
- json.pattern =
217
- '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$'
212
+ json.pattern
213
+ = '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$'
218
214
  break
219
215
  case 'nanoid':
220
216
  json.pattern = '^[a-zA-Z0-9_-]{21}$'
@@ -278,14 +274,10 @@ export function zodToJsonSchema(
278
274
  }
279
275
 
280
276
  case ZodFirstPartyTypeKind.ZodNaN: {
281
- const schema_ = schema as ZodNaN
282
-
283
277
  return { const: 'NaN' }
284
278
  }
285
279
 
286
280
  case ZodFirstPartyTypeKind.ZodBigInt: {
287
- const schema_ = schema as ZodBigInt
288
-
289
281
  const json: JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' }
290
282
 
291
283
  // WARN: ignore checks
@@ -294,14 +286,10 @@ export function zodToJsonSchema(
294
286
  }
295
287
 
296
288
  case ZodFirstPartyTypeKind.ZodBoolean: {
297
- const schema_ = schema as ZodBoolean
298
-
299
289
  return { type: 'boolean' }
300
290
  }
301
291
 
302
292
  case ZodFirstPartyTypeKind.ZodDate: {
303
- const schema_ = schema as ZodDate
304
-
305
293
  const jsonSchema: JSONSchema = { type: 'string', format: Format.Date }
306
294
 
307
295
  // WARN: ignore checks
@@ -398,7 +386,7 @@ export function zodToJsonSchema(
398
386
  for (const [key, value] of Object.entries(schema_.shape)) {
399
387
  const { schema, matches } = extractJSONSchema(
400
388
  zodToJsonSchema(value, childOptions),
401
- (schema) => schema === UNDEFINED_JSON_SCHEMA,
389
+ schema => schema === UNDEFINED_JSON_SCHEMA,
402
390
  )
403
391
 
404
392
  if (schema) {
@@ -423,14 +411,15 @@ export function zodToJsonSchema(
423
411
  childOptions,
424
412
  )
425
413
  if (schema_._def.unknownKeys === 'strict') {
426
- json.additionalProperties =
427
- additionalProperties === UNSUPPORTED_JSON_SCHEMA
414
+ json.additionalProperties
415
+ = additionalProperties === UNSUPPORTED_JSON_SCHEMA
428
416
  ? false
429
417
  : additionalProperties
430
- } else {
418
+ }
419
+ else {
431
420
  if (
432
- additionalProperties &&
433
- additionalProperties !== UNSUPPORTED_JSON_SCHEMA
421
+ additionalProperties
422
+ && additionalProperties !== UNSUPPORTED_JSON_SCHEMA
434
423
  ) {
435
424
  json.additionalProperties = additionalProperties
436
425
  }
@@ -551,8 +540,8 @@ export function zodToJsonSchema(
551
540
  const schema_ = schema as ZodEffects<ZodTypeAny>
552
541
 
553
542
  if (
554
- schema_._def.effect.type === 'transform' &&
555
- childOptions?.mode === 'output'
543
+ schema_._def.effect.type === 'transform'
544
+ && childOptions?.mode === 'output'
556
545
  ) {
557
546
  return {}
558
547
  }
@@ -602,7 +591,7 @@ export function extractJSONSchema(
602
591
  schema: JSONSchema,
603
592
  check: (schema: JSONSchema) => boolean,
604
593
  matches: JSONSchema[] = [],
605
- ): { schema: JSONSchema | undefined; matches: JSONSchema[] } {
594
+ ): { schema: JSONSchema | undefined, matches: JSONSchema[] } {
606
595
  if (check(schema)) {
607
596
  matches.push(schema)
608
597
  return { schema: undefined, matches }
@@ -615,14 +604,14 @@ export function extractJSONSchema(
615
604
  // TODO: $ref
616
605
 
617
606
  if (
618
- schema.anyOf &&
619
- Object.keys(schema).every(
620
- (k) => k === 'anyOf' || NON_LOGIC_KEYWORDS.includes(k as any),
607
+ schema.anyOf
608
+ && Object.keys(schema).every(
609
+ k => k === 'anyOf' || NON_LOGIC_KEYWORDS.includes(k as any),
621
610
  )
622
611
  ) {
623
612
  const anyOf = schema.anyOf
624
- .map((s) => extractJSONSchema(s, check, matches).schema)
625
- .filter((v) => !!v)
613
+ .map(s => extractJSONSchema(s, check, matches).schema)
614
+ .filter(v => !!v)
626
615
 
627
616
  if (anyOf.length === 1 && typeof anyOf[0] === 'object') {
628
617
  return { schema: { ...schema, anyOf: undefined, ...anyOf[0] }, matches }
@@ -640,14 +629,14 @@ export function extractJSONSchema(
640
629
  // TODO: $ref
641
630
 
642
631
  if (
643
- schema.oneOf &&
644
- Object.keys(schema).every(
645
- (k) => k === 'oneOf' || NON_LOGIC_KEYWORDS.includes(k as any),
632
+ schema.oneOf
633
+ && Object.keys(schema).every(
634
+ k => k === 'oneOf' || NON_LOGIC_KEYWORDS.includes(k as any),
646
635
  )
647
636
  ) {
648
637
  const oneOf = schema.oneOf
649
- .map((s) => extractJSONSchema(s, check, matches).schema)
650
- .filter((v) => !!v)
638
+ .map(s => extractJSONSchema(s, check, matches).schema)
639
+ .filter(v => !!v)
651
640
 
652
641
  if (oneOf.length === 1 && typeof oneOf[0] === 'object') {
653
642
  return { schema: { ...schema, oneOf: undefined, ...oneOf[0] }, matches }