├── .editorconfig ├── .github └── workflows │ ├── test-build-publish.yml │ └── test-build.yml ├── .gitignore ├── README.md ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json └── test ├── expectedSchema.json └── openapi.mixin.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/test-build-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | 6 | name: Test, build, publish 7 | jobs: 8 | master: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v1 13 | 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | 18 | - name: Test 19 | run: | 20 | npm install 21 | npm test 22 | 23 | - name: Build and publish 24 | run: | 25 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTH_TOKEN }}" > ~/.npmrc 26 | npm publish --access public 27 | -------------------------------------------------------------------------------- /.github/workflows/test-build.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test, build 3 | jobs: 4 | master: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v1 9 | 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | 14 | - name: Test 15 | run: | 16 | npm install 17 | npm test 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | 3 | # code editors 4 | .idea 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Local env files 18 | .env.development 19 | .env 20 | 21 | # tests coverage 22 | /coverage 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # moleculer-auto-openapi 2 | Auto generate openapi(swagger) scheme for molecular. 3 | Scheme generated based on action validation params, routes on all avalaibled services and paths in ApiGateway. 4 | 5 | ## Install 6 | ```shell script 7 | npm i moleculer-auto-openapi --save 8 | ``` 9 | 10 | ## Usage 11 | Create openapi.service.js with content: 12 | ```javascript 13 | const Openapi = require("moleculer-auto-openapi"); 14 | 15 | module.exports = { 16 | name: 'openapi', 17 | mixins: [Openapi], 18 | settings: { 19 | // all setting optional 20 | openapi: { 21 | info: { 22 | // about project 23 | description: "Foo", 24 | title: "Bar", 25 | }, 26 | tags: [ 27 | // you tags 28 | { name: "auth", description: "My custom name" }, 29 | ], 30 | components: { 31 | // you auth 32 | securitySchemes: { 33 | myBasicAuth: { 34 | type: 'http', 35 | scheme: 'basic', 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | ``` 43 | And add resolvers to your webapi service: 44 | ```javascript 45 | module.exports = { 46 | name: `api`, 47 | mixins: [ApiGateway], 48 | settings: { 49 | routes: [ 50 | // moleculer-auto-openapi routes 51 | { 52 | path: '/api/openapi', 53 | aliases: { 54 | 'GET /openapi.json': 'openapi.generateDocs', // swagger scheme 55 | 'GET /ui': 'openapi.ui', // ui 56 | 'GET /assets/:file': 'openapi.assets', // js/css files 57 | }, 58 | }, 59 | ], 60 | }, 61 | }; 62 | ``` 63 | 64 | Describe params in service: 65 | ```javascript 66 | module.exports = { 67 | actions: { 68 | update: { 69 | openapi: { 70 | summary: "Foo bar baz", 71 | }, 72 | params: { 73 | $$strict: "remove", 74 | roles: { type: "array", items: "string", enum: [ "user", "admin" ] }, 75 | sex: { type: "enum", values: ["male", "female"], default: "female" }, 76 | id: { type: "number", convert: true, default: 5 }, 77 | numberBy: "number", 78 | someNum: { $$t: "Is some num", type: "number", convert: true }, 79 | types: { 80 | type: "array", 81 | $$t: "Types arr", 82 | default: [{ id: 1, typeId: 5 }], 83 | length: 1, 84 | items: { 85 | type: "object", strict: "remove", props: { 86 | id: { type: "number", optional: true }, 87 | typeId: { type: "number", optional: true }, 88 | }, 89 | }, 90 | }, 91 | bars: { 92 | type: "array", 93 | $$t: "Bars arr", 94 | min: 1, 95 | max: 2, 96 | items: { 97 | type: "object", strict: "remove", props: { 98 | id: { type: "number", optional: true }, 99 | fooNum: { $$t: "fooNum", type: "number", optional: true }, 100 | }, 101 | }, 102 | }, 103 | someObj: { 104 | $$t: "Some obj", 105 | default: { name: "bar" }, 106 | type: "object", strict: "remove", props: { 107 | id: { $$t: "Some obj ID", type: "number", optional: true }, 108 | numberId: { type: "number", optional: true }, 109 | name: { type: "string", optional: true, max: 100 }, 110 | }, 111 | }, 112 | someBool: { type: "boolean", optional: true }, 113 | desc: { type: "string", optional: true, max: 10, min: 4, }, 114 | email: "email", 115 | date: "date|optional|min:0|max:99", 116 | uuid: "uuid", 117 | url: "url", 118 | shortObject: { 119 | $$type: "object", 120 | desc: { type: "string", optional: true, max: 10000 }, 121 | url: "url", 122 | }, 123 | shortObject2: { 124 | $$type: "object|optional", 125 | desc: { type: "string", optional: true, max: 10000 }, 126 | url: "url", 127 | }, 128 | password: { type: 'string', min: 8, pattern: /^[a-zA-Z0-9]+$/ }, 129 | password2: { type: 'string', min: 8, pattern: '^[a-zA-Z0-9]+$' } 130 | }, 131 | handler() {}, 132 | }, 133 | }, 134 | } 135 | ``` 136 | end etc. See test/openapi.mixin.spec.js for examples 137 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "moleculer-auto-openapi"; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const swaggerUiAssetPath = require("swagger-ui-dist").getAbsoluteFSPath(); 2 | const fs = require('fs'); 3 | 4 | const UNRESOLVED_ACTION_NAME = "unknown-action"; 5 | 6 | const NODE_TYPES = { 7 | boolean: "boolean", 8 | number: "number", 9 | date: "date", 10 | uuid: "uuid", 11 | email: "email", 12 | url: "url", 13 | string: "string", 14 | enum: "enum", 15 | } 16 | 17 | /* 18 | * Inspired by https://github.com/icebob/kantab/blob/fd8cfe38d0e159937f4e3f2f5857c111cadedf44/backend/mixins/openapi.mixin.js 19 | */ 20 | module.exports = { 21 | name: `openapi`, 22 | settings: { 23 | port: process.env.PORT || 3000, 24 | onlyLocal: false, // build schema from only local services 25 | schemaPath: "/api/openapi/openapi.json", 26 | uiPath: "/api/openapi/ui", 27 | // set //unpkg.com/swagger-ui-dist@3.38.0 for fetch assets from unpkg 28 | assetsPath: "/api/openapi/assets", 29 | // names of moleculer-web services which contains urls, by default - all 30 | collectOnlyFromWebServices: [], 31 | commonPathItemObjectResponses: { 32 | 200: { 33 | $ref: "#/components/responses/ReturnedData", 34 | }, 35 | 401: { 36 | $ref: "#/components/responses/UnauthorizedError", 37 | }, 38 | 422: { 39 | $ref: "#/components/responses/ValidationError", 40 | }, 41 | default: { 42 | $ref: "#/components/responses/ServerError", 43 | }, 44 | }, 45 | requestBodyAndResponseBodyAreSameOnMethods: [ 46 | /* 'post', 47 | 'patch', 48 | 'put', */ 49 | ], 50 | requestBodyAndResponseBodyAreSameDescription: "The answer may vary slightly from what is indicated here. Contain id and/or other additional attributes.", 51 | openapi: { 52 | "openapi": "3.0.3", 53 | "info": { 54 | "description": "", 55 | "version": "0.0.0", 56 | "title": "Api docs", 57 | }, 58 | "tags": [], 59 | "paths": {}, 60 | "components": { 61 | "schemas": { 62 | // Standart moleculer schemas 63 | "DbMixinList": { 64 | "type": "object", 65 | "properties": { 66 | "rows": { 67 | "type": "array", 68 | "items": { 69 | "type": "object", 70 | }, 71 | }, 72 | "totalCount": { 73 | "type": "number", 74 | }, 75 | }, 76 | }, 77 | "DbMixinFindList": { 78 | "type": "array", 79 | "items": { 80 | "type": "object", 81 | }, 82 | }, 83 | "Item": { 84 | "type": "object", 85 | }, 86 | }, 87 | "securitySchemes": {}, 88 | "responses": { 89 | // Standart moleculer responses 90 | "ServerError": { 91 | "description": "Server errors: 500, 501, 400, 404 and etc...", 92 | "content": { 93 | "application/json": { 94 | "schema": { 95 | "type": "object", 96 | "example": { "name": "MoleculerClientError", "message": "Server error message", "code": 500 }, 97 | }, 98 | }, 99 | }, 100 | }, 101 | "UnauthorizedError": { 102 | "description": "Need auth", 103 | "content": { 104 | "application/json": { 105 | "schema": { 106 | "type": "object", 107 | "example": { "name": "MoleculerClientError", "message": "Unauth error message", "code": 401 }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | "ValidationError": { 113 | "description": "Fields invalid", 114 | "content": { 115 | "application/json": { 116 | "schema": { 117 | "type": "object", 118 | "example": { 119 | "name": "MoleculerClientError", "message": "Error message", "code": 422, "data": [ 120 | { "name": "fieldName", "message": "Field invalid" }, 121 | { "name": "arrayField[0].fieldName", "message": "Whats wrong" }, 122 | { "name": "object.fieldName", "message": "Whats wrong" }, 123 | ], 124 | }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | "ReturnedData": { 130 | "description": "", 131 | "content": { 132 | "application/json": { 133 | "schema": { 134 | "oneOf": [ 135 | { 136 | "$ref": "#/components/schemas/DbMixinList", 137 | }, 138 | { 139 | "$ref": "#/components/schemas/DbMixinFindList", 140 | }, 141 | { 142 | "$ref": "#/components/schemas/Item", 143 | }, 144 | ], 145 | }, 146 | }, 147 | }, 148 | }, 149 | "FileNotExist": { 150 | "description": "File not exist", 151 | "content": { 152 | "application/json": { 153 | "schema": { 154 | "type": "object", 155 | "example": { 156 | "name": "MoleculerClientError", 157 | "message": "File missing in the request", 158 | "code": 400, 159 | }, 160 | }, 161 | }, 162 | }, 163 | }, 164 | "FileTooBig": { 165 | "description": "File too big", 166 | "content": { 167 | "application/json": { 168 | "schema": { 169 | "type": "object", 170 | "example": { 171 | "name": "PayloadTooLarge", 172 | "message": "Payload too large", 173 | "code": 413, 174 | "type": "PAYLOAD_TOO_LARGE", 175 | "data": { 176 | "fieldname": "file", 177 | "filename": "4b2005c0b8.png", 178 | "encoding": "7bit", 179 | "mimetype": "image/png", 180 | }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | actions: { 191 | generateDocs: { 192 | openapi: { 193 | // you can declare custom Path Item Object 194 | // which override autogenerated object from params 195 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#path-item-object-example 196 | summary: "OpenAPI schema url", 197 | 198 | // you custom response 199 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#response-object-examples 200 | responses: { 201 | "200": { 202 | "description": "", 203 | "content": { 204 | "application/json": { 205 | "schema": { 206 | "$ref": "#/components/schemas/OpenAPIModel", 207 | }, 208 | }, 209 | }, 210 | }, 211 | }, 212 | 213 | // you custom tag 214 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#fixed-fields-8 215 | tags: ["openapi"], 216 | 217 | // components which attached to root of docx 218 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#components-object 219 | components: { 220 | schemas: { 221 | // you custom schema 222 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#models-with-polymorphism-support 223 | OpenAPIModel: { 224 | type: "object", 225 | properties: { 226 | openapi: { 227 | example: "3.0.3", 228 | type: "string", 229 | description: "OpenAPI version", 230 | }, 231 | info: { 232 | type: "object", 233 | properties: { 234 | description: { 235 | type: "string", 236 | }, 237 | }, 238 | }, 239 | tags: { 240 | type: "array", 241 | items: { 242 | type: "string", 243 | }, 244 | }, 245 | }, 246 | required: ["openapi"], 247 | }, 248 | }, 249 | }, 250 | }, 251 | handler() { 252 | return this.generateSchema(); 253 | }, 254 | }, 255 | assets: { 256 | openapi: { 257 | summary: "OpenAPI assets", 258 | description: "Return files from swagger-ui-dist folder", 259 | }, 260 | params: { 261 | file: { 262 | type: "enum", 263 | values: [ 264 | `swagger-ui.css`, `swagger-ui.css.map`, 265 | `swagger-ui-bundle.js`, `swagger-ui-bundle.js.map`, 266 | `swagger-ui-standalone-preset.js`, `swagger-ui-standalone-preset.js.map`, 267 | ] 268 | }, 269 | }, 270 | handler(ctx) { 271 | if (ctx.params.file.indexOf('.css') > -1) { 272 | ctx.meta.$responseType = "text/css"; 273 | } else if (ctx.params.file.indexOf('.js') > -1) { 274 | ctx.meta.$responseType = "text/javascript"; 275 | } else { 276 | ctx.meta.$responseType = "application/octet-stream"; 277 | } 278 | 279 | return fs.createReadStream(`${swaggerUiAssetPath}/${ctx.params.file}`); 280 | } 281 | }, 282 | ui: { 283 | openapi: { 284 | summary: "OpenAPI ui", 285 | description: "You can provide any schema file in query param", 286 | }, 287 | params: { 288 | url: { $$t: "Schema url", type: "string", optional: true }, 289 | }, 290 | handler(ctx) { 291 | ctx.meta.$responseType = "text/html; charset=utf-8"; 292 | 293 | return ` 294 | 295 | 296 | OpenAPI UI 297 | 298 | 299 | 300 | 301 |
302 |

