Skip to content

Commit 3a318dd

Browse files
authored
Merge pull request #267 from runyasak/feat/type-enum
Add TypeBox Enum Support for OpenAPI
2 parents 01b4d6a + f372188 commit 3a318dd

File tree

2 files changed

+164
-30
lines changed

2 files changed

+164
-30
lines changed

src/openapi.ts

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,48 @@ export const unwrapSchema = (
203203
return schema.toJSONSchema?.() ?? schema?.toJsonSchema?.()
204204
}
205205

206+
export const convertEnumToOpenApi = (schema: any): any => {
207+
if (!schema || typeof schema !== 'object') return schema
208+
209+
if (
210+
schema[Kind] === 'Union' &&
211+
schema.anyOf &&
212+
Array.isArray(schema.anyOf) &&
213+
schema.anyOf.length > 0 &&
214+
schema.anyOf.every(
215+
(item: any) =>
216+
item && typeof item === 'object' && item.const !== undefined
217+
)
218+
) {
219+
const enumValues = schema.anyOf.map((item: any) => item.const)
220+
221+
return {
222+
type: 'string',
223+
enum: enumValues
224+
}
225+
}
226+
227+
if (schema.type === 'object' && schema.properties) {
228+
const convertedProperties: any = {}
229+
for (const [key, value] of Object.entries(schema.properties)) {
230+
convertedProperties[key] = convertEnumToOpenApi(value)
231+
}
232+
return {
233+
...schema,
234+
properties: convertedProperties
235+
}
236+
}
237+
238+
if (schema.type === 'array' && schema.items) {
239+
return {
240+
...schema,
241+
items: convertEnumToOpenApi(schema.items)
242+
}
243+
}
244+
245+
return schema
246+
}
247+
206248
/**
207249
* Converts Elysia routes to OpenAPI 3.0.3 paths schema
208250
* @param routes Array of Elysia route objects
@@ -342,16 +384,25 @@ export function toOpenAPISchema(
342384
definitions
343385
)
344386

345-
if (params && params.type === 'object' && params.properties)
387+
if (params && params.type === 'object' && params.properties) {
388+
const convertedProperties: any = {}
346389
for (const [paramName, paramSchema] of Object.entries(
347390
params.properties
391+
)) {
392+
convertedProperties[paramName] =
393+
convertEnumToOpenApi(paramSchema)
394+
}
395+
396+
for (const [paramName, paramSchema] of Object.entries(
397+
convertedProperties
348398
))
349399
parameters.push({
350400
name: paramName,
351401
in: 'path',
352402
required: true, // Path parameters are always required
353403
schema: paramSchema
354404
})
405+
}
355406
}
356407

357408
// Handle query parameters
@@ -362,9 +413,17 @@ export function toOpenAPISchema(
362413
)
363414

364415
if (query && query.type === 'object' && query.properties) {
365-
const required = query.required || []
416+
const convertedProperties: any = {}
366417
for (const [queryName, querySchema] of Object.entries(
367418
query.properties
419+
)) {
420+
convertedProperties[queryName] =
421+
convertEnumToOpenApi(querySchema)
422+
}
423+
424+
const required = query.required || []
425+
for (const [queryName, querySchema] of Object.entries(
426+
convertedProperties
368427
))
369428
parameters.push({
370429
name: queryName,
@@ -383,9 +442,17 @@ export function toOpenAPISchema(
383442
)
384443

385444
if (headers && headers.type === 'object' && headers.properties) {
386-
const required = headers.required || []
445+
const convertedProperties: any = {}
387446
for (const [headerName, headerSchema] of Object.entries(
388447
headers.properties
448+
)) {
449+
convertedProperties[headerName] =
450+
convertEnumToOpenApi(headerSchema)
451+
}
452+
453+
const required = headers.required || []
454+
for (const [headerName, headerSchema] of Object.entries(
455+
convertedProperties
389456
))
390457
parameters.push({
391458
name: headerName,
@@ -404,9 +471,17 @@ export function toOpenAPISchema(
404471
)
405472

406473
if (cookie && cookie.type === 'object' && cookie.properties) {
407-
const required = cookie.required || []
474+
const convertedProperties: any = {}
408475
for (const [cookieName, cookieSchema] of Object.entries(
409476
cookie.properties
477+
)) {
478+
convertedProperties[cookieName] =
479+
convertEnumToOpenApi(cookieSchema)
480+
}
481+
482+
const required = cookie.required || []
483+
for (const [cookieName, cookieSchema] of Object.entries(
484+
convertedProperties
410485
))
411486
parameters.push({
412487
name: cookieName,
@@ -425,11 +500,11 @@ export function toOpenAPISchema(
425500
const body = unwrapSchema(hooks.body, vendors)
426501

427502
if (body) {
503+
const convertedBody = convertEnumToOpenApi(body)
504+
428505
// @ts-ignore
429-
const { type, description, $ref, ...options } = unwrapReference(
430-
body,
431-
definitions
432-
)
506+
const { type: _type, description, $ref, ...options } = convertedBody
507+
const type = _type as string | undefined
433508

434509
// @ts-ignore
435510
if (hooks.parse) {
@@ -447,26 +522,24 @@ export function toOpenAPISchema(
447522
switch (parser.fn) {
448523
case 'text':
449524
case 'text/plain':
450-
content['text/plain'] = { schema: body }
525+
content['text/plain'] = { schema: convertedBody }
451526
continue
452527

453528
case 'urlencoded':
454529
case 'application/x-www-form-urlencoded':
455530
content['application/x-www-form-urlencoded'] = {
456-
schema: body
531+
schema: convertedBody
457532
}
458533
continue
459534

460535
case 'json':
461536
case 'application/json':
462-
content['application/json'] = { schema: body }
537+
content['application/json'] = { schema: convertedBody }
463538
continue
464539

465540
case 'formdata':
466541
case 'multipart/form-data':
467-
content['multipart/form-data'] = {
468-
schema: body
469-
}
542+
content['multipart/form-data'] = { schema: convertedBody }
470543
continue
471544
}
472545
}
@@ -485,19 +558,17 @@ export function toOpenAPISchema(
485558
type === 'integer' ||
486559
type === 'boolean'
487560
? {
488-
'text/plain': {
489-
schema: body
490-
}
561+
'text/plain': convertedBody
491562
}
492563
: {
493564
'application/json': {
494-
schema: body
565+
schema: convertedBody
495566
},
496567
'application/x-www-form-urlencoded': {
497-
schema: body
568+
schema: convertedBody
498569
},
499570
'multipart/form-data': {
500-
schema: body
571+
schema: convertedBody
501572
}
502573
},
503574
required: true
@@ -522,9 +593,10 @@ export function toOpenAPISchema(
522593

523594
if (!response) continue
524595

596+
const convertedResponse = convertEnumToOpenApi(response)
525597
// @ts-ignore Must exclude $ref from root options
526-
const { type, description, $ref, ...options } =
527-
unwrapReference(response, definitions)
598+
const { type: _type, description, $ref, ...options } = convertedResponse
599+
const type = _type as string | undefined
528600

529601
operation.responses[status] = {
530602
description:
@@ -533,19 +605,19 @@ export function toOpenAPISchema(
533605
type === 'void' ||
534606
type === 'null' ||
535607
type === 'undefined'
536-
? ({ type, description } as any)
608+
? (convertedResponse as any)
537609
: type === 'string' ||
538610
type === 'number' ||
539611
type === 'integer' ||
540612
type === 'boolean'
541613
? {
542614
'text/plain': {
543-
schema: response
615+
schema: convertedResponse
544616
}
545617
}
546618
: {
547619
'application/json': {
548-
schema: response
620+
schema: convertedResponse
549621
}
550622
}
551623
}
@@ -554,12 +626,14 @@ export function toOpenAPISchema(
554626
const response = unwrapSchema(hooks.response as any, vendors)
555627

556628
if (response) {
629+
const convertedResponse = convertEnumToOpenApi(response)
630+
557631
// @ts-ignore
558632
const {
559633
type: _type,
560634
description,
561635
...options
562-
} = unwrapReference(response, definitions)
636+
} = convertedResponse
563637
const type = _type as string | undefined
564638

565639
// It's a single schema, default to 200
@@ -569,19 +643,19 @@ export function toOpenAPISchema(
569643
type === 'void' ||
570644
type === 'null' ||
571645
type === 'undefined'
572-
? ({ type, description } as any)
646+
? (convertedResponse as any)
573647
: type === 'string' ||
574648
type === 'number' ||
575649
type === 'integer' ||
576650
type === 'boolean'
577651
? {
578652
'text/plain': {
579-
schema: response
653+
schema: convertedResponse
580654
}
581655
}
582656
: {
583657
'application/json': {
584-
schema: response
658+
schema: convertedResponse
585659
}
586660
}
587661
}

test/openapi.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'bun:test'
2-
import { getPossiblePath } from '../src/openapi'
2+
import { Kind } from '@sinclair/typebox'
3+
import { convertEnumToOpenApi, getPossiblePath } from '../src/openapi'
34

45
describe('OpenAPI utilities', () => {
56
it('getPossiblePath', () => {
@@ -12,3 +13,62 @@ describe('OpenAPI utilities', () => {
1213
])
1314
})
1415
})
16+
17+
describe('convertEnumToOpenApi', () => {
18+
it('should convert enum schema to OpenAPI enum format', () => {
19+
const expectedSchema = {
20+
[Kind]: 'Union',
21+
anyOf: [
22+
{ const: 'male' },
23+
{ const: 'female' }
24+
]
25+
}
26+
27+
const result = convertEnumToOpenApi(expectedSchema)
28+
29+
expect(result).toEqual({
30+
type: 'string',
31+
enum: ['male', 'female']
32+
})
33+
})
34+
35+
it('should convert nested enums in object properties', () => {
36+
const expectedSchema = {
37+
type: 'object',
38+
properties: {
39+
name: { type: 'string' },
40+
gender: {
41+
[Kind]: 'Union',
42+
anyOf: [
43+
{ const: 'male' },
44+
{ const: 'female' }
45+
]
46+
}
47+
}
48+
}
49+
50+
const result = convertEnumToOpenApi(expectedSchema)
51+
52+
expect(result).toEqual({
53+
type: 'object',
54+
properties: {
55+
name: { type: 'string' },
56+
gender: {
57+
type: 'string',
58+
enum: ['male', 'female']
59+
}
60+
}
61+
})
62+
})
63+
64+
it('should return original schema if not enum', () => {
65+
const expectedSchema = {
66+
type: 'string',
67+
description: 'Regular string field'
68+
}
69+
70+
const result = convertEnumToOpenApi(expectedSchema)
71+
72+
expect(result).toEqual(expectedSchema)
73+
})
74+
})

0 commit comments

Comments
 (0)