@checkdigit/eslint-athena-plugin 1.0.0-PR.2-dcdf
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/LICENSE.txt +21 -0
- package/README.md +17 -0
- package/SECURITY.md +13 -0
- package/dist-mjs/athena/api-locator.mjs +66 -0
- package/dist-mjs/athena/api-matcher.mjs +206 -0
- package/dist-mjs/athena/athena.mjs +165 -0
- package/dist-mjs/athena/column.mjs +1 -0
- package/dist-mjs/athena/context.mjs +21 -0
- package/dist-mjs/athena/index.mjs +1 -0
- package/dist-mjs/athena/service-table.mjs +45 -0
- package/dist-mjs/athena/sql-file.mjs +123 -0
- package/dist-mjs/athena/types.mjs +1 -0
- package/dist-mjs/athena/validate.mjs +619 -0
- package/dist-mjs/athena/visitor.mjs +291 -0
- package/dist-mjs/get-documentation-url.mjs +9 -0
- package/dist-mjs/index.mjs +56 -0
- package/dist-mjs/openapi/deref-schema.mjs +20 -0
- package/dist-mjs/openapi/generate-schema.mjs +375 -0
- package/dist-mjs/openapi/service-schema-generator.mjs +176 -0
- package/dist-mjs/peggy/athena-peggy.mjs +20700 -0
- package/dist-mjs/service.mjs +9 -0
- package/dist-mjs/sql-parser.mjs +28 -0
- package/dist-types/athena/api-locator.d.ts +2 -0
- package/dist-types/athena/api-matcher.d.ts +14 -0
- package/dist-types/athena/athena.d.ts +5 -0
- package/dist-types/athena/column.d.ts +1 -0
- package/dist-types/athena/context.d.ts +21 -0
- package/dist-types/athena/index.d.ts +8 -0
- package/dist-types/athena/service-table.d.ts +8 -0
- package/dist-types/athena/sql-file.d.ts +5 -0
- package/dist-types/athena/types.d.ts +493 -0
- package/dist-types/athena/validate.d.ts +14 -0
- package/dist-types/athena/visitor.d.ts +75 -0
- package/dist-types/get-documentation-url.d.ts +1 -0
- package/dist-types/index.d.ts +5 -0
- package/dist-types/openapi/deref-schema.d.ts +1 -0
- package/dist-types/openapi/generate-schema.d.ts +33 -0
- package/dist-types/openapi/service-schema-generator.d.ts +5 -0
- package/dist-types/peggy/athena-peggy.d.ts +13 -0
- package/dist-types/service.d.ts +2 -0
- package/dist-types/sql-parser.d.ts +25 -0
- package/package.json +1 -0
- package/src/api/v1/swagger.yml +619 -0
- package/src/api/v2/swagger.yml +477 -0
- package/src/athena/api-locator.ts +78 -0
- package/src/athena/api-matcher.ts +323 -0
- package/src/athena/athena.ts +224 -0
- package/src/athena/column.ts +4 -0
- package/src/athena/context.ts +47 -0
- package/src/athena/index.ts +13 -0
- package/src/athena/service-table.ts +78 -0
- package/src/athena/sql-file.ts +161 -0
- package/src/athena/types.ts +568 -0
- package/src/athena/validate.ts +902 -0
- package/src/athena/visitor.ts +406 -0
- package/src/get-documentation-url.ts +7 -0
- package/src/index.ts +67 -0
- package/src/openapi/deref-schema.ts +20 -0
- package/src/openapi/generate-schema.ts +553 -0
- package/src/openapi/service-schema-generator.ts +241 -0
- package/src/peggy/athena-peggy.ts +22149 -0
- package/src/peggy/athena.peggy +2971 -0
- package/src/service.ts +11 -0
- package/src/services/eslintAthenaPlugin/v1/swagger.schema.deref.json +1931 -0
- package/src/services/eslintAthenaPlugin/v2/swagger.schema.deref.json +978 -0
- package/src/sql-parser.ts +53 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// openapi/generate-schema.ts
|
|
2
|
+
|
|
3
|
+
import { strict as assert } from 'node:assert';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
import debug from 'debug';
|
|
7
|
+
import { getReasonPhrase } from 'http-status-codes';
|
|
8
|
+
import pointer from 'json-pointer';
|
|
9
|
+
import jsYaml from 'js-yaml';
|
|
10
|
+
import type { OpenAPIV3_1 as v31 } from 'openapi-types';
|
|
11
|
+
import type { SchemaObject } from 'ajv/dist/2020';
|
|
12
|
+
|
|
13
|
+
export const commandName = 'generate-schema';
|
|
14
|
+
export { generateSchemasForService } from './service-schema-generator.ts';
|
|
15
|
+
|
|
16
|
+
const log = debug('openapi-cli:generate-schema');
|
|
17
|
+
|
|
18
|
+
const ALL_OPERATION_METHODS = [
|
|
19
|
+
'get',
|
|
20
|
+
'put',
|
|
21
|
+
'post',
|
|
22
|
+
'head',
|
|
23
|
+
'trace',
|
|
24
|
+
'patch',
|
|
25
|
+
'delete',
|
|
26
|
+
'options',
|
|
27
|
+
] as const;
|
|
28
|
+
const JSON_SCHEMA_META_2020_URL =
|
|
29
|
+
'https://json-schema.org/draft/2020-12/schema';
|
|
30
|
+
const OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE = '#/components/schemas/';
|
|
31
|
+
export const SWAGGER_SCHEMA_FILENAME = 'swagger.schema.json';
|
|
32
|
+
|
|
33
|
+
export type HttpMethod = (typeof ALL_OPERATION_METHODS)[number];
|
|
34
|
+
|
|
35
|
+
export interface RequestContext {
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
body?: unknown;
|
|
38
|
+
params?: unknown;
|
|
39
|
+
query?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResponseContext {
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
body?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ApiOperation {
|
|
48
|
+
path: string;
|
|
49
|
+
method: string;
|
|
50
|
+
operationId: string;
|
|
51
|
+
request: RequestContext;
|
|
52
|
+
responses: Record<string, ResponseContext>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OperationSchemas {
|
|
56
|
+
request: SchemaObject;
|
|
57
|
+
responses: Record<string, SchemaObject>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ApiSchemas {
|
|
61
|
+
apis: Record<string, Record<string, OperationSchemas>>;
|
|
62
|
+
definitions?: Record<string, SchemaObject>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isRequestBodyAllowed(method: string): boolean {
|
|
66
|
+
return ['post', 'put', 'patch'].includes(method);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isReferenceObject(schema: unknown): schema is v31.ReferenceObject {
|
|
70
|
+
return Object.hasOwn(schema as v31.ReferenceObject, '$ref');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolve<T>(
|
|
74
|
+
document: v31.Document,
|
|
75
|
+
reference: v31.ReferenceObject | T,
|
|
76
|
+
): T {
|
|
77
|
+
if (!isReferenceObject(reference)) {
|
|
78
|
+
return reference;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const referencePointer = reference.$ref.slice(1);
|
|
82
|
+
const resolvedReference = pointer.get(document, referencePointer) as
|
|
83
|
+
| T
|
|
84
|
+
| v31.ReferenceObject;
|
|
85
|
+
return resolve(document, resolvedReference);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getParameters(
|
|
89
|
+
parameters: (v31.ParameterObject | v31.ReferenceObject)[],
|
|
90
|
+
document: v31.Document,
|
|
91
|
+
): v31.ParameterObject[] {
|
|
92
|
+
return parameters.map((parameter) => resolve(document, parameter));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getRequestParametersSchema(
|
|
96
|
+
operation: v31.OperationObject,
|
|
97
|
+
parameterType: string,
|
|
98
|
+
document: v31.Document,
|
|
99
|
+
): v31.SchemaObject | undefined {
|
|
100
|
+
if (operation.parameters === undefined) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parameters = getParameters(operation.parameters, document).filter(
|
|
105
|
+
(parameter) => parameter.in === parameterType,
|
|
106
|
+
);
|
|
107
|
+
if (parameters.length === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parametersSchema = Object.fromEntries(
|
|
112
|
+
parameters.map((parameter) => [
|
|
113
|
+
parameterType === 'header'
|
|
114
|
+
? parameter.name.toLowerCase()
|
|
115
|
+
: parameter.name,
|
|
116
|
+
parameter.schema ?? { type: 'string' as const },
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
const requiredParameterNames = parameters
|
|
120
|
+
.filter((parameter) => parameter.required === true)
|
|
121
|
+
.map((parameter) =>
|
|
122
|
+
parameterType === 'header'
|
|
123
|
+
? parameter.name.toLowerCase()
|
|
124
|
+
: parameter.name,
|
|
125
|
+
);
|
|
126
|
+
const schema: v31.SchemaObject = {
|
|
127
|
+
type: 'object',
|
|
128
|
+
// header parameters can have additional properties, we allow them in the runtime validation
|
|
129
|
+
additionalProperties: parameterType === 'header',
|
|
130
|
+
properties: parametersSchema,
|
|
131
|
+
...(requiredParameterNames.length > 0
|
|
132
|
+
? { required: requiredParameterNames }
|
|
133
|
+
: {}),
|
|
134
|
+
};
|
|
135
|
+
return schema;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getBodySchema(
|
|
139
|
+
contents: Record<string, v31.MediaTypeObject> | undefined,
|
|
140
|
+
): v31.SchemaObject | undefined {
|
|
141
|
+
const schema = Object.values(contents ?? {})[0]?.schema;
|
|
142
|
+
return schema !== undefined && Object.keys(schema).length > 0
|
|
143
|
+
? schema
|
|
144
|
+
: undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getRequestBodySchema(
|
|
148
|
+
operation: v31.OperationObject,
|
|
149
|
+
document: v31.Document,
|
|
150
|
+
) {
|
|
151
|
+
if (!Object.hasOwn(operation, 'requestBody')) {
|
|
152
|
+
return {
|
|
153
|
+
isRequestBodyRequired: false,
|
|
154
|
+
requestBodySchema: undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const requestBody = resolve(document, operation.requestBody);
|
|
159
|
+
return {
|
|
160
|
+
isRequestBodyRequired: requestBody?.required,
|
|
161
|
+
requestBodySchema: getBodySchema(requestBody?.content),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getRequestContextSchema(
|
|
166
|
+
method: HttpMethod,
|
|
167
|
+
operation: v31.OperationObject,
|
|
168
|
+
operationId: string,
|
|
169
|
+
document: v31.Document,
|
|
170
|
+
apiSchemasBaseUri: string,
|
|
171
|
+
) {
|
|
172
|
+
const requestPathParametersSchema = getRequestParametersSchema(
|
|
173
|
+
operation,
|
|
174
|
+
'path',
|
|
175
|
+
document,
|
|
176
|
+
);
|
|
177
|
+
const requestQueryParametersSchema = getRequestParametersSchema(
|
|
178
|
+
operation,
|
|
179
|
+
'query',
|
|
180
|
+
document,
|
|
181
|
+
);
|
|
182
|
+
const requestHeadersSchema = getRequestParametersSchema(
|
|
183
|
+
operation,
|
|
184
|
+
'header',
|
|
185
|
+
document,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const { requestBodySchema, isRequestBodyRequired } = getRequestBodySchema(
|
|
189
|
+
operation,
|
|
190
|
+
document,
|
|
191
|
+
);
|
|
192
|
+
if (requestBodySchema !== undefined && !isRequestBodyAllowed(method)) {
|
|
193
|
+
throw new Error(`Request body is not allowed for ${method} method`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const responseContextSchemaName = `${operationId}RequestContext`;
|
|
197
|
+
// eslint-disable-next-line sonarjs/prefer-immediate-return
|
|
198
|
+
const requestContextSchema = {
|
|
199
|
+
$schema: JSON_SCHEMA_META_2020_URL,
|
|
200
|
+
$id: `${apiSchemasBaseUri}/${responseContextSchemaName}`,
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
...(requestPathParametersSchema
|
|
204
|
+
? { params: requestPathParametersSchema }
|
|
205
|
+
: {}),
|
|
206
|
+
...(requestQueryParametersSchema
|
|
207
|
+
? { query: requestQueryParametersSchema }
|
|
208
|
+
: {}),
|
|
209
|
+
headers: requestHeadersSchema ?? {
|
|
210
|
+
type: 'object',
|
|
211
|
+
additionalProperties: true,
|
|
212
|
+
},
|
|
213
|
+
...(requestBodySchema ? { body: requestBodySchema } : {}),
|
|
214
|
+
},
|
|
215
|
+
required: [
|
|
216
|
+
...(requestPathParametersSchema?.required ? ['params'] : []),
|
|
217
|
+
...(requestQueryParametersSchema?.required ? ['query'] : []),
|
|
218
|
+
...(requestHeadersSchema?.required ? ['headers'] : []),
|
|
219
|
+
...(isRequestBodyRequired === true ? ['body'] : []),
|
|
220
|
+
],
|
|
221
|
+
additionalProperties: false,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return requestContextSchema;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getResponseReason(status: string): string {
|
|
228
|
+
return status === 'default'
|
|
229
|
+
? `Default`
|
|
230
|
+
: getReasonPhrase(status).replaceAll(/\s/gu, ''); // remove spaces
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getResponseBodySchema(response: v31.ResponseObject) {
|
|
234
|
+
return getBodySchema(response.content);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getResponseHeadersSchema(
|
|
238
|
+
headers: Record<string, v31.HeaderObject> | undefined,
|
|
239
|
+
document: v31.Document,
|
|
240
|
+
): v31.SchemaObject | undefined {
|
|
241
|
+
if (headers === undefined || Object.keys(headers).length === 0) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const resolvedHeaders = Object.fromEntries(
|
|
246
|
+
Object.entries(headers).map(([name, header]) => [
|
|
247
|
+
name.toLowerCase(),
|
|
248
|
+
resolve(document, header),
|
|
249
|
+
]),
|
|
250
|
+
);
|
|
251
|
+
const resolvedHeaderSchemas = Object.fromEntries(
|
|
252
|
+
Object.entries(resolvedHeaders).map(([name, header]) => [
|
|
253
|
+
name,
|
|
254
|
+
header.schema ?? { type: 'string' as const },
|
|
255
|
+
]),
|
|
256
|
+
);
|
|
257
|
+
const requiredHeaderNames = Object.entries(resolvedHeaders)
|
|
258
|
+
.filter(([, header]) => header.required === true)
|
|
259
|
+
.map(([name]) => name);
|
|
260
|
+
return {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: resolvedHeaderSchemas,
|
|
263
|
+
...(requiredHeaderNames.length === 0
|
|
264
|
+
? {}
|
|
265
|
+
: { required: requiredHeaderNames }),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getResponseSchema(
|
|
270
|
+
status: string,
|
|
271
|
+
response: v31.ResponseObject | v31.ReferenceObject,
|
|
272
|
+
document: v31.Document,
|
|
273
|
+
apiSchemasBaseUri: string,
|
|
274
|
+
operationId: string,
|
|
275
|
+
) {
|
|
276
|
+
const resolvedResponse = resolve(document, response);
|
|
277
|
+
const responseBodySchema = getResponseBodySchema(resolvedResponse);
|
|
278
|
+
const responseHeadersSchema = getResponseHeadersSchema(
|
|
279
|
+
resolvedResponse.headers,
|
|
280
|
+
document,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const schemaName = `${operationId}Response${getResponseReason(status)}`;
|
|
284
|
+
return {
|
|
285
|
+
$schema: JSON_SCHEMA_META_2020_URL,
|
|
286
|
+
$id: `${apiSchemasBaseUri}/${schemaName}`,
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
headers: responseHeadersSchema ?? {
|
|
290
|
+
type: 'object',
|
|
291
|
+
additionalProperties: true,
|
|
292
|
+
},
|
|
293
|
+
...(responseBodySchema ? { body: responseBodySchema } : {}),
|
|
294
|
+
},
|
|
295
|
+
required: [
|
|
296
|
+
...(responseHeadersSchema?.required !== undefined &&
|
|
297
|
+
responseHeadersSchema.required.length > 0
|
|
298
|
+
? ['headers']
|
|
299
|
+
: []),
|
|
300
|
+
...(responseBodySchema ? ['body'] : []),
|
|
301
|
+
],
|
|
302
|
+
additionalProperties: false,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getResponseContextSchemas(
|
|
307
|
+
operation: v31.OperationObject,
|
|
308
|
+
operationId: string,
|
|
309
|
+
document: v31.Document,
|
|
310
|
+
apiSchemasBaseUri: string,
|
|
311
|
+
) {
|
|
312
|
+
assert.ok(
|
|
313
|
+
operation.responses !== undefined,
|
|
314
|
+
'Operation responses must be defined',
|
|
315
|
+
);
|
|
316
|
+
return Object.fromEntries(
|
|
317
|
+
Object.entries(operation.responses).map(([status, response]) => [
|
|
318
|
+
status.toLowerCase(),
|
|
319
|
+
getResponseSchema(
|
|
320
|
+
status.toLowerCase(),
|
|
321
|
+
response,
|
|
322
|
+
document,
|
|
323
|
+
apiSchemasBaseUri,
|
|
324
|
+
operationId,
|
|
325
|
+
),
|
|
326
|
+
]),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getOperationId(
|
|
331
|
+
path: string,
|
|
332
|
+
method: string,
|
|
333
|
+
operation: v31.OperationObject,
|
|
334
|
+
operationIds: Set<string>,
|
|
335
|
+
): string {
|
|
336
|
+
const operationIdBase = operation.operationId ?? `${path}-${method}`;
|
|
337
|
+
const parts = operationIdBase.split(/[-=/]/u); // split operationId into parts by -, =, or /
|
|
338
|
+
|
|
339
|
+
const operationId = parts
|
|
340
|
+
.filter((part) => part.trim() !== '' && !/\{.*\}/u.test(part)) // keep only non-empty parts that are not path parameters
|
|
341
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
|
342
|
+
.join('');
|
|
343
|
+
if (!operationIds.has(operationId)) {
|
|
344
|
+
return operationId;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// KISS: we could try to come up with a better naming convention in case of name collisions, but it's probably better to leave it to the service to decide an appropriate operationId.
|
|
348
|
+
let operationIdIndex = 1;
|
|
349
|
+
while (operationIds.has(`${operationId}${operationIdIndex.toString()}`)) {
|
|
350
|
+
operationIdIndex += 1;
|
|
351
|
+
}
|
|
352
|
+
return `${operationId}${operationIdIndex.toString()}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function updateOpenapiSchemaDefinitionsReferences(
|
|
356
|
+
value: unknown,
|
|
357
|
+
relativeSchemaDefinitionReferenceUri: string,
|
|
358
|
+
key?: string,
|
|
359
|
+
): unknown {
|
|
360
|
+
if (
|
|
361
|
+
typeof value === 'string' &&
|
|
362
|
+
key === '$ref' &&
|
|
363
|
+
value.startsWith(OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE)
|
|
364
|
+
) {
|
|
365
|
+
return value.replace(
|
|
366
|
+
OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE,
|
|
367
|
+
relativeSchemaDefinitionReferenceUri,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (Array.isArray(value)) {
|
|
372
|
+
return value.map((item) =>
|
|
373
|
+
updateOpenapiSchemaDefinitionsReferences(
|
|
374
|
+
item,
|
|
375
|
+
relativeSchemaDefinitionReferenceUri,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (typeof value === 'object' && value !== null) {
|
|
381
|
+
return Object.fromEntries(
|
|
382
|
+
Object.entries(value).map(([childKey, childValue]) => [
|
|
383
|
+
childKey,
|
|
384
|
+
updateOpenapiSchemaDefinitionsReferences(
|
|
385
|
+
childValue,
|
|
386
|
+
relativeSchemaDefinitionReferenceUri,
|
|
387
|
+
childKey,
|
|
388
|
+
),
|
|
389
|
+
]),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getFirehoseLoggedExtension(obj: object): unknown {
|
|
397
|
+
const record = obj as Record<string, unknown>;
|
|
398
|
+
return record['x-firehose-logged'] ?? record['x-firehoseLogged'];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildApiSchemaFromDocument(
|
|
402
|
+
document: v31.Document,
|
|
403
|
+
organization: string,
|
|
404
|
+
serviceName: string,
|
|
405
|
+
): ApiSchemas | undefined {
|
|
406
|
+
if (document.paths === undefined) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
const serverUri = document.servers?.[0]?.url;
|
|
410
|
+
if (serverUri === undefined) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
const serverPathname = serverUri.startsWith('http')
|
|
414
|
+
? new URL(serverUri).pathname
|
|
415
|
+
: serverUri;
|
|
416
|
+
const endpointSchemasBaseUri = `https://${serviceName}.${organization}${serverPathname}/schemas`;
|
|
417
|
+
const apiSchemasBaseUri = `${endpointSchemasBaseUri}/api`;
|
|
418
|
+
const apiSchemas: Record<string, Record<string, OperationSchemas>> = {};
|
|
419
|
+
const allSchemas: ApiSchemas = { apis: apiSchemas };
|
|
420
|
+
const operationIds = new Set<string>();
|
|
421
|
+
const documentFirehoseLogged = getFirehoseLoggedExtension(document);
|
|
422
|
+
log('document firehose logged value', documentFirehoseLogged);
|
|
423
|
+
|
|
424
|
+
for (const [path, pathItems] of Object.entries(document.paths)) {
|
|
425
|
+
// convert openapi path to koa router path, e.g. "/user/{userId}" --> "/user/:userId"
|
|
426
|
+
const koaPath = path.replaceAll(/\{(?<param>[^}]+)\}/gu, ':$<param>');
|
|
427
|
+
const pathSchemas: Record<string, OperationSchemas> = {};
|
|
428
|
+
apiSchemas[`${serverPathname}${koaPath}`] = pathSchemas;
|
|
429
|
+
|
|
430
|
+
for (const method of ALL_OPERATION_METHODS) {
|
|
431
|
+
const operation = pathItems?.[method];
|
|
432
|
+
if (operation !== undefined) {
|
|
433
|
+
const operationFirehoseLogged = getFirehoseLoggedExtension(operation);
|
|
434
|
+
log('operation firehose logged value', operationFirehoseLogged);
|
|
435
|
+
const effectiveFirehoseLogged =
|
|
436
|
+
operationFirehoseLogged ?? documentFirehoseLogged;
|
|
437
|
+
if (effectiveFirehoseLogged !== true) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const operationId = getOperationId(
|
|
441
|
+
path,
|
|
442
|
+
method,
|
|
443
|
+
operation,
|
|
444
|
+
operationIds,
|
|
445
|
+
);
|
|
446
|
+
operationIds.add(operationId);
|
|
447
|
+
pathSchemas[method] = {
|
|
448
|
+
request: getRequestContextSchema(
|
|
449
|
+
method,
|
|
450
|
+
operation,
|
|
451
|
+
operationId,
|
|
452
|
+
document,
|
|
453
|
+
apiSchemasBaseUri,
|
|
454
|
+
),
|
|
455
|
+
responses: getResponseContextSchemas(
|
|
456
|
+
operation,
|
|
457
|
+
operationId,
|
|
458
|
+
document,
|
|
459
|
+
apiSchemasBaseUri,
|
|
460
|
+
),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (document.components?.schemas !== undefined) {
|
|
467
|
+
allSchemas.definitions = Object.fromEntries(
|
|
468
|
+
Object.entries(document.components.schemas).map(([name, schema]) => [
|
|
469
|
+
name,
|
|
470
|
+
{
|
|
471
|
+
$schema: JSON_SCHEMA_META_2020_URL,
|
|
472
|
+
$id: `${endpointSchemasBaseUri}/definitions/${name}`,
|
|
473
|
+
...schema,
|
|
474
|
+
},
|
|
475
|
+
]),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const relativeSchemaDefinitionReferenceUri = `${serverPathname}/schemas/definitions/`;
|
|
480
|
+
return updateOpenapiSchemaDefinitionsReferences(
|
|
481
|
+
structuredClone(allSchemas),
|
|
482
|
+
relativeSchemaDefinitionReferenceUri,
|
|
483
|
+
) as ApiSchemas;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function buildApiSchemaFromYaml(
|
|
487
|
+
yamlContent: string,
|
|
488
|
+
organization: string,
|
|
489
|
+
serviceName: string,
|
|
490
|
+
): ApiSchemas | undefined {
|
|
491
|
+
// eslint-disable-next-line import/no-named-as-default-member
|
|
492
|
+
const document = jsYaml.load(yamlContent) as v31.Document;
|
|
493
|
+
return buildApiSchemaFromDocument(document, organization, serviceName);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function generateEndpointSchemas(
|
|
497
|
+
organization: string,
|
|
498
|
+
serviceName: string,
|
|
499
|
+
root: string,
|
|
500
|
+
endpoint: string,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const documentContents = await fs.readFile(
|
|
503
|
+
`${root}/${endpoint}/swagger.yml`,
|
|
504
|
+
'utf8',
|
|
505
|
+
);
|
|
506
|
+
// eslint-disable-next-line import/no-named-as-default-member
|
|
507
|
+
const document = (await jsYaml.load(documentContents)) as v31.Document;
|
|
508
|
+
const normalizedApiSchemas = buildApiSchemaFromDocument(
|
|
509
|
+
document,
|
|
510
|
+
organization,
|
|
511
|
+
serviceName,
|
|
512
|
+
);
|
|
513
|
+
if (normalizedApiSchemas === undefined) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const swaggerSchemaFilename = `${root}/${endpoint}/${SWAGGER_SCHEMA_FILENAME}`;
|
|
517
|
+
await fs.writeFile(
|
|
518
|
+
swaggerSchemaFilename,
|
|
519
|
+
JSON.stringify(normalizedApiSchemas, undefined, 2),
|
|
520
|
+
);
|
|
521
|
+
log(`Generated schema ${swaggerSchemaFilename}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function generateSchemas(): Promise<void> {
|
|
525
|
+
const serviceJsonPackageFile = await fs.readFile(`./package.json`, 'utf8');
|
|
526
|
+
const packageJson = JSON.parse(serviceJsonPackageFile) as {
|
|
527
|
+
name: string;
|
|
528
|
+
service: {
|
|
529
|
+
api: {
|
|
530
|
+
root: string;
|
|
531
|
+
endpoints: string[];
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// assume that the package name is in the format of `@organization/service-name`
|
|
537
|
+
const [organization, serviceName] = packageJson.name.slice(1).split('/');
|
|
538
|
+
assert.ok(
|
|
539
|
+
organization !== undefined && serviceName !== undefined,
|
|
540
|
+
'Invalid package name',
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
await Promise.all(
|
|
544
|
+
packageJson.service.api.endpoints.map((endpoint) =>
|
|
545
|
+
generateEndpointSchemas(
|
|
546
|
+
organization,
|
|
547
|
+
serviceName,
|
|
548
|
+
packageJson.service.api.root,
|
|
549
|
+
endpoint,
|
|
550
|
+
),
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|