Loading...

303 | 304 |
305 | 306 | 307 | 308 | 325 | 326 | 327 | `; 328 | }, 329 | }, 330 | }, 331 | methods: { 332 | fetchServicesWithActions() { 333 | return this.broker.call("$node.services", { 334 | withActions: true, 335 | onlyLocal: this.settings.onlyLocal, 336 | }); 337 | }, 338 | fetchAliasesForService(service) { 339 | return this.broker.call(`${service}.listAliases`); 340 | }, 341 | async generateSchema() { 342 | const doc = JSON.parse(JSON.stringify(this.settings.openapi)); 343 | 344 | const nodes = await this.fetchServicesWithActions(); 345 | 346 | const routes = await this.collectRoutes(nodes); 347 | 348 | this.attachParamsAndOpenapiFromEveryActionToRoutes(routes, nodes); 349 | 350 | this.attachRoutesToDoc(routes, doc); 351 | 352 | return doc; 353 | }, 354 | attachParamsAndOpenapiFromEveryActionToRoutes(routes, nodes) { 355 | for (const routeAction in routes) { 356 | for (const node of nodes) { 357 | for (const nodeAction in node.actions) { 358 | if (routeAction === nodeAction) { 359 | const actionProps = node.actions[nodeAction]; 360 | 361 | routes[routeAction].params = actionProps.params || {}; 362 | routes[routeAction].openapi = actionProps.openapi || null; 363 | break; 364 | } 365 | } 366 | } 367 | } 368 | }, 369 | async collectRoutes(nodes) { 370 | const routes = {}; 371 | 372 | for (const node of nodes) { 373 | // find routes in web-api service 374 | if (node.settings && node.settings.routes) { 375 | 376 | if (this.settings.collectOnlyFromWebServices && this.settings.collectOnlyFromWebServices.length > 0 && !this.settings.collectOnlyFromWebServices.includes(node.name)) { 377 | continue; 378 | } 379 | 380 | // iterate each route 381 | for (const route of node.settings.routes) { 382 | // map standart aliases 383 | this.buildActionRouteStructFromAliases(route, routes); 384 | } 385 | 386 | let service = node.name; 387 | // resolve paths with auto aliases 388 | const hasAutoAliases = node.settings.routes.some(route => route.autoAliases); 389 | if (hasAutoAliases) { 390 | // suport services that has version, like v1.api 391 | if (Object.prototype.hasOwnProperty.call(node, "version") && node.version !== undefined) { 392 | service = `v${node.version}.` + service; 393 | } 394 | const autoAliases = await this.fetchAliasesForService(service); 395 | const convertedRoute = this.convertAutoAliasesToRoute(autoAliases); 396 | this.buildActionRouteStructFromAliases(convertedRoute, routes); 397 | } 398 | } 399 | } 400 | 401 | return routes; 402 | }, 403 | /** 404 | * @link https://github.com/moleculerjs/moleculer-web/blob/155ccf1d3cb755dafd434e84eb95e35ee324a26d/src/index.js#L229 405 | * @param autoAliases 406 | * @returns {{path: string, aliases: {}}} 407 | */ 408 | convertAutoAliasesToRoute(autoAliases) { 409 | const route = { 410 | path: '', 411 | autoAliases: true, 412 | aliases: {}, 413 | }; 414 | 415 | for (const obj of autoAliases) { 416 | const alias = `${obj.methods} ${obj.fullPath}`; 417 | route.aliases[alias] = obj.actionName || UNRESOLVED_ACTION_NAME; 418 | } 419 | 420 | return route; 421 | }, 422 | /** 423 | * convert `GET /table`: `table.get` 424 | * to {action: { 425 | * actionType:'multipart|null', 426 | * params: {}, 427 | * autoAliases: true|undefined 428 | * paths: [ 429 | * {base: 'api/uploads', alias: 'GET /table'} 430 | * ] 431 | * openapi: null 432 | * }} 433 | * @param route 434 | * @param routes 435 | * @returns {{}} 436 | */ 437 | buildActionRouteStructFromAliases(route, routes) { 438 | for (const alias in route.aliases) { 439 | const aliasInfo = route.aliases[alias]; 440 | let actionType = aliasInfo.type; 441 | 442 | let action = ""; 443 | if (aliasInfo.action) { 444 | action = aliasInfo.action; 445 | } else if (Array.isArray(aliasInfo)) { 446 | action = aliasInfo[aliasInfo.length - 1] 447 | } else if (typeof aliasInfo !== "string") { 448 | action = UNRESOLVED_ACTION_NAME; 449 | } else { 450 | action = aliasInfo; 451 | } 452 | // support actions like multipart:import.proceedFile 453 | if (action.includes(":")) { 454 | ([actionType, action] = action.split(":")); 455 | } 456 | 457 | if (!routes[action]) { 458 | routes[action] = { 459 | actionType, 460 | params: {}, 461 | paths: [], 462 | openapi: null, 463 | }; 464 | } 465 | 466 | routes[action].paths.push({ 467 | base: route.path || "", 468 | alias, 469 | autoAliases: route.autoAliases, 470 | openapi: aliasInfo.openapi || null, 471 | }); 472 | } 473 | 474 | return routes; 475 | }, 476 | attachRoutesToDoc(routes, doc) { 477 | // route to openapi paths 478 | for (const action in routes) { 479 | const { paths, params, actionType, openapi = {} } = routes[action]; 480 | const service = action.split(".").slice(0, -1).join("."); 481 | 482 | this.addTagToDoc(doc, service); 483 | 484 | for (const path of paths) { 485 | // parse method and path from: POST /api/table 486 | const [tmpMethod, subPath] = path.alias.split(" "); 487 | const method = tmpMethod.toLowerCase(); 488 | 489 | // convert /:table to /{table} 490 | const openapiPath = this.formatParamUrl( 491 | this.normalizePath(`${path.base}/${subPath}`), 492 | ); 493 | 494 | const [queryParams, addedQueryParams] = this.extractParamsFromUrl(openapiPath); 495 | 496 | if (!doc.paths[openapiPath]) { 497 | doc.paths[openapiPath] = {}; 498 | } 499 | 500 | if (doc.paths[openapiPath][method]) { 501 | continue; 502 | } 503 | 504 | // Path Item Object 505 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#path-item-object-example 506 | doc.paths[openapiPath][method] = { 507 | summary: "", 508 | tags: [service], 509 | // rawParams: params, 510 | parameters: [...queryParams], 511 | responses: { 512 | // attach common responses 513 | ...this.settings.commonPathItemObjectResponses, 514 | }, 515 | }; 516 | 517 | if (method === "get" || method === "delete") { 518 | doc.paths[openapiPath][method].parameters.push( 519 | ...this.moleculerParamsToQuery(params, addedQueryParams), 520 | ); 521 | } else { 522 | const schemaName = action; 523 | this.createSchemaFromParams(doc, schemaName, params, addedQueryParams); 524 | doc.paths[openapiPath][method].requestBody = { 525 | "content": { 526 | "application/json": { 527 | "schema": { 528 | "$ref": `#/components/schemas/${schemaName}`, 529 | }, 530 | }, 531 | }, 532 | }; 533 | } 534 | 535 | if (this.settings.requestBodyAndResponseBodyAreSameOnMethods.includes(method)) { 536 | doc.paths[openapiPath][method].responses[200] = { 537 | "description": this.settings.requestBodyAndResponseBodyAreSameDescription, 538 | ...doc.paths[openapiPath][method].requestBody, 539 | }; 540 | } 541 | 542 | // if multipart/stream convert fo formData/binary 543 | if (actionType === "multipart" || actionType === "stream") { 544 | doc.paths[openapiPath][method] = { 545 | ...doc.paths[openapiPath][method], 546 | parameters: [...queryParams], 547 | requestBody: this.getFileContentRequestBodyScheme(openapiPath, method, actionType), 548 | }; 549 | } 550 | 551 | // merge values from action 552 | doc.paths[openapiPath][method] = this.mergePathItemObjects( 553 | doc.paths[openapiPath][method], 554 | openapi, 555 | ); 556 | 557 | // merge values which exist in web-api service 558 | // in routes or custom function 559 | doc.paths[openapiPath][method] = this.mergePathItemObjects( 560 | doc.paths[openapiPath][method], 561 | path.openapi, 562 | ); 563 | 564 | // add tags to root of scheme 565 | if (doc.paths[openapiPath][method].tags) { 566 | doc.paths[openapiPath][method].tags.forEach(name => { 567 | this.addTagToDoc(doc, name); 568 | }); 569 | } 570 | 571 | // add components to root of scheme 572 | if (doc.paths[openapiPath][method].components) { 573 | doc.components = this.mergeObjects( 574 | doc.components, 575 | doc.paths[openapiPath][method].components, 576 | ); 577 | delete doc.paths[openapiPath][method].components; 578 | } 579 | 580 | doc.paths[openapiPath][method].summary = ` 581 | ${doc.paths[openapiPath][method].summary} 582 | (${action}) 583 | ${path.autoAliases ? '[autoAlias]' : ''} 584 | `.trim(); 585 | } 586 | } 587 | }, 588 | addTagToDoc(doc, tagName) { 589 | const exist = doc.tags.some(v => v.name === tagName); 590 | if (!exist && tagName) { 591 | doc.tags.push({ 592 | name: tagName, 593 | }); 594 | } 595 | }, 596 | /** 597 | * Convert moleculer params to openapi query params 598 | * @param obj 599 | * @param exclude{Array} 600 | * @returns {[]} 601 | */ 602 | moleculerParamsToQuery(obj = {}, exclude = []) { 603 | const out = []; 604 | 605 | for (const fieldName in obj) { 606 | // skip system field in validator scheme 607 | if (fieldName.startsWith("$$")) { 608 | continue; 609 | } 610 | if (exclude.includes(fieldName)) { 611 | continue; 612 | } 613 | 614 | const node = obj[fieldName]; 615 | 616 | // array nodes 617 | if (Array.isArray(node) || (node.type && node.type === "array")) { 618 | const item = { 619 | "name": `${fieldName}[]`, 620 | "description": node.$$t, 621 | "in": "query", 622 | "schema": { 623 | "type": "array", 624 | "items": this.getTypeAndExample({ 625 | default: node.default ? node.default[0] : undefined, 626 | enum: node.enum, 627 | type: node.items, 628 | }), 629 | unique: node.unique, 630 | minItems: node.length || node.min, 631 | maxItems: node.length || node.max, 632 | }, 633 | }; 634 | out.push(item); 635 | continue; 636 | } 637 | 638 | out.push({ 639 | "in": "query", 640 | "name": fieldName, 641 | "description": node.$$t, 642 | "schema": this.getTypeAndExample(node), 643 | }); 644 | } 645 | 646 | return out; 647 | }, 648 | /** 649 | * Convert moleculer params to openapi definitions(components schemas) 650 | * @param doc 651 | * @param schemeName 652 | * @param obj 653 | * @param exclude{Array} 654 | * @param parentNode 655 | */ 656 | createSchemaFromParams(doc, schemeName, obj, exclude = [], parentNode = {}) { 657 | // Schema model 658 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#models-with-polymorphism-support 659 | const def = { 660 | "type": "object", 661 | "properties": {}, 662 | "required": [], 663 | default: parentNode.default, 664 | }; 665 | doc.components.schemas[schemeName] = def; 666 | 667 | for (const fieldName in obj) { 668 | // arr or object desc 669 | if (fieldName === "$$t") { 670 | def.description = obj[fieldName]; 671 | } 672 | 673 | let node = obj[fieldName]; 674 | const nextSchemeName = `${schemeName}.${fieldName}`; 675 | 676 | if ( 677 | // expand $$type: "object|optional" 678 | node && node.$$type && node.$$type.includes('object') 679 | ) { 680 | node = { 681 | type: 'object', 682 | optional: node.$$type.includes('optional'), 683 | $$t: node.$$t || '', 684 | props: { 685 | ...node, 686 | } 687 | } 688 | } else if ( 689 | // skip system field in validator scheme 690 | fieldName.startsWith("$$") 691 | ) { 692 | continue; 693 | } 694 | 695 | if (exclude.includes(fieldName)) { 696 | continue; 697 | } 698 | 699 | // expand from short rule to full 700 | if (!(node && node.type)) { 701 | node = this.expandShortDefinition(node); 702 | } 703 | 704 | // mark as required 705 | if (node.type === "array") { 706 | if (node.min || node.length || node.max) { 707 | def.required.push(fieldName); 708 | def.minItems = node.length || node.min; 709 | def.maxItems = node.length || node.max; 710 | } 711 | def.unique = node.unique; 712 | } else if (!node.optional) { 713 | def.required.push(fieldName); 714 | } 715 | 716 | // common props 717 | def.properties[fieldName] = { 718 | description: node.$$t, 719 | }; 720 | 721 | if (node.type === "object") { 722 | def.properties[fieldName] = { 723 | ...def.properties[fieldName], 724 | $ref: `#/components/schemas/${nextSchemeName}`, 725 | }; 726 | this.createSchemaFromParams(doc, nextSchemeName, node.props, [], node); 727 | continue; 728 | } 729 | 730 | // array with objects 731 | if (node.type === "array" && node.items && node.items.type === "object") { 732 | def.properties[fieldName] = { 733 | ...def.properties[fieldName], 734 | type: "array", 735 | default: node.default, 736 | unique: node.unique, 737 | minItems: node.length || node.min, 738 | maxItems: node.length || node.max, 739 | items: { 740 | $ref: `#/components/schemas/${nextSchemeName}`, 741 | }, 742 | }; 743 | this.createSchemaFromParams(doc, nextSchemeName, node.items.props, [], node); 744 | continue; 745 | } 746 | 747 | // simple array 748 | if (node.type === "array" || node.type === "tuple") { 749 | def.properties[fieldName] = { 750 | ...def.properties[fieldName], 751 | type: "array", 752 | items: this.getTypeAndExample({ 753 | enum: node.enum, 754 | type: node.items && node.items.type ? node.items.type : node.items, 755 | values: node.items && node.items.values ? node.items.values : undefined, 756 | }), 757 | default: node.default, 758 | unique: node.unique, 759 | minItems: node.length || node.min, 760 | maxItems: node.length || node.max, 761 | }; 762 | continue; 763 | } 764 | 765 | // string/number/boolean 766 | def.properties[fieldName] = { 767 | ...def.properties[fieldName], 768 | ...this.getTypeAndExample(node), 769 | }; 770 | } 771 | 772 | if (def.required.length === 0) { 773 | delete def.required; 774 | } 775 | }, 776 | getTypeAndExample(node) { 777 | if (!node) { 778 | node = {}; 779 | } 780 | let out = {}; 781 | let nodeType = node.type; 782 | 783 | if (Array.isArray(nodeType)) { 784 | nodeType = (nodeType[0] || "string").toString(); 785 | } 786 | 787 | switch (nodeType) { 788 | case NODE_TYPES.boolean: 789 | out = { 790 | example: false, 791 | type: "boolean", 792 | }; 793 | break; 794 | case NODE_TYPES.number: 795 | out = { 796 | example: null, 797 | type: "number", 798 | }; 799 | break; 800 | case NODE_TYPES.date: 801 | out = { 802 | example: "1998-01-10T13:00:00.000Z", 803 | type: "string", 804 | format: "date-time", 805 | }; 806 | break; 807 | case NODE_TYPES.uuid: 808 | out = { 809 | example: "10ba038e-48da-487b-96e8-8d3b99b6d18a", 810 | type: "string", 811 | format: "uuid", 812 | }; 813 | break; 814 | case NODE_TYPES.email: 815 | out = { 816 | example: "foo@example.com", 817 | type: "string", 818 | format: "email", 819 | }; 820 | break; 821 | case NODE_TYPES.url: 822 | out = { 823 | example: "https://example.com", 824 | type: "string", 825 | format: "uri", 826 | }; 827 | break; 828 | case NODE_TYPES.enum: 829 | out = { 830 | type: "string", 831 | enum: node.values, 832 | example: Array.isArray(node.values) ? node.values[0] : undefined, 833 | }; 834 | break; 835 | default: 836 | out = { 837 | example: "", 838 | type: "string", 839 | }; 840 | break; 841 | } 842 | 843 | if (Array.isArray(node.enum)) { 844 | out.example = node.enum[0]; 845 | out.enum = node.enum; 846 | } 847 | 848 | if (node.default) { 849 | out.default = node.default; 850 | delete out.example; 851 | } 852 | 853 | out.minLength = node.length || node.min; 854 | out.maxLength = node.length || node.max; 855 | 856 | /** 857 | * by DenisFerrero 858 | * @link https://github.com/grinat/moleculer-auto-openapi/issues/13 859 | */ 860 | if (node.pattern && (node.pattern.length > 0 || node.pattern.source.length > 0)) { 861 | out.pattern = new RegExp(node.pattern).source; 862 | } 863 | 864 | return out; 865 | }, 866 | mergePathItemObjects(orig = {}, toMerge = {}) { 867 | for (const key in toMerge) { 868 | // merge components 869 | if (key === "components") { 870 | orig[key] = this.mergeObjects( 871 | orig[key], 872 | toMerge[key], 873 | ); 874 | continue; 875 | } 876 | 877 | // merge responses 878 | if (key === "responses") { 879 | orig[key] = this.mergeObjects( 880 | orig[key], 881 | toMerge[key], 882 | ); 883 | 884 | // iterate codes 885 | for (const code in orig[key]) { 886 | // remove $ref if exist content 887 | if (orig[key][code] && orig[key][code].content) { 888 | delete orig[key][code].$ref; 889 | } 890 | } 891 | 892 | continue; 893 | } 894 | 895 | // replace non components attributes 896 | orig[key] = toMerge[key]; 897 | } 898 | return orig; 899 | }, 900 | mergeObjects(orig = {}, toMerge = {}) { 901 | for (const key in toMerge) { 902 | orig[key] = { 903 | ...(orig[key] || {}), 904 | ...toMerge[key], 905 | }; 906 | } 907 | return orig; 908 | }, 909 | /** 910 | * replace // to / 911 | * @param path 912 | * @returns {string} 913 | */ 914 | normalizePath(path = "") { 915 | path = path.replace(/\/{2,}/g, "/"); 916 | return path; 917 | }, 918 | /** 919 | * convert /:table to /{table} 920 | * @param url 921 | * @returns {string|string} 922 | */ 923 | formatParamUrl(url = "") { 924 | let start = url.indexOf("/:"); 925 | if (start === -1) { 926 | return url; 927 | } 928 | 929 | const end = url.indexOf("/", ++start); 930 | 931 | if (end === -1) { 932 | return url.slice(0, start) + "{" + url.slice(++start) + "}"; 933 | } 934 | 935 | return this.formatParamUrl(url.slice(0, start) + "{" + url.slice(++start, end) + "}" + url.slice(end)); 936 | }, 937 | /** 938 | * extract params from /{table} 939 | * @param url 940 | * @returns {[]} 941 | */ 942 | extractParamsFromUrl(url = "") { 943 | const params = []; 944 | const added = []; 945 | 946 | const matches = [...this.matchAll(/{(\w+)}/g, url)]; 947 | for (const match of matches) { 948 | const [, name] = match; 949 | 950 | added.push(name); 951 | params.push({ name, "in": "path", "required": true, "schema": { type: "string" } }); 952 | } 953 | 954 | return [params, added]; 955 | }, 956 | /** 957 | * matchAll polyfill for es8 and older 958 | * @param regexPattern 959 | * @param sourceString 960 | * @returns {[]} 961 | */ 962 | matchAll(regexPattern, sourceString) { 963 | const output = []; 964 | let match; 965 | // make sure the pattern has the global flag 966 | const regexPatternWithGlobal = RegExp(regexPattern, "g"); 967 | while ((match = regexPatternWithGlobal.exec(sourceString)) !== null) { 968 | // get rid of the string copy 969 | delete match.input; 970 | // store the match data 971 | output.push(match); 972 | } 973 | return output; 974 | }, 975 | expandShortDefinition(shortDefinition) { 976 | const node = { 977 | type: "string", 978 | }; 979 | 980 | let params = shortDefinition.split('|'); 981 | params = params.map(v => v.trim()); 982 | 983 | if (params.includes('optional')) { 984 | node.optional = true; 985 | } 986 | 987 | for (const type of Object.values(NODE_TYPES)) { 988 | if (params.includes(type)) { 989 | node.type = type; 990 | break; 991 | } else if (params.includes(`${type}[]`)) { 992 | const [arrayType,] = node.type.split("["); 993 | node.type = "array"; 994 | node.items = arrayType; 995 | break; 996 | } 997 | } 998 | 999 | return node; 1000 | }, 1001 | getFileContentRequestBodyScheme(openapiPath, method, actionType) { 1002 | return { 1003 | content: { 1004 | ...(actionType === "multipart" ? { 1005 | "multipart/form-data": { 1006 | schema: { 1007 | type: "object", 1008 | properties: { 1009 | file: { 1010 | type: "array", 1011 | items: { 1012 | type: "string", 1013 | format: "binary" 1014 | }, 1015 | }, 1016 | someField: { 1017 | type: "string" 1018 | } 1019 | }, 1020 | }, 1021 | }, 1022 | } : { 1023 | "application/octet-stream": { 1024 | schema: { 1025 | type: "string", 1026 | format: "binary", 1027 | }, 1028 | }, 1029 | }), 1030 | }, 1031 | } 1032 | } 1033 | }, 1034 | started() { 1035 | this.logger.info(`📜OpenAPI Docs server is available at http://0.0.0.0:${this.settings.port}${this.settings.uiPath}`); 1036 | }, 1037 | }; 1038 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moleculer-auto-openapi", 3 | "version": "1.1.6", 4 | "description": "Auto generate openapi(swagger) scheme for molecular", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "scripts": { 8 | "test": "jest --verbose --runInBand --coverage test/*" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/grinat/moleculer-auto-openapi.git" 13 | }, 14 | "keywords": [ 15 | "openapi", 16 | "swagger", 17 | "moleculer" 18 | ], 19 | "author": "Gabdrashitov Rinat ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/grinat/moleculer-auto-openapi/issues" 23 | }, 24 | "homepage": "https://github.com/grinat/moleculer-auto-openapi#readme", 25 | "jest": { 26 | "testEnvironment": "node", 27 | "testTimeout": 60000 28 | }, 29 | "dependencies": { 30 | "swagger-ui-dist": "^4.1.3" 31 | }, 32 | "devDependencies": { 33 | "jest": "^27.0.6", 34 | "moleculer": "^0.14.13", 35 | "moleculer-web": "^0.10.0-beta4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/expectedSchema.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "description": "Foo", 4 | "title": "Bar", 5 | "version": "0.0.0" 6 | }, 7 | "openapi": "3.0.3", 8 | "tags": [ 9 | { 10 | "name": "auth" 11 | }, 12 | { 13 | "name": "some" 14 | } 15 | ], 16 | "paths": { 17 | "/api/login-custom-function": { 18 | "post": { 19 | "summary": "Login\n (unknown-action)", 20 | "tags": [ 21 | "auth" 22 | ], 23 | "parameters": [], 24 | "responses": { 25 | "200": { 26 | "$ref": "#/components/responses/ReturnedData" 27 | }, 28 | "401": { 29 | "$ref": "#/components/responses/UnauthorizedError" 30 | }, 31 | "422": { 32 | "$ref": "#/components/responses/ValidationError" 33 | }, 34 | "default": { 35 | "$ref": "#/components/responses/ServerError" 36 | } 37 | }, 38 | "requestBody": { 39 | "content": { 40 | "application/json": { 41 | "schema": { 42 | "type": "object", 43 | "example": { 44 | "login": "", 45 | "pass": "" 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | }, 53 | "/api/upload": { 54 | "put": { 55 | "summary": "(some.upload)", 56 | "tags": [ 57 | "some" 58 | ], 59 | "parameters": [], 60 | "responses": { 61 | "200": { 62 | "description": "", 63 | "content": { 64 | "application/json": { 65 | "schema": { 66 | "type": "array", 67 | "items": { 68 | "type": "object", 69 | "example": { 70 | "id": 1, 71 | "filename": "foo.txt", 72 | "mimetype": "text/plain", 73 | "sizeInBytes": 100 74 | } 75 | } 76 | } 77 | } 78 | } 79 | }, 80 | "400": { 81 | "$ref": "#/components/responses/FileNotExist" 82 | }, 83 | "401": { 84 | "$ref": "#/components/responses/UnauthorizedError" 85 | }, 86 | "413": { 87 | "$ref": "#/components/responses/FileTooBig" 88 | }, 89 | "422": { 90 | "$ref": "#/components/responses/ValidationError" 91 | }, 92 | "default": { 93 | "$ref": "#/components/responses/ServerError" 94 | } 95 | }, 96 | "requestBody": { 97 | "content": { 98 | "multipart/form-data": { 99 | "schema": { 100 | "type": "object", 101 | "properties": { 102 | "file": { 103 | "type": "array", 104 | "items": { 105 | "type": "string", 106 | "format": "binary" 107 | } 108 | }, 109 | "someField": { 110 | "type": "string" 111 | } 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | "/api/update/{id}": { 120 | "patch": { 121 | "summary": "Foo bar baz\n (some.update)", 122 | "tags": [ 123 | "some" 124 | ], 125 | "parameters": [ 126 | { 127 | "name": "id", 128 | "in": "path", 129 | "required": true, 130 | "schema": { 131 | "type": "string" 132 | } 133 | } 134 | ], 135 | "responses": { 136 | "200": { 137 | "$ref": "#/components/responses/ReturnedData" 138 | }, 139 | "401": { 140 | "$ref": "#/components/responses/UnauthorizedError" 141 | }, 142 | "422": { 143 | "$ref": "#/components/responses/ValidationError" 144 | }, 145 | "default": { 146 | "$ref": "#/components/responses/ServerError" 147 | } 148 | }, 149 | "requestBody": { 150 | "content": { 151 | "application/json": { 152 | "schema": { 153 | "$ref": "#/components/schemas/some.update" 154 | } 155 | } 156 | } 157 | } 158 | } 159 | }, 160 | "/api/find": { 161 | "get": { 162 | "summary": "Some find summary\n (some.find)", 163 | "tags": [ 164 | "some" 165 | ], 166 | "parameters": [ 167 | { 168 | "name": "roles[]", 169 | "in": "query", 170 | "schema": { 171 | "type": "array", 172 | "items": { 173 | "example": "user", 174 | "type": "string", 175 | "enum": [ 176 | "user", 177 | "admin" 178 | ] 179 | } 180 | } 181 | }, 182 | { 183 | "in": "query", 184 | "name": "sex", 185 | "schema": { 186 | "type": "string", 187 | "enum": [ 188 | "male", 189 | "female" 190 | ], 191 | "example": "male" 192 | } 193 | }, 194 | { 195 | "name": "populate[]", 196 | "in": "query", 197 | "schema": { 198 | "type": "array", 199 | "items": { 200 | "example": "", 201 | "type": "string" 202 | }, 203 | "minItems": 2, 204 | "maxItems": 2 205 | } 206 | }, 207 | { 208 | "name": "fields[]", 209 | "in": "query", 210 | "schema": { 211 | "type": "array", 212 | "items": { 213 | "example": "", 214 | "type": "string" 215 | }, 216 | "minItems": 2, 217 | "maxItems": 2 218 | } 219 | }, 220 | { 221 | "in": "query", 222 | "name": "limit", 223 | "schema": { 224 | "example": null, 225 | "type": "number", 226 | "minLength": 0 227 | } 228 | }, 229 | { 230 | "in": "query", 231 | "name": "offset", 232 | "schema": { 233 | "example": null, 234 | "type": "number", 235 | "minLength": 0 236 | } 237 | }, 238 | { 239 | "in": "query", 240 | "name": "sort", 241 | "schema": { 242 | "example": "", 243 | "type": "string" 244 | } 245 | }, 246 | { 247 | "in": "query", 248 | "name": "search", 249 | "schema": { 250 | "type": "string", 251 | "default": "find me now" 252 | } 253 | }, 254 | { 255 | "name": "searchFields[]", 256 | "in": "query", 257 | "schema": { 258 | "type": "array", 259 | "items": { 260 | "example": "", 261 | "type": "string" 262 | }, 263 | "minItems": 2, 264 | "maxItems": 2 265 | } 266 | }, 267 | { 268 | "name": "query[]", 269 | "in": "query", 270 | "schema": { 271 | "type": "array", 272 | "items": { 273 | "example": "", 274 | "type": "string" 275 | }, 276 | "minItems": 2, 277 | "maxItems": 2 278 | } 279 | } 280 | ], 281 | "responses": { 282 | "200": { 283 | "$ref": "#/components/responses/ReturnedData" 284 | }, 285 | "401": { 286 | "$ref": "#/components/responses/UnauthorizedError" 287 | }, 288 | "422": { 289 | "$ref": "#/components/responses/ValidationError" 290 | }, 291 | "default": { 292 | "$ref": "#/components/responses/ServerError" 293 | } 294 | } 295 | } 296 | }, 297 | "/api/go": { 298 | "post": { 299 | "summary": "(some.go)", 300 | "tags": [ 301 | "some" 302 | ], 303 | "parameters": [], 304 | "responses": { 305 | "200": { 306 | "description": "", 307 | "content": { 308 | "application/json": { 309 | "schema": { 310 | "type": "object", 311 | "example": { 312 | "line": "number", 313 | "text": "string" 314 | } 315 | } 316 | } 317 | } 318 | }, 319 | "401": { 320 | "$ref": "#/components/responses/UnauthorizedError" 321 | }, 322 | "422": { 323 | "$ref": "#/components/responses/ValidationError" 324 | }, 325 | "default": { 326 | "$ref": "#/components/responses/ServerError" 327 | } 328 | }, 329 | "requestBody": { 330 | "content": { 331 | "application/json": { 332 | "schema": { 333 | "$ref": "#/components/schemas/some.go" 334 | } 335 | } 336 | } 337 | } 338 | } 339 | }, 340 | "/api/some-login": { 341 | "post": { 342 | "summary": "(some.login)", 343 | "tags": [ 344 | "some" 345 | ], 346 | "parameters": [], 347 | "responses": { 348 | "200": { 349 | "$ref": "#/components/responses/ReturnedData" 350 | }, 351 | "401": { 352 | "$ref": "#/components/responses/UnauthorizedError" 353 | }, 354 | "422": { 355 | "$ref": "#/components/responses/ValidationError" 356 | }, 357 | "default": { 358 | "$ref": "#/components/responses/ServerError" 359 | } 360 | }, 361 | "requestBody": { 362 | "content": { 363 | "application/json": { 364 | "schema": { 365 | "$ref": "#/components/schemas/some.login" 366 | } 367 | } 368 | } 369 | } 370 | } 371 | } 372 | }, 373 | "components": { 374 | "schemas": { 375 | "DbMixinList": { 376 | "type": "object", 377 | "properties": { 378 | "rows": { 379 | "type": "array", 380 | "items": { 381 | "type": "object" 382 | } 383 | }, 384 | "totalCount": { 385 | "type": "number" 386 | } 387 | } 388 | }, 389 | "DbMixinFindList": { 390 | "type": "array", 391 | "items": { 392 | "type": "object" 393 | } 394 | }, 395 | "Item": { 396 | "type": "object" 397 | }, 398 | "unknown-action": { 399 | "type": "object", 400 | "properties": {} 401 | }, 402 | "some.upload": { 403 | "type": "object", 404 | "properties": {} 405 | }, 406 | "some.update": { 407 | "type": "object", 408 | "properties": { 409 | "roles": { 410 | "type": "array", 411 | "items": { 412 | "example": "user", 413 | "type": "string", 414 | "enum": [ 415 | "user", 416 | "admin" 417 | ] 418 | } 419 | }, 420 | "sex": { 421 | "type": "string", 422 | "enum": [ 423 | "male", 424 | "female" 425 | ], 426 | "default": "female" 427 | }, 428 | "numberBy": { 429 | "example": null, 430 | "type": "number" 431 | }, 432 | "someNum": { 433 | "description": "Is some num", 434 | "example": null, 435 | "type": "number" 436 | }, 437 | "types": { 438 | "description": "Types arr", 439 | "type": "array", 440 | "default": [ 441 | { 442 | "id": 1, 443 | "typeId": 5 444 | } 445 | ], 446 | "minItems": 1, 447 | "maxItems": 1, 448 | "items": { 449 | "$ref": "#/components/schemas/some.update.types" 450 | } 451 | }, 452 | "bars": { 453 | "description": "Bars arr", 454 | "type": "array", 455 | "minItems": 1, 456 | "maxItems": 2, 457 | "items": { 458 | "$ref": "#/components/schemas/some.update.bars" 459 | } 460 | }, 461 | "someObj": { 462 | "description": "Some obj", 463 | "$ref": "#/components/schemas/some.update.someObj" 464 | }, 465 | "someBool": { 466 | "example": false, 467 | "type": "boolean" 468 | }, 469 | "desc": { 470 | "example": "", 471 | "type": "string", 472 | "minLength": 4, 473 | "maxLength": 10 474 | }, 475 | "email": { 476 | "example": "foo@example.com", 477 | "type": "string", 478 | "format": "email" 479 | }, 480 | "date": { 481 | "example": "1998-01-10T13:00:00.000Z", 482 | "type": "string", 483 | "format": "date-time" 484 | }, 485 | "uuid": { 486 | "example": "10ba038e-48da-487b-96e8-8d3b99b6d18a", 487 | "type": "string", 488 | "format": "uuid" 489 | }, 490 | "url": { 491 | "example": "https://example.com", 492 | "type": "string", 493 | "format": "uri" 494 | }, 495 | "shortObject": { 496 | "$ref": "#/components/schemas/some.update.shortObject" 497 | }, 498 | "shortObject2": { 499 | "$ref": "#/components/schemas/some.update.shortObject2" 500 | }, 501 | "tupleSimple": { 502 | "type": "array", 503 | "items": { 504 | "example": "", 505 | "type": "string" 506 | } 507 | }, 508 | "tupleDifficult": { 509 | "type": "array", 510 | "items": { 511 | "example": "", 512 | "type": "string" 513 | } 514 | } 515 | }, 516 | "required": [ 517 | "sex", 518 | "numberBy", 519 | "someNum", 520 | "types", 521 | "bars", 522 | "someObj", 523 | "email", 524 | "uuid", 525 | "url", 526 | "shortObject", 527 | "tupleSimple", 528 | "tupleDifficult" 529 | ], 530 | "minItems": 1, 531 | "maxItems": 2 532 | }, 533 | "some.update.types": { 534 | "type": "object", 535 | "properties": { 536 | "id": { 537 | "example": null, 538 | "type": "number" 539 | }, 540 | "typeId": { 541 | "example": null, 542 | "type": "number" 543 | } 544 | }, 545 | "default": [ 546 | { 547 | "id": 1, 548 | "typeId": 5 549 | } 550 | ] 551 | }, 552 | "some.update.bars": { 553 | "type": "object", 554 | "properties": { 555 | "id": { 556 | "example": null, 557 | "type": "number" 558 | }, 559 | "fooNum": { 560 | "description": "fooNum", 561 | "example": null, 562 | "type": "number" 563 | } 564 | } 565 | }, 566 | "some.update.someObj": { 567 | "type": "object", 568 | "properties": { 569 | "id": { 570 | "description": "Some obj ID", 571 | "example": null, 572 | "type": "number" 573 | }, 574 | "numberId": { 575 | "example": null, 576 | "type": "number" 577 | }, 578 | "name": { 579 | "example": "", 580 | "type": "string", 581 | "maxLength": 100 582 | } 583 | }, 584 | "default": { 585 | "name": "bar" 586 | } 587 | }, 588 | "some.update.shortObject": { 589 | "type": "object", 590 | "properties": { 591 | "desc": { 592 | "example": "", 593 | "type": "string", 594 | "maxLength": 10000 595 | }, 596 | "url": { 597 | "example": "https://example.com", 598 | "type": "string", 599 | "format": "uri" 600 | } 601 | }, 602 | "required": [ 603 | "url" 604 | ] 605 | }, 606 | "some.update.shortObject2": { 607 | "type": "object", 608 | "properties": { 609 | "desc": { 610 | "example": "", 611 | "type": "string", 612 | "maxLength": 10000 613 | }, 614 | "url": { 615 | "example": "https://example.com", 616 | "type": "string", 617 | "format": "uri" 618 | } 619 | }, 620 | "required": [ 621 | "url" 622 | ] 623 | }, 624 | "some.go": { 625 | "type": "object", 626 | "properties": { 627 | "line": { 628 | "example": null, 629 | "type": "number" 630 | } 631 | }, 632 | "required": [ 633 | "line" 634 | ] 635 | }, 636 | "some.login": { 637 | "type": "object", 638 | "properties": { 639 | "password": { 640 | "example": "", 641 | "type": "string", 642 | "minLength": 8, 643 | "pattern": "^[a-zA-Z0-9]+$" 644 | }, 645 | "repeatPassword": { 646 | "example": "", 647 | "type": "string", 648 | "minLength": 8, 649 | "pattern": "^[a-zA-Z0-9]+$" 650 | } 651 | }, 652 | "required": [ 653 | "password", 654 | "repeatPassword" 655 | ] 656 | } 657 | }, 658 | "securitySchemes": {}, 659 | "responses": { 660 | "ServerError": { 661 | "description": "Server errors: 500, 501, 400, 404 and etc...", 662 | "content": { 663 | "application/json": { 664 | "schema": { 665 | "type": "object", 666 | "example": { 667 | "name": "MoleculerClientError", 668 | "message": "Server error message", 669 | "code": 500 670 | } 671 | } 672 | } 673 | } 674 | }, 675 | "UnauthorizedError": { 676 | "description": "Need auth", 677 | "content": { 678 | "application/json": { 679 | "schema": { 680 | "type": "object", 681 | "example": { 682 | "name": "MoleculerClientError", 683 | "message": "Unauth error message", 684 | "code": 401 685 | } 686 | } 687 | } 688 | } 689 | }, 690 | "ValidationError": { 691 | "description": "Fields invalid", 692 | "content": { 693 | "application/json": { 694 | "schema": { 695 | "type": "object", 696 | "example": { 697 | "name": "MoleculerClientError", 698 | "message": "Error message", 699 | "code": 422, 700 | "data": [ 701 | { 702 | "name": "fieldName", 703 | "message": "Field invalid" 704 | }, 705 | { 706 | "name": "arrayField[0].fieldName", 707 | "message": "Whats wrong" 708 | }, 709 | { 710 | "name": "object.fieldName", 711 | "message": "Whats wrong" 712 | } 713 | ] 714 | } 715 | } 716 | } 717 | } 718 | }, 719 | "ReturnedData": { 720 | "description": "", 721 | "content": { 722 | "application/json": { 723 | "schema": { 724 | "oneOf": [ 725 | { 726 | "$ref": "#/components/schemas/DbMixinList" 727 | }, 728 | { 729 | "$ref": "#/components/schemas/DbMixinFindList" 730 | }, 731 | { 732 | "$ref": "#/components/schemas/Item" 733 | } 734 | ] 735 | } 736 | } 737 | } 738 | }, 739 | "FileNotExist": { 740 | "description": "File not exist", 741 | "content": { 742 | "application/json": { 743 | "schema": { 744 | "type": "object", 745 | "example": { 746 | "name": "MoleculerClientError", 747 | "message": "File missing in the request", 748 | "code": 400 749 | } 750 | } 751 | } 752 | } 753 | }, 754 | "FileTooBig": { 755 | "description": "File too big", 756 | "content": { 757 | "application/json": { 758 | "schema": { 759 | "type": "object", 760 | "example": { 761 | "name": "PayloadTooLarge", 762 | "message": "Payload too large", 763 | "code": 413, 764 | "type": "PAYLOAD_TOO_LARGE", 765 | "data": { 766 | "fieldname": "file", 767 | "filename": "4b2005c0b8.png", 768 | "encoding": "7bit", 769 | "mimetype": "image/png" 770 | } 771 | } 772 | } 773 | } 774 | } 775 | } 776 | } 777 | } 778 | } 779 | -------------------------------------------------------------------------------- /test/openapi.mixin.spec.js: -------------------------------------------------------------------------------- 1 | process.env.PORT = 0; // Use random ports during tests 2 | 3 | const { ServiceBroker } = require("moleculer"); 4 | const ApiGateway = require("moleculer-web"); 5 | 6 | const Openapi = require("../index"); 7 | 8 | const fs = require("fs"); 9 | 10 | const OpenapiService = { 11 | mixins: [Openapi], 12 | settings: { 13 | openapi: { 14 | "info": { 15 | "description": "Foo", 16 | "title": "Bar", 17 | }, 18 | }, 19 | }, 20 | }; 21 | 22 | const SomeService = { 23 | name: "some", 24 | actions: { 25 | upload: { 26 | openapi: { 27 | responses: { 28 | 200: { 29 | "description": "", 30 | "content": { 31 | "application/json": { 32 | "schema": { 33 | "type": "array", 34 | "items": { 35 | "type": "object", 36 | "example": { id: 1, filename: 'foo.txt', mimetype: 'text/plain', sizeInBytes: 100 }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | 400: { 43 | $ref: "#/components/responses/FileNotExist", 44 | }, 45 | 401: { 46 | $ref: "#/components/responses/UnauthorizedError", 47 | }, 48 | 413: { 49 | $ref: "#/components/responses/FileTooBig", 50 | }, 51 | 422: { 52 | $ref: "#/components/responses/ValidationError", 53 | }, 54 | default: { 55 | $ref: "#/components/responses/ServerError", 56 | }, 57 | }, 58 | }, 59 | handler() {}, 60 | }, 61 | update: { 62 | openapi: { 63 | summary: "Foo bar baz", 64 | }, 65 | params: { 66 | $$strict: "remove", 67 | roles: { type: "array", items: "string", enum: [ "user", "admin" ] }, 68 | sex: { type: "enum", values: ["male", "female"], default: "female" }, 69 | id: { type: "number", convert: true, default: 5 }, 70 | numberBy: "number", 71 | someNum: { $$t: "Is some num", type: "number", convert: true }, 72 | types: { 73 | type: "array", 74 | $$t: "Types arr", 75 | default: [{ id: 1, typeId: 5 }], 76 | length: 1, 77 | items: { 78 | type: "object", strict: "remove", props: { 79 | id: { type: "number", optional: true }, 80 | typeId: { type: "number", optional: true }, 81 | }, 82 | }, 83 | }, 84 | bars: { 85 | type: "array", 86 | $$t: "Bars arr", 87 | min: 1, 88 | max: 2, 89 | items: { 90 | type: "object", strict: "remove", props: { 91 | id: { type: "number", optional: true }, 92 | fooNum: { $$t: "fooNum", type: "number", optional: true }, 93 | }, 94 | }, 95 | }, 96 | someObj: { 97 | $$t: "Some obj", 98 | default: { name: "bar" }, 99 | type: "object", strict: "remove", props: { 100 | id: { $$t: "Some obj ID", type: "number", optional: true }, 101 | numberId: { type: "number", optional: true }, 102 | name: { type: "string", optional: true, max: 100 }, 103 | }, 104 | }, 105 | someBool: { type: "boolean", optional: true }, 106 | desc: { type: "string", optional: true, max: 10, min: 4, }, 107 | email: "email", 108 | date: "date|optional|min:0|max:99", 109 | uuid: "uuid", 110 | url: "url", 111 | shortObject: { 112 | $$type: "object", 113 | desc: { type: "string", optional: true, max: 10000 }, 114 | url: "url", 115 | }, 116 | shortObject2: { 117 | $$type: "object|optional", 118 | desc: { type: "string", optional: true, max: 10000 }, 119 | url: "url", 120 | }, 121 | tupleSimple: { 122 | type: "tuple", items: ["string", "number"], 123 | }, 124 | tupleDifficult: { 125 | type: "tuple", items: [ 126 | "string", 127 | { 128 | type: "tuple", empty: false, items: [ 129 | { type: "number", min: 35, max: 45 }, 130 | { type: "number", min: -75, max: -65 }, 131 | ], 132 | }, 133 | ], 134 | }, 135 | }, 136 | handler() {}, 137 | }, 138 | /** 139 | * Action from moleculer-db mixin 140 | */ 141 | find: { 142 | cache: { 143 | keys: ["populate", "fields", "limit", "offset", "sort", "search", "searchFields", "query"], 144 | }, 145 | params: { 146 | roles: { type: "array", items: "string", enum: [ "user", "admin" ] }, 147 | sex: { type: "enum", values: ["male", "female"] }, 148 | populate: [ 149 | { type: "string", optional: true }, 150 | { type: "array", optional: true, items: "string" }, 151 | ], 152 | fields: [ 153 | { type: "string", optional: true }, 154 | { type: "array", optional: true, items: "string" }, 155 | ], 156 | limit: { type: "number", integer: true, min: 0, optional: true, convert: true }, 157 | offset: { type: "number", integer: true, min: 0, optional: true, convert: true }, 158 | sort: { type: "string", optional: true }, 159 | search: { type: "string", optional: true, default: "find me now" }, 160 | searchFields: [ 161 | { type: "string", optional: true }, 162 | { type: "array", optional: true, items: "string" }, 163 | ], 164 | query: [ 165 | { type: "object", optional: true }, 166 | { type: "string", optional: true }, 167 | ], 168 | }, 169 | handler() {}, 170 | }, 171 | go: { 172 | openapi: { 173 | responses: { 174 | 200: { 175 | "description": ``, 176 | "content": { 177 | "application/json": { 178 | "schema": { 179 | "type": `object`, 180 | "example": { line: `number`, text: `string` }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | }, 186 | }, 187 | params: { 188 | line: { type: `number` }, 189 | }, 190 | handler() {}, 191 | }, 192 | login: { 193 | params: { 194 | password: { type: 'string', min: 8, pattern: /^[a-zA-Z0-9]+$/ }, 195 | repeatPassword: { type: 'string', min: 8, pattern: '^[a-zA-Z0-9]+$' } 196 | }, 197 | handler() {}, 198 | }, 199 | }, 200 | }; 201 | 202 | const ApiService = { 203 | name: "api", 204 | mixins: [ApiGateway], 205 | settings: { 206 | routes: [ 207 | { 208 | path: "/api", 209 | aliases: { 210 | "POST login-custom-function": { 211 | handler(req, res) { 212 | res.end(); 213 | }, 214 | openapi: { 215 | summary: "Login", 216 | tags: ["auth"], 217 | requestBody: { 218 | content: { 219 | "application/json": { 220 | schema: { 221 | type: "object", 222 | example: { login: "", pass: "" }, 223 | }, 224 | }, 225 | }, 226 | }, 227 | }, 228 | }, 229 | }, 230 | }, 231 | { 232 | path: "/api", 233 | aliases: { 234 | "PUT upload": "multipart:some.upload", 235 | "PATCH update/:id": "some.update", 236 | "GET find": { 237 | openapi: { 238 | summary: "Some find summary", 239 | }, 240 | action: "some.find", 241 | }, 242 | "POST go": "some.go", 243 | "POST some-login": "some.login", 244 | }, 245 | }, 246 | { 247 | path: "/api", 248 | whitelist: ["openapi.*"], 249 | autoAliases: true, 250 | }, 251 | ], 252 | }, 253 | }; 254 | 255 | describe("Test 'openapi' mixin", () => { 256 | const broker = new ServiceBroker({ logger: false }); 257 | broker.createService(SomeService); 258 | broker.createService(OpenapiService); 259 | broker.createService(ApiService); 260 | 261 | beforeAll(async () => { 262 | await broker.start(); 263 | 264 | // wait for all services auto resolved 265 | await new Promise(resolve => setTimeout(resolve, 500)); 266 | }); 267 | 268 | afterAll(() => broker.stop()); 269 | 270 | it("generate schema json file", async () => { 271 | expect.assertions(1); 272 | 273 | const json = await broker.call("openapi.generateDocs"); 274 | 275 | const expectedSchema = require("./expectedSchema.json"); 276 | 277 | // check json https://editor.swagger.io/ 278 | //console.log(JSON.stringify(json, null, "")); 279 | expect(json).toMatchObject(expectedSchema); 280 | }); 281 | 282 | it("Asset is returned as a stream", async () => { 283 | const file = "swagger-ui-bundle.js.map"; 284 | const path = require("swagger-ui-dist").getAbsoluteFSPath(); 285 | 286 | const stream = await broker.call("openapi.assets", { file }); 287 | 288 | const expected = fs.readFileSync(`${path}/${file}`).toString(); 289 | 290 | let buffer = ""; 291 | i = 0; 292 | for await (const chunk of stream) { 293 | buffer += chunk; 294 | } 295 | 296 | expect(stream).toBeInstanceOf(fs.ReadStream); 297 | expect(buffer).toEqual(expected); 298 | }); 299 | }); 300 | --------------------------------------------------------------------------------