@checkdigit/eslint-plugin 7.18.0-PR.143-e2e9 → 7.18.0-PR.143-cf9d
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/dist-mjs/athena/api-locator.mjs +19 -10
- package/dist-mjs/athena/api-matcher.mjs +37 -77
- package/dist-mjs/athena/validate.mjs +130 -161
- package/dist-mjs/athena/visitor.mjs +32 -52
- package/dist-mjs/openapi/generate-schema.mjs +17 -23
- package/dist-mjs/openapi/service-schema-generator.mjs +7 -9
- package/dist-types/athena/visitor.d.ts +5 -3
- package/package.json +1 -1
- package/src/athena/api-locator.ts +20 -12
- package/src/athena/api-matcher.ts +45 -79
- package/src/athena/validate.ts +175 -170
- package/src/athena/visitor.ts +38 -71
- package/src/openapi/generate-schema.ts +18 -34
- package/src/openapi/service-schema-generator.ts +7 -8
package/src/athena/visitor.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
// athena/visitor.ts
|
|
2
2
|
|
|
3
|
-
import debug from 'debug';
|
|
4
|
-
|
|
5
|
-
const log = debug('athena:visitor');
|
|
6
|
-
|
|
7
3
|
import type {
|
|
8
4
|
AggrFunc,
|
|
9
5
|
BaseFrom,
|
|
@@ -70,20 +66,23 @@ export interface VisitorMap {
|
|
|
70
66
|
// nodes without an intermediate cast.
|
|
71
67
|
// -------------------------------------------------------------------
|
|
72
68
|
|
|
69
|
+
function hasNodeType(node: unknown, type: string): boolean {
|
|
70
|
+
return typeof node === 'object' && node !== null && (node as { type?: unknown }).type === type;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
73
|
export function isUnnestFrom(node: unknown): node is UnnestFrom {
|
|
74
|
-
return
|
|
74
|
+
return hasNodeType(node, 'unnest');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function isDual(node: unknown): node is Dual {
|
|
78
|
-
return
|
|
78
|
+
return hasNodeType(node, 'dual');
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function isValuesFrom(node: unknown): node is ValuesFrom {
|
|
82
82
|
if (typeof node !== 'object' || node === null) {
|
|
83
83
|
return false;
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
return typeof expr === 'object' && expr !== null && (expr as { type?: unknown }).type === 'values';
|
|
85
|
+
return hasNodeType((node as { expr?: unknown }).expr, 'values');
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
export function isTableExpr(node: unknown): node is TableExpr {
|
|
@@ -94,27 +93,16 @@ export function isTableExpr(node: unknown): node is TableExpr {
|
|
|
94
93
|
return typeof expr === 'object' && expr !== null && 'ast' in expr;
|
|
95
94
|
}
|
|
96
95
|
|
|
96
|
+
function isRegularFromItem(node: unknown): boolean {
|
|
97
|
+
return typeof node === 'object' && node !== null && !isUnnestFrom(node) && !isDual(node) && !isTableExpr(node);
|
|
98
|
+
}
|
|
99
|
+
|
|
97
100
|
export function isJoin(node: unknown): node is Join {
|
|
98
|
-
return (
|
|
99
|
-
!isUnnestFrom(node) &&
|
|
100
|
-
!isDual(node) &&
|
|
101
|
-
!isTableExpr(node) &&
|
|
102
|
-
typeof node === 'object' &&
|
|
103
|
-
node !== null &&
|
|
104
|
-
'join' in node
|
|
105
|
-
);
|
|
101
|
+
return isRegularFromItem(node) && 'join' in (node as object);
|
|
106
102
|
}
|
|
107
103
|
|
|
108
104
|
export function isBaseFrom(node: unknown): node is BaseFrom {
|
|
109
|
-
return (
|
|
110
|
-
!isUnnestFrom(node) &&
|
|
111
|
-
!isDual(node) &&
|
|
112
|
-
!isTableExpr(node) &&
|
|
113
|
-
!isJoin(node) &&
|
|
114
|
-
typeof node === 'object' &&
|
|
115
|
-
node !== null &&
|
|
116
|
-
'table' in node
|
|
117
|
-
);
|
|
105
|
+
return isRegularFromItem(node) && !isJoin(node) && 'table' in (node as object);
|
|
118
106
|
}
|
|
119
107
|
|
|
120
108
|
export function hasArrayIndex(node: ColumnRefItem): node is ColumnRefWithIndex {
|
|
@@ -237,6 +225,17 @@ function walkFrom(node: From, visitor: VisitorMap): void {
|
|
|
237
225
|
visitor.visitBaseFrom?.(node);
|
|
238
226
|
}
|
|
239
227
|
|
|
228
|
+
/** Normalise select.from (null / single item / array) to a From[]. */
|
|
229
|
+
export function fromClauseItems(select: Select): From[] {
|
|
230
|
+
if (Array.isArray(select.from)) {
|
|
231
|
+
return select.from;
|
|
232
|
+
}
|
|
233
|
+
if (select.from !== null) {
|
|
234
|
+
return [select.from];
|
|
235
|
+
}
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
240
239
|
// -------------------------------------------------------------------
|
|
241
240
|
// Core walk — dispatches per node type and recurses into children.
|
|
242
241
|
// -------------------------------------------------------------------
|
|
@@ -254,15 +253,7 @@ export function walk(node: unknown, visitor: VisitorMap): void {
|
|
|
254
253
|
visitor.visitWith?.(withItem);
|
|
255
254
|
walk(withItem.stmt.ast, visitor);
|
|
256
255
|
}
|
|
257
|
-
|
|
258
|
-
if (Array.isArray(sel.from)) {
|
|
259
|
-
fromItems = sel.from;
|
|
260
|
-
} else if (sel.from !== null) {
|
|
261
|
-
fromItems = [sel.from];
|
|
262
|
-
} else {
|
|
263
|
-
fromItems = [];
|
|
264
|
-
}
|
|
265
|
-
for (const fromItem of fromItems) {
|
|
256
|
+
for (const fromItem of fromClauseItems(sel)) {
|
|
266
257
|
walkFrom(fromItem, visitor);
|
|
267
258
|
}
|
|
268
259
|
for (const col of sel.columns) {
|
|
@@ -307,30 +298,6 @@ export function extractColumnRefs(expr: unknown): ColumnRefItem[] {
|
|
|
307
298
|
return refs;
|
|
308
299
|
}
|
|
309
300
|
|
|
310
|
-
/** Return the path string from the first json_extract_scalar / json_extract call found. */
|
|
311
|
-
export function extractJsonExtractPath(expr: unknown): string | undefined {
|
|
312
|
-
let path: string | undefined;
|
|
313
|
-
walkExpr(expr, {
|
|
314
|
-
visitFunction(node) {
|
|
315
|
-
if (path !== undefined) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const fnName = node.name.name[0]?.value;
|
|
319
|
-
if (fnName === 'json_extract_scalar' || fnName === 'json_extract') {
|
|
320
|
-
const pathArg = node.args?.value[1];
|
|
321
|
-
if (pathArg !== undefined) {
|
|
322
|
-
const val = (pathArg as unknown as { value?: unknown }).value;
|
|
323
|
-
if (typeof val === 'string') {
|
|
324
|
-
path = val;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
log('extractJsonExtractPath, function:', fnName, path);
|
|
328
|
-
}
|
|
329
|
-
},
|
|
330
|
-
});
|
|
331
|
-
return path;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
301
|
export interface JsonExtractCall {
|
|
335
302
|
ref: ColumnRefItem;
|
|
336
303
|
path: string;
|
|
@@ -366,6 +333,11 @@ export function extractJsonExtractCalls(expr: unknown): JsonExtractCall[] {
|
|
|
366
333
|
return calls;
|
|
367
334
|
}
|
|
368
335
|
|
|
336
|
+
/** Return the path string from the first json_extract_scalar / json_extract call found. */
|
|
337
|
+
export function extractJsonExtractPath(expr: unknown): string | undefined {
|
|
338
|
+
return extractJsonExtractCalls(expr)[0]?.path;
|
|
339
|
+
}
|
|
340
|
+
|
|
369
341
|
/** Return the JSONPath-style string from a bracket accessor (col['key']), or undefined. */
|
|
370
342
|
export function extractBracketAccessorPath(expr: unknown): string | undefined {
|
|
371
343
|
let path: string | undefined;
|
|
@@ -397,13 +369,12 @@ export function hasFunctionCalls(expr: unknown): boolean {
|
|
|
397
369
|
return found;
|
|
398
370
|
}
|
|
399
371
|
|
|
400
|
-
|
|
401
|
-
export function containsCastToArray(expr: unknown): boolean {
|
|
372
|
+
function containsCastToType(expr: unknown, dataType: 'ARRAY' | 'MAP'): boolean {
|
|
402
373
|
let found = false;
|
|
403
374
|
walkExpr(expr, {
|
|
404
375
|
visitCast(node) {
|
|
405
376
|
const target = (node as unknown as { target?: { dataType?: string }[] }).target?.[0];
|
|
406
|
-
if (target?.dataType ===
|
|
377
|
+
if (target?.dataType === dataType) {
|
|
407
378
|
found = true;
|
|
408
379
|
}
|
|
409
380
|
},
|
|
@@ -411,16 +382,12 @@ export function containsCastToArray(expr: unknown): boolean {
|
|
|
411
382
|
return found;
|
|
412
383
|
}
|
|
413
384
|
|
|
385
|
+
/** Return true when the expression tree contains any CAST / TRY_CAST to ARRAY<…>. */
|
|
386
|
+
export function containsCastToArray(expr: unknown): boolean {
|
|
387
|
+
return containsCastToType(expr, 'ARRAY');
|
|
388
|
+
}
|
|
389
|
+
|
|
414
390
|
/** Return true when the expression tree contains any CAST / TRY_CAST to MAP<…>. */
|
|
415
391
|
export function containsCastToMap(expr: unknown): boolean {
|
|
416
|
-
|
|
417
|
-
walkExpr(expr, {
|
|
418
|
-
visitCast(node) {
|
|
419
|
-
const target = (node as unknown as { target?: { dataType?: string }[] }).target?.[0];
|
|
420
|
-
if (target?.dataType === 'MAP') {
|
|
421
|
-
found = true;
|
|
422
|
-
}
|
|
423
|
-
},
|
|
424
|
-
});
|
|
425
|
-
return found;
|
|
392
|
+
return containsCastToType(expr, 'MAP');
|
|
426
393
|
}
|
|
@@ -86,8 +86,9 @@ function getRequestParametersSchema(
|
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
const parameters = getParameters(operation.parameters, document).filter(
|
|
90
|
+
(parameter) => parameter.in === parameterType,
|
|
91
|
+
);
|
|
91
92
|
if (parameters.length === 0) {
|
|
92
93
|
return;
|
|
93
94
|
}
|
|
@@ -95,8 +96,7 @@ function getRequestParametersSchema(
|
|
|
95
96
|
const parametersSchema = Object.fromEntries(
|
|
96
97
|
parameters.map((parameter) => [
|
|
97
98
|
parameterType === 'header' ? parameter.name.toLowerCase() : parameter.name,
|
|
98
|
-
|
|
99
|
-
parameter.schema ?? ({ type: 'string' } as v31.SchemaObject),
|
|
99
|
+
parameter.schema ?? { type: 'string' as const },
|
|
100
100
|
]),
|
|
101
101
|
);
|
|
102
102
|
const requiredParameterNames = parameters
|
|
@@ -113,17 +113,8 @@ function getRequestParametersSchema(
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function getBodySchema(contents: Record<string, v31.MediaTypeObject> | undefined): v31.SchemaObject | undefined {
|
|
116
|
-
|
|
117
|
-
|
|
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;
|
|
116
|
+
const schema = Object.values(contents ?? {})[0]?.schema;
|
|
117
|
+
return schema !== undefined && Object.keys(schema).length > 0 ? schema : undefined;
|
|
127
118
|
}
|
|
128
119
|
|
|
129
120
|
function getRequestBodySchema(operation: v31.OperationObject, document: v31.Document) {
|
|
@@ -201,15 +192,10 @@ function getResponseHeadersSchema(
|
|
|
201
192
|
Object.entries(headers).map(([name, header]) => [name.toLowerCase(), resolve(document, header)]),
|
|
202
193
|
);
|
|
203
194
|
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
|
-
]),
|
|
195
|
+
Object.entries(resolvedHeaders).map(([name, header]) => [name, header.schema ?? { type: 'string' as const }]),
|
|
209
196
|
);
|
|
210
197
|
const requiredHeaderNames = Object.entries(resolvedHeaders)
|
|
211
|
-
|
|
212
|
-
.filter(([_name, header]) => header.required === true)
|
|
198
|
+
.filter(([, header]) => header.required === true)
|
|
213
199
|
.map(([name]) => name);
|
|
214
200
|
return {
|
|
215
201
|
type: 'object',
|
|
@@ -282,12 +268,10 @@ function getOperationId(
|
|
|
282
268
|
|
|
283
269
|
// 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
270
|
let operationIdIndex = 1;
|
|
285
|
-
|
|
286
|
-
while (operationIds.has(`${operationId}${operationIdIndex}`)) {
|
|
271
|
+
while (operationIds.has(`${operationId}${operationIdIndex.toString()}`)) {
|
|
287
272
|
operationIdIndex += 1;
|
|
288
273
|
}
|
|
289
|
-
|
|
290
|
-
return `${operationId}${operationIdIndex}`;
|
|
274
|
+
return `${operationId}${operationIdIndex.toString()}`;
|
|
291
275
|
}
|
|
292
276
|
|
|
293
277
|
function updateOpenapiSchemaDefinitionsReferences(
|
|
@@ -315,6 +299,11 @@ function updateOpenapiSchemaDefinitionsReferences(
|
|
|
315
299
|
return value;
|
|
316
300
|
}
|
|
317
301
|
|
|
302
|
+
function getFirehoseLoggedExtension(obj: object): unknown {
|
|
303
|
+
const record = obj as Record<string, unknown>;
|
|
304
|
+
return record['x-firehose-logged'] ?? record['x-firehoseLogged'];
|
|
305
|
+
}
|
|
306
|
+
|
|
318
307
|
function buildApiSchemaFromDocument(
|
|
319
308
|
document: v31.Document,
|
|
320
309
|
organization: string,
|
|
@@ -333,24 +322,19 @@ function buildApiSchemaFromDocument(
|
|
|
333
322
|
const apiSchemas: Record<string, Record<string, OperationSchemas>> = {};
|
|
334
323
|
const allSchemas: ApiSchemas = { apis: apiSchemas };
|
|
335
324
|
const operationIds = new Set<string>();
|
|
336
|
-
const documentFirehoseLogged =
|
|
337
|
-
(document as Record<string, unknown>)['x-firehose-logged'] ??
|
|
338
|
-
(document as Record<string, unknown>)['x-firehoseLogged'];
|
|
325
|
+
const documentFirehoseLogged = getFirehoseLoggedExtension(document);
|
|
339
326
|
log('document firehose logged value', documentFirehoseLogged);
|
|
340
327
|
|
|
341
328
|
for (const [path, pathItems] of Object.entries(document.paths)) {
|
|
342
329
|
// convert openapi path to koa router path, e.g. "/user/{userId}" --> "/user/:userId"
|
|
343
|
-
|
|
344
|
-
const koaPath = path.replaceAll(/\{([^}]+)\}/gu, ':$1');
|
|
330
|
+
const koaPath = path.replaceAll(/\{(?<param>[^}]+)\}/gu, ':$<param>');
|
|
345
331
|
const pathSchemas: Record<string, OperationSchemas> = {};
|
|
346
332
|
apiSchemas[`${serverPathname}${koaPath}`] = pathSchemas;
|
|
347
333
|
|
|
348
334
|
for (const method of ALL_OPERATION_METHODS) {
|
|
349
335
|
const operation = pathItems?.[method];
|
|
350
336
|
if (operation !== undefined) {
|
|
351
|
-
const operationFirehoseLogged =
|
|
352
|
-
(operation as Record<string, unknown>)['x-firehose-logged'] ??
|
|
353
|
-
(operation as Record<string, unknown>)['x-firehoseLogged'];
|
|
337
|
+
const operationFirehoseLogged = getFirehoseLoggedExtension(operation);
|
|
354
338
|
log('operation firehose logged value', operationFirehoseLogged);
|
|
355
339
|
const effectiveFirehoseLogged = operationFirehoseLogged ?? documentFirehoseLogged;
|
|
356
340
|
if (effectiveFirehoseLogged !== true) {
|
|
@@ -11,6 +11,10 @@ const log = debug('eslint-plugin:athena:service-schema-generator');
|
|
|
11
11
|
const GITHUB_ORGANIZATIONS = ['checkdigit', 'rebolt-checkdigit'] as const;
|
|
12
12
|
const SWAGGER_SCHEMA_DEREF_FILENAME = 'swagger.schema.deref.json';
|
|
13
13
|
|
|
14
|
+
function errorMessageFromError(error: unknown): string {
|
|
15
|
+
return error instanceof Error ? error.message : String(error);
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
interface ServiceEndpoint {
|
|
15
19
|
path: string;
|
|
16
20
|
yamlContent: string;
|
|
@@ -66,8 +70,7 @@ function readServiceConfig(
|
|
|
66
70
|
if (apiRoot === undefined || apiEndpoints === undefined || apiEndpoints.length === 0) {
|
|
67
71
|
return null;
|
|
68
72
|
}
|
|
69
|
-
const pkgOrg = packageJson.name?.slice(1).split('/')
|
|
70
|
-
const pkgServiceName = packageJson.name?.slice(1).split('/')[1] ?? serviceName;
|
|
73
|
+
const [pkgOrg = org, pkgServiceName = serviceName] = packageJson.name?.slice(1).split('/') ?? [];
|
|
71
74
|
return { organization: pkgOrg, serviceName: pkgServiceName, apiRoot, endpoints: apiEndpoints };
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -116,9 +119,7 @@ function findServiceInNodeModules(serviceName: string): ServiceSource | null {
|
|
|
116
119
|
}
|
|
117
120
|
log(`[schema-generator] no swagger schema inside node_modules/@${org}/${serviceName}`);
|
|
118
121
|
} catch (error) {
|
|
119
|
-
log(
|
|
120
|
-
`[schema-generator] error reading node_modules/@${org}/${serviceName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
-
);
|
|
122
|
+
log(`[schema-generator] error reading node_modules/@${org}/${serviceName}: ${errorMessageFromError(error)}`);
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
return null;
|
|
@@ -158,9 +159,7 @@ export function generateSchemasForService(
|
|
|
158
159
|
log(`[schema-generator] cached schema to ${dir}/${SWAGGER_SCHEMA_DEREF_FILENAME}`);
|
|
159
160
|
}
|
|
160
161
|
} catch (error) {
|
|
161
|
-
log(
|
|
162
|
-
`[schema-generator] error processing endpoint ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
|
|
163
|
-
);
|
|
162
|
+
log(`[schema-generator] error processing endpoint ${endpoint}: ${errorMessageFromError(error)}`);
|
|
164
163
|
}
|
|
165
164
|
}
|
|
166
165
|
|