diff --git a/openapi.yaml b/openapi.yaml index 11e01d1d..886337a1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -36,7 +36,7 @@ paths: $ref: '#/components/schemas/Template' responses: '200': - description: Template succesfully generated. + description: Template successfully generated. content: application/json: schema: @@ -60,6 +60,40 @@ paths: schema: $ref: '#/components/schemas/Problem' + /validate: + post: + summary: Validate the given AsyncAPI document. + operationId: validate + tags: + - validate + requestBody: + description: Validate the given AsyncAPI document with the AsyncAPI parser. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateDocument' + responses: + '204': + description: The given AsyncAPI document is valid. + '400': + description: The given AsyncAPI document is not valid. + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: The given AsyncAPI document is not valid due to invalid parameters in the request. + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' + default: + description: Unexpected problem. + content: + application/json: + schema: + $ref: '#/components/schemas/Problem' components: schemas: AsyncAPIDocument: @@ -90,6 +124,13 @@ components: which is usually located in the template's repository. This field is optional but may be required for some templates. additionalProperties: true + ValidateDocument: + type: object + required: + - asyncapi + properties: + asyncapi: + $ref: '#/components/schemas/AsyncAPIDocument' Problem: type: object properties: diff --git a/src/controllers/generate.controller.ts b/src/controllers/generate.controller.ts index 77ab6c98..81df72d2 100644 --- a/src/controllers/generate.controller.ts +++ b/src/controllers/generate.controller.ts @@ -29,7 +29,7 @@ export class GenerateController implements Controller { } catch (err) { return next(err); } - + const zip = this.archiverService.createZip(res); let tmpDir: string; @@ -136,9 +136,9 @@ export class GenerateController implements Controller { logger: false, }); const router = Router(); - + router.post( - `${this.basepath}`, + `${this.basepath}`, documentValidationMiddleware, this.generate.bind(this) ); diff --git a/src/controllers/tests/generator.controller.test.ts b/src/controllers/tests/generator.controller.test.ts index 938bb81e..165b58f1 100644 --- a/src/controllers/tests/generator.controller.test.ts +++ b/src/controllers/tests/generator.controller.test.ts @@ -10,7 +10,7 @@ describe('GeneratorController', () => { it('should generate template ', async () => { const app = new App([new GenerateController()]); - return await request(app.getServer()) + return request(app.getServer()) .post('/generate') .send({ asyncapi: { @@ -32,7 +32,7 @@ describe('GeneratorController', () => { it('should pass when sent template parameters are empty', async () => { const app = new App([new GenerateController()]); - return await request(app.getServer()) + return request(app.getServer()) .post('/generate') .send({ asyncapi: { @@ -51,7 +51,7 @@ describe('GeneratorController', () => { it('should throw error when sent template parameters are invalid', async () => { const app = new App([new GenerateController()]); - return await request(app.getServer()) + return request(app.getServer()) .post('/generate') .send({ asyncapi: { @@ -76,7 +76,7 @@ describe('GeneratorController', () => { instancePath: '', schemaPath: '#/additionalProperties', keyword: 'additionalProperties', - params: { + params: { additionalProperty: 'customParameter' }, message: 'must NOT have additional properties' diff --git a/src/controllers/tests/validate.controller.test.ts b/src/controllers/tests/validate.controller.test.ts new file mode 100644 index 00000000..0327d96d --- /dev/null +++ b/src/controllers/tests/validate.controller.test.ts @@ -0,0 +1,164 @@ +import request from 'supertest'; + +import { App } from '../../app'; +import { ProblemException } from '../../exceptions/problem.exception'; + +import { ValidateController } from '../validate.controller'; + +const validJSONAsyncAPI = { + asyncapi: '2.2.0', + info: { + title: 'Account Service', + version: '1.0.0', + description: 'This service is in charge of processing user signups' + }, + channels: { + 'user/signedup': { + subscribe: { + message: { + $ref: '#/components/messages/UserSignedUp' + } + } + } + }, + components: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string', + description: 'Name of the user' + }, + email: { + type: 'string', + format: 'email', + description: 'Email of the user' + } + } + } + } + } + } +}; +const validYAMLAsyncAPI = ` +asyncapi: '2.2.0' +info: + title: Account Service + version: 1.0.0 + description: This service is in charge of processing user signups +channels: + user/signedup: + subscribe: + message: + $ref: '#/components/messages/UserSignedUp' +components: + messages: + UserSignedUp: + payload: + type: object + properties: + displayName: + type: string + description: Name of the user + email: + type: string + format: email + description: Email of the user +`; +const invalidJSONAsyncAPI = { + asyncapi: '2.0.0', + info: { + tite: 'My API', // spelled wrong on purpose to throw an error in the test + version: '1.0.0' + }, + channels: {} +}; + +describe('ValidateController', () => { + describe('[POST] /validate', () => { + it('should validate AsyncAPI document in JSON', async () => { + const app = new App([new ValidateController()]); + + return request(app.getServer()) + .post('/validate') + .send({ + asyncapi: validJSONAsyncAPI + }) + .expect(204); + }); + + it('should validate AsyncAPI document in YAML', async () => { + const app = new App([new ValidateController()]); + + return request(app.getServer()) + .post('/validate') + .send({ + asyncapi: validYAMLAsyncAPI + }) + .expect(204); + }); + + it('should throw error when sent an empty document', async () => { + const app = new App([new ValidateController()]); + + return request(app.getServer()) + .post('/validate') + .send({}) + .expect(422, { + type: ProblemException.createType('invalid-request-body'), + title: 'Invalid Request Body', + status: 422, + validationErrors: [ + { + instancePath: '', + schemaPath: '#/required', + keyword: 'required', + params: { + missingProperty: 'asyncapi' + }, + message: 'must have required property \'asyncapi\'' + } + ] + }); + }); + + it('should throw error when sent an invalid AsyncAPI document', async () => { + const app = new App([new ValidateController()]); + + return request(app.getServer()) + .post('/validate') + .send({ + asyncapi: invalidJSONAsyncAPI + }) + .expect(422, { + type: ProblemException.createType('validation-errors'), + title: 'There were errors validating the AsyncAPI document.', + status: 422, + validationErrors: [ + { + title: '/info should NOT have additional properties', + location: { + jsonPointer: '/info' + } + }, + { + title: '/info should have required property \'title\'', + location: { + jsonPointer: '/info' + } + } + ], + parsedJSON: { + asyncapi: '2.0.0', + info: { + tite: 'My API', + version: '1.0.0' + }, + channels: {} + } + }); + }); + }); +}); diff --git a/src/controllers/validate.controller.ts b/src/controllers/validate.controller.ts new file mode 100644 index 00000000..073c89a0 --- /dev/null +++ b/src/controllers/validate.controller.ts @@ -0,0 +1,34 @@ +import { NextFunction, Request, Response, Router } from 'express'; + +import { Controller } from '../interfaces'; + +import { parse, prepareParserConfig, tryConvertToProblemException } from '../utils/parser'; + +/** + * Controller which exposes the Parser functionality, to validate the AsyncAPI document. + */ +export class ValidateController implements Controller { + public basepath = '/validate'; + + private async validate(req: Request, res: Response, next: NextFunction) { + try { + const options = prepareParserConfig(req); + await parse(req.body?.asyncapi, options); + + res.status(204).end(); + } catch (err: unknown) { + return next(tryConvertToProblemException(err)); + } + } + + public boot(): Router { + const router = Router(); + + router.post( + `${this.basepath}`, + this.validate.bind(this) + ); + + return router; + } +} diff --git a/src/middlewares/document-validation.middleware.ts b/src/middlewares/document-validation.middleware.ts index c175afb7..f86e6059 100644 --- a/src/middlewares/document-validation.middleware.ts +++ b/src/middlewares/document-validation.middleware.ts @@ -1,74 +1,22 @@ -import { ParserError } from '@asyncapi/parser'; import { Request, Response, NextFunction } from 'express'; -import { ProblemException } from '../exceptions/problem.exception'; -import { parse, prepareParserConfig } from '../utils/parser'; - -const TYPES_400 = [ - 'null-or-falsey-document', - 'impossible-to-convert-to-json', - 'invalid-document-type', - 'invalid-json', - 'invalid-yaml', -]; - -/** - * Some error types have to be treated as 400 HTTP Status Code, another as 422. - */ -function retrieveStatusCode(type: string): number { - if (TYPES_400.includes(type)) { - return 400; - } - return 422; -} - -/** - * Merges fields from ParserError to ProblemException. - */ -function mergeParserError(error: ProblemException, parserError: any): ProblemException { - if (parserError.detail) { - error.detail = parserError.detail; - } - if (parserError.validationErrors) { - error.validationErrors = parserError.validationErrors; - } - if (parserError.parsedJSON) { - error.parsedJSON = parserError.parsedJSON; - } - if (parserError.location) { - error.location = parserError.location; - } - if (parserError.refs) { - error.refs = parserError.refs; - } - return error; -} +import { parse, prepareParserConfig, tryConvertToProblemException } from '../utils/parser'; /** * Validate sent AsyncAPI document. */ export async function documentValidationMiddleware(req: Request, _: Response, next: NextFunction) { try { - const { asyncapi } = req.body; - if (asyncapi === undefined) { + const asyncapi = req.body?.asyncapi; + if (!asyncapi) { return next(); } const parsedDocument = await parse(asyncapi, prepareParserConfig(req)); + req.parsedDocument = parsedDocument; next(); } catch (err: any) { - let error = err; - if (error instanceof ParserError) { - const typeName = err.type.replace('https://github.com/asyncapi/parser-js/', ''); - error = new ProblemException({ - type: typeName, - title: err.title, - status: retrieveStatusCode(typeName), - }); - mergeParserError(error, err); - } - - next(error); + next(tryConvertToProblemException(err)); } -} \ No newline at end of file +} diff --git a/src/middlewares/request-body-validation.middleware.ts b/src/middlewares/request-body-validation.middleware.ts index 71fd4f30..3d78438b 100644 --- a/src/middlewares/request-body-validation.middleware.ts +++ b/src/middlewares/request-body-validation.middleware.ts @@ -38,6 +38,7 @@ async function getValidator(req: Request) { if (!requestBody) { return undefined; } + const schema = requestBody.content['application/json'].schema; // asyncapi is validated in another middleware so make so annotate it as `any` type diff --git a/src/server.ts b/src/server.ts index 182679e9..5770aec3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,8 +6,10 @@ process.env['NODE_CONFIG_DIR'] = `${__dirname }/configs`; import { App } from './app'; import { GenerateController } from './controllers/generate.controller'; +import { ValidateController } from './controllers/validate.controller'; const app = new App([ - new GenerateController() + new GenerateController(), + new ValidateController() ]); app.listen(); diff --git a/src/services/tests/generator.service.test.ts b/src/services/tests/generator.service.test.ts index 4b02f46b..e5a56dbe 100644 --- a/src/services/tests/generator.service.test.ts +++ b/src/services/tests/generator.service.test.ts @@ -22,7 +22,7 @@ describe('GeneratorService', () => { const parameters = { version: '2.1.37', }; - + const tmpDir = await createTempDirectory(); try { await generatorService.generate( @@ -32,7 +32,7 @@ describe('GeneratorService', () => { tmpDir, prepareParserConfig(), ); - + expect(fs.existsSync(path.join(tmpDir, 'template'))).toEqual(true); expect(fs.existsSync(path.join(tmpDir, 'template/index.html'))).toEqual(true); } catch (e: any) { diff --git a/src/utils/parser.ts b/src/utils/parser.ts index b56cac5e..7c1f4ec0 100644 --- a/src/utils/parser.ts +++ b/src/utils/parser.ts @@ -1,5 +1,6 @@ -import { registerSchemaParser, parse } from '@asyncapi/parser'; +import { registerSchemaParser, parse, ParserError } from '@asyncapi/parser'; import { Request } from 'express'; +import { ProblemException } from '../exceptions/problem.exception'; import ramlDtParser from '@asyncapi/raml-dt-schema-parser'; import openapiSchemaParser from '@asyncapi/openapi-schema-parser'; @@ -32,4 +33,59 @@ function prepareParserConfig(req?: Request) { }; } -export { prepareParserConfig, parse }; +const TYPES_400 = [ + 'null-or-falsey-document', + 'impossible-to-convert-to-json', + 'invalid-document-type', + 'invalid-json', + 'invalid-yaml', +]; + +/** + * Some error types have to be treated as 400 HTTP Status Code, another as 422. + */ +function retrieveStatusCode(type: string): number { + if (TYPES_400.includes(type)) { + return 400; + } + return 422; +} + +/** + * Merges fields from ParserError to ProblemException. + */ +function mergeParserError(error: ProblemException, parserError: any): ProblemException { + if (parserError.detail) { + error.detail = parserError.detail; + } + if (parserError.validationErrors) { + error.validationErrors = parserError.validationErrors; + } + if (parserError.parsedJSON) { + error.parsedJSON = parserError.parsedJSON; + } + if (parserError.location) { + error.location = parserError.location; + } + if (parserError.refs) { + error.refs = parserError.refs; + } + return error; +} + +function tryConvertToProblemException(err: any) { + let error = err; + if (error instanceof ParserError) { + const typeName = err.type.replace('https://github.com/asyncapi/parser-js/', ''); + error = new ProblemException({ + type: typeName, + title: err.title, + status: retrieveStatusCode(typeName), + }); + mergeParserError(error, err); + } + + return error; +} + +export { prepareParserConfig, parse, mergeParserError, retrieveStatusCode, tryConvertToProblemException }; diff --git a/tests/test.controller.ts b/tests/test.controller.ts index 3b6dc488..027b7d76 100644 --- a/tests/test.controller.ts +++ b/tests/test.controller.ts @@ -19,12 +19,12 @@ export function createTestController(paths: Path | Path[]) { const p = Array.isArray(paths) ? paths : [paths]; p.forEach(path => { router[path.method]( - path.path, + path.path, ...(path.middlewares || []), path.callback, ); }); - + return router; } };