@visulima/jsdoc-open-api 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +91 -1
- package/package.json +4 -3
- package/src/exported.d.ts +266 -0
- package/src/index.ts +7 -0
- package/src/jsdoc/comments-to-open-api.ts +402 -0
- package/src/options.ts +28 -0
- package/src/parse-file.ts +52 -0
- package/src/spec-builder.ts +61 -0
- package/src/swagger-jsdoc/comments-to-open-api.ts +89 -0
- package/src/swagger-jsdoc/organize-swagger-object.ts +66 -0
- package/src/swagger-jsdoc/utils.ts +43 -0
- package/src/util/customizer.ts +9 -0
- package/src/util/load-definition.ts +22 -0
- package/src/util/object-merge.ts +20 -0
- package/src/util/yaml-loc.ts +17 -0
- package/src/webpack/swagger-compiler-plugin.ts +168 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { Spec } from "comment-parser";
|
|
2
|
+
import { parse as parseComments } from "comment-parser";
|
|
3
|
+
import mergeWith from "lodash.mergewith";
|
|
4
|
+
|
|
5
|
+
import { OpenApiObject, PathsObject } from "../exported";
|
|
6
|
+
import customizer from "../util/customizer";
|
|
7
|
+
|
|
8
|
+
// The security object has a bizare setup...
|
|
9
|
+
function fixSecurityObject(thing: any) {
|
|
10
|
+
if (thing.security) {
|
|
11
|
+
// eslint-disable-next-line no-param-reassign
|
|
12
|
+
thing.security = Object.keys(thing.security).map((s) => {
|
|
13
|
+
return {
|
|
14
|
+
[s]: thing.security[s],
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const primitiveTypes = new Set(["integer", "number", "string", "boolean", "object", "array"]);
|
|
21
|
+
|
|
22
|
+
const formatMap: { [key: string]: string } = {
|
|
23
|
+
int32: "integer",
|
|
24
|
+
int64: "integer",
|
|
25
|
+
float: "number",
|
|
26
|
+
double: "number",
|
|
27
|
+
date: "string",
|
|
28
|
+
"date-time": "string",
|
|
29
|
+
password: "string",
|
|
30
|
+
byte: "string",
|
|
31
|
+
binary: "string",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function parseDescription(tag: any) {
|
|
35
|
+
const rawType = tag.type;
|
|
36
|
+
const isArray = rawType && rawType.endsWith("[]");
|
|
37
|
+
|
|
38
|
+
let parsedType;
|
|
39
|
+
|
|
40
|
+
if (rawType) {
|
|
41
|
+
parsedType = rawType.replace(/\[]$/, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isPrimitive = primitiveTypes.has(parsedType);
|
|
45
|
+
const isFormat = Object.keys(formatMap).includes(parsedType);
|
|
46
|
+
|
|
47
|
+
let defaultValue;
|
|
48
|
+
|
|
49
|
+
if (tag.default) {
|
|
50
|
+
switch (parsedType) {
|
|
51
|
+
case "integer":
|
|
52
|
+
case "int32":
|
|
53
|
+
case "int64": {
|
|
54
|
+
defaultValue = Number.parseInt(tag.default, 10);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "number":
|
|
58
|
+
case "double":
|
|
59
|
+
case "float": {
|
|
60
|
+
defaultValue = Number.parseFloat(tag.default);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
default: {
|
|
64
|
+
defaultValue = tag.default;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let rootType;
|
|
71
|
+
|
|
72
|
+
if (isPrimitive) {
|
|
73
|
+
rootType = { type: parsedType, default: defaultValue };
|
|
74
|
+
} else if (isFormat) {
|
|
75
|
+
rootType = {
|
|
76
|
+
type: formatMap[parsedType],
|
|
77
|
+
format: parsedType,
|
|
78
|
+
default: defaultValue,
|
|
79
|
+
};
|
|
80
|
+
} else {
|
|
81
|
+
rootType = { $ref: `#/components/schemas/${parsedType}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let schema: undefined | object = isArray
|
|
85
|
+
? {
|
|
86
|
+
type: "array",
|
|
87
|
+
items: {
|
|
88
|
+
...rootType,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
...rootType,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (parsedType === undefined) {
|
|
96
|
+
schema = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// remove the optional dash from the description.
|
|
100
|
+
let description = tag.description.trim().replace(/^- /, "");
|
|
101
|
+
|
|
102
|
+
if (description === "") {
|
|
103
|
+
description = undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: tag.name,
|
|
108
|
+
description,
|
|
109
|
+
required: !tag.optional,
|
|
110
|
+
schema,
|
|
111
|
+
rawType,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// @ts-ignore
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
117
|
+
function tagsToObjects(tags: Spec[], verbose?: boolean) {
|
|
118
|
+
return tags.map((tag) => {
|
|
119
|
+
const parsedResponse = parseDescription(tag);
|
|
120
|
+
|
|
121
|
+
// Some ops only have a `description`, merge `name` and `description`
|
|
122
|
+
// for these.
|
|
123
|
+
let nameAndDescription = "";
|
|
124
|
+
|
|
125
|
+
if (parsedResponse.name) {
|
|
126
|
+
nameAndDescription += parsedResponse.name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parsedResponse.description) {
|
|
130
|
+
nameAndDescription += ` ${parsedResponse.description.trim()}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
switch (tag.tag) {
|
|
134
|
+
case "operationId":
|
|
135
|
+
case "summary":
|
|
136
|
+
case "description": {
|
|
137
|
+
return { [tag.tag]: nameAndDescription };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case "deprecated": {
|
|
141
|
+
return { deprecated: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "externalDocs": {
|
|
145
|
+
return {
|
|
146
|
+
externalDocs: {
|
|
147
|
+
url: parsedResponse.name,
|
|
148
|
+
description: parsedResponse.description,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case "server": {
|
|
154
|
+
return {
|
|
155
|
+
servers: [
|
|
156
|
+
{
|
|
157
|
+
url: parsedResponse.name,
|
|
158
|
+
description: parsedResponse.description,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "tag": {
|
|
165
|
+
return { tags: [nameAndDescription] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "cookieParam":
|
|
169
|
+
case "headerParam":
|
|
170
|
+
case "queryParam":
|
|
171
|
+
case "pathParam": {
|
|
172
|
+
return {
|
|
173
|
+
parameters: [
|
|
174
|
+
{
|
|
175
|
+
name: parsedResponse.name,
|
|
176
|
+
in: tag.tag.replace(/Param$/, ""),
|
|
177
|
+
description: parsedResponse.description,
|
|
178
|
+
required: parsedResponse.required,
|
|
179
|
+
schema: parsedResponse.schema,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case "bodyContent": {
|
|
186
|
+
return {
|
|
187
|
+
requestBody: {
|
|
188
|
+
content: {
|
|
189
|
+
[parsedResponse.name.replace("*\\/*", "*/*")]: {
|
|
190
|
+
schema: parsedResponse.schema,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "bodyExample": {
|
|
198
|
+
const [contentType, example] = parsedResponse.name.split(".");
|
|
199
|
+
return {
|
|
200
|
+
requestBody: {
|
|
201
|
+
content: {
|
|
202
|
+
[contentType]: {
|
|
203
|
+
examples: {
|
|
204
|
+
[example]: {
|
|
205
|
+
$ref: `#/components/examples/${parsedResponse.rawType}`,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case "bodyDescription": {
|
|
215
|
+
return { requestBody: { description: nameAndDescription } };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "bodyRequired": {
|
|
219
|
+
return { requestBody: { required: true } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "response": {
|
|
223
|
+
return {
|
|
224
|
+
responses: {
|
|
225
|
+
[parsedResponse.name]: {
|
|
226
|
+
description: parsedResponse.description,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case "callback": {
|
|
233
|
+
return {
|
|
234
|
+
callbacks: {
|
|
235
|
+
[parsedResponse.name]: {
|
|
236
|
+
$ref: `#/components/callbacks/${parsedResponse.rawType}`,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "responseContent": {
|
|
243
|
+
const [status, contentType] = parsedResponse.name.split(".");
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
responses: {
|
|
247
|
+
[status]: {
|
|
248
|
+
content: {
|
|
249
|
+
[contentType]: {
|
|
250
|
+
schema: parsedResponse.schema,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case "responseHeaderComponent": {
|
|
259
|
+
const [status, header] = parsedResponse.name.split(".");
|
|
260
|
+
return {
|
|
261
|
+
responses: {
|
|
262
|
+
[status]: {
|
|
263
|
+
headers: {
|
|
264
|
+
[header]: {
|
|
265
|
+
$ref: `#/components/headers/${parsedResponse.rawType}`,
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "responseHeader": {
|
|
274
|
+
const [status, header] = parsedResponse.name.split(".");
|
|
275
|
+
return {
|
|
276
|
+
responses: {
|
|
277
|
+
[status]: {
|
|
278
|
+
headers: {
|
|
279
|
+
[header]: {
|
|
280
|
+
description: parsedResponse.description,
|
|
281
|
+
schema: parsedResponse.schema,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
case "responseExample": {
|
|
290
|
+
const [status, contentType, example] = parsedResponse.name.split(".");
|
|
291
|
+
return {
|
|
292
|
+
responses: {
|
|
293
|
+
[status]: {
|
|
294
|
+
content: {
|
|
295
|
+
[contentType]: {
|
|
296
|
+
examples: {
|
|
297
|
+
[example]: {
|
|
298
|
+
$ref: `#/components/examples/${parsedResponse.rawType}`,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case "responseLink": {
|
|
309
|
+
const [status, link] = parsedResponse.name.split(".");
|
|
310
|
+
return {
|
|
311
|
+
responses: {
|
|
312
|
+
[status]: {
|
|
313
|
+
links: {
|
|
314
|
+
[link]: {
|
|
315
|
+
$ref: `#/components/links/${parsedResponse.rawType}`,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case "bodyComponent": {
|
|
324
|
+
return {
|
|
325
|
+
requestBody: {
|
|
326
|
+
$ref: `#/components/requestBodies/${parsedResponse.rawType}`,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
case "responseComponent": {
|
|
332
|
+
return {
|
|
333
|
+
responses: {
|
|
334
|
+
[parsedResponse.name]: {
|
|
335
|
+
$ref: `#/components/responses/${parsedResponse.rawType}`,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case "paramComponent": {
|
|
342
|
+
return {
|
|
343
|
+
parameters: [{ $ref: `#/components/parameters/${parsedResponse.rawType}` }],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case "security": {
|
|
348
|
+
const [security, scopeItem] = parsedResponse.name.split(".");
|
|
349
|
+
let scope: string[] = [];
|
|
350
|
+
if (scopeItem) {
|
|
351
|
+
scope = [scopeItem];
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
security: { [security]: scope },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
default: {
|
|
359
|
+
return {};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const commentsToOpenApi = (fileContents: string, verbose?: boolean): { spec: OpenApiObject; loc: number }[] => {
|
|
366
|
+
const openAPIRegex = /^(GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE) \/.*$/;
|
|
367
|
+
|
|
368
|
+
const jsDocumentComments = parseComments(fileContents, { spacing: "preserve" });
|
|
369
|
+
|
|
370
|
+
return jsDocumentComments
|
|
371
|
+
.filter((comment) => openAPIRegex.test(comment.description.trim()))
|
|
372
|
+
.map((comment) => {
|
|
373
|
+
// Line count, number of tags + 1 for description.
|
|
374
|
+
// - Don't count line-breaking due to long descriptions
|
|
375
|
+
// - Don't count empty lines
|
|
376
|
+
const loc = comment.tags.length + 1;
|
|
377
|
+
|
|
378
|
+
const result = mergeWith({}, ...tagsToObjects(comment.tags, verbose), customizer);
|
|
379
|
+
|
|
380
|
+
fixSecurityObject(result);
|
|
381
|
+
|
|
382
|
+
const [method, path]: string[] = comment.description.split(" ");
|
|
383
|
+
|
|
384
|
+
const pathsObject: PathsObject = {
|
|
385
|
+
[(path as string).trim()]: {
|
|
386
|
+
[(method as string).toLowerCase().trim()]: {
|
|
387
|
+
...result,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Purge all undefined objects/arrays.
|
|
393
|
+
const spec = JSON.parse(JSON.stringify({ paths: pathsObject }));
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
spec,
|
|
397
|
+
loc,
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export default commentsToOpenApi;
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
cwd: undefined,
|
|
3
|
+
extension: [".js", ".cjs", ".mjs", ".ts", ".tsx", ".jsx", ".yaml", ".yml"],
|
|
4
|
+
include: ["**"],
|
|
5
|
+
exclude: [
|
|
6
|
+
"coverage/**",
|
|
7
|
+
"packages/*/test{,s}/**",
|
|
8
|
+
"**/*.d.ts",
|
|
9
|
+
"test{,s}/**",
|
|
10
|
+
"test{,-*}.{js,cjs,mjs,ts,tsx,jsx,yaml,yml}",
|
|
11
|
+
"**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx,yaml,yml}",
|
|
12
|
+
"**/__tests__/**",
|
|
13
|
+
|
|
14
|
+
/* Exclude common development tool configuration files */
|
|
15
|
+
"**/{ava,babel,nyc}.config.{js,cjs,mjs}",
|
|
16
|
+
"**/jest.config.{js,cjs,mjs,ts}",
|
|
17
|
+
"**/{karma,rollup,webpack}.config.js",
|
|
18
|
+
"**/.{eslint,mocha}rc.{js,cjs}",
|
|
19
|
+
"**/.{travis,yarnrc}.yml",
|
|
20
|
+
"**/{docker-compose}.yml",
|
|
21
|
+
|
|
22
|
+
// always ignore '**/node_modules/**'
|
|
23
|
+
],
|
|
24
|
+
excludeNodeModules: true,
|
|
25
|
+
verbose: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default DEFAULT_OPTIONS;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "yaml";
|
|
4
|
+
|
|
5
|
+
import { OpenApiObject } from "./exported";
|
|
6
|
+
import yamlLoc from "./util/yaml-loc";
|
|
7
|
+
|
|
8
|
+
const ALLOWED_KEYS = new Set(["openapi", "info", "servers", "security", "tags", "externalDocs", "components", "paths"]);
|
|
9
|
+
|
|
10
|
+
class ParseError extends Error {
|
|
11
|
+
filePath?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parseFile = (
|
|
15
|
+
file: string,
|
|
16
|
+
commentsToOpenApi: (fileContent: string, verbose?: boolean) => { spec: OpenApiObject; loc: number }[],
|
|
17
|
+
verbose?: boolean,
|
|
18
|
+
): { spec: OpenApiObject; loc: number }[] => {
|
|
19
|
+
const fileContent = fs.readFileSync(file, { encoding: "utf8" });
|
|
20
|
+
const extension = path.extname(file);
|
|
21
|
+
|
|
22
|
+
if (extension === ".yaml" || extension === ".yml") {
|
|
23
|
+
const spec = yaml.parse(fileContent);
|
|
24
|
+
const invalidKeys = Object.keys(spec).filter((key) => !ALLOWED_KEYS.has(key));
|
|
25
|
+
|
|
26
|
+
if (invalidKeys.length > 0) {
|
|
27
|
+
const error = new ParseError(`Unexpected keys: ${invalidKeys.join(", ")}`);
|
|
28
|
+
|
|
29
|
+
error.filePath = file;
|
|
30
|
+
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (Object.keys(spec).some((key) => ALLOWED_KEYS.has(key))) {
|
|
35
|
+
const loc = yamlLoc(fileContent);
|
|
36
|
+
|
|
37
|
+
return [{ spec, loc }];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return commentsToOpenApi(fileContent, verbose);
|
|
45
|
+
} catch (error: any) {
|
|
46
|
+
error.filePath = file;
|
|
47
|
+
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default parseFile;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseDefinition,
|
|
3
|
+
ComponentsObject,
|
|
4
|
+
ExternalDocumentationObject,
|
|
5
|
+
InfoObject,
|
|
6
|
+
OpenApiObject,
|
|
7
|
+
PathsObject,
|
|
8
|
+
SecurityRequirementObject,
|
|
9
|
+
ServerObject,
|
|
10
|
+
TagObject,
|
|
11
|
+
} from "./exported";
|
|
12
|
+
import objectMerge from "./util/object-merge";
|
|
13
|
+
|
|
14
|
+
class SpecBuilder implements OpenApiObject {
|
|
15
|
+
openapi: string;
|
|
16
|
+
|
|
17
|
+
info: InfoObject;
|
|
18
|
+
|
|
19
|
+
servers?: ServerObject[];
|
|
20
|
+
|
|
21
|
+
paths: PathsObject;
|
|
22
|
+
|
|
23
|
+
components?: ComponentsObject;
|
|
24
|
+
|
|
25
|
+
security?: SecurityRequirementObject[];
|
|
26
|
+
|
|
27
|
+
tags?: TagObject[];
|
|
28
|
+
|
|
29
|
+
externalDocs?: ExternalDocumentationObject;
|
|
30
|
+
|
|
31
|
+
constructor(baseDefinition: BaseDefinition) {
|
|
32
|
+
this.openapi = baseDefinition.openapi;
|
|
33
|
+
this.info = baseDefinition.info;
|
|
34
|
+
this.servers = baseDefinition.servers;
|
|
35
|
+
this.paths = baseDefinition.paths || {};
|
|
36
|
+
this.components = baseDefinition.components;
|
|
37
|
+
this.security = baseDefinition.security;
|
|
38
|
+
this.tags = baseDefinition.tags;
|
|
39
|
+
this.externalDocs = baseDefinition.externalDocs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
addData(parsedFile: OpenApiObject[]) {
|
|
43
|
+
parsedFile.forEach((file) => {
|
|
44
|
+
const { paths, components, ...rest } = file;
|
|
45
|
+
|
|
46
|
+
// only merge paths and components
|
|
47
|
+
objectMerge(this, {
|
|
48
|
+
paths: paths || {},
|
|
49
|
+
components: components || {},
|
|
50
|
+
} as OpenApiObject);
|
|
51
|
+
|
|
52
|
+
// overwrite everything else:
|
|
53
|
+
Object.entries(rest).forEach(([key, value]) => {
|
|
54
|
+
// @ts-ignore
|
|
55
|
+
this[key as keyof OpenApiObject] = value;
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default SpecBuilder;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Spec } from "comment-parser";
|
|
2
|
+
import { parse as parseComments } from "comment-parser";
|
|
3
|
+
import mergeWith from "lodash.mergewith";
|
|
4
|
+
import yaml, { YAMLError } from "yaml";
|
|
5
|
+
|
|
6
|
+
import { OpenApiObject } from "../exported";
|
|
7
|
+
import customizer from "../util/customizer";
|
|
8
|
+
import organizeSwaggerObject from "./organize-swagger-object";
|
|
9
|
+
import { getSwaggerVersionFromSpec, hasEmptyProperty } from "./utils";
|
|
10
|
+
|
|
11
|
+
const specificationTemplate = {
|
|
12
|
+
v2: ["paths", "definitions", "responses", "parameters", "securityDefinitions"],
|
|
13
|
+
v3: ["paths", "definitions", "responses", "parameters", "securityDefinitions", "components"],
|
|
14
|
+
v4: ["components", "channels"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ExtendedYAMLError = YAMLError & { annotation?: string };
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
20
|
+
const tagsToObjects = (specs: Spec[], verbose?: boolean) => specs.map((spec: Spec) => {
|
|
21
|
+
if ((spec.tag === "openapi" || spec.tag === "swagger" || spec.tag === "asyncapi") && spec.description !== "") {
|
|
22
|
+
const parsed = yaml.parseDocument(spec.description);
|
|
23
|
+
|
|
24
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
25
|
+
parsed.errors.map<ExtendedYAMLError>((error) => {
|
|
26
|
+
const newError: ExtendedYAMLError = error;
|
|
27
|
+
|
|
28
|
+
newError.annotation = spec.description;
|
|
29
|
+
|
|
30
|
+
return newError;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let errorString = "Error parsing YAML in @openapi spec:";
|
|
34
|
+
|
|
35
|
+
errorString += verbose
|
|
36
|
+
? (parsed.errors as ExtendedYAMLError[])
|
|
37
|
+
.map((error) => `${error.toString()}\nImbedded within:\n\`\`\`\n ${error?.annotation?.replace(/\n/g, "\n ")}\n\`\`\``)
|
|
38
|
+
.join("\n")
|
|
39
|
+
: parsed.errors.map((error) => error.toString()).join("\n");
|
|
40
|
+
|
|
41
|
+
throw new Error(errorString);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsedDocument = parsed.toJSON();
|
|
45
|
+
const specification: Record<string, any> = {
|
|
46
|
+
tags: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
specificationTemplate[getSwaggerVersionFromSpec(spec)].forEach((property) => {
|
|
50
|
+
specification[property] = specification[property] || {};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Object.keys(parsedDocument).forEach((property) => {
|
|
54
|
+
organizeSwaggerObject(specification, parsedDocument, property);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return specification;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const commentsToOpenApi = (fileContents: string, verbose?: boolean): { spec: OpenApiObject; loc: number }[] => {
|
|
64
|
+
const jsDocumentComments = parseComments(fileContents, { spacing: "preserve" });
|
|
65
|
+
|
|
66
|
+
return jsDocumentComments.map((comment) => {
|
|
67
|
+
// Line count, number of tags + 1 for description.
|
|
68
|
+
// - Don't count line-breaking due to long descriptions
|
|
69
|
+
// - Don't count empty lines
|
|
70
|
+
const loc = comment.tags.length + 1;
|
|
71
|
+
const result = mergeWith({}, ...tagsToObjects(comment.tags, verbose), customizer);
|
|
72
|
+
|
|
73
|
+
["definitions", "responses", "parameters", "securityDefinitions", "components", "tags"].forEach((property) => {
|
|
74
|
+
if (typeof result[property] !== "undefined" && hasEmptyProperty(result[property])) {
|
|
75
|
+
delete result[property];
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Purge all undefined objects/arrays.
|
|
80
|
+
const spec = JSON.parse(JSON.stringify(result));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
spec,
|
|
84
|
+
loc,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default commentsToOpenApi;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isTagPresentInTags, mergeDeep } from "./utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} swaggerObject
|
|
5
|
+
* @param {object} annotation
|
|
6
|
+
* @param {string} property
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line radar/no-duplicate-string
|
|
9
|
+
const organizeSwaggerObject = (swaggerObject: Record<string, any>, annotation: Record<string, any>, property: string) => {
|
|
10
|
+
// Root property on purpose.
|
|
11
|
+
// eslint-disable-next-line no-secrets/no-secrets
|
|
12
|
+
// @see https://github.com/OAI/OpenAPI-Specification/blob/master/proposals/002_Webhooks.md#proposed-solution
|
|
13
|
+
if (property === "x-webhooks") {
|
|
14
|
+
// eslint-disable-next-line no-param-reassign
|
|
15
|
+
swaggerObject[property] = annotation[property];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Other extensions can be in varying places depending on different vendors and opinions.
|
|
19
|
+
// The following return makes it so that they are not put in `paths` in the last case.
|
|
20
|
+
// New specific extensions will need to be handled on case-by-case if to be included in `paths`.
|
|
21
|
+
if (property.startsWith("x-")) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const commonProperties = [
|
|
26
|
+
"components",
|
|
27
|
+
"consumes",
|
|
28
|
+
"produces",
|
|
29
|
+
"paths",
|
|
30
|
+
"schemas",
|
|
31
|
+
"securityDefinitions",
|
|
32
|
+
"responses",
|
|
33
|
+
"parameters",
|
|
34
|
+
"definitions",
|
|
35
|
+
"channels",
|
|
36
|
+
];
|
|
37
|
+
if (commonProperties.includes(property)) {
|
|
38
|
+
Object.keys(annotation[property]).forEach((definition) => {
|
|
39
|
+
// eslint-disable-next-line no-param-reassign
|
|
40
|
+
swaggerObject[property][definition] = mergeDeep(swaggerObject[property][definition], annotation[property][definition]);
|
|
41
|
+
});
|
|
42
|
+
} else if (property === "tags") {
|
|
43
|
+
const { tags } = annotation;
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(tags)) {
|
|
46
|
+
tags.forEach((tag) => {
|
|
47
|
+
if (!isTagPresentInTags(tag, swaggerObject.tags)) {
|
|
48
|
+
swaggerObject.tags.push(tag);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else if (!isTagPresentInTags(tags, swaggerObject.tags)) {
|
|
52
|
+
swaggerObject.tags.push(tags);
|
|
53
|
+
}
|
|
54
|
+
} else if (property === "security") {
|
|
55
|
+
const { security } = annotation;
|
|
56
|
+
|
|
57
|
+
// eslint-disable-next-line no-param-reassign
|
|
58
|
+
swaggerObject.security = security;
|
|
59
|
+
} else if (property.startsWith("/")) {
|
|
60
|
+
// Paths which are not defined as "paths" property, starting with a slash "/"
|
|
61
|
+
// eslint-disable-next-line no-param-reassign
|
|
62
|
+
swaggerObject.paths[property] = mergeDeep(swaggerObject.paths[property], annotation[property]);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default organizeSwaggerObject;
|