diff --git a/openapi.yaml b/openapi.yaml index 176431dd..e0246576 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -226,6 +226,49 @@ paths: schema: $ref: '#/components/schemas/Problem' + /help: + get: + summary: Retrieve help information for the given command. + operationId: help + tags: + - help + parameters: + - name: command + in: query + style: form + explode: true + description: The command for which help information is needed. + required: true + schema: + type: string + responses: + "200": + description: Help information retrieved successfully. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/HelpListResponse' + - $ref: '#/components/schemas/HelpCommandResponse' + "400": + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + "404": + description: Command not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + default: + description: Unexpected problem. + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + /diff: post: summary: Compare the given AsyncAPI documents. @@ -415,6 +458,25 @@ components: type: [object, string] description: The diff between the two AsyncAPI documents. + HelpListResponse: + type: object + properties: + commands: + type: array + items: + type: string + description: A list of all available commands. + HelpCommandResponse: + type: object + description: Detailed help information for a specific command. + properties: + command: + type: string + description: The name of the command. + description: + type: string + description: Detailed description of the command. + Problem: type: object properties: diff --git a/src/controllers/help.controller.ts b/src/controllers/help.controller.ts new file mode 100644 index 00000000..8ef78f27 --- /dev/null +++ b/src/controllers/help.controller.ts @@ -0,0 +1,92 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Controller } from '../interfaces'; +import { ProblemException } from '../exceptions/problem.exception'; +import { getAppOpenAPI } from '../utils/app-openapi'; + +const getCommandsFromRequest = (req: Request): string[] => { + return req.params.command ? req.params.command.split('/').filter(cmd => cmd.trim()) : []; +}; + +const isKeyValid = (key: string, obj: any): boolean => { + return Object.keys(obj).includes(key); +}; + +const getPathKeysMatchingCommands = (commands: string[], pathKeys: string[]): string | undefined => { + if (!Array.isArray(pathKeys) || !pathKeys.every(key => typeof key === 'string')) { + return undefined; + } + return pathKeys.find(pathKey => { + const pathParts = pathKey.split('/').filter(part => part !== ''); + return pathParts.every((pathPart, i) => { + const command = commands[Number(i)]; + return pathPart === command || pathPart.startsWith('{'); + }); + }); +}; + +const getFullRequestBodySpec = (operationDetails: any) => { + return isKeyValid('requestBody', operationDetails) ? operationDetails.requestBody.content['application/json'].schema : null; +}; + +const buildResponseObject = (matchedPathKey: string, method: string, operationDetails: any, requestBodySchema: any) => { + return { + command: matchedPathKey, + method: method.toUpperCase(), + summary: operationDetails.summary || '', + requestBody: requestBodySchema + }; +}; + +export class HelpController implements Controller { + public basepath = '/help'; + + public async boot(): Promise { + const router: Router = Router(); + + router.get('/help/:command*?', async (req: Request, res: Response, next: NextFunction) => { + const commands = getCommandsFromRequest(req); + let openapiSpec: any; + + try { + openapiSpec = await getAppOpenAPI(); + } catch (err) { + return next(err); + } + + if (commands.length === 0) { + const routes = isKeyValid('paths', openapiSpec) ? Object.keys(openapiSpec.paths).map(path => ({ command: path.replace(/^\//, ''), url: `${this.basepath}${path}` })) : []; + return res.json(routes); + } + + const pathKeys = isKeyValid('paths', openapiSpec) ? Object.keys(openapiSpec.paths) : []; + const matchedPathKey = getPathKeysMatchingCommands(commands, pathKeys); + + if (!matchedPathKey) { + return next(new ProblemException({ + type: 'invalid-asyncapi-command', + title: 'Invalid AsyncAPI Command', + status: 404, + detail: 'The given AsyncAPI command is not valid.' + })); + } + + const pathInfo = isKeyValid(matchedPathKey, openapiSpec.paths) ? openapiSpec.paths[String(matchedPathKey)] : undefined; + const method = commands.length > 1 ? 'get' : 'post'; + const operationDetails = isKeyValid(method, pathInfo) ? pathInfo[String(method)] : undefined; + if (!operationDetails) { + return next(new ProblemException({ + type: 'invalid-asyncapi-command', + title: 'Invalid AsyncAPI Command', + status: 404, + detail: 'The given AsyncAPI command is not valid.' + })); + } + + const requestBodySchema = getFullRequestBodySpec(operationDetails); + + return res.json(buildResponseObject(matchedPathKey, method, operationDetails, requestBodySchema)); + }); + + return router; + } +} \ No newline at end of file diff --git a/src/controllers/tests/help.controller.test.ts b/src/controllers/tests/help.controller.test.ts new file mode 100644 index 00000000..45b395ef --- /dev/null +++ b/src/controllers/tests/help.controller.test.ts @@ -0,0 +1,99 @@ +import request from 'supertest'; +import { App } from '../../app'; +import { HelpController } from '../help.controller'; +import { getAppOpenAPI } from '../../utils/app-openapi'; + +jest.mock('../../utils/app-openapi', () => ({ + getAppOpenAPI: jest.fn(), +})); + +describe('HelpController', () => { + let app; + beforeAll(async () => { + app = new App([new HelpController()]); + await app.init(); + }); + + describe('[GET] /help', () => { + it('should return all commands', async () => { + (getAppOpenAPI as jest.Mock).mockResolvedValue({ + paths: { + '/validate': {}, + '/parse': {}, + '/generate': {}, + '/convert': {}, + '/bundle': {}, + '/help': {}, + '/diff': {} + } + }); + + const response = await request(app.getServer()) + .get('/v1/help') + .expect(200); + + expect(response.body).toEqual([ + { + command: 'validate', + url: '/help/validate' + }, + { + command: 'parse', + url: '/help/parse' + }, + { + command: 'generate', + url: '/help/generate' + }, + { + command: 'convert', + url: '/help/convert' + }, + { + command: 'bundle', + url: '/help/bundle' + }, + { + command: 'help', + url: '/help/help' + }, + { + command: 'diff', + url: '/help/diff' + } + ]); + }); + + it('should return 404 error for an invalid command', async () => { + const response = await request(app.getServer()) + .get('/v1/help/invalidCommand') + .expect(404); + + expect(response.body).toEqual({ + type: 'https://api.asyncapi.com/problem/invalid-asyncapi-command', + title: 'Invalid AsyncAPI Command', + status: 404, + detail: 'The given AsyncAPI command is not valid.' + }); + }); + + it('should return 404 error for a command without a method', async () => { + (getAppOpenAPI as jest.Mock).mockResolvedValue({ + paths: { + '/someCommand': {} + } + }); + + const response = await request(app.getServer()) + .get('/v1/help/someCommand') + .expect(404); + + expect(response.body).toEqual({ + type: 'https://api.asyncapi.com/problem/invalid-asyncapi-command', + title: 'Invalid AsyncAPI Command', + status: 404, + detail: 'The given AsyncAPI command is not valid.' + }); + }); + }); +}); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index f090e523..be2f8cd6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { ConvertController } from './controllers/convert.controller'; import { BundleController } from './controllers/bundle.controller'; import { DiffController } from './controllers/diff.controller'; import { DocsController } from './controllers/docs.controller'; +import { HelpController } from './controllers/help.controller'; async function main() { const app = new App([ @@ -22,6 +23,7 @@ async function main() { new BundleController(), new DiffController(), new DocsController(), + new HelpController(), ]); await app.init(); app.listen();