@checkdigit/eslint-plugin 7.17.1 → 7.18.0-PR.143-8290

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.
Files changed (80) hide show
  1. package/dist-mjs/athena/api-locator.mjs +30 -0
  2. package/dist-mjs/athena/api-matcher.mjs +108 -0
  3. package/dist-mjs/athena/athena.mjs +331 -0
  4. package/dist-mjs/athena/column.mjs +1 -0
  5. package/dist-mjs/athena/context.mjs +21 -0
  6. package/dist-mjs/athena/index.mjs +1 -0
  7. package/dist-mjs/athena/service-table.mjs +32 -0
  8. package/dist-mjs/athena/types.mjs +1 -0
  9. package/dist-mjs/athena/visitor.mjs +258 -0
  10. package/dist-mjs/index.mjs +8 -4
  11. package/dist-mjs/no-status-code-assert.mjs +1 -1
  12. package/dist-mjs/openapi/deref-schema.mjs +14 -0
  13. package/dist-mjs/openapi/generate-schema.mjs +273 -0
  14. package/dist-mjs/openapi/service-schema-generator.mjs +147 -0
  15. package/dist-mjs/peggy/athena-peggy.mjs +20629 -0
  16. package/dist-types/athena/api-locator.d.ts +2 -0
  17. package/dist-types/athena/api-matcher.d.ts +14 -0
  18. package/dist-types/athena/athena.d.ts +6 -0
  19. package/dist-types/athena/column.d.ts +1 -0
  20. package/dist-types/athena/context.d.ts +21 -0
  21. package/dist-types/athena/index.d.ts +8 -0
  22. package/dist-types/athena/service-table.d.ts +8 -0
  23. package/dist-types/athena/types.d.ts +474 -0
  24. package/dist-types/athena/visitor.d.ts +63 -0
  25. package/dist-types/no-status-code-assert.d.ts +1 -1
  26. package/dist-types/openapi/deref-schema.d.ts +1 -0
  27. package/dist-types/openapi/generate-schema.d.ts +33 -0
  28. package/dist-types/openapi/service-schema-generator.d.ts +5 -0
  29. package/dist-types/peggy/athena-peggy.d.ts +13 -0
  30. package/package.json +1 -96
  31. package/src/athena/ATHENA.md +387 -0
  32. package/src/athena/PLAN.md +355 -0
  33. package/src/athena/api-locator.ts +39 -0
  34. package/src/athena/api-matcher.ts +169 -0
  35. package/src/athena/athena.ts +491 -0
  36. package/src/athena/column.ts +2 -0
  37. package/src/athena/context.ts +47 -0
  38. package/src/athena/index.ts +11 -0
  39. package/src/athena/service-table.ts +55 -0
  40. package/src/athena/types.ts +526 -0
  41. package/src/athena/visitor.ts +365 -0
  42. package/src/index.ts +4 -0
  43. package/src/no-side-effects.ts +1 -1
  44. package/src/no-status-code-assert.ts +2 -2
  45. package/src/openapi/deref-schema.ts +14 -0
  46. package/src/openapi/generate-schema.ts +422 -0
  47. package/src/openapi/service-schema-generator.ts +189 -0
  48. package/src/peggy/athena-chat.peggy +608 -0
  49. package/src/peggy/athena-peggy.ts +22078 -0
  50. package/src/peggy/athena.peggy +2967 -0
  51. package/src/require-service-call-response-declaration.ts +2 -2
  52. package/src/services/interchange/v1/swagger.schema.deref.json +849 -0
  53. package/src/services/interchange/v1/swagger.schema.json +473 -0
  54. package/src/services/interchange/v1/swagger.yml +414 -0
  55. package/src/services/ledger/v1/swagger.schema.deref.json +6694 -0
  56. package/src/services/ledger/v1/swagger.schema.json +1820 -0
  57. package/src/services/ledger/v1/swagger.yml +1094 -0
  58. package/src/services/link/v1/swagger.schema.deref.json +648 -0
  59. package/src/services/link/v1/swagger.schema.json +444 -0
  60. package/src/services/link/v1/swagger.yml +343 -0
  61. package/src/services/message/v1/swagger.schema.deref.json +22049 -0
  62. package/src/services/message/v1/swagger.schema.json +3470 -0
  63. package/src/services/message/v1/swagger.yml +2798 -0
  64. package/src/services/message/v2/swagger.schema.deref.json +72221 -0
  65. package/src/services/message/v2/swagger.schema.json +3558 -0
  66. package/src/services/message/v2/swagger.yml +3009 -0
  67. package/src/services/paymentCard/v1/swagger.schema.deref.json +4346 -0
  68. package/src/services/paymentCard/v1/swagger.schema.json +2181 -0
  69. package/src/services/paymentCard/v1/swagger.yml +1161 -0
  70. package/src/services/paymentCard/v2/swagger.schema.deref.json +4336 -0
  71. package/src/services/paymentCard/v2/swagger.schema.json +2155 -0
  72. package/src/services/paymentCard/v2/swagger.yml +1149 -0
  73. package/src/services/person/v1/swagger.schema.deref.json +6786 -0
  74. package/src/services/person/v1/swagger.schema.json +1445 -0
  75. package/src/services/person/v1/swagger.yml +1157 -0
  76. package/src/services/teampayApproval/v1/swagger.schema.deref.json +9898 -0
  77. package/src/services/teampayCardManagement/v1/swagger.schema.deref.json +6187 -0
  78. package/src/services/teampayClientManagement/v1/swagger.schema.deref.json +4914 -0
  79. package/src/services/teampayClientManagement/v1/swagger.schema.json +1964 -0
  80. package/src/services/teampayClientManagement/v1/swagger.yml +1376 -0
