├── index.d.ts ├── .editorconfig ├── .gitignore ├── .github └── workflows │ ├── test-build.yml │ └── test-build-publish.yml ├── package.json ├── README.md ├── test ├── openapi.mixin.spec.js └── expectedSchema.json └── index.js /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "moleculer-auto-openapi"; 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moleculer-auto-openapi", 3 | "version": "1.1.7", 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | // attribute name to use for getting params schema from actions (default: 'params') 32 | paramsAttribute: "params", 33 | commonPathItemObjectResponses: { 34 | 200: { 35 | $ref: "#/components/responses/ReturnedData", 36 | }, 37 | 401: { 38 | $ref: "#/components/responses/UnauthorizedError", 39 | }, 40 | 422: { 41 | $ref: "#/components/responses/ValidationError", 42 | }, 43 | default: { 44 | $ref: "#/components/responses/ServerError", 45 | }, 46 | }, 47 | requestBodyAndResponseBodyAreSameOnMethods: [ 48 | /* 'post', 49 | 'patch', 50 | 'put', */ 51 | ], 52 | requestBodyAndResponseBodyAreSameDescription: "The answer may vary slightly from what is indicated here. Contain id and/or other additional attributes.", 53 | openapi: { 54 | "openapi": "3.0.3", 55 | "info": { 56 | "description": "", 57 | "version": "0.0.0", 58 | "title": "Api docs", 59 | }, 60 | "tags": [], 61 | "paths": {}, 62 | "components": { 63 | "schemas": { 64 | // Standart moleculer schemas 65 | "DbMixinList": { 66 | "type": "object", 67 | "properties": { 68 | "rows": { 69 | "type": "array", 70 | "items": { 71 | "type": "object", 72 | }, 73 | }, 74 | "totalCount": { 75 | "type": "number", 76 | }, 77 | }, 78 | }, 79 | "DbMixinFindList": { 80 | "type": "array", 81 | "items": { 82 | "type": "object", 83 | }, 84 | }, 85 | "Item": { 86 | "type": "object", 87 | }, 88 | }, 89 | "securitySchemes": {}, 90 | "responses": { 91 | // Standart moleculer responses 92 | "ServerError": { 93 | "description": "Server errors: 500, 501, 400, 404 and etc...", 94 | "content": { 95 | "application/json": { 96 | "schema": { 97 | "type": "object", 98 | "example": { "name": "MoleculerClientError", "message": "Server error message", "code": 500 }, 99 | }, 100 | }, 101 | }, 102 | }, 103 | "UnauthorizedError": { 104 | "description": "Need auth", 105 | "content": { 106 | "application/json": { 107 | "schema": { 108 | "type": "object", 109 | "example": { "name": "MoleculerClientError", "message": "Unauth error message", "code": 401 }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | "ValidationError": { 115 | "description": "Fields invalid", 116 | "content": { 117 | "application/json": { 118 | "schema": { 119 | "type": "object", 120 | "example": { 121 | "name": "MoleculerClientError", "message": "Error message", "code": 422, "data": [ 122 | { "name": "fieldName", "message": "Field invalid" }, 123 | { "name": "arrayField[0].fieldName", "message": "Whats wrong" }, 124 | { "name": "object.fieldName", "message": "Whats wrong" }, 125 | ], 126 | }, 127 | }, 128 | }, 129 | }, 130 | }, 131 | "ReturnedData": { 132 | "description": "", 133 | "content": { 134 | "application/json": { 135 | "schema": { 136 | "oneOf": [ 137 | { 138 | "$ref": "#/components/schemas/DbMixinList", 139 | }, 140 | { 141 | "$ref": "#/components/schemas/DbMixinFindList", 142 | }, 143 | { 144 | "$ref": "#/components/schemas/Item", 145 | }, 146 | ], 147 | }, 148 | }, 149 | }, 150 | }, 151 | "FileNotExist": { 152 | "description": "File not exist", 153 | "content": { 154 | "application/json": { 155 | "schema": { 156 | "type": "object", 157 | "example": { 158 | "name": "MoleculerClientError", 159 | "message": "File missing in the request", 160 | "code": 400, 161 | }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | "FileTooBig": { 167 | "description": "File too big", 168 | "content": { 169 | "application/json": { 170 | "schema": { 171 | "type": "object", 172 | "example": { 173 | "name": "PayloadTooLarge", 174 | "message": "Payload too large", 175 | "code": 413, 176 | "type": "PAYLOAD_TOO_LARGE", 177 | "data": { 178 | "fieldname": "file", 179 | "filename": "4b2005c0b8.png", 180 | "encoding": "7bit", 181 | "mimetype": "image/png", 182 | }, 183 | }, 184 | }, 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | }, 191 | }, 192 | actions: { 193 | generateDocs: { 194 | openapi: { 195 | // you can declare custom Path Item Object 196 | // which override autogenerated object from params 197 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#path-item-object-example 198 | summary: "OpenAPI schema url", 199 | 200 | // you custom response 201 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#response-object-examples 202 | responses: { 203 | "200": { 204 | "description": "", 205 | "content": { 206 | "application/json": { 207 | "schema": { 208 | "$ref": "#/components/schemas/OpenAPIModel", 209 | }, 210 | }, 211 | }, 212 | }, 213 | }, 214 | 215 | // you custom tag 216 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#fixed-fields-8 217 | tags: ["openapi"], 218 | 219 | // components which attached to root of docx 220 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#components-object 221 | components: { 222 | schemas: { 223 | // you custom schema 224 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#models-with-polymorphism-support 225 | OpenAPIModel: { 226 | type: "object", 227 | properties: { 228 | openapi: { 229 | example: "3.0.3", 230 | type: "string", 231 | description: "OpenAPI version", 232 | }, 233 | info: { 234 | type: "object", 235 | properties: { 236 | description: { 237 | type: "string", 238 | }, 239 | }, 240 | }, 241 | tags: { 242 | type: "array", 243 | items: { 244 | type: "string", 245 | }, 246 | }, 247 | }, 248 | required: ["openapi"], 249 | }, 250 | }, 251 | }, 252 | }, 253 | handler() { 254 | return this.generateSchema(); 255 | }, 256 | }, 257 | assets: { 258 | openapi: { 259 | summary: "OpenAPI assets", 260 | description: "Return files from swagger-ui-dist folder", 261 | }, 262 | params: { 263 | file: { 264 | type: "enum", 265 | values: [ 266 | `swagger-ui.css`, `swagger-ui.css.map`, 267 | `swagger-ui-bundle.js`, `swagger-ui-bundle.js.map`, 268 | `swagger-ui-standalone-preset.js`, `swagger-ui-standalone-preset.js.map`, 269 | ] 270 | }, 271 | }, 272 | handler(ctx) { 273 | if (ctx.params.file.indexOf('.css') > -1) { 274 | ctx.meta.$responseType = "text/css"; 275 | } else if (ctx.params.file.indexOf('.js') > -1) { 276 | ctx.meta.$responseType = "text/javascript"; 277 | } else { 278 | ctx.meta.$responseType = "application/octet-stream"; 279 | } 280 | 281 | return fs.createReadStream(`${swaggerUiAssetPath}/${ctx.params.file}`); 282 | } 283 | }, 284 | ui: { 285 | openapi: { 286 | summary: "OpenAPI ui", 287 | description: "You can provide any schema file in query param", 288 | }, 289 | params: { 290 | url: { $$t: "Schema url", type: "string", optional: true }, 291 | }, 292 | handler(ctx) { 293 | ctx.meta.$responseType = "text/html; charset=utf-8"; 294 | 295 | return ` 296 | 297 | 298 | OpenAPI UI 299 | 300 | 301 | 302 | 303 |
304 |

Loading...

305 | 306 |
307 | 308 | 309 | 310 | 327 | 328 | 329 | `; 330 | }, 331 | }, 332 | }, 333 | methods: { 334 | fetchServicesWithActions() { 335 | return this.broker.call("$node.services", { 336 | withActions: true, 337 | onlyLocal: this.settings.onlyLocal, 338 | }); 339 | }, 340 | fetchAliasesForService(service) { 341 | return this.broker.call(`${service}.listAliases`); 342 | }, 343 | async generateSchema() { 344 | const doc = JSON.parse(JSON.stringify(this.settings.openapi)); 345 | 346 | const nodes = await this.fetchServicesWithActions(); 347 | 348 | const routes = await this.collectRoutes(nodes); 349 | 350 | this.attachParamsAndOpenapiFromEveryActionToRoutes(routes, nodes); 351 | 352 | this.attachRoutesToDoc(routes, doc); 353 | 354 | return doc; 355 | }, 356 | attachParamsAndOpenapiFromEveryActionToRoutes(routes, nodes) { 357 | const paramsAttr = this.settings.paramsAttribute; 358 | for (const routeAction in routes) { 359 | for (const node of nodes) { 360 | for (const nodeAction in node.actions) { 361 | if (routeAction === nodeAction) { 362 | const actionProps = node.actions[nodeAction]; 363 | 364 | routes[routeAction].params = actionProps[paramsAttr] || {}; 365 | routes[routeAction].openapi = actionProps.openapi || null; 366 | break; 367 | } 368 | } 369 | } 370 | } 371 | }, 372 | async collectRoutes(nodes) { 373 | const routes = {}; 374 | 375 | for (const node of nodes) { 376 | // find routes in web-api service 377 | if (node.settings && node.settings.routes) { 378 | 379 | if (this.settings.collectOnlyFromWebServices && this.settings.collectOnlyFromWebServices.length > 0 && !this.settings.collectOnlyFromWebServices.includes(node.name)) { 380 | continue; 381 | } 382 | 383 | // iterate each route 384 | for (const route of node.settings.routes) { 385 | // map standart aliases 386 | this.buildActionRouteStructFromAliases(route, routes); 387 | } 388 | 389 | let service = node.name; 390 | // resolve paths with auto aliases 391 | const hasAutoAliases = node.settings.routes.some(route => route.autoAliases); 392 | if (hasAutoAliases) { 393 | // suport services that has version, like v1.api 394 | if (Object.prototype.hasOwnProperty.call(node, "version") && node.version !== undefined) { 395 | service = `v${node.version}.` + service; 396 | } 397 | const autoAliases = await this.fetchAliasesForService(service); 398 | const convertedRoute = this.convertAutoAliasesToRoute(autoAliases); 399 | this.buildActionRouteStructFromAliases(convertedRoute, routes); 400 | } 401 | } 402 | } 403 | 404 | return routes; 405 | }, 406 | /** 407 | * @link https://github.com/moleculerjs/moleculer-web/blob/155ccf1d3cb755dafd434e84eb95e35ee324a26d/src/index.js#L229 408 | * @param autoAliases 409 | * @returns {{path: string, aliases: {}}} 410 | */ 411 | convertAutoAliasesToRoute(autoAliases) { 412 | const route = { 413 | path: '', 414 | autoAliases: true, 415 | aliases: {}, 416 | }; 417 | 418 | for (const obj of autoAliases) { 419 | const alias = `${obj.methods} ${obj.fullPath}`; 420 | route.aliases[alias] = obj.actionName || UNRESOLVED_ACTION_NAME; 421 | } 422 | 423 | return route; 424 | }, 425 | /** 426 | * convert `GET /table`: `table.get` 427 | * to {action: { 428 | * actionType:'multipart|null', 429 | * params: {}, 430 | * autoAliases: true|undefined 431 | * paths: [ 432 | * {base: 'api/uploads', alias: 'GET /table'} 433 | * ] 434 | * openapi: null 435 | * }} 436 | * @param route 437 | * @param routes 438 | * @returns {{}} 439 | */ 440 | buildActionRouteStructFromAliases(route, routes) { 441 | for (const alias in route.aliases) { 442 | const aliasInfo = route.aliases[alias]; 443 | let actionType = aliasInfo.type; 444 | 445 | let action = ""; 446 | if (aliasInfo.action) { 447 | action = aliasInfo.action; 448 | } else if (Array.isArray(aliasInfo)) { 449 | action = aliasInfo[aliasInfo.length - 1] 450 | } else if (typeof aliasInfo !== "string") { 451 | action = UNRESOLVED_ACTION_NAME; 452 | } else { 453 | action = aliasInfo; 454 | } 455 | // support actions like multipart:import.proceedFile 456 | if (action.includes(":")) { 457 | ([actionType, action] = action.split(":")); 458 | } 459 | 460 | if (!routes[action]) { 461 | routes[action] = { 462 | actionType, 463 | params: {}, 464 | paths: [], 465 | openapi: null, 466 | }; 467 | } 468 | 469 | routes[action].paths.push({ 470 | base: route.path || "", 471 | alias, 472 | autoAliases: route.autoAliases, 473 | openapi: aliasInfo.openapi || null, 474 | }); 475 | } 476 | 477 | return routes; 478 | }, 479 | attachRoutesToDoc(routes, doc) { 480 | // route to openapi paths 481 | for (const action in routes) { 482 | const { paths, params, actionType, openapi = {} } = routes[action]; 483 | const service = action.split(".").slice(0, -1).join("."); 484 | 485 | this.addTagToDoc(doc, service); 486 | 487 | for (const path of paths) { 488 | // parse method and path from: POST /api/table 489 | const [tmpMethod, subPath] = path.alias.split(" "); 490 | const method = tmpMethod.toLowerCase(); 491 | 492 | // convert /:table to /{table} 493 | const openapiPath = this.formatParamUrl( 494 | this.normalizePath(`${path.base}/${subPath}`), 495 | ); 496 | 497 | const [queryParams, addedQueryParams] = this.extractParamsFromUrl(openapiPath); 498 | 499 | if (!doc.paths[openapiPath]) { 500 | doc.paths[openapiPath] = {}; 501 | } 502 | 503 | if (doc.paths[openapiPath][method]) { 504 | continue; 505 | } 506 | 507 | // Path Item Object 508 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#path-item-object-example 509 | doc.paths[openapiPath][method] = { 510 | summary: "", 511 | tags: [service], 512 | // rawParams: params, 513 | parameters: [...queryParams], 514 | responses: { 515 | // attach common responses 516 | ...this.settings.commonPathItemObjectResponses, 517 | }, 518 | }; 519 | 520 | if (method === "get" || method === "delete") { 521 | doc.paths[openapiPath][method].parameters.push( 522 | ...this.moleculerParamsToQuery(params, addedQueryParams), 523 | ); 524 | } else { 525 | const schemaName = action; 526 | this.createSchemaFromParams(doc, schemaName, params, addedQueryParams); 527 | doc.paths[openapiPath][method].requestBody = { 528 | "content": { 529 | "application/json": { 530 | "schema": { 531 | "$ref": `#/components/schemas/${schemaName}`, 532 | }, 533 | }, 534 | }, 535 | }; 536 | } 537 | 538 | if (this.settings.requestBodyAndResponseBodyAreSameOnMethods.includes(method)) { 539 | doc.paths[openapiPath][method].responses[200] = { 540 | "description": this.settings.requestBodyAndResponseBodyAreSameDescription, 541 | ...doc.paths[openapiPath][method].requestBody, 542 | }; 543 | } 544 | 545 | // if multipart/stream convert fo formData/binary 546 | if (actionType === "multipart" || actionType === "stream") { 547 | doc.paths[openapiPath][method] = { 548 | ...doc.paths[openapiPath][method], 549 | parameters: [...queryParams], 550 | requestBody: this.getFileContentRequestBodyScheme(openapiPath, method, actionType), 551 | }; 552 | } 553 | 554 | // merge values from action 555 | doc.paths[openapiPath][method] = this.mergePathItemObjects( 556 | doc.paths[openapiPath][method], 557 | openapi, 558 | ); 559 | 560 | // merge values which exist in web-api service 561 | // in routes or custom function 562 | doc.paths[openapiPath][method] = this.mergePathItemObjects( 563 | doc.paths[openapiPath][method], 564 | path.openapi, 565 | ); 566 | 567 | // add tags to root of scheme 568 | if (doc.paths[openapiPath][method].tags) { 569 | doc.paths[openapiPath][method].tags.forEach(name => { 570 | this.addTagToDoc(doc, name); 571 | }); 572 | } 573 | 574 | // add components to root of scheme 575 | if (doc.paths[openapiPath][method].components) { 576 | doc.components = this.mergeObjects( 577 | doc.components, 578 | doc.paths[openapiPath][method].components, 579 | ); 580 | delete doc.paths[openapiPath][method].components; 581 | } 582 | 583 | doc.paths[openapiPath][method].summary = ` 584 | ${doc.paths[openapiPath][method].summary} 585 | (${action}) 586 | ${path.autoAliases ? '[autoAlias]' : ''} 587 | `.trim(); 588 | } 589 | } 590 | }, 591 | addTagToDoc(doc, tagName) { 592 | const exist = doc.tags.some(v => v.name === tagName); 593 | if (!exist && tagName) { 594 | doc.tags.push({ 595 | name: tagName, 596 | }); 597 | } 598 | }, 599 | /** 600 | * Convert moleculer params to openapi query params 601 | * @param obj 602 | * @param exclude{Array} 603 | * @returns {[]} 604 | */ 605 | moleculerParamsToQuery(obj = {}, exclude = []) { 606 | const out = []; 607 | 608 | for (const fieldName in obj) { 609 | // skip system field in validator scheme 610 | if (fieldName.startsWith("$$")) { 611 | continue; 612 | } 613 | if (exclude.includes(fieldName)) { 614 | continue; 615 | } 616 | 617 | const node = obj[fieldName]; 618 | 619 | // array nodes 620 | if (Array.isArray(node) || (node.type && node.type === "array")) { 621 | const item = { 622 | "name": `${fieldName}[]`, 623 | "description": node.$$t, 624 | "in": "query", 625 | "schema": { 626 | "type": "array", 627 | "items": this.getTypeAndExample({ 628 | default: node.default ? node.default[0] : undefined, 629 | enum: node.enum, 630 | type: node.items, 631 | }), 632 | unique: node.unique, 633 | minItems: node.length || node.min, 634 | maxItems: node.length || node.max, 635 | }, 636 | }; 637 | out.push(item); 638 | continue; 639 | } 640 | 641 | out.push({ 642 | "in": "query", 643 | "name": fieldName, 644 | "description": node.$$t, 645 | "schema": this.getTypeAndExample(node), 646 | }); 647 | } 648 | 649 | return out; 650 | }, 651 | /** 652 | * Convert moleculer params to openapi definitions(components schemas) 653 | * @param doc 654 | * @param schemeName 655 | * @param obj 656 | * @param exclude{Array} 657 | * @param parentNode 658 | */ 659 | createSchemaFromParams(doc, schemeName, obj, exclude = [], parentNode = {}) { 660 | // Schema model 661 | // https://github.com/OAI/OpenAPI-Specification/blob/b748a884fa4571ffb6dd6ed9a4d20e38e41a878c/versions/3.0.3.md#models-with-polymorphism-support 662 | const def = { 663 | "type": "object", 664 | "properties": {}, 665 | "required": [], 666 | default: parentNode.default, 667 | }; 668 | doc.components.schemas[schemeName] = def; 669 | 670 | for (const fieldName in obj) { 671 | // arr or object desc 672 | if (fieldName === "$$t") { 673 | def.description = obj[fieldName]; 674 | } 675 | 676 | let node = obj[fieldName]; 677 | const nextSchemeName = `${schemeName}.${fieldName}`; 678 | 679 | if ( 680 | // expand $$type: "object|optional" 681 | node && node.$$type && node.$$type.includes('object') 682 | ) { 683 | node = { 684 | type: 'object', 685 | optional: node.$$type.includes('optional'), 686 | $$t: node.$$t || '', 687 | props: { 688 | ...node, 689 | } 690 | } 691 | } else if ( 692 | // skip system field in validator scheme 693 | fieldName.startsWith("$$") 694 | ) { 695 | continue; 696 | } 697 | 698 | if (exclude.includes(fieldName)) { 699 | continue; 700 | } 701 | 702 | // expand from short rule to full 703 | if (!(node && node.type)) { 704 | node = this.expandShortDefinition(node); 705 | } 706 | 707 | // mark as required 708 | if (node.type === "array") { 709 | if (node.min || node.length || node.max) { 710 | def.required.push(fieldName); 711 | def.minItems = node.length || node.min; 712 | def.maxItems = node.length || node.max; 713 | } 714 | def.unique = node.unique; 715 | } else if (!node.optional) { 716 | def.required.push(fieldName); 717 | } 718 | 719 | // common props 720 | def.properties[fieldName] = { 721 | description: node.$$t, 722 | }; 723 | 724 | if (node.type === "object") { 725 | def.properties[fieldName] = { 726 | ...def.properties[fieldName], 727 | $ref: `#/components/schemas/${nextSchemeName}`, 728 | }; 729 | this.createSchemaFromParams(doc, nextSchemeName, node.props, [], node); 730 | continue; 731 | } 732 | 733 | // array with objects 734 | if (node.type === "array" && node.items && node.items.type === "object") { 735 | def.properties[fieldName] = { 736 | ...def.properties[fieldName], 737 | type: "array", 738 | default: node.default, 739 | unique: node.unique, 740 | minItems: node.length || node.min, 741 | maxItems: node.length || node.max, 742 | items: { 743 | $ref: `#/components/schemas/${nextSchemeName}`, 744 | }, 745 | }; 746 | this.createSchemaFromParams(doc, nextSchemeName, node.items.props, [], node); 747 | continue; 748 | } 749 | 750 | // simple array 751 | if (node.type === "array" || node.type === "tuple") { 752 | def.properties[fieldName] = { 753 | ...def.properties[fieldName], 754 | type: "array", 755 | items: this.getTypeAndExample({ 756 | enum: node.enum, 757 | type: node.items && node.items.type ? node.items.type : node.items, 758 | values: node.items && node.items.values ? node.items.values : undefined, 759 | }), 760 | default: node.default, 761 | unique: node.unique, 762 | minItems: node.length || node.min, 763 | maxItems: node.length || node.max, 764 | }; 765 | continue; 766 | } 767 | 768 | // string/number/boolean 769 | def.properties[fieldName] = { 770 | ...def.properties[fieldName], 771 | ...this.getTypeAndExample(node), 772 | }; 773 | } 774 | 775 | // Add example if parent node has example field 776 | if (parentNode.example) { 777 | def.example = parentNode.example; 778 | } 779 | 780 | if (def.required.length === 0) { 781 | delete def.required; 782 | } 783 | }, 784 | getTypeAndExample(node) { 785 | if (!node) { 786 | node = {}; 787 | } 788 | let out = {}; 789 | let nodeType = node.type; 790 | 791 | if (Array.isArray(nodeType)) { 792 | nodeType = (nodeType[0] || "string").toString(); 793 | } 794 | 795 | switch (nodeType) { 796 | case NODE_TYPES.boolean: 797 | out = { 798 | example: false, 799 | type: "boolean", 800 | }; 801 | break; 802 | case NODE_TYPES.number: 803 | out = { 804 | example: null, 805 | type: "number", 806 | }; 807 | break; 808 | case NODE_TYPES.date: 809 | out = { 810 | example: "1998-01-10T13:00:00.000Z", 811 | type: "string", 812 | format: "date-time", 813 | }; 814 | break; 815 | case NODE_TYPES.uuid: 816 | out = { 817 | example: "10ba038e-48da-487b-96e8-8d3b99b6d18a", 818 | type: "string", 819 | format: "uuid", 820 | }; 821 | break; 822 | case NODE_TYPES.email: 823 | out = { 824 | example: "foo@example.com", 825 | type: "string", 826 | format: "email", 827 | }; 828 | break; 829 | case NODE_TYPES.url: 830 | out = { 831 | example: "https://example.com", 832 | type: "string", 833 | format: "uri", 834 | }; 835 | break; 836 | case NODE_TYPES.enum: 837 | out = { 838 | type: "string", 839 | enum: node.values, 840 | example: Array.isArray(node.values) ? node.values[0] : undefined, 841 | }; 842 | break; 843 | default: 844 | out = { 845 | example: "", 846 | type: "string", 847 | }; 848 | break; 849 | } 850 | 851 | if (Array.isArray(node.enum)) { 852 | out.example = node.enum[0]; 853 | out.enum = node.enum; 854 | } 855 | 856 | if (node.default) { 857 | out.default = node.default; 858 | delete out.example; 859 | } 860 | // Check set example from node value 861 | if (node.example) { 862 | out.example = node.example; 863 | } 864 | 865 | out.minLength = node.length || node.min; 866 | out.maxLength = node.length || node.max; 867 | 868 | /** 869 | * by DenisFerrero 870 | * @link https://github.com/grinat/moleculer-auto-openapi/issues/13 871 | */ 872 | if (node.pattern && (node.pattern.length > 0 || node.pattern.source.length > 0)) { 873 | out.pattern = new RegExp(node.pattern).source; 874 | } 875 | 876 | return out; 877 | }, 878 | mergePathItemObjects(orig = {}, toMerge = {}) { 879 | for (const key in toMerge) { 880 | // merge components 881 | if (key === "components") { 882 | orig[key] = this.mergeObjects( 883 | orig[key], 884 | toMerge[key], 885 | ); 886 | continue; 887 | } 888 | 889 | // merge responses 890 | if (key === "responses") { 891 | orig[key] = this.mergeObjects( 892 | orig[key], 893 | toMerge[key], 894 | ); 895 | 896 | // iterate codes 897 | for (const code in orig[key]) { 898 | // remove $ref if exist content 899 | if (orig[key][code] && orig[key][code].content) { 900 | delete orig[key][code].$ref; 901 | } 902 | } 903 | 904 | continue; 905 | } 906 | 907 | // replace non components attributes 908 | orig[key] = toMerge[key]; 909 | } 910 | return orig; 911 | }, 912 | mergeObjects(orig = {}, toMerge = {}) { 913 | for (const key in toMerge) { 914 | orig[key] = { 915 | ...(orig[key] || {}), 916 | ...toMerge[key], 917 | }; 918 | } 919 | return orig; 920 | }, 921 | /** 922 | * replace // to / 923 | * @param path 924 | * @returns {string} 925 | */ 926 | normalizePath(path = "") { 927 | path = path.replace(/\/{2,}/g, "/"); 928 | return path; 929 | }, 930 | /** 931 | * convert /:table to /{table} 932 | * @param url 933 | * @returns {string|string} 934 | */ 935 | formatParamUrl(url = "") { 936 | let start = url.indexOf("/:"); 937 | if (start === -1) { 938 | return url; 939 | } 940 | 941 | const end = url.indexOf("/", ++start); 942 | 943 | if (end === -1) { 944 | return url.slice(0, start) + "{" + url.slice(++start) + "}"; 945 | } 946 | 947 | return this.formatParamUrl(url.slice(0, start) + "{" + url.slice(++start, end) + "}" + url.slice(end)); 948 | }, 949 | /** 950 | * extract params from /{table} 951 | * @param url 952 | * @returns {[]} 953 | */ 954 | extractParamsFromUrl(url = "") { 955 | const params = []; 956 | const added = []; 957 | 958 | const matches = [...this.matchAll(/{(\w+)}/g, url)]; 959 | for (const match of matches) { 960 | const [, name] = match; 961 | 962 | added.push(name); 963 | params.push({ name, "in": "path", "required": true, "schema": { type: "string" } }); 964 | } 965 | 966 | return [params, added]; 967 | }, 968 | /** 969 | * matchAll polyfill for es8 and older 970 | * @param regexPattern 971 | * @param sourceString 972 | * @returns {[]} 973 | */ 974 | matchAll(regexPattern, sourceString) { 975 | const output = []; 976 | let match; 977 | // make sure the pattern has the global flag 978 | const regexPatternWithGlobal = RegExp(regexPattern, "g"); 979 | while ((match = regexPatternWithGlobal.exec(sourceString)) !== null) { 980 | // get rid of the string copy 981 | delete match.input; 982 | // store the match data 983 | output.push(match); 984 | } 985 | return output; 986 | }, 987 | expandShortDefinition(shortDefinition) { 988 | const node = { 989 | type: "string", 990 | }; 991 | 992 | if (typeof shortDefinition !== 'string') { 993 | return node; 994 | } 995 | 996 | let params = shortDefinition.split('|'); 997 | params = params.map(v => v.trim()); 998 | 999 | if (params.includes('optional')) { 1000 | node.optional = true; 1001 | } 1002 | 1003 | for (const type of Object.values(NODE_TYPES)) { 1004 | if (params.includes(type)) { 1005 | node.type = type; 1006 | break; 1007 | } else if (params.includes(`${type}[]`)) { 1008 | const [arrayType,] = node.type.split("["); 1009 | node.type = "array"; 1010 | node.items = arrayType; 1011 | break; 1012 | } 1013 | } 1014 | 1015 | return node; 1016 | }, 1017 | getFileContentRequestBodyScheme(openapiPath, method, actionType) { 1018 | return { 1019 | content: { 1020 | ...(actionType === "multipart" ? { 1021 | "multipart/form-data": { 1022 | schema: { 1023 | type: "object", 1024 | properties: { 1025 | file: { 1026 | type: "array", 1027 | items: { 1028 | type: "string", 1029 | format: "binary" 1030 | }, 1031 | }, 1032 | someField: { 1033 | type: "string" 1034 | } 1035 | }, 1036 | }, 1037 | }, 1038 | } : { 1039 | "application/octet-stream": { 1040 | schema: { 1041 | type: "string", 1042 | format: "binary", 1043 | }, 1044 | }, 1045 | }), 1046 | }, 1047 | } 1048 | } 1049 | }, 1050 | started() { 1051 | this.logger.info(`📜OpenAPI Docs server is available at http://0.0.0.0:${this.settings.port}${this.settings.uiPath}`); 1052 | }, 1053 | }; 1054 | --------------------------------------------------------------------------------