@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.
Files changed (66) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +17 -0
  3. package/SECURITY.md +13 -0
  4. package/dist-mjs/athena/api-locator.mjs +66 -0
  5. package/dist-mjs/athena/api-matcher.mjs +206 -0
  6. package/dist-mjs/athena/athena.mjs +165 -0
  7. package/dist-mjs/athena/column.mjs +1 -0
  8. package/dist-mjs/athena/context.mjs +21 -0
  9. package/dist-mjs/athena/index.mjs +1 -0
  10. package/dist-mjs/athena/service-table.mjs +45 -0
  11. package/dist-mjs/athena/sql-file.mjs +123 -0
  12. package/dist-mjs/athena/types.mjs +1 -0
  13. package/dist-mjs/athena/validate.mjs +619 -0
  14. package/dist-mjs/athena/visitor.mjs +291 -0
  15. package/dist-mjs/get-documentation-url.mjs +9 -0
  16. package/dist-mjs/index.mjs +56 -0
  17. package/dist-mjs/openapi/deref-schema.mjs +20 -0
  18. package/dist-mjs/openapi/generate-schema.mjs +375 -0
  19. package/dist-mjs/openapi/service-schema-generator.mjs +176 -0
  20. package/dist-mjs/peggy/athena-peggy.mjs +20700 -0
  21. package/dist-mjs/service.mjs +9 -0
  22. package/dist-mjs/sql-parser.mjs +28 -0
  23. package/dist-types/athena/api-locator.d.ts +2 -0
  24. package/dist-types/athena/api-matcher.d.ts +14 -0
  25. package/dist-types/athena/athena.d.ts +5 -0
  26. package/dist-types/athena/column.d.ts +1 -0
  27. package/dist-types/athena/context.d.ts +21 -0
  28. package/dist-types/athena/index.d.ts +8 -0
  29. package/dist-types/athena/service-table.d.ts +8 -0
  30. package/dist-types/athena/sql-file.d.ts +5 -0
  31. package/dist-types/athena/types.d.ts +493 -0
  32. package/dist-types/athena/validate.d.ts +14 -0
  33. package/dist-types/athena/visitor.d.ts +75 -0
  34. package/dist-types/get-documentation-url.d.ts +1 -0
  35. package/dist-types/index.d.ts +5 -0
  36. package/dist-types/openapi/deref-schema.d.ts +1 -0
  37. package/dist-types/openapi/generate-schema.d.ts +33 -0
  38. package/dist-types/openapi/service-schema-generator.d.ts +5 -0
  39. package/dist-types/peggy/athena-peggy.d.ts +13 -0
  40. package/dist-types/service.d.ts +2 -0
  41. package/dist-types/sql-parser.d.ts +25 -0
  42. package/package.json +1 -0
  43. package/src/api/v1/swagger.yml +619 -0
  44. package/src/api/v2/swagger.yml +477 -0
  45. package/src/athena/api-locator.ts +78 -0
  46. package/src/athena/api-matcher.ts +323 -0
  47. package/src/athena/athena.ts +224 -0
  48. package/src/athena/column.ts +4 -0
  49. package/src/athena/context.ts +47 -0
  50. package/src/athena/index.ts +13 -0
  51. package/src/athena/service-table.ts +78 -0
  52. package/src/athena/sql-file.ts +161 -0
  53. package/src/athena/types.ts +568 -0
  54. package/src/athena/validate.ts +902 -0
  55. package/src/athena/visitor.ts +406 -0
  56. package/src/get-documentation-url.ts +7 -0
  57. package/src/index.ts +67 -0
  58. package/src/openapi/deref-schema.ts +20 -0
  59. package/src/openapi/generate-schema.ts +553 -0
  60. package/src/openapi/service-schema-generator.ts +241 -0
  61. package/src/peggy/athena-peggy.ts +22149 -0
  62. package/src/peggy/athena.peggy +2971 -0
  63. package/src/service.ts +11 -0
  64. package/src/services/eslintAthenaPlugin/v1/swagger.schema.deref.json +1931 -0
  65. package/src/services/eslintAthenaPlugin/v2/swagger.schema.deref.json +978 -0
  66. 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
+ }