@ygorazambuja/sauron 1.0.0
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/README.md +570 -0
- package/bin.ts +15 -0
- package/docs/plugins.md +154 -0
- package/package.json +59 -0
- package/src/cli/args.ts +196 -0
- package/src/cli/config.ts +228 -0
- package/src/cli/main.ts +276 -0
- package/src/cli/project.ts +113 -0
- package/src/cli/types.ts +22 -0
- package/src/generators/angular.ts +76 -0
- package/src/generators/fetch.ts +994 -0
- package/src/generators/missing-definitions.ts +776 -0
- package/src/generators/type-coverage.ts +938 -0
- package/src/index.ts +47 -0
- package/src/plugins/builtin/angular.ts +146 -0
- package/src/plugins/builtin/axios.ts +307 -0
- package/src/plugins/builtin/fetch.ts +140 -0
- package/src/plugins/registry.ts +84 -0
- package/src/plugins/runner.ts +174 -0
- package/src/plugins/types.ts +97 -0
- package/src/schemas/swagger.ts +134 -0
- package/src/utils/index.ts +1599 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { SwaggerOrOpenAPISchema } from "../schemas/swagger";
|
|
3
|
+
import type {
|
|
4
|
+
OpenApiOperation,
|
|
5
|
+
OpenApiPath,
|
|
6
|
+
OperationTypeInfo,
|
|
7
|
+
OperationTypeMap,
|
|
8
|
+
} from "../utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Type coverage location.
|
|
12
|
+
*/
|
|
13
|
+
export type TypeCoverageLocation =
|
|
14
|
+
| "path.parameter"
|
|
15
|
+
| "query.parameter"
|
|
16
|
+
| "request.body"
|
|
17
|
+
| "response.body";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Type coverage issue.
|
|
21
|
+
*/
|
|
22
|
+
export type TypeCoverageIssue = {
|
|
23
|
+
path: string;
|
|
24
|
+
method: string;
|
|
25
|
+
location: TypeCoverageLocation;
|
|
26
|
+
field?: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Type coverage metrics.
|
|
32
|
+
*/
|
|
33
|
+
export type TypeCoverageMetrics = {
|
|
34
|
+
total: number;
|
|
35
|
+
typed: number;
|
|
36
|
+
untyped: number;
|
|
37
|
+
coveragePercentage: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Type coverage operation summary.
|
|
42
|
+
*/
|
|
43
|
+
export type TypeCoverageOperationSummary = {
|
|
44
|
+
path: string;
|
|
45
|
+
method: string;
|
|
46
|
+
total: number;
|
|
47
|
+
typed: number;
|
|
48
|
+
untyped: number;
|
|
49
|
+
coveragePercentage: number;
|
|
50
|
+
untypedLocations: TypeCoverageLocation[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Type coverage report.
|
|
55
|
+
*/
|
|
56
|
+
export type TypeCoverageReport = {
|
|
57
|
+
generatedAt: string;
|
|
58
|
+
totalOperations: number;
|
|
59
|
+
totals: TypeCoverageMetrics;
|
|
60
|
+
summary: {
|
|
61
|
+
pathParameters: TypeCoverageMetrics;
|
|
62
|
+
queryParameters: TypeCoverageMetrics;
|
|
63
|
+
requestBodies: TypeCoverageMetrics;
|
|
64
|
+
responseBodies: TypeCoverageMetrics;
|
|
65
|
+
};
|
|
66
|
+
operations: TypeCoverageOperationSummary[];
|
|
67
|
+
issues: TypeCoverageIssue[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type CoverageEntry = {
|
|
71
|
+
path: string;
|
|
72
|
+
method: string;
|
|
73
|
+
location: TypeCoverageLocation;
|
|
74
|
+
field?: string;
|
|
75
|
+
isTyped: boolean;
|
|
76
|
+
reason?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create type coverage report.
|
|
81
|
+
* @param data Input parameter `data`.
|
|
82
|
+
* @param operationTypes Input parameter `operationTypes`.
|
|
83
|
+
* @returns Create type coverage report output as `TypeCoverageReport`.
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const result = createTypeCoverageReport({ paths: {} } as never, {});
|
|
87
|
+
* // result: TypeCoverageReport
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function createTypeCoverageReport(
|
|
91
|
+
data: z.infer<typeof SwaggerOrOpenAPISchema>,
|
|
92
|
+
operationTypes?: OperationTypeMap,
|
|
93
|
+
): TypeCoverageReport {
|
|
94
|
+
const entries = collectCoverageEntries(data, operationTypes);
|
|
95
|
+
const operations = buildOperationSummary(entries);
|
|
96
|
+
const issues = buildTypeCoverageIssues(entries);
|
|
97
|
+
return {
|
|
98
|
+
generatedAt: new Date().toISOString(),
|
|
99
|
+
totalOperations: operations.length,
|
|
100
|
+
totals: buildMetrics(entries),
|
|
101
|
+
summary: {
|
|
102
|
+
pathParameters: buildMetricsByLocation(entries, "path.parameter"),
|
|
103
|
+
queryParameters: buildMetricsByLocation(entries, "query.parameter"),
|
|
104
|
+
requestBodies: buildMetricsByLocation(entries, "request.body"),
|
|
105
|
+
responseBodies: buildMetricsByLocation(entries, "response.body"),
|
|
106
|
+
},
|
|
107
|
+
operations,
|
|
108
|
+
issues,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate type coverage report file.
|
|
114
|
+
* @param report Input parameter `report`.
|
|
115
|
+
* @returns Generate type coverage report file output as `string`.
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* const result = generateTypeCoverageReportFile({
|
|
119
|
+
* generatedAt: "",
|
|
120
|
+
* totalOperations: 0,
|
|
121
|
+
* totals: { total: 0, typed: 0, untyped: 0, coveragePercentage: 100 },
|
|
122
|
+
* summary: {
|
|
123
|
+
* pathParameters: { total: 0, typed: 0, untyped: 0, coveragePercentage: 100 },
|
|
124
|
+
* queryParameters: { total: 0, typed: 0, untyped: 0, coveragePercentage: 100 },
|
|
125
|
+
* requestBodies: { total: 0, typed: 0, untyped: 0, coveragePercentage: 100 },
|
|
126
|
+
* responseBodies: { total: 0, typed: 0, untyped: 0, coveragePercentage: 100 },
|
|
127
|
+
* },
|
|
128
|
+
* operations: [],
|
|
129
|
+
* issues: [],
|
|
130
|
+
* });
|
|
131
|
+
* // result: string
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function generateTypeCoverageReportFile(
|
|
135
|
+
report: TypeCoverageReport,
|
|
136
|
+
): string {
|
|
137
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Collect coverage entries.
|
|
142
|
+
* @param data Input parameter `data`.
|
|
143
|
+
* @param operationTypes Input parameter `operationTypes`.
|
|
144
|
+
* @returns Collect coverage entries output as `CoverageEntry[]`.
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* const result = collectCoverageEntries({ paths: {} } as never, {});
|
|
148
|
+
* // result: CoverageEntry[]
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function collectCoverageEntries(
|
|
152
|
+
data: z.infer<typeof SwaggerOrOpenAPISchema>,
|
|
153
|
+
operationTypes?: OperationTypeMap,
|
|
154
|
+
): CoverageEntry[] {
|
|
155
|
+
if (!data.paths) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entries: CoverageEntry[] = [];
|
|
160
|
+
const httpMethods = [
|
|
161
|
+
"get",
|
|
162
|
+
"post",
|
|
163
|
+
"put",
|
|
164
|
+
"delete",
|
|
165
|
+
"patch",
|
|
166
|
+
"head",
|
|
167
|
+
"options",
|
|
168
|
+
] as const;
|
|
169
|
+
|
|
170
|
+
for (const [path, pathItem] of Object.entries(data.paths)) {
|
|
171
|
+
for (const httpMethod of httpMethods) {
|
|
172
|
+
const operation = (pathItem as OpenApiPath)[httpMethod];
|
|
173
|
+
if (!operation) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const typeInfo = operationTypes?.[path]?.[httpMethod];
|
|
178
|
+
const operationEntries = collectOperationEntries(
|
|
179
|
+
path,
|
|
180
|
+
httpMethod,
|
|
181
|
+
operation,
|
|
182
|
+
typeInfo,
|
|
183
|
+
);
|
|
184
|
+
entries.push(...operationEntries);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return entries;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Collect operation coverage entries.
|
|
193
|
+
* @param path Input parameter `path`.
|
|
194
|
+
* @param httpMethod Input parameter `httpMethod`.
|
|
195
|
+
* @param operation Input parameter `operation`.
|
|
196
|
+
* @param typeInfo Input parameter `typeInfo`.
|
|
197
|
+
* @returns Collect operation coverage entries output as `CoverageEntry[]`.
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* const result = collectOperationEntries("/users", "get", {}, undefined);
|
|
201
|
+
* // result: CoverageEntry[]
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
function collectOperationEntries(
|
|
205
|
+
path: string,
|
|
206
|
+
httpMethod: string,
|
|
207
|
+
operation: OpenApiOperation,
|
|
208
|
+
typeInfo?: OperationTypeInfo,
|
|
209
|
+
): CoverageEntry[] {
|
|
210
|
+
const entries: CoverageEntry[] = [];
|
|
211
|
+
const parameterEntries = collectParameterEntries(path, httpMethod, operation);
|
|
212
|
+
entries.push(...parameterEntries);
|
|
213
|
+
|
|
214
|
+
const requestEntry = collectRequestBodyEntry(path, httpMethod, operation, typeInfo);
|
|
215
|
+
if (requestEntry) {
|
|
216
|
+
entries.push(requestEntry);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const responseEntry = collectResponseBodyEntry(path, httpMethod, operation, typeInfo);
|
|
220
|
+
entries.push(responseEntry);
|
|
221
|
+
|
|
222
|
+
return entries;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Collect parameter coverage entries.
|
|
227
|
+
* @param path Input parameter `path`.
|
|
228
|
+
* @param httpMethod Input parameter `httpMethod`.
|
|
229
|
+
* @param operation Input parameter `operation`.
|
|
230
|
+
* @returns Collect parameter coverage entries output as `CoverageEntry[]`.
|
|
231
|
+
* @example
|
|
232
|
+
* ```ts
|
|
233
|
+
* const result = collectParameterEntries("/users/{id}", "get", {});
|
|
234
|
+
* // result: CoverageEntry[]
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
function collectParameterEntries(
|
|
238
|
+
path: string,
|
|
239
|
+
httpMethod: string,
|
|
240
|
+
operation: OpenApiOperation,
|
|
241
|
+
): CoverageEntry[] {
|
|
242
|
+
const entries: CoverageEntry[] = [];
|
|
243
|
+
const method = httpMethod.toUpperCase();
|
|
244
|
+
const parameters = Array.isArray(operation.parameters)
|
|
245
|
+
? operation.parameters
|
|
246
|
+
: [];
|
|
247
|
+
|
|
248
|
+
const pathPlaceholders = getPathPlaceholders(path);
|
|
249
|
+
for (const placeholder of pathPlaceholders) {
|
|
250
|
+
const pathParameter = parameters.find(
|
|
251
|
+
(parameter) => parameter.in === "path" && parameter.name === placeholder,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (!pathParameter) {
|
|
255
|
+
entries.push({
|
|
256
|
+
path,
|
|
257
|
+
method,
|
|
258
|
+
location: "path.parameter",
|
|
259
|
+
field: placeholder,
|
|
260
|
+
isTyped: false,
|
|
261
|
+
reason: "Path parameter is missing from operation.parameters.",
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isTyped = !isSchemaAny(pathParameter.schema);
|
|
267
|
+
entries.push({
|
|
268
|
+
path,
|
|
269
|
+
method,
|
|
270
|
+
location: "path.parameter",
|
|
271
|
+
field: placeholder,
|
|
272
|
+
isTyped,
|
|
273
|
+
reason: isTyped
|
|
274
|
+
? undefined
|
|
275
|
+
: "Path parameter schema is missing or unresolved.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const parameter of parameters) {
|
|
280
|
+
if (parameter.in !== "query") {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const isTyped = !isSchemaAny(parameter.schema);
|
|
285
|
+
entries.push({
|
|
286
|
+
path,
|
|
287
|
+
method,
|
|
288
|
+
location: "query.parameter",
|
|
289
|
+
field: parameter.name,
|
|
290
|
+
isTyped,
|
|
291
|
+
reason: isTyped
|
|
292
|
+
? undefined
|
|
293
|
+
: "Query parameter schema is missing or unresolved.",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return entries;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Collect request body coverage entry.
|
|
302
|
+
* @param path Input parameter `path`.
|
|
303
|
+
* @param httpMethod Input parameter `httpMethod`.
|
|
304
|
+
* @param operation Input parameter `operation`.
|
|
305
|
+
* @param typeInfo Input parameter `typeInfo`.
|
|
306
|
+
* @returns Collect request body coverage entry output as `CoverageEntry | undefined`.
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* const result = collectRequestBodyEntry("/users", "post", {}, undefined);
|
|
310
|
+
* // result: CoverageEntry | undefined
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
function collectRequestBodyEntry(
|
|
314
|
+
path: string,
|
|
315
|
+
httpMethod: string,
|
|
316
|
+
operation: OpenApiOperation,
|
|
317
|
+
typeInfo?: OperationTypeInfo,
|
|
318
|
+
): CoverageEntry | undefined {
|
|
319
|
+
if (!operation.requestBody) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const requestType = typeInfo?.requestType ?? extractRequestType(operation) ?? "any";
|
|
324
|
+
const isTyped = !containsAnyType(requestType);
|
|
325
|
+
const schema = getPreferredContentSchema(operation.requestBody.content);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
path,
|
|
329
|
+
method: httpMethod.toUpperCase(),
|
|
330
|
+
location: "request.body",
|
|
331
|
+
isTyped,
|
|
332
|
+
reason: resolveRequestBodyReason(schema, isTyped),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve request body reason.
|
|
338
|
+
* @param schema Input parameter `schema`.
|
|
339
|
+
* @param isTyped Input parameter `isTyped`.
|
|
340
|
+
* @returns Resolve request body reason output as `string | undefined`.
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* const result = resolveRequestBodyReason(undefined, false);
|
|
344
|
+
* // result: string | undefined
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
function resolveRequestBodyReason(
|
|
348
|
+
schema: Record<string, unknown> | undefined,
|
|
349
|
+
isTyped: boolean,
|
|
350
|
+
): string | undefined {
|
|
351
|
+
if (isTyped) {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
if (!schema) {
|
|
355
|
+
return "Request body exists but no schema was documented in content.";
|
|
356
|
+
}
|
|
357
|
+
return "Request body schema could not be resolved to a concrete model type.";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Collect response body coverage entry.
|
|
362
|
+
* @param path Input parameter `path`.
|
|
363
|
+
* @param httpMethod Input parameter `httpMethod`.
|
|
364
|
+
* @param operation Input parameter `operation`.
|
|
365
|
+
* @param typeInfo Input parameter `typeInfo`.
|
|
366
|
+
* @returns Collect response body coverage entry output as `CoverageEntry`.
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* const result = collectResponseBodyEntry("/users", "get", {}, undefined);
|
|
370
|
+
* // result: CoverageEntry
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
function collectResponseBodyEntry(
|
|
374
|
+
path: string,
|
|
375
|
+
httpMethod: string,
|
|
376
|
+
operation: OpenApiOperation,
|
|
377
|
+
typeInfo?: OperationTypeInfo,
|
|
378
|
+
): CoverageEntry {
|
|
379
|
+
const requestType = typeInfo?.requestType ?? extractRequestType(operation) ?? "any";
|
|
380
|
+
let responseType = typeInfo?.responseType ?? extractResponseType(operation);
|
|
381
|
+
if (!responseType) {
|
|
382
|
+
responseType = "any";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const isMutatingMethod = ["post", "put", "patch"].includes(httpMethod);
|
|
386
|
+
if (containsAnyType(responseType) && isMutatingMethod && !containsAnyType(requestType)) {
|
|
387
|
+
responseType = requestType;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const isTyped = !containsAnyType(responseType);
|
|
391
|
+
const successResponse = getSuccessResponse(operation);
|
|
392
|
+
const schema = getPreferredContentSchema(successResponse?.content);
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
path,
|
|
396
|
+
method: httpMethod.toUpperCase(),
|
|
397
|
+
location: "response.body",
|
|
398
|
+
isTyped,
|
|
399
|
+
reason: resolveResponseBodyReason(successResponse, schema, isTyped),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resolve response body reason.
|
|
405
|
+
* @param successResponse Input parameter `successResponse`.
|
|
406
|
+
* @param schema Input parameter `schema`.
|
|
407
|
+
* @param isTyped Input parameter `isTyped`.
|
|
408
|
+
* @returns Resolve response body reason output as `string | undefined`.
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* const result = resolveResponseBodyReason(undefined, undefined, false);
|
|
412
|
+
* // result: string | undefined
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
function resolveResponseBodyReason(
|
|
416
|
+
successResponse:
|
|
417
|
+
| { content?: Record<string, { schema: Record<string, unknown> }> }
|
|
418
|
+
| undefined,
|
|
419
|
+
schema: Record<string, unknown> | undefined,
|
|
420
|
+
isTyped: boolean,
|
|
421
|
+
): string | undefined {
|
|
422
|
+
if (isTyped) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
if (!successResponse) {
|
|
426
|
+
return "No 2xx success response is documented for this operation.";
|
|
427
|
+
}
|
|
428
|
+
if (!schema) {
|
|
429
|
+
return "Success response exists but no response schema was documented in content.";
|
|
430
|
+
}
|
|
431
|
+
return "Response schema could not be resolved to a concrete model type.";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Build type coverage issues.
|
|
436
|
+
* @param entries Input parameter `entries`.
|
|
437
|
+
* @returns Build type coverage issues output as `TypeCoverageIssue[]`.
|
|
438
|
+
* @example
|
|
439
|
+
* ```ts
|
|
440
|
+
* const result = buildTypeCoverageIssues([]);
|
|
441
|
+
* // result: TypeCoverageIssue[]
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
function buildTypeCoverageIssues(entries: CoverageEntry[]): TypeCoverageIssue[] {
|
|
445
|
+
return entries
|
|
446
|
+
.filter((entry) => !entry.isTyped)
|
|
447
|
+
.map((entry) => ({
|
|
448
|
+
path: entry.path,
|
|
449
|
+
method: entry.method,
|
|
450
|
+
location: entry.location,
|
|
451
|
+
field: entry.field,
|
|
452
|
+
reason: entry.reason ?? "Type could not be resolved.",
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build operation summary.
|
|
458
|
+
* @param entries Input parameter `entries`.
|
|
459
|
+
* @returns Build operation summary output as `TypeCoverageOperationSummary[]`.
|
|
460
|
+
* @example
|
|
461
|
+
* ```ts
|
|
462
|
+
* const result = buildOperationSummary([]);
|
|
463
|
+
* // result: TypeCoverageOperationSummary[]
|
|
464
|
+
* ```
|
|
465
|
+
*/
|
|
466
|
+
function buildOperationSummary(
|
|
467
|
+
entries: CoverageEntry[],
|
|
468
|
+
): TypeCoverageOperationSummary[] {
|
|
469
|
+
const byOperation = new Map<string, CoverageEntry[]>();
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
const key = `${entry.method} ${entry.path}`;
|
|
472
|
+
const list = byOperation.get(key);
|
|
473
|
+
if (list) {
|
|
474
|
+
list.push(entry);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
byOperation.set(key, [entry]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const operations: TypeCoverageOperationSummary[] = [];
|
|
481
|
+
for (const [key, operationEntries] of byOperation) {
|
|
482
|
+
const [method, ...pathParts] = key.split(" ");
|
|
483
|
+
const path = pathParts.join(" ");
|
|
484
|
+
const total = operationEntries.length;
|
|
485
|
+
const typed = operationEntries.filter((entry) => entry.isTyped).length;
|
|
486
|
+
const untyped = total - typed;
|
|
487
|
+
const untypedLocations = operationEntries
|
|
488
|
+
.filter((entry) => !entry.isTyped)
|
|
489
|
+
.map((entry) => entry.location);
|
|
490
|
+
|
|
491
|
+
operations.push({
|
|
492
|
+
path,
|
|
493
|
+
method,
|
|
494
|
+
total,
|
|
495
|
+
typed,
|
|
496
|
+
untyped,
|
|
497
|
+
coveragePercentage: calculateCoveragePercentage(typed, total),
|
|
498
|
+
untypedLocations,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return operations;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Build metrics by location.
|
|
507
|
+
* @param entries Input parameter `entries`.
|
|
508
|
+
* @param location Input parameter `location`.
|
|
509
|
+
* @returns Build metrics by location output as `TypeCoverageMetrics`.
|
|
510
|
+
* @example
|
|
511
|
+
* ```ts
|
|
512
|
+
* const result = buildMetricsByLocation([], "query.parameter");
|
|
513
|
+
* // result: TypeCoverageMetrics
|
|
514
|
+
* ```
|
|
515
|
+
*/
|
|
516
|
+
function buildMetricsByLocation(
|
|
517
|
+
entries: CoverageEntry[],
|
|
518
|
+
location: TypeCoverageLocation,
|
|
519
|
+
): TypeCoverageMetrics {
|
|
520
|
+
const filtered = entries.filter((entry) => entry.location === location);
|
|
521
|
+
return buildMetrics(filtered);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Build metrics.
|
|
526
|
+
* @param entries Input parameter `entries`.
|
|
527
|
+
* @returns Build metrics output as `TypeCoverageMetrics`.
|
|
528
|
+
* @example
|
|
529
|
+
* ```ts
|
|
530
|
+
* const result = buildMetrics([]);
|
|
531
|
+
* // result: TypeCoverageMetrics
|
|
532
|
+
* ```
|
|
533
|
+
*/
|
|
534
|
+
function buildMetrics(entries: CoverageEntry[]): TypeCoverageMetrics {
|
|
535
|
+
const total = entries.length;
|
|
536
|
+
const typed = entries.filter((entry) => entry.isTyped).length;
|
|
537
|
+
const untyped = total - typed;
|
|
538
|
+
return {
|
|
539
|
+
total,
|
|
540
|
+
typed,
|
|
541
|
+
untyped,
|
|
542
|
+
coveragePercentage: calculateCoveragePercentage(typed, total),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Calculate coverage percentage.
|
|
548
|
+
* @param typed Input parameter `typed`.
|
|
549
|
+
* @param total Input parameter `total`.
|
|
550
|
+
* @returns Calculate coverage percentage output as `number`.
|
|
551
|
+
* @example
|
|
552
|
+
* ```ts
|
|
553
|
+
* const result = calculateCoveragePercentage(1, 2);
|
|
554
|
+
* // result: number
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
function calculateCoveragePercentage(typed: number, total: number): number {
|
|
558
|
+
if (total === 0) {
|
|
559
|
+
return 100;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const percentage = (typed / total) * 100;
|
|
563
|
+
return Number(percentage.toFixed(2));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Get path placeholders.
|
|
568
|
+
* @param path Input parameter `path`.
|
|
569
|
+
* @returns Get path placeholders output as `string[]`.
|
|
570
|
+
* @example
|
|
571
|
+
* ```ts
|
|
572
|
+
* const result = getPathPlaceholders("/users/{id}");
|
|
573
|
+
* // result: string[]
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
function getPathPlaceholders(path: string): string[] {
|
|
577
|
+
const matches = path.match(/\{([^}]+)\}/g);
|
|
578
|
+
if (!matches) {
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return matches.map((match) => match.slice(1, -1));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Get preferred content schema.
|
|
587
|
+
* @param content Input parameter `content`.
|
|
588
|
+
* @returns Get preferred content schema output as `Record<string, unknown> | undefined`.
|
|
589
|
+
* @example
|
|
590
|
+
* ```ts
|
|
591
|
+
* const result = getPreferredContentSchema(undefined);
|
|
592
|
+
* // result: Record<string, unknown> | undefined
|
|
593
|
+
* ```
|
|
594
|
+
*/
|
|
595
|
+
function getPreferredContentSchema(
|
|
596
|
+
content?: Record<string, { schema: Record<string, unknown> }>,
|
|
597
|
+
): Record<string, unknown> | undefined {
|
|
598
|
+
if (!content) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const jsonSchema = content["application/json"]?.schema;
|
|
603
|
+
if (jsonSchema && typeof jsonSchema === "object") {
|
|
604
|
+
return jsonSchema;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const firstKey = Object.keys(content)[0];
|
|
608
|
+
if (!firstKey) {
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const firstSchema = content[firstKey]?.schema;
|
|
613
|
+
if (!firstSchema || typeof firstSchema !== "object") {
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return firstSchema;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Get success response.
|
|
622
|
+
* @param operation Input parameter `operation`.
|
|
623
|
+
* @returns Get success response output as `{ content?: Record<string, { schema: Record<string, unknown> }> } | undefined`.
|
|
624
|
+
* @example
|
|
625
|
+
* ```ts
|
|
626
|
+
* const result = getSuccessResponse({});
|
|
627
|
+
* // result: { content?: Record<string, { schema: Record<string, unknown> }> } | undefined
|
|
628
|
+
* ```
|
|
629
|
+
*/
|
|
630
|
+
function getSuccessResponse(
|
|
631
|
+
operation: OpenApiOperation,
|
|
632
|
+
): { content?: Record<string, { schema: Record<string, unknown> }> } | undefined {
|
|
633
|
+
const responses = operation.responses ?? {};
|
|
634
|
+
|
|
635
|
+
const response200 = responses["200"];
|
|
636
|
+
if (response200) {
|
|
637
|
+
return response200 as {
|
|
638
|
+
content?: Record<string, { schema: Record<string, unknown> }>;
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const response201 = responses["201"];
|
|
643
|
+
if (response201) {
|
|
644
|
+
return response201 as {
|
|
645
|
+
content?: Record<string, { schema: Record<string, unknown> }>;
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const successStatus = Object.keys(responses).find(
|
|
650
|
+
(statusCode) => statusCode.startsWith("2") && responses[statusCode],
|
|
651
|
+
);
|
|
652
|
+
if (!successStatus) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return responses[successStatus] as {
|
|
657
|
+
content?: Record<string, { schema: Record<string, unknown> }>;
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Extract request type.
|
|
663
|
+
* @param operation Input parameter `operation`.
|
|
664
|
+
* @returns Extract request type output as `string | undefined`.
|
|
665
|
+
* @example
|
|
666
|
+
* ```ts
|
|
667
|
+
* const result = extractRequestType({});
|
|
668
|
+
* // result: string | undefined
|
|
669
|
+
* ```
|
|
670
|
+
*/
|
|
671
|
+
function extractRequestType(operation: OpenApiOperation): string | undefined {
|
|
672
|
+
const schema = getPreferredContentSchema(operation.requestBody?.content as never);
|
|
673
|
+
if (!schema) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const schemaRef = getSchemaRef(schema);
|
|
678
|
+
if (schemaRef) {
|
|
679
|
+
return schemaRef;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const schemaType = schema.type;
|
|
683
|
+
if (schemaType !== "array") {
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const items = schema.items;
|
|
688
|
+
if (!items || typeof items !== "object") {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const itemRef = getSchemaRef(items);
|
|
693
|
+
if (!itemRef) {
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return `${itemRef}[]`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Extract response type.
|
|
702
|
+
* @param operation Input parameter `operation`.
|
|
703
|
+
* @returns Extract response type output as `string`.
|
|
704
|
+
* @example
|
|
705
|
+
* ```ts
|
|
706
|
+
* const result = extractResponseType({});
|
|
707
|
+
* // result: string
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
function extractResponseType(operation: OpenApiOperation): string {
|
|
711
|
+
const response = getSuccessResponse(operation);
|
|
712
|
+
if (!response) {
|
|
713
|
+
return "any";
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const schema = getPreferredContentSchema(response.content);
|
|
717
|
+
if (!schema) {
|
|
718
|
+
return "any";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const schemaRef = getSchemaRef(schema);
|
|
722
|
+
if (schemaRef) {
|
|
723
|
+
return schemaRef;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const schemaType = schema.type;
|
|
727
|
+
if (schemaType !== "array") {
|
|
728
|
+
return "any";
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const items = schema.items;
|
|
732
|
+
if (!items || typeof items !== "object") {
|
|
733
|
+
return "any";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const itemRef = getSchemaRef(items);
|
|
737
|
+
if (!itemRef) {
|
|
738
|
+
return "any[]";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return `${itemRef}[]`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Get schema reference name.
|
|
746
|
+
* @param schema Input parameter `schema`.
|
|
747
|
+
* @returns Get schema reference name output as `string | undefined`.
|
|
748
|
+
* @example
|
|
749
|
+
* ```ts
|
|
750
|
+
* const result = getSchemaRef({ $ref: "#/components/schemas/User" });
|
|
751
|
+
* // result: string | undefined
|
|
752
|
+
* ```
|
|
753
|
+
*/
|
|
754
|
+
function getSchemaRef(schema: Record<string, unknown>): string | undefined {
|
|
755
|
+
const schemaReference = schema.$ref;
|
|
756
|
+
if (typeof schemaReference !== "string") {
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const referencePathParts = schemaReference.split("/");
|
|
761
|
+
const referenceName = referencePathParts[referencePathParts.length - 1];
|
|
762
|
+
if (!referenceName) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return referenceName;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Check if schema resolves to any.
|
|
771
|
+
* @param schema Input parameter `schema`.
|
|
772
|
+
* @returns Check if schema resolves to any output as `boolean`.
|
|
773
|
+
* @example
|
|
774
|
+
* ```ts
|
|
775
|
+
* const result = isSchemaAny(undefined);
|
|
776
|
+
* // result: boolean
|
|
777
|
+
* ```
|
|
778
|
+
*/
|
|
779
|
+
function isSchemaAny(schema: unknown): boolean {
|
|
780
|
+
const resolvedType = resolveSchemaType(schema);
|
|
781
|
+
return containsAnyType(resolvedType);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Resolve schema type.
|
|
786
|
+
* @param schema Input parameter `schema`.
|
|
787
|
+
* @returns Resolve schema type output as `string`.
|
|
788
|
+
* @example
|
|
789
|
+
* ```ts
|
|
790
|
+
* const result = resolveSchemaType({ type: "string" });
|
|
791
|
+
* // result: string
|
|
792
|
+
* ```
|
|
793
|
+
*/
|
|
794
|
+
function resolveSchemaType(schema: unknown): string {
|
|
795
|
+
if (!schema || typeof schema !== "object") {
|
|
796
|
+
return "any";
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const typedSchema = schema as Record<string, unknown>;
|
|
800
|
+
const schemaRef = getSchemaRef(typedSchema);
|
|
801
|
+
if (schemaRef) {
|
|
802
|
+
return schemaRef;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const schemaEnum = typedSchema.enum;
|
|
806
|
+
if (Array.isArray(schemaEnum)) {
|
|
807
|
+
const union = schemaEnum
|
|
808
|
+
.map((enumValue) =>
|
|
809
|
+
typeof enumValue === "string" ? `\"${enumValue}\"` : String(enumValue),
|
|
810
|
+
)
|
|
811
|
+
.join(" | ");
|
|
812
|
+
if (!union) {
|
|
813
|
+
return "any";
|
|
814
|
+
}
|
|
815
|
+
return union;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const anyOfType = resolveUnionType(typedSchema.anyOf);
|
|
819
|
+
if (anyOfType) {
|
|
820
|
+
return anyOfType;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const oneOfType = resolveUnionType(typedSchema.oneOf);
|
|
824
|
+
if (oneOfType) {
|
|
825
|
+
return oneOfType;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const allOfType = resolveIntersectionType(typedSchema.allOf);
|
|
829
|
+
if (allOfType) {
|
|
830
|
+
return allOfType;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const schemaType = typedSchema.type;
|
|
834
|
+
if (schemaType === "array") {
|
|
835
|
+
const itemType = resolveSchemaType(typedSchema.items);
|
|
836
|
+
return `${itemType}[]`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (schemaType === "object" && typedSchema.properties) {
|
|
840
|
+
return "object";
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (schemaType === "string") {
|
|
844
|
+
const format = typedSchema.format;
|
|
845
|
+
if (format === "numeric") {
|
|
846
|
+
return "number";
|
|
847
|
+
}
|
|
848
|
+
return "string";
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (schemaType === "number") {
|
|
852
|
+
return "number";
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (schemaType === "integer") {
|
|
856
|
+
return "number";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (schemaType === "boolean") {
|
|
860
|
+
return "boolean";
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return "any";
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Resolve union type.
|
|
868
|
+
* @param schemaVariants Input parameter `schemaVariants`.
|
|
869
|
+
* @returns Resolve union type output as `string | undefined`.
|
|
870
|
+
* @example
|
|
871
|
+
* ```ts
|
|
872
|
+
* const result = resolveUnionType([{ type: "string" }]);
|
|
873
|
+
* // result: string | undefined
|
|
874
|
+
* ```
|
|
875
|
+
*/
|
|
876
|
+
function resolveUnionType(schemaVariants: unknown): string | undefined {
|
|
877
|
+
if (!Array.isArray(schemaVariants)) {
|
|
878
|
+
return undefined;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const variants = schemaVariants
|
|
882
|
+
.map((variant) => resolveSchemaType(variant))
|
|
883
|
+
.filter(Boolean);
|
|
884
|
+
if (variants.length === 0) {
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return variants.join(" | ");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Resolve intersection type.
|
|
893
|
+
* @param schemaVariants Input parameter `schemaVariants`.
|
|
894
|
+
* @returns Resolve intersection type output as `string | undefined`.
|
|
895
|
+
* @example
|
|
896
|
+
* ```ts
|
|
897
|
+
* const result = resolveIntersectionType([{ type: "string" }]);
|
|
898
|
+
* // result: string | undefined
|
|
899
|
+
* ```
|
|
900
|
+
*/
|
|
901
|
+
function resolveIntersectionType(schemaVariants: unknown): string | undefined {
|
|
902
|
+
if (!Array.isArray(schemaVariants)) {
|
|
903
|
+
return undefined;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const variants = schemaVariants
|
|
907
|
+
.map((variant) => resolveSchemaType(variant))
|
|
908
|
+
.filter(Boolean);
|
|
909
|
+
if (variants.length === 0) {
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return variants.join(" & ");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Check if type includes any.
|
|
918
|
+
* @param typeName Input parameter `typeName`.
|
|
919
|
+
* @returns Check if type includes any output as `boolean`.
|
|
920
|
+
* @example
|
|
921
|
+
* ```ts
|
|
922
|
+
* const result = containsAnyType("any[]");
|
|
923
|
+
* // result: boolean
|
|
924
|
+
* ```
|
|
925
|
+
*/
|
|
926
|
+
function containsAnyType(typeName: string): boolean {
|
|
927
|
+
const normalized = typeName.trim();
|
|
928
|
+
if (normalized === "any") {
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (normalized === "any[]") {
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const tokens = normalized.split(/[|&]/).map((token) => token.trim());
|
|
937
|
+
return tokens.some((token) => token === "any" || token === "any[]");
|
|
938
|
+
}
|