@@ -0,0 +1,422 @@
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 = ['get', 'put', 'post', 'head', 'trace', 'patch', 'delete', 'options'] as const;
19
+ const JSON_SCHEMA_META_2020_URL = 'https://json-schema.org/draft/2020-12/schema';
20
+ const OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE = '#/components/schemas/';
21
+ export const SWAGGER_SCHEMA_FILENAME = 'swagger.schema.json';
22
+
23
+ export type HttpMethod = (typeof ALL_OPERATION_METHODS)[number];
24
+
25
+ export interface RequestContext {
26
+ headers?: Record<string, string>;
27
+ body?: unknown;
28
+ params?: unknown;
29
+ query?: unknown;
30
+ }
31
+
32
+ export interface ResponseContext {
33
+ headers?: Record<string, string>;
34
+ body?: unknown;
35
+ }
36
+
37
+ export interface ApiOperation {
38
+ path: string;
39
+ method: string;
40
+ operationId: string;
41
+ request: RequestContext;
42
+ responses: Record<string, ResponseContext>;
43
+ }
44
+
45
+ export interface OperationSchemas {
46
+ request: SchemaObject;
47
+ responses: Record<string, SchemaObject>;
48
+ }
49
+
50
+ export interface ApiSchemas {
51
+ apis: Record<string, Record<string, OperationSchemas>>;
52
+ definitions?: Record<string, SchemaObject>;
53
+ }
54
+
55
+ function isRequestBodyAllowed(method: string): boolean {
56
+ return ['post', 'put', 'patch'].includes(method);
57
+ }
58
+
59
+ function isReferenceObject(schema: unknown): schema is v31.ReferenceObject {
60
+ return Object.hasOwn(schema as v31.ReferenceObject, '$ref');
61
+ }
62
+
63
+ function resolve<T>(document: v31.Document, reference: v31.ReferenceObject | T): T {
64
+ if (!isReferenceObject(reference)) {
65
+ return reference;
66
+ }
67
+
68
+ const referencePointer = reference.$ref.slice(1);
69
+ const resolvedReference = pointer.get(document, referencePointer) as T | v31.ReferenceObject;
70
+ return resolve(document, resolvedReference);
71
+ }
72
+
73
+ function getParameters(
74
+ parameters: (v31.ParameterObject | v31.ReferenceObject)[],
75
+ document: v31.Document,
76
+ ): v31.ParameterObject[] {
77
+ return parameters.map((parameter) => resolve(document, parameter));
78
+ }
79
+
80
+ function getRequestParametersSchema(
81
+ operation: v31.OperationObject,
82
+ parameterType: string,
83
+ document: v31.Document,
84
+ ): v31.SchemaObject | undefined {
85
+ if (operation.parameters === undefined) {
86
+ return;
87
+ }
88
+
89
+ let parameters = getParameters(operation.parameters, document);
90
+ parameters = parameters.filter((parameter) => parameter.in === parameterType);
91
+ if (parameters.length === 0) {
92
+ return;
93
+ }
94
+
95
+ const parametersSchema = Object.fromEntries(
96
+ parameters.map((parameter) => [
97
+ parameterType === 'header' ? parameter.name.toLowerCase() : parameter.name,
98
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
99
+ parameter.schema ?? ({ type: 'string' } as v31.SchemaObject),
100
+ ]),
101
+ );
102
+ const requiredParameterNames = parameters
103
+ .filter((parameter) => parameter.required === true)
104
+ .map((parameter) => parameter.name);
105
+ const schema: v31.SchemaObject = {
106
+ type: 'object',
107
+ // header parameters can have additional properties, we allow them in the runtime validation
108
+ additionalProperties: parameterType === 'header',
109
+ properties: parametersSchema,
110
+ ...(requiredParameterNames.length > 0 ? { required: requiredParameterNames } : {}),
111
+ };
112
+ return schema;
113
+ }
114
+
115
+ function getBodySchema(contents: Record<string, v31.MediaTypeObject> | undefined): v31.SchemaObject | undefined {
116
+ if (contents === undefined) {
117
+ return undefined;
118
+ }
119
+
120
+ const schema = Object.values(contents)[0]?.schema;
121
+ if (schema !== undefined && Object.keys(schema).length === 0) {
122
+ // empty schema should be treated as undefined
123
+ return undefined;
124
+ }
125
+
126
+ return schema;
127
+ }
128
+
129
+ function getRequestBodySchema(operation: v31.OperationObject, document: v31.Document) {
130
+ if (!Object.hasOwn(operation, 'requestBody')) {
131
+ return {
132
+ isRequestBodyRequired: false,
133
+ requestBodySchema: undefined,
134
+ };
135
+ }
136
+
137
+ const requestBody = resolve(document, operation.requestBody);
138
+ return {
139
+ isRequestBodyRequired: requestBody?.required,
140
+ requestBodySchema: getBodySchema(requestBody?.content),
141
+ };
142
+ }
143
+
144
+ function getRequestContextSchema(
145
+ method: HttpMethod,
146
+ operation: v31.OperationObject,
147
+ operationId: string,
148
+ document: v31.Document,
149
+ apiSchemasBaseUri: string,
150
+ ) {
151
+ const requestPathParametersSchema = getRequestParametersSchema(operation, 'path', document);
152
+ const requestQueryParametersSchema = getRequestParametersSchema(operation, 'query', document);
153
+ const requestHeadersSchema = getRequestParametersSchema(operation, 'header', document);
154
+
155
+ const { requestBodySchema, isRequestBodyRequired } = getRequestBodySchema(operation, document);
156
+ if (requestBodySchema !== undefined && !isRequestBodyAllowed(method)) {
157
+ throw new Error(`Request body is not allowed for ${method} method`);
158
+ }
159
+
160
+ const responseContextSchemaName = `${operationId}RequestContext`;
161
+ // eslint-disable-next-line sonarjs/prefer-immediate-return
162
+ const requestContextSchema = {
163
+ $schema: JSON_SCHEMA_META_2020_URL,
164
+ $id: `${apiSchemasBaseUri}/${responseContextSchemaName}`,
165
+ type: 'object',
166
+ properties: {
167
+ ...(requestPathParametersSchema ? { params: requestPathParametersSchema } : {}),
168
+ ...(requestQueryParametersSchema ? { params: requestQueryParametersSchema } : {}),
169
+ headers: requestHeadersSchema ?? { type: 'object', additionalProperties: true },
170
+ ...(requestBodySchema ? { body: requestBodySchema } : {}),
171
+ },
172
+ required: [
173
+ ...(requestPathParametersSchema?.required ? ['params'] : []),
174
+ ...(requestQueryParametersSchema?.required ? ['query'] : []),
175
+ ...(requestHeadersSchema?.required ? ['headers'] : []),
176
+ ...(isRequestBodyRequired === true ? ['body'] : []),
177
+ ],
178
+ additionalProperties: false,
179
+ };
180
+
181
+ return requestContextSchema;
182
+ }
183
+
184
+ function getResponseReason(status: string): string {
185
+ return status === 'default' ? `Default` : getReasonPhrase(status).replaceAll(/\s/gu, ''); // remove spaces
186
+ }
187
+
188
+ function getResponseBodySchema(response: v31.ResponseObject) {
189
+ return getBodySchema(response.content);
190
+ }
191
+
192
+ function getResponseHeadersSchema(
193
+ headers: Record<string, v31.HeaderObject> | undefined,
194
+ document: v31.Document,
195
+ ): v31.SchemaObject | undefined {
196
+ if (headers === undefined || Object.keys(headers).length === 0) {
197
+ return undefined;
198
+ }
199
+
200
+ const resolvedHeaders = Object.fromEntries(
201
+ Object.entries(headers).map(([name, header]) => [name.toLowerCase(), resolve(document, header)]),
202
+ );
203
+ const resolvedHeaderSchemas = Object.fromEntries(
204
+ Object.entries(resolvedHeaders).map(([name, header]) => [
205
+ name,
206
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
207
+ header.schema ?? ({ type: 'string' } as v31.SchemaObject),
208
+ ]),
209
+ );
210
+ const requiredHeaderNames = Object.entries(resolvedHeaders)
211
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
212
+ .filter(([_name, header]) => header.required === true)
213
+ .map(([name]) => name);
214
+ return {
215
+ type: 'object',
216
+ properties: resolvedHeaderSchemas,
217
+ ...(requiredHeaderNames.length === 0 ? {} : { required: requiredHeaderNames }),
218
+ };
219
+ }
220
+
221
+ function getResponseSchema(
222
+ status: string,
223
+ response: v31.ResponseObject | v31.ReferenceObject,
224
+ document: v31.Document,
225
+ apiSchemasBaseUri: string,
226
+ operationId: string,
227
+ ) {
228
+ const resolvedResponse = resolve(document, response);
229
+ const responseBodySchema = getResponseBodySchema(resolvedResponse);
230
+ const responseHeadersSchema = getResponseHeadersSchema(resolvedResponse.headers, document);
231
+
232
+ const schemaName = `${operationId}Response${getResponseReason(status)}`;
233
+ return {
234
+ $schema: JSON_SCHEMA_META_2020_URL,
235
+ $id: `${apiSchemasBaseUri}/${schemaName}`,
236
+ type: 'object',
237
+ properties: {
238
+ headers: responseHeadersSchema ?? { type: 'object', additionalProperties: true },
239
+ ...(responseBodySchema ? { body: responseBodySchema } : {}),
240
+ },
241
+ required: [
242
+ ...(responseHeadersSchema?.required !== undefined && responseHeadersSchema.required.length > 0
243
+ ? ['headers']
244
+ : []),
245
+ ...(responseBodySchema ? ['body'] : []),
246
+ ],
247
+ additionalProperties: false,
248
+ };
249
+ }
250
+
251
+ function getResponseContextSchemas(
252
+ operation: v31.OperationObject,
253
+ operationId: string,
254
+ document: v31.Document,
255
+ apiSchemasBaseUri: string,
256
+ ) {
257
+ assert.ok(operation.responses !== undefined, 'Operation responses must be defined');
258
+ return Object.fromEntries(
259
+ Object.entries(operation.responses).map(([status, response]) => [
260
+ status.toLowerCase(),
261
+ getResponseSchema(status.toLowerCase(), response, document, apiSchemasBaseUri, operationId),
262
+ ]),
263
+ );
264
+ }
265
+
266
+ function getOperationId(
267
+ path: string,
268
+ method: string,
269
+ operation: v31.OperationObject,
270
+ operationIds: Set<string>,
271
+ ): string {
272
+ const operationIdBase = operation.operationId ?? `${path}-${method}`;
273
+ const parts = operationIdBase.split(/[-=/]/u); // split operationId into parts by -, =, or /
274
+
275
+ const operationId = parts
276
+ .filter((part) => part.trim() !== '' && !/\{.*\}/u.test(part)) // keep only non-empty parts that are not path parameters
277
+ .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
278
+ .join('');
279
+ if (!operationIds.has(operationId)) {
280
+ return operationId;
281
+ }
282
+
283
+ // KISS, we could try to to come up with a better naming convension in case of name collision, but it's probably better to leave it to the service to decide a appropriate operationId
284
+ let operationIdIndex = 1;
285
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
286
+ while (operationIds.has(`${operationId}${operationIdIndex}`)) {
287
+ operationIdIndex += 1;
288
+ }
289
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
290
+ return `${operationId}${operationIdIndex}`;
291
+ }
292
+
293
+ function updateOpenapiSchemaDefinitionsReferences(
294
+ value: unknown,
295
+ relativeSchemaDefinitionReferenceUri: string,
296
+ key?: string,
297
+ ): unknown {
298
+ if (typeof value === 'string' && key === '$ref' && value.startsWith(OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE)) {
299
+ return value.replace(OPENAPI_SCHEMA_DEFINITIONS_REFERENCE_URI_BASE, relativeSchemaDefinitionReferenceUri);
300
+ }
301
+
302
+ if (Array.isArray(value)) {
303
+ return value.map((item) => updateOpenapiSchemaDefinitionsReferences(item, relativeSchemaDefinitionReferenceUri));
304
+ }
305
+
306
+ if (typeof value === 'object' && value !== null) {
307
+ return Object.fromEntries(
308
+ Object.entries(value).map(([childKey, childValue]) => [
309
+ childKey,
310
+ updateOpenapiSchemaDefinitionsReferences(childValue, relativeSchemaDefinitionReferenceUri, childKey),
311
+ ]),
312
+ );
313
+ }
314
+
315
+ return value;
316
+ }
317
+
318
+ function buildApiSchemaFromDocument(
319
+ document: v31.Document,
320
+ organization: string,
321
+ serviceName: string,
322
+ ): ApiSchemas | null {
323
+ if (document.paths === undefined) {
324
+ return null;
325
+ }
326
+ const serverUri = document.servers?.[0]?.url;
327
+ if (serverUri === undefined) {
328
+ return null;
329
+ }
330
+ const serverPathname = serverUri.startsWith('http') ? new URL(serverUri).pathname : serverUri;
331
+ const endpointSchemasBaseUri = `https://${serviceName}.${organization}${serverPathname}/schemas`;
332
+ const apiSchemasBaseUri = `${endpointSchemasBaseUri}/api`;
333
+ const apiSchemas: Record<string, Record<string, OperationSchemas>> = {};
334
+ const allSchemas: ApiSchemas = { apis: apiSchemas };
335
+ const operationIds = new Set<string>();
336
+
337
+ for (const [path, pathItems] of Object.entries(document.paths)) {
338
+ // convert openapi path to koa router path, e.g. "/user/{userId}" --> "/user/:userId"
339
+ // eslint-disable-next-line prefer-named-capture-group
340
+ const koaPath = path.replaceAll(/\{([^}]+)\}/gu, ':$1');
341
+ const pathSchemas: Record<string, OperationSchemas> = {};
342
+ apiSchemas[`${serverPathname}${koaPath}`] = pathSchemas;
343
+
344
+ for (const method of ALL_OPERATION_METHODS) {
345
+ const operation = pathItems?.[method];
346
+ if (operation !== undefined) {
347
+ const operationId = getOperationId(path, method, operation, operationIds);
348
+ operationIds.add(operationId);
349
+ pathSchemas[method] = {
350
+ request: getRequestContextSchema(method, operation, operationId, document, apiSchemasBaseUri),
351
+ responses: getResponseContextSchemas(operation, operationId, document, apiSchemasBaseUri),
352
+ };
353
+ }
354
+ }
355
+ }
356
+
357
+ if (document.components?.schemas !== undefined) {
358
+ allSchemas.definitions = Object.fromEntries(
359
+ Object.entries(document.components.schemas).map(([name, schema]) => [
360
+ name,
361
+ { $schema: JSON_SCHEMA_META_2020_URL, $id: `${endpointSchemasBaseUri}/definitions/${name}`, ...schema },
362
+ ]),
363
+ );
364
+ }
365
+
366
+ const relativeSchemaDefinitionReferenceUri = `${serverPathname}/schemas/definitions/`;
367
+ return updateOpenapiSchemaDefinitionsReferences(
368
+ structuredClone(allSchemas),
369
+ relativeSchemaDefinitionReferenceUri,
370
+ ) as ApiSchemas;
371
+ }
372
+
373
+ export function buildApiSchemaFromYaml(
374
+ yamlContent: string,
375
+ organization: string,
376
+ serviceName: string,
377
+ ): ApiSchemas | null {
378
+ // eslint-disable-next-line import/no-named-as-default-member
379
+ const document = jsYaml.load(yamlContent) as v31.Document;
380
+ return buildApiSchemaFromDocument(document, organization, serviceName);
381
+ }
382
+
383
+ async function generateEndpointSchemas(
384
+ organization: string,
385
+ serviceName: string,
386
+ root: string,
387
+ endpoint: string,
388
+ ): Promise<void> {
389
+ const documentContents = await fs.readFile(`${root}/${endpoint}/swagger.yml`, 'utf8');
390
+ // eslint-disable-next-line import/no-named-as-default-member
391
+ const document = (await jsYaml.load(documentContents)) as v31.Document;
392
+ const normalizedApiSchemas = buildApiSchemaFromDocument(document, organization, serviceName);
393
+ if (normalizedApiSchemas === null) {
394
+ return;
395
+ }
396
+ const swaggerSchemaFilename = `${root}/${endpoint}/${SWAGGER_SCHEMA_FILENAME}`;
397
+ await fs.writeFile(swaggerSchemaFilename, JSON.stringify(normalizedApiSchemas, undefined, 2));
398
+ log(`Generated schema ${swaggerSchemaFilename}`);
399
+ }
400
+
401
+ export async function generateSchemas(): Promise<void> {
402
+ const serviceJsonPackageFile = await fs.readFile(`./package.json`, 'utf8');
403
+ const packageJson = JSON.parse(serviceJsonPackageFile) as {
404
+ name: string;
405
+ service: {
406
+ api: {
407
+ root: string;
408
+ endpoints: string[];
409
+ };
410
+ };
411
+ };
412
+
413
+ // assume that the package name is in the format of `@organization/service-name`
414
+ const [organization, serviceName] = packageJson.name.slice(1).split('/');
415
+ assert.ok(organization !== undefined && serviceName !== undefined, 'Invalid package name');
416
+
417
+ await Promise.all(
418
+ packageJson.service.api.endpoints.map((endpoint) =>
419
+ generateEndpointSchemas(organization, serviceName, packageJson.service.api.root, endpoint),
420
+ ),
421
+ );
422
+ }
@@ -0,0 +1,189 @@
1
+ // openapi/service-schema-generator.ts
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+
6
+ import debug from 'debug';
7
+
8
+ import { type ApiSchemas, buildApiSchemaFromYaml } from './generate-schema.ts';
9
+
10
+ const log = debug('eslint-plugin:athena:service-schema-generator');
11
+
12
+ const GITHUB_ORGANIZATIONS = ['checkdigit', 'rebolt-checkdigit'] as const;
13
+ const SWAGGER_SCHEMA_DEREF_FILENAME = 'swagger.schema.deref.json';
14
+
15
+ interface ServiceEndpoint {
16
+ path: string;
17
+ yamlContent: string;
18
+ }
19
+
20
+ interface ServiceSource {
21
+ organization: string;
22
+ serviceName: string;
23
+ endpoints: ServiceEndpoint[];
24
+ }
25
+
26
+ function derefApiSchemas(schemas: ApiSchemas): ApiSchemas {
27
+ const definitions = schemas.definitions ?? {};
28
+
29
+ function derefValue(value: unknown, resolving: Set<string> = new Set<string>()): unknown {
30
+ if (value === null || typeof value !== 'object') {
31
+ return value;
32
+ }
33
+ if (Array.isArray(value)) {
34
+ return value.map((item) => derefValue(item, resolving));
35
+ }
36
+ const obj = value as Record<string, unknown>;
37
+ if (typeof obj['$ref'] === 'string') {
38
+ const refName = /\/definitions\/(?<name>[^/]+)$/u.exec(obj['$ref'])?.groups?.['name'];
39
+ if (refName !== undefined && !resolving.has(refName)) {
40
+ return derefValue(definitions[refName], new Set<string>([...resolving, refName]));
41
+ }
42
+ }
43
+ return Object.fromEntries(Object.entries(obj).map(([key, val]) => [key, derefValue(val, resolving)]));
44
+ }
45
+
46
+ const resolvedApis = derefValue(schemas.apis) as ApiSchemas['apis'];
47
+ if (schemas.definitions !== undefined) {
48
+ return {
49
+ apis: resolvedApis,
50
+ definitions: derefValue(schemas.definitions) as NonNullable<ApiSchemas['definitions']>,
51
+ };
52
+ }
53
+ return { apis: resolvedApis };
54
+ }
55
+
56
+ function fetchUrlSync(url: string): string | null {
57
+ try {
58
+ return execFileSync(process.execPath, ['--input-type=module'], {
59
+ input: `const r=await fetch(${JSON.stringify(url)});if(!r.ok)process.exit(1);process.stdout.write(await r.text());`,
60
+ encoding: 'utf-8',
61
+ timeout: 15_000,
62
+ });
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function readServiceConfig(
69
+ packageJsonContent: string,
70
+ org: string,
71
+ serviceName: string,
72
+ ): { organization: string; serviceName: string; apiRoot: string; endpoints: string[] } | null {
73
+ const packageJson = JSON.parse(packageJsonContent) as {
74
+ name?: string;
75
+ service?: { api?: { root?: string; endpoints?: string[] } };
76
+ };
77
+ const apiRoot = packageJson.service?.api?.root;
78
+ const apiEndpoints = packageJson.service?.api?.endpoints;
79
+ if (apiRoot === undefined || apiEndpoints === undefined || apiEndpoints.length === 0) {
80
+ return null;
81
+ }
82
+ const pkgOrg = packageJson.name?.slice(1).split('/')[0] ?? org;
83
+ const pkgServiceName = packageJson.name?.slice(1).split('/')[1] ?? serviceName;
84
+ return { organization: pkgOrg, serviceName: pkgServiceName, apiRoot, endpoints: apiEndpoints };
85
+ }
86
+
87
+ function findServiceInNodeModules(serviceName: string): ServiceSource | null {
88
+ for (const org of GITHUB_ORGANIZATIONS) {
89
+ const packageDir = `node_modules/@${org}/${serviceName}`;
90
+ const packageJsonPath = `${packageDir}/package.json`;
91
+ if (!existsSync(packageJsonPath)) {
92
+ continue;
93
+ }
94
+
95
+ try {
96
+ const config = readServiceConfig(readFileSync(packageJsonPath, 'utf-8'), org, serviceName);
97
+ if (config === null) {
98
+ continue;
99
+ }
100
+
101
+ const endpoints: ServiceEndpoint[] = [];
102
+ for (const endpoint of config.endpoints) {
103
+ const swaggerPath = `${packageDir}/${config.apiRoot}/${endpoint}/swagger.yml`;
104
+ if (existsSync(swaggerPath)) {
105
+ endpoints.push({ path: endpoint, yamlContent: readFileSync(swaggerPath, 'utf-8') });
106
+ }
107
+ }
108
+
109
+ if (endpoints.length > 0) {
110
+ return { organization: config.organization, serviceName: config.serviceName, endpoints };
111
+ }
112
+ } catch {
113
+ // continue to next org
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function findServiceOnGitHub(serviceName: string): ServiceSource | null {
120
+ for (const org of GITHUB_ORGANIZATIONS) {
121
+ const pkgUrl = `https://raw.githubusercontent.com/${org}/${serviceName}/main/package.json`;
122
+ log(`fetching package.json from ${pkgUrl}`);
123
+ const pkgContent = fetchUrlSync(pkgUrl);
124
+ if (pkgContent === null) {
125
+ continue;
126
+ }
127
+
128
+ try {
129
+ const config = readServiceConfig(pkgContent, org, serviceName);
130
+ if (config === null) {
131
+ continue;
132
+ }
133
+
134
+ const endpoints: ServiceEndpoint[] = [];
135
+ for (const endpoint of config.endpoints) {
136
+ const swaggerUrl = `https://raw.githubusercontent.com/${org}/${serviceName}/main/${config.apiRoot}/${endpoint}/swagger.yml`;
137
+ log(`fetching swagger.yml from ${swaggerUrl}`);
138
+ const yamlContent = fetchUrlSync(swaggerUrl);
139
+ if (yamlContent !== null) {
140
+ endpoints.push({ path: endpoint, yamlContent });
141
+ }
142
+ }
143
+
144
+ if (endpoints.length > 0) {
145
+ return { organization: config.organization, serviceName: config.serviceName, endpoints };
146
+ }
147
+ } catch {
148
+ // continue to next org
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export function generateSchemasForService(
155
+ serviceName: string,
156
+ outputDir?: string,
157
+ ): { schema: ApiSchemas; endpoint: string }[] {
158
+ log('generating schemas for service', serviceName);
159
+
160
+ const source = findServiceInNodeModules(serviceName) ?? findServiceOnGitHub(serviceName);
161
+ if (source === null) {
162
+ log('no swagger source found for service', serviceName);
163
+ return [];
164
+ }
165
+
166
+ const results: { schema: ApiSchemas; endpoint: string }[] = [];
167
+ for (const { path: endpoint, yamlContent } of source.endpoints) {
168
+ try {
169
+ const rawSchema = buildApiSchemaFromYaml(yamlContent, source.organization, source.serviceName);
170
+ if (rawSchema === null) {
171
+ continue;
172
+ }
173
+ const schema = derefApiSchemas(rawSchema);
174
+ results.push({ schema, endpoint });
175
+
176
+ if (outputDir !== undefined) {
177
+ const versionFolder = endpoint.split('/').at(-1) ?? endpoint;
178
+ const dir = `${outputDir}/${versionFolder}`;
179
+ mkdirSync(dir, { recursive: true });
180
+ writeFileSync(`${dir}/${SWAGGER_SCHEMA_DEREF_FILENAME}`, JSON.stringify(schema, undefined, 2));
181
+ log(`cached schema to ${dir}/${SWAGGER_SCHEMA_DEREF_FILENAME}`);
182
+ }
183
+ } catch (error) {
184
+ log('error generating schema for endpoint', endpoint, error);
185
+ }
186
+ }
187
+
188
+ return results;
189
+ }