Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 103 additions & 29 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,48 @@ export const unwrapSchema = (
return schema.toJSONSchema?.() ?? schema?.toJsonSchema?.()
}

export const convertEnumToOpenApi = (schema: any): any => {
if (!schema || typeof schema !== 'object') return schema

if (
schema[Kind] === 'Union' &&
schema.anyOf &&
Array.isArray(schema.anyOf) &&
schema.anyOf.length > 0 &&
schema.anyOf.every(
(item: any) =>
item && typeof item === 'object' && item.const !== undefined
)
) {
const enumValues = schema.anyOf.map((item: any) => item.const)

return {
type: 'string',
enum: enumValues
}
}

if (schema.type === 'object' && schema.properties) {
const convertedProperties: any = {}
for (const [key, value] of Object.entries(schema.properties)) {
convertedProperties[key] = convertEnumToOpenApi(value)
}
return {
...schema,
properties: convertedProperties
}
}

if (schema.type === 'array' && schema.items) {
return {
...schema,
items: convertEnumToOpenApi(schema.items)
}
}

return schema
}

/**
* Converts Elysia routes to OpenAPI 3.0.3 paths schema
* @param routes Array of Elysia route objects
Expand Down Expand Up @@ -342,16 +384,25 @@ export function toOpenAPISchema(
definitions
)

if (params && params.type === 'object' && params.properties)
if (params && params.type === 'object' && params.properties) {
const convertedProperties: any = {}
for (const [paramName, paramSchema] of Object.entries(
params.properties
)) {
convertedProperties[paramName] =
convertEnumToOpenApi(paramSchema)
}

for (const [paramName, paramSchema] of Object.entries(
convertedProperties
))
parameters.push({
name: paramName,
in: 'path',
required: true, // Path parameters are always required
schema: paramSchema
})
}
}

// Handle query parameters
Expand All @@ -362,9 +413,17 @@ export function toOpenAPISchema(
)

if (query && query.type === 'object' && query.properties) {
const required = query.required || []
const convertedProperties: any = {}
for (const [queryName, querySchema] of Object.entries(
query.properties
)) {
convertedProperties[queryName] =
convertEnumToOpenApi(querySchema)
}

const required = query.required || []
for (const [queryName, querySchema] of Object.entries(
convertedProperties
))
parameters.push({
name: queryName,
Expand All @@ -383,9 +442,17 @@ export function toOpenAPISchema(
)

if (headers && headers.type === 'object' && headers.properties) {
const required = headers.required || []
const convertedProperties: any = {}
for (const [headerName, headerSchema] of Object.entries(
headers.properties
)) {
convertedProperties[headerName] =
convertEnumToOpenApi(headerSchema)
}

const required = headers.required || []
for (const [headerName, headerSchema] of Object.entries(
convertedProperties
))
parameters.push({
name: headerName,
Expand All @@ -404,9 +471,17 @@ export function toOpenAPISchema(
)

if (cookie && cookie.type === 'object' && cookie.properties) {
const required = cookie.required || []
const convertedProperties: any = {}
for (const [cookieName, cookieSchema] of Object.entries(
cookie.properties
)) {
convertedProperties[cookieName] =
convertEnumToOpenApi(cookieSchema)
}

const required = cookie.required || []
for (const [cookieName, cookieSchema] of Object.entries(
convertedProperties
))
parameters.push({
name: cookieName,
Expand All @@ -425,11 +500,11 @@ export function toOpenAPISchema(
const body = unwrapSchema(hooks.body, vendors)

if (body) {
const convertedBody = convertEnumToOpenApi(body)

// @ts-ignore
const { type, description, $ref, ...options } = unwrapReference(
body,
definitions
)
const { type: _type, description, $ref, ...options } = convertedBody
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unwrapReference is missing, but I can handle that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I apologized for implementing unwrapReference.
Thank you for handling it for me.

const type = _type as string | undefined

// @ts-ignore
if (hooks.parse) {
Expand All @@ -447,26 +522,24 @@ export function toOpenAPISchema(
switch (parser.fn) {
case 'text':
case 'text/plain':
content['text/plain'] = { schema: body }
content['text/plain'] = { schema: convertedBody }
continue

case 'urlencoded':
case 'application/x-www-form-urlencoded':
content['application/x-www-form-urlencoded'] = {
schema: body
schema: convertedBody
}
continue

case 'json':
case 'application/json':
content['application/json'] = { schema: body }
content['application/json'] = { schema: convertedBody }
continue

case 'formdata':
case 'multipart/form-data':
content['multipart/form-data'] = {
schema: body
}
content['multipart/form-data'] = { schema: convertedBody }
continue
}
}
Expand All @@ -485,19 +558,17 @@ export function toOpenAPISchema(
type === 'integer' ||
type === 'boolean'
? {
'text/plain': {
schema: body
}
'text/plain': convertedBody
}
: {
'application/json': {
schema: body
schema: convertedBody
},
'application/x-www-form-urlencoded': {
schema: body
schema: convertedBody
},
'multipart/form-data': {
schema: body
schema: convertedBody
}
},
required: true
Expand All @@ -522,9 +593,10 @@ export function toOpenAPISchema(

if (!response) continue

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

operation.responses[status] = {
description:
Expand All @@ -533,19 +605,19 @@ export function toOpenAPISchema(
type === 'void' ||
type === 'null' ||
type === 'undefined'
? ({ type, description } as any)
? (convertedResponse as any)
: type === 'string' ||
type === 'number' ||
type === 'integer' ||
type === 'boolean'
? {
'text/plain': {
schema: response
schema: convertedResponse
}
}
: {
'application/json': {
schema: response
schema: convertedResponse
}
}
}
Expand All @@ -554,12 +626,14 @@ export function toOpenAPISchema(
const response = unwrapSchema(hooks.response as any, vendors)

if (response) {
const convertedResponse = convertEnumToOpenApi(response)

// @ts-ignore
const {
type: _type,
description,
...options
} = unwrapReference(response, definitions)
} = convertedResponse
const type = _type as string | undefined

// It's a single schema, default to 200
Expand All @@ -569,19 +643,19 @@ export function toOpenAPISchema(
type === 'void' ||
type === 'null' ||
type === 'undefined'
? ({ type, description } as any)
? (convertedResponse as any)
: type === 'string' ||
type === 'number' ||
type === 'integer' ||
type === 'boolean'
? {
'text/plain': {
schema: response
schema: convertedResponse
}
}
: {
'application/json': {
schema: response
schema: convertedResponse
}
}
}
Expand Down
62 changes: 61 additions & 1 deletion test/openapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'bun:test'
import { getPossiblePath } from '../src/openapi'
import { Kind } from '@sinclair/typebox'
import { convertEnumToOpenApi, getPossiblePath } from '../src/openapi'

describe('OpenAPI utilities', () => {
it('getPossiblePath', () => {
Expand All @@ -12,3 +13,62 @@ describe('OpenAPI utilities', () => {
])
})
})

describe('convertEnumToOpenApi', () => {
it('should convert enum schema to OpenAPI enum format', () => {
const expectedSchema = {
[Kind]: 'Union',
anyOf: [
{ const: 'male' },
{ const: 'female' }
]
}

const result = convertEnumToOpenApi(expectedSchema)

expect(result).toEqual({
type: 'string',
enum: ['male', 'female']
})
})

it('should convert nested enums in object properties', () => {
const expectedSchema = {
type: 'object',
properties: {
name: { type: 'string' },
gender: {
[Kind]: 'Union',
anyOf: [
{ const: 'male' },
{ const: 'female' }
]
}
}
}

const result = convertEnumToOpenApi(expectedSchema)

expect(result).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
gender: {
type: 'string',
enum: ['male', 'female']
}
}
})
})

it('should return original schema if not enum', () => {
const expectedSchema = {
type: 'string',
description: 'Regular string field'
}

const result = convertEnumToOpenApi(expectedSchema)

expect(result).toEqual(expectedSchema)
})
})