From 9da14c67acf8f4abefc284b4577276aed2089ce6 Mon Sep 17 00:00:00 2001 From: Ezequiel Korenblum Date: Tue, 30 Apr 2024 13:14:50 -0300 Subject: [PATCH] feat(openapi-generator): take and parse @example tags on JSDoc --- packages/openapi-generator/src/jsdoc.ts | 43 +++- packages/openapi-generator/src/openapi.ts | 6 +- packages/openapi-generator/test/jsdoc.test.ts | 124 ++++++++++++ .../openapi-generator/test/openapi.test.ts | 191 ++++++++++++++++++ 4 files changed, 353 insertions(+), 11 deletions(-) diff --git a/packages/openapi-generator/src/jsdoc.ts b/packages/openapi-generator/src/jsdoc.ts index d800107b..74925e3f 100644 --- a/packages/openapi-generator/src/jsdoc.ts +++ b/packages/openapi-generator/src/jsdoc.ts @@ -3,26 +3,49 @@ import type { Block } from 'comment-parser'; export type JSDoc = { summary?: string; description?: string; - tags?: Record; + tags?: Record & { example?: unknown }; }; export function parseCommentBlock(comment: Block): JSDoc { let summary: string = ''; let description: string = ''; - let tags: Record = {}; + let tags: Record & { example?: any } = {}; + let writingExample = false; for (const line of comment.source) { - if (summary.length === 0) { - if (line.tokens.description === '') { - continue; + if (writingExample) { + tags['example'] = `${tags['example']}\n${line.source.split('*')[1]?.trim()}`; + try { + tags['example'] = JSON.parse(tags['example']); + writingExample = false; + } catch (e) { + if (line.source.endsWith('*/')) + throw new Error('@example contains invalid JSON'); + else continue; } - summary = line.tokens.description; } else { - if (line.tokens.tag !== undefined && line.tokens.tag.length > 0) { - tags[line.tokens.tag.slice(1)] = - `${line.tokens.name} ${line.tokens.description}`.trim(); + if (summary.length === 0) { + if (line.tokens.description === '') { + continue; + } + summary = line.tokens.description; } else { - description = `${description ?? ''}\n${line.tokens.description}`; + if (line.tokens.tag !== undefined && line.tokens.tag.length > 0) { + if (line.tokens.tag === '@example') { + tags['example'] = line.source.split('@example')[1]?.trim(); + if (tags['example'].startsWith('{') || tags['example'].startsWith('[')) { + try { + tags['example'] = JSON.parse(tags['example']); + } catch (e) { + writingExample = true; + } + } + } else + tags[line.tokens.tag.slice(1)] = + `${line.tokens.name} ${line.tokens.description}`.trim(); + } else { + description = `${description ?? ''}\n${line.tokens.description}`; + } } } } diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index d511fd28..6aa92361 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -117,6 +117,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec const tag = jsdoc.tags?.tag ?? ''; const isInternal = jsdoc.tags?.private !== undefined; const isUnstable = jsdoc.tags?.unstable !== undefined; + const example = jsdoc.tags?.example; const requestBody = route.body === undefined @@ -163,7 +164,10 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec [Number(code)]: { description, content: { - 'application/json': { schema: schemaToOpenAPI(response) }, + 'application/json': { + schema: schemaToOpenAPI(response), + ...(example !== undefined ? { example } : undefined), + }, }, }, }; diff --git a/packages/openapi-generator/test/jsdoc.test.ts b/packages/openapi-generator/test/jsdoc.test.ts index b10f88f1..12c7c361 100644 --- a/packages/openapi-generator/test/jsdoc.test.ts +++ b/packages/openapi-generator/test/jsdoc.test.ts @@ -152,3 +152,127 @@ test('comment with a summary, description, and a tag in the middle of the descri assert.deepEqual(parseJSDoc(comment), expected); }); + +test('parameter with a comment and an example string', () => { + const comment = ` + /** + * A variable with example + * + * @example foo + */ + `; + + const expected: JSDoc = { + summary: 'A variable with example', + tags: { + example: 'foo', + }, + }; + + assert.deepEqual(parseJSDoc(comment), expected); +}); + +test('parameter with a comment and an example object', () => { + const comment = ` + /** + * A variable with example + * + * @example { "test": "foo" } + */ + `; + + const expected: JSDoc = { + summary: 'A variable with example', + // @ts-expect-error parser doesn't properly infer type + tags: { + example: { test: 'foo' }, + }, + }; + + assert.deepEqual(parseJSDoc(comment), expected); +}); + +test('parameter with a comment and an example object (multi-line)', () => { + const comment = ` + /** + * A variable with example + * + * @example { + * "test": "foo" + * } + */ + `; + + const expected: JSDoc = { + summary: 'A variable with example', + // @ts-expect-error parser doesn't properly infer type + tags: { + example: { test: 'foo' }, + }, + }; + + assert.deepEqual(parseJSDoc(comment), expected); +}); + +test('parameter with a comment and an example array', () => { + const comment = ` + /** + * A variable with example + * + * @example ["foo", "bar", "baz"] + */ + `; + + const expected: JSDoc = { + summary: 'A variable with example', + // @ts-expect-error parser doesn't properly infer type + tags: { + example: ['foo', 'bar', 'baz'], + }, + }; + + assert.deepEqual(parseJSDoc(comment), expected); +}); + +test('parameter with a comment and an invalid example object', () => { + const comment = ` + /** + * A variable with example + * + * @example { "test": "foo" + */ + `; + + assert.throws(() => parseJSDoc(comment), { + message: '@example contains invalid JSON', + }); +}); + +test('parameter with a comment and an invalid example object (multi-line)', () => { + const comment = ` + /** + * A variable with example + * + * @example { + * "test": "foo" + */ + `; + + assert.throws(() => parseJSDoc(comment), { + message: '@example contains invalid JSON', + }); +}); + +test('parameter with a comment and an invalid example array', () => { + const comment = ` + /** + * A variable with example + * + * @example ["foo", "bar", "baz" + */ + `; + + assert.throws(() => parseJSDoc(comment), { + message: '@example contains invalid JSON', + }); +}); diff --git a/packages/openapi-generator/test/openapi.test.ts b/packages/openapi-generator/test/openapi.test.ts index c2ce86a9..bd12b7b5 100644 --- a/packages/openapi-generator/test/openapi.test.ts +++ b/packages/openapi-generator/test/openapi.test.ts @@ -927,3 +927,194 @@ testCase('optional parameter', OPTIONAL_PARAM, { schemas: {}, }, }); + +const ROUTE_WITH_RESPONSE_EXAMPLE_STRING = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * A simple route + * + * @operationId api.v1.test + * @tag Test Routes + * @example bar + */ +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: t.string + }, +}); +`; + +testCase('route with example string', ROUTE_WITH_RESPONSE_EXAMPLE_STRING, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/foo': { + get: { + summary: 'A simple route', + operationId: 'api.v1.test', + tags: ['Test Routes'], + parameters: [], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'string', + }, + example: 'bar', + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); + +const ROUTE_WITH_RESPONSE_EXAMPLE_OBJECT = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * A simple route + * + * @operationId api.v1.test + * @tag Test Routes + * @example { "test": "bar" } + */ +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: { + test: t.string + } + }, +}); +`; + +testCase('route with example object', ROUTE_WITH_RESPONSE_EXAMPLE_OBJECT, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/foo': { + get: { + summary: 'A simple route', + operationId: 'api.v1.test', + tags: ['Test Routes'], + parameters: [], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + test: { + type: 'string', + }, + }, + required: ['test'], + }, + example: { + test: 'bar', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); + +const ROUTE_WITH_RESPONSE_EXAMPLE_OBJECT_MULTILINE = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * A simple route + * + * @operationId api.v1.test + * @tag Test Routes + * @example { + * "test": "bar" + * } + */ +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: { + test: t.string + } + }, +}); +`; + +testCase( + 'route with example object multi-line', + ROUTE_WITH_RESPONSE_EXAMPLE_OBJECT_MULTILINE, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/foo': { + get: { + summary: 'A simple route', + operationId: 'api.v1.test', + tags: ['Test Routes'], + parameters: [], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + test: { + type: 'string', + }, + }, + required: ['test'], + }, + example: { + test: 'bar', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, + }, +);