├── .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 |
--------------------------------------------------------------------------------