diff --git a/src/index.ts b/src/index.ts index 7dbecc2..f48c76f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,6 +146,15 @@ export const swagger = ({ }) } + // @ts-expect-error Private property + const globalDefinitions = app.getGlobalDefinitions?.().type as const + + const globalSchemas: Record = {} + for (const [key, value] of Object.entries(globalDefinitions)) { + const { $id: _1, ...schemaFields } = value + globalSchemas[key] = schemaFields + } + return { openapi: '3.0.3', ...{ @@ -171,7 +180,7 @@ export const swagger = ({ ...documentation.components, schemas: { // @ts-ignore - ...app.getGlobalDefinitions?.().type, + ...globalSchemas, ...documentation.components?.schemas } } diff --git a/src/utils.ts b/src/utils.ts index 19f5170..d9dce84 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { normalize } from 'pathe' import { replaceSchemaType, t, type HTTPMethod, type LocalHook } from 'elysia' import { Kind, type TSchema } from '@sinclair/typebox' @@ -70,39 +69,49 @@ const mapTypesResponse = ( const responses: Record = {} for (const type of types) { - responses[type] = { - schema: - typeof schema === 'string' - ? { - $ref: `#/components/schemas/${schema}` - } - : '$ref' in schema && - Kind in schema && - schema[Kind] === 'Ref' - ? { - ...schema, - $ref: `#/components/schemas/${schema.$ref}` - } - : replaceSchemaType( - { ...(schema as any) }, - { - from: t.Ref(''), - // @ts-expect-error - to: ({ $ref, ...options }) => { - if ( - !$ref.startsWith( - '#/components/schemas/' - ) - ) - return t.Ref( - `#/components/schemas/${$ref}`, - options - ) - - return t.Ref($ref, options) - } - } + if (typeof schema === 'string' && schema.slice(-2) === '[]') { + responses[type] = { + schema: { + type: 'array', + items: { + $ref: `#/components/schemas/${schema.slice(0, -2)}` + } + } + } + } else if (typeof schema === 'string') { + responses[type] = { + schema: { + $ref: `#/components/schemas/${schema}` + } + } + } else if ( + '$ref' in schema && + Kind in schema && + schema[Kind] === 'Ref' + ) { + responses[type] = { + schema: { + ...schema, + $ref: `#/components/schemas/${schema.$ref}` + } + } + } else { + replaceSchemaType( + { ...(schema as any) }, + { + from: t.Ref(''), + // @ts-expect-error + to: ({ $ref, ...options }) => { + if (!$ref.startsWith('#/components/schemas/')) + return t.Ref( + `#/components/schemas/${$ref}`, + options ) + + return t.Ref($ref, options) + } + } + ) } } @@ -245,7 +254,24 @@ export const registerSchemaPath = ({ Object.entries(responseSchema as Record).forEach( ([key, value]) => { if (typeof value === 'string') { - if (!models[value]) return + const valueAsStr = value as string + const isArray = valueAsStr.endsWith('[]') + const modelName = isArray + ? valueAsStr.slice(0, -2) + : valueAsStr + + if (!models[modelName]) return + + if (isArray) { + responseSchema[key] = { + content: mapTypesResponse( + contentTypes, + valueAsStr + ), + description: `Array of ${modelName}` + } + return + } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { @@ -254,6 +280,7 @@ export const registerSchemaPath = ({ required, additionalProperties: _1, patternProperties: _2, + $id: _3, ...rest } = models[value] as TSchema & { type: string @@ -273,6 +300,7 @@ export const registerSchemaPath = ({ required, additionalProperties, patternProperties, + $id: _1, ...rest } = value as typeof value & { type: string diff --git a/test/index.test.ts b/test/index.test.ts index 868be79..f086440 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -232,27 +232,27 @@ describe('Swagger', () => { }) it('should hide routes with hide = true from paths', async () => { - const app = new Elysia().use(swagger()) - .get("/public", "omg") + const app = new Elysia() + .use(swagger()) + .get('/public', 'omg') .guard({ detail: { hide: true } }) - .get("/hidden", "ok") + .get('/hidden', 'ok') await app.modules const res = await app.handle(req('/swagger/json')) expect(res.status).toBe(200) const response = await res.json() - expect(response.paths['/public']).not.toBeUndefined(); - expect(response.paths['/hidden']).toBeUndefined(); + expect(response.paths['/public']).not.toBeUndefined() + expect(response.paths['/hidden']).toBeUndefined() }) it('should expand .all routes', async () => { - const app = new Elysia().use(swagger()) - .all("/all", "woah") + const app = new Elysia().use(swagger()).all('/all', 'woah') await app.modules @@ -263,17 +263,93 @@ describe('Swagger', () => { }) it('should hide routes that are invalid', async () => { - const app = new Elysia().use(swagger()) - .get("/valid", "ok") - .route("LOCK", "/invalid", "nope") + const app = new Elysia() + .use(swagger()) + .get('/valid', 'ok') + .route('LOCK', '/invalid', 'nope') await app.modules const res = await app.handle(req('/swagger/json')) expect(res.status).toBe(200) const response = await res.json() - expect(response.paths['/valid']).not.toBeUndefined(); - expect(response.paths['/invalid']).toBeUndefined(); + expect(response.paths['/valid']).not.toBeUndefined() + expect(response.paths['/invalid']).toBeUndefined() + }) + + it('should produce a valid OpenAPI spec with component schemas', async () => { + const app = new Elysia() + .model({ + UserResponse: t.Object( + { + id: t.Number({ description: 'ID of the user' }), + name: t.String({ description: 'Name of the user' }) + }, + { description: 'User response' } + ), + ErrorResponse: t.Object( + { + message: t.String({ description: 'Error message' }), + cause: t.String({ description: 'Error cause' }) + }, + { description: 'Error response' } + ) + }) + .get( + '/user', + ({ status }) => { + return status(200, [{ id: 1, name: 'John Doe' }]) + }, + { + detail: { operationId: 'listUsers' }, + parse: 'application/json', + response: { + 200: 'UserResponse[]', + 400: 'ErrorResponse' + } + } + ) + .get( + '/user/:id', + ({ status, params }) => { + const user = { id: Number(params.id), name: 'John Doe' } + return status(200, user) + }, + { + detail: { operationId: 'getUserById' }, + params: t.Object( + { id: t.Number() }, + { description: 'User ID' } + ), + parse: 'application/json', + response: { + 200: 'UserResponse', + 404: 'ErrorResponse' + } + } + ) + .use( + swagger({ + documentation: { + info: { title: 'Test API', version: '0.0.0' } + } + }) + ) + + await app.modules + + const res = await app.handle(req('/swagger/json')) + expect(res.status).toBe(200) + + const responseText = await res.text() + expect(responseText).toBeString() + expect(responseText).not.toContain('UserResponse[]') + expect(responseText).toContain('#/components/schemas/UserResponse') + expect(responseText).toContain('#/components/schemas/ErrorResponse') + expect(responseText).not.toContain('$id') + const responseJson = JSON.parse(responseText) + const validationResult = await SwaggerParser.validate(responseJson) + expect(validationResult).toBeDefined() }) })