@sungen/driver-api 3.2.4 → 3.2.6
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/package.json +2 -2
- package/src/api-catalog.ts +29 -2
- package/src/api-context.ts +7 -1
- package/src/api-field-coverage.ts +115 -0
- package/src/api-field-spec.ts +136 -0
- package/src/api-file-fields.ts +43 -0
- package/src/api-repair.ts +7 -1
- package/src/cli-import.ts +31 -1
- package/src/csv-import.ts +13 -3
- package/src/index.ts +34 -2
- package/src/openapi-import.ts +254 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.6",
|
|
4
4
|
"description": "Sungen API capability — the @api annotation, API catalog, OpenAPI import + `sungen api import`, and the specs/api.ts runtime template. Plugs into @sun-asterisk/sungen via the capability SPI.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@sun-asterisk/sungen": "3.2.
|
|
16
|
+
"@sun-asterisk/sungen": "3.2.6",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
package/src/api-catalog.ts
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { parse as parseYaml } from 'yaml';
|
|
20
|
+
import { parseFileSpec, parseFiles, type FileFieldSpec } from './api-file-fields';
|
|
21
|
+
import { parseFields, type FieldSpec } from './api-field-spec';
|
|
22
|
+
|
|
23
|
+
export type { FileFieldSpec } from './api-file-fields';
|
|
24
|
+
export type { FieldSpec } from './api-field-spec';
|
|
20
25
|
|
|
21
26
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
|
22
27
|
const METHODS = new Set<string>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
@@ -28,8 +33,14 @@ export interface ApiEntry {
|
|
|
28
33
|
path: string;
|
|
29
34
|
body?: unknown;
|
|
30
35
|
encoding?: 'json' | 'form' | 'multipart'; // request-body wire format (default json); form = application/x-www-form-urlencoded
|
|
36
|
+
files?: Record<string, FileFieldSpec>; // multipart/form-data file fields (file as a form part) — presence forces multipart
|
|
37
|
+
bodyFile?: FileFieldSpec; // raw-binary body: the request body IS the file's bytes (Content-Type = mimeType, default application/octet-stream)
|
|
31
38
|
headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
|
|
32
39
|
params: string[];
|
|
40
|
+
timeout_ms?: number; // per-endpoint request timeout override (else the datasource timeout, default 15s)
|
|
41
|
+
fields?: Record<string, FieldSpec>; // optional field metadata (required/type/enum/range) — drives error+boundary matrices + the viewpoint gate
|
|
42
|
+
responseSchema?: unknown; // inline 2xx response body schema (JSON Schema) — for `matches schema` assertions
|
|
43
|
+
schemaRef?: string; // named schema (schemas.yaml key) for the 2xx response body
|
|
33
44
|
expect?: { status?: number | string };
|
|
34
45
|
description?: string;
|
|
35
46
|
scope: 'screen' | 'shared';
|
|
@@ -70,12 +81,14 @@ function refsIn(text: string): string[] {
|
|
|
70
81
|
return (text.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
/** All `:param` refs an entry binds — path + JSON body + header templates. */
|
|
84
|
+
/** All `:param` refs an entry binds — path + JSON body + header templates + file specs. */
|
|
74
85
|
function entryRefs(entry: ApiEntry): string[] {
|
|
75
86
|
const fromPath = refsIn(entry.path);
|
|
76
87
|
const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
|
|
77
88
|
const fromHeaders = entry.headers ? refsIn(JSON.stringify(entry.headers)) : [];
|
|
78
|
-
|
|
89
|
+
const fromFiles = entry.files ? refsIn(JSON.stringify(entry.files)) : [];
|
|
90
|
+
const fromBodyFile = entry.bodyFile ? refsIn(JSON.stringify(entry.bodyFile)) : [];
|
|
91
|
+
return [...new Set([...fromPath, ...fromBody, ...fromHeaders, ...fromFiles, ...fromBodyFile])];
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, ApiEntry> {
|
|
@@ -91,8 +104,14 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
|
|
|
91
104
|
path: v.path,
|
|
92
105
|
body: v.body,
|
|
93
106
|
encoding: v.encoding === 'form' || v.encoding === 'multipart' ? v.encoding : undefined, // default (undefined) = json
|
|
107
|
+
files: parseFiles(v.files),
|
|
108
|
+
bodyFile: parseFileSpec(v.bodyFile),
|
|
94
109
|
headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
|
|
95
110
|
params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
|
|
111
|
+
timeout_ms: typeof v.timeout_ms === 'number' && v.timeout_ms > 0 ? v.timeout_ms : undefined,
|
|
112
|
+
fields: parseFields(v.fields),
|
|
113
|
+
responseSchema: v.responseSchema,
|
|
114
|
+
schemaRef: typeof v.schemaRef === 'string' ? v.schemaRef : undefined,
|
|
96
115
|
expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
|
|
97
116
|
description: typeof v.description === 'string' ? v.description : undefined,
|
|
98
117
|
scope,
|
|
@@ -150,6 +169,14 @@ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | nu
|
|
|
150
169
|
const declared = new Set(entry.params);
|
|
151
170
|
for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
|
|
152
171
|
for (const d of declared) if (!refs.has(d)) errs.push(`api "${entry.name}": param "${d}" is declared but never used in path/body.`);
|
|
172
|
+
if (entry.files) {
|
|
173
|
+
for (const [field, spec] of Object.entries(entry.files)) if (!spec.path) errs.push(`api "${entry.name}": file field "${field}" has no path.`);
|
|
174
|
+
// files are always sent as multipart; an explicit json/form encoding contradicts that.
|
|
175
|
+
if (entry.encoding && entry.encoding !== 'multipart') errs.push(`api "${entry.name}": has files: but encoding: ${entry.encoding} — a file upload must be multipart (omit encoding).`);
|
|
176
|
+
}
|
|
177
|
+
if (entry.bodyFile && !entry.bodyFile.path) errs.push(`api "${entry.name}": bodyFile has no path.`);
|
|
178
|
+
if (entry.bodyFile && entry.files) errs.push(`api "${entry.name}": use either files: (multipart form parts) or bodyFile: (raw binary body), not both.`);
|
|
179
|
+
if (entry.bodyFile && entry.body !== undefined) errs.push(`api "${entry.name}": bodyFile: (raw binary body) cannot combine with a body:.`);
|
|
153
180
|
if (datasourceNames && entry.datasource && !datasourceNames.includes(entry.datasource)) {
|
|
154
181
|
errs.push(`api "${entry.name}": datasource "${entry.datasource}" is not defined in datasources.yaml.`);
|
|
155
182
|
}
|
package/src/api-context.ts
CHANGED
|
@@ -11,8 +11,13 @@ import type { Context, DiscoveryProvider, ContextMapper, GenerationUnit } from '
|
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import { lintApiCatalog } from './api-catalog';
|
|
14
|
+
import type { FieldSpec } from './api-field-spec';
|
|
14
15
|
|
|
15
|
-
interface EndpointFact {
|
|
16
|
+
interface EndpointFact {
|
|
17
|
+
name: string; method: string; path: string; datasource?: string; expectStatus?: number | string;
|
|
18
|
+
fields?: Record<string, FieldSpec>; // field metadata → the coverage model enumerates required/boundary/format matrices from it
|
|
19
|
+
schemaRef?: string; // response-schema name → the contract case can assert `matches schema`
|
|
20
|
+
}
|
|
16
21
|
|
|
17
22
|
const MUTATING = /^(POST|PUT|PATCH|DELETE)$/;
|
|
18
23
|
|
|
@@ -25,6 +30,7 @@ export const apiDiscovery: DiscoveryProvider = {
|
|
|
25
30
|
try {
|
|
26
31
|
endpoints = lintApiCatalog(unitId, null, cwd).entries.map((e) => ({
|
|
27
32
|
name: e.name, method: e.method, path: e.path, datasource: e.datasource, expectStatus: e.expect?.status,
|
|
33
|
+
...(e.fields ? { fields: e.fields } : {}), ...(e.schemaRef ? { schemaRef: e.schemaRef } : {}),
|
|
28
34
|
}));
|
|
29
35
|
} catch { /* no catalog → the verification gate reports it */ }
|
|
30
36
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-driven matrix coverage — the deterministic backstop for the `fields:` block.
|
|
3
|
+
*
|
|
4
|
+
* The coverage model (skill) makes the generator enumerate the required / boundary / format matrix
|
|
5
|
+
* mechanically from `fields:`; this module is the AUDIT backstop that flags when it didn't. For each
|
|
6
|
+
* endpoint that declares `fields:`, it checks the `@cases` rows of the scenarios referencing it and
|
|
7
|
+
* reports any constrained field with no matching negative case:
|
|
8
|
+
* - REQUIRED — a `required` field with no row that omits/empties it asserting a 4xx
|
|
9
|
+
* - BOUNDARY — a `min/max/minLength/maxLength` field with no row whose value is out of range (4xx)
|
|
10
|
+
* - FORMAT — an enum field with no row whose value is outside its `values` (4xx)
|
|
11
|
+
*
|
|
12
|
+
* Advisory only (warn). It NEVER fires on a catalog without `fields:` (no false demands). When an
|
|
13
|
+
* endpoint's `@cases` rows can't be resolved from test-data (or there are none), that IS the honest
|
|
14
|
+
* "no negative case" signal → it flags. It never THROWS on a missing/unparseable file (fail-safe):
|
|
15
|
+
* an unreadable dataset degrades to "no rows", not a crash. (The sensor additionally suppresses the
|
|
16
|
+
* finding when the endpoint tests errors via an inline 4xx instead of `@cases` — see index.ts.)
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { parse as parseYaml } from 'yaml';
|
|
21
|
+
import type { FieldSpec } from './api-field-spec';
|
|
22
|
+
|
|
23
|
+
export interface FieldCoverageGap {
|
|
24
|
+
endpoint: string;
|
|
25
|
+
kind: 'required' | 'boundary' | 'format';
|
|
26
|
+
fields: string[]; // the uncovered field names
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CaseRow { [col: string]: unknown }
|
|
30
|
+
|
|
31
|
+
/** Locate + read the test-data file for a unit, mirroring the api-catalog path rules. Undefined on miss. */
|
|
32
|
+
function readUnitTestData(screenName: string, cwd: string): Record<string, unknown> | undefined {
|
|
33
|
+
if (!screenName) return undefined;
|
|
34
|
+
const unitDir = screenName.startsWith('flows/') || screenName.startsWith('api/')
|
|
35
|
+
? path.join(cwd, 'qa', screenName)
|
|
36
|
+
: path.join(cwd, 'qa', 'screens', screenName);
|
|
37
|
+
const base = path.basename(screenName);
|
|
38
|
+
const file = path.join(unitDir, 'test-data', `${base}.yaml`);
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(file)) return undefined;
|
|
41
|
+
const doc = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
42
|
+
return doc && typeof doc === 'object' ? (doc as Record<string, unknown>) : undefined;
|
|
43
|
+
} catch { return undefined; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a `@cases:<name>` dataset (a list of row objects) from the unit's test-data. */
|
|
47
|
+
function resolveDataset(testData: Record<string, unknown> | undefined, name: string): CaseRow[] | undefined {
|
|
48
|
+
if (!testData) return undefined;
|
|
49
|
+
const v = testData[name];
|
|
50
|
+
if (!Array.isArray(v)) return undefined;
|
|
51
|
+
return v.filter((r): r is CaseRow => !!r && typeof r === 'object' && !Array.isArray(r));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const is4xx = (row: CaseRow): boolean => /^4\d\d$/.test(String(row.expect_status ?? row.status ?? '').trim());
|
|
55
|
+
const missing = (row: CaseRow, col: string): boolean => !(col in row) || String(row[col] ?? '').trim() === '';
|
|
56
|
+
const numOf = (v: unknown): number | undefined => { const n = Number(String(v).trim()); return Number.isFinite(n) ? n : undefined; };
|
|
57
|
+
|
|
58
|
+
/** A row proves field `col` is out of its numeric/length range (→ expects 4xx). */
|
|
59
|
+
function violatesBoundary(row: CaseRow, col: string, spec: FieldSpec): boolean {
|
|
60
|
+
if (!(col in row) || String(row[col] ?? '').trim() === '') return false;
|
|
61
|
+
const raw = String(row[col]).trim();
|
|
62
|
+
if (spec.min != null || spec.max != null) {
|
|
63
|
+
const n = numOf(raw);
|
|
64
|
+
if (n != null && ((spec.min != null && n < spec.min) || (spec.max != null && n > spec.max))) return true;
|
|
65
|
+
}
|
|
66
|
+
if (spec.minLength != null || spec.maxLength != null) {
|
|
67
|
+
const len = raw.length;
|
|
68
|
+
if ((spec.minLength != null && len < spec.minLength) || (spec.maxLength != null && len > spec.maxLength)) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A row proves field `col` is outside its enum values (→ expects 4xx). */
|
|
74
|
+
function violatesEnum(row: CaseRow, col: string, spec: FieldSpec): boolean {
|
|
75
|
+
if (!spec.values || !(col in row)) return false;
|
|
76
|
+
const raw = String(row[col] ?? '').trim();
|
|
77
|
+
if (raw === '') return false;
|
|
78
|
+
return !spec.values.map((x) => String(x)).includes(raw);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compute the field-coverage gaps for one endpoint given its `fields:` and the `@cases` datasets of
|
|
83
|
+
* the scenarios that reference it. Returns [] when there's nothing to say (fail-quiet on unresolved
|
|
84
|
+
* datasets: an endpoint with `fields:` but no resolvable rows yields the REQUIRED/BOUNDARY/FORMAT
|
|
85
|
+
* gaps for "no negative case at all", which is the honest signal).
|
|
86
|
+
*/
|
|
87
|
+
export function fieldCoverageGaps(
|
|
88
|
+
endpoint: string,
|
|
89
|
+
fields: Record<string, FieldSpec>,
|
|
90
|
+
datasetNames: string[],
|
|
91
|
+
testData: Record<string, unknown> | undefined,
|
|
92
|
+
): FieldCoverageGap[] {
|
|
93
|
+
const rows: CaseRow[] = [];
|
|
94
|
+
for (const name of datasetNames) { const ds = resolveDataset(testData, name); if (ds) rows.push(...ds); }
|
|
95
|
+
const errorRows = rows.filter(is4xx);
|
|
96
|
+
|
|
97
|
+
const requiredUncovered: string[] = [];
|
|
98
|
+
const boundaryUncovered: string[] = [];
|
|
99
|
+
const formatUncovered: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const [name, spec] of Object.entries(fields)) {
|
|
102
|
+
if (spec.required && !errorRows.some((r) => missing(r, name))) requiredUncovered.push(name);
|
|
103
|
+
const hasRange = spec.min != null || spec.max != null || spec.minLength != null || spec.maxLength != null;
|
|
104
|
+
if (hasRange && !errorRows.some((r) => violatesBoundary(r, name, spec))) boundaryUncovered.push(name);
|
|
105
|
+
if (spec.values && spec.values.length && !errorRows.some((r) => violatesEnum(r, name, spec))) formatUncovered.push(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const gaps: FieldCoverageGap[] = [];
|
|
109
|
+
if (requiredUncovered.length) gaps.push({ endpoint, kind: 'required', fields: requiredUncovered });
|
|
110
|
+
if (boundaryUncovered.length) gaps.push({ endpoint, kind: 'boundary', fields: boundaryUncovered });
|
|
111
|
+
if (formatUncovered.length) gaps.push({ endpoint, kind: 'format', fields: formatUncovered });
|
|
112
|
+
return gaps;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { readUnitTestData };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field metadata for the API catalog — the optional `fields:` block on an `apis.yaml` entry.
|
|
3
|
+
*
|
|
4
|
+
* The flat `params:` list says WHICH inputs an endpoint takes; `fields:` says WHAT each one is —
|
|
5
|
+
* required?, type/format, range/length, enum, pattern, uniqueness, and where it lives (body / query /
|
|
6
|
+
* header). This is the metadata the coverage model + the viewpoint gate need to enumerate the
|
|
7
|
+
* required / boundary / format matrices mechanically instead of the AI guessing them.
|
|
8
|
+
*
|
|
9
|
+
* Additive + optional: an entry with no `fields:` behaves exactly as before. Derivable from OpenAPI
|
|
10
|
+
* (`sungen api import` populates it from the request schema + declared parameters), so imported
|
|
11
|
+
* catalogs need no hand-authoring for standard specs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type FieldType = 'string' | 'integer' | 'number' | 'boolean' | 'enum' | 'array' | 'object';
|
|
15
|
+
export type FieldIn = 'body' | 'query' | 'header' | 'path';
|
|
16
|
+
|
|
17
|
+
export interface FieldSpec {
|
|
18
|
+
in?: FieldIn; // where the value travels (default body); drives how a negative case is built
|
|
19
|
+
required?: boolean;
|
|
20
|
+
type?: FieldType;
|
|
21
|
+
format?: string; // email | uuid | url | date | datetime | … (OpenAPI `format`)
|
|
22
|
+
minLength?: number;
|
|
23
|
+
maxLength?: number;
|
|
24
|
+
min?: number; // OpenAPI `minimum`
|
|
25
|
+
max?: number; // OpenAPI `maximum`
|
|
26
|
+
pattern?: string; // regex source
|
|
27
|
+
values?: Array<string | number | boolean>; // enum
|
|
28
|
+
unique?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NUM = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
32
|
+
const STR = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
33
|
+
|
|
34
|
+
/** A `:param` snake token for a query/header name (names may carry `-`, `.`, spaces). Shared with the
|
|
35
|
+
* importer so a field's key matches the `params`/path token it maps to (e.g. `X-Trace` → `x_trace`). */
|
|
36
|
+
export function paramToken(name: string): string {
|
|
37
|
+
return name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || name.toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize an OpenAPI property/parameter schema type into a FieldType (enum wins when `enum` present). */
|
|
41
|
+
function fieldTypeOf(schema: any): FieldType | undefined {
|
|
42
|
+
if (schema && Array.isArray(schema.enum) && schema.enum.length) return 'enum';
|
|
43
|
+
const t = STR(schema?.type);
|
|
44
|
+
if (t === 'string' || t === 'integer' || t === 'number' || t === 'boolean' || t === 'array' || t === 'object') return t;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map a single OpenAPI schema (a property schema or a parameter's `.schema`) → FieldSpec. */
|
|
49
|
+
export function fieldFromSchema(schema: any, opts: { in?: FieldIn; required?: boolean } = {}): FieldSpec {
|
|
50
|
+
const s = schema && typeof schema === 'object' ? schema : {};
|
|
51
|
+
const spec: FieldSpec = {};
|
|
52
|
+
if (opts.in && opts.in !== 'body') spec.in = opts.in; // body is the default → omit for terseness
|
|
53
|
+
if (opts.required) spec.required = true;
|
|
54
|
+
const type = fieldTypeOf(s);
|
|
55
|
+
if (type) spec.type = type;
|
|
56
|
+
const format = STR(s.format);
|
|
57
|
+
if (format) spec.format = format;
|
|
58
|
+
const minLength = NUM(s.minLength); if (minLength != null) spec.minLength = minLength;
|
|
59
|
+
const maxLength = NUM(s.maxLength); if (maxLength != null) spec.maxLength = maxLength;
|
|
60
|
+
const min = NUM(s.minimum); if (min != null) spec.min = min;
|
|
61
|
+
const max = NUM(s.maximum); if (max != null) spec.max = max;
|
|
62
|
+
const pattern = STR(s.pattern); if (pattern) spec.pattern = pattern;
|
|
63
|
+
if (Array.isArray(s.enum) && s.enum.length) spec.values = s.enum.filter((v: unknown) => ['string', 'number', 'boolean'].includes(typeof v));
|
|
64
|
+
if (s.uniqueItems === true || s['x-unique'] === true) spec.unique = true;
|
|
65
|
+
return spec;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Empty FieldSpec (no attributes) — used to decide whether carrying it adds any signal. */
|
|
69
|
+
function isEmpty(spec: FieldSpec): boolean {
|
|
70
|
+
return Object.keys(spec).length === 0 || (Object.keys(spec).length === 1 && spec.in != null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derive `fields:` from an OpenAPI request-body schema (its `properties` + `required[]`).
|
|
75
|
+
* `location` marks where these live (body by default). Skips fields that carry no useful metadata.
|
|
76
|
+
*/
|
|
77
|
+
export function fieldsFromSchema(schema: any, location: FieldIn = 'body'): Record<string, FieldSpec> {
|
|
78
|
+
const out: Record<string, FieldSpec> = {};
|
|
79
|
+
const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object' ? schema.properties : null;
|
|
80
|
+
if (!props) return out;
|
|
81
|
+
const required = new Set<string>(Array.isArray(schema.required) ? schema.required.filter((r: unknown) => typeof r === 'string') : []);
|
|
82
|
+
for (const [name, propSchema] of Object.entries(props)) {
|
|
83
|
+
const spec = fieldFromSchema(propSchema, { in: location, required: required.has(name) });
|
|
84
|
+
if (!isEmpty(spec)) out[name] = spec;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Derive `fields:` from an OpenAPI operation `parameters[]` (query + header; path is an identifier,
|
|
91
|
+
* carried as a param but not a validatable field). Each parameter carries `required` + a `.schema`.
|
|
92
|
+
*/
|
|
93
|
+
export function fieldsFromParameters(parameters: any[]): Record<string, FieldSpec> {
|
|
94
|
+
const out: Record<string, FieldSpec> = {};
|
|
95
|
+
if (!Array.isArray(parameters)) return out;
|
|
96
|
+
for (const p of parameters) {
|
|
97
|
+
if (!p || typeof p !== 'object' || typeof p.name !== 'string') continue;
|
|
98
|
+
const loc = p.in === 'query' ? 'query' : p.in === 'header' ? 'header' : null;
|
|
99
|
+
if (!loc) continue; // skip path (identifier) + cookie
|
|
100
|
+
const spec = fieldFromSchema(p.schema ?? {}, { in: loc, required: p.required === true });
|
|
101
|
+
// A required query/header param carries signal even with no constraints (missing-field case).
|
|
102
|
+
// Key by the param TOKEN (not the raw name) so it matches the `params`/path token downstream.
|
|
103
|
+
if (!isEmpty(spec) || spec.required) out[paramToken(p.name)] = spec.required ? spec : { ...spec, in: loc };
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Merge field maps (later wins); drops nothing. */
|
|
109
|
+
export function mergeFields(...maps: Array<Record<string, FieldSpec> | undefined>): Record<string, FieldSpec> | undefined {
|
|
110
|
+
const out: Record<string, FieldSpec> = {};
|
|
111
|
+
for (const m of maps) if (m) for (const [k, v] of Object.entries(m)) out[k] = v;
|
|
112
|
+
return Object.keys(out).length ? out : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Parse + validate the `fields:` block from a loaded catalog entry. Undefined when absent/empty. */
|
|
116
|
+
export function parseFields(raw: any): Record<string, FieldSpec> | undefined {
|
|
117
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
118
|
+
const out: Record<string, FieldSpec> = {};
|
|
119
|
+
for (const [name, v] of Object.entries(raw as Record<string, any>)) {
|
|
120
|
+
if (!v || typeof v !== 'object') continue;
|
|
121
|
+
const spec: FieldSpec = {};
|
|
122
|
+
if (v.in === 'query' || v.in === 'header' || v.in === 'path') spec.in = v.in;
|
|
123
|
+
if (v.required === true) spec.required = true;
|
|
124
|
+
if (typeof v.type === 'string') spec.type = v.type as FieldType;
|
|
125
|
+
if (typeof v.format === 'string') spec.format = v.format;
|
|
126
|
+
if (typeof v.minLength === 'number') spec.minLength = v.minLength;
|
|
127
|
+
if (typeof v.maxLength === 'number') spec.maxLength = v.maxLength;
|
|
128
|
+
if (typeof v.min === 'number') spec.min = v.min;
|
|
129
|
+
if (typeof v.max === 'number') spec.max = v.max;
|
|
130
|
+
if (typeof v.pattern === 'string') spec.pattern = v.pattern;
|
|
131
|
+
if (Array.isArray(v.values)) spec.values = v.values;
|
|
132
|
+
if (v.unique === true) spec.unique = true;
|
|
133
|
+
out[name] = spec;
|
|
134
|
+
}
|
|
135
|
+
return Object.keys(out).length ? out : undefined;
|
|
136
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-field normalization for the API catalog — the `files:` (multipart form parts) and `bodyFile:`
|
|
3
|
+
* (raw binary body) keys of an `apis.yaml` entry. Kept separate from api-catalog.ts (endpoint
|
|
4
|
+
* resolution) since file-upload declaration is a distinct concern with its own shorthand rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A file to upload. `path` is a fixture location (a literal or a `:param` bound at runtime); the
|
|
9
|
+
* runtime reads it into bytes. `mimeType`/`filename` are optional overrides (else inferred from the
|
|
10
|
+
* extension / basename); `name` overrides the multipart part name (defaults to the field key —
|
|
11
|
+
* unused by `bodyFile`, which has no part name).
|
|
12
|
+
*/
|
|
13
|
+
export interface FileFieldSpec {
|
|
14
|
+
path: string;
|
|
15
|
+
mimeType?: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Normalize a single file spec — a string value is shorthand for `{ path }`. Undefined if invalid. */
|
|
21
|
+
export function parseFileSpec(v: any): FileFieldSpec | undefined {
|
|
22
|
+
if (typeof v === 'string') return { path: v };
|
|
23
|
+
if (v && typeof v === 'object' && typeof v.path === 'string') {
|
|
24
|
+
return {
|
|
25
|
+
path: v.path,
|
|
26
|
+
mimeType: typeof v.mimeType === 'string' ? v.mimeType : undefined,
|
|
27
|
+
filename: typeof v.filename === 'string' ? v.filename : undefined,
|
|
28
|
+
name: typeof v.name === 'string' ? v.name : undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Normalize the `files:` block — each value via parseFileSpec. Undefined when no valid fields. */
|
|
35
|
+
export function parseFiles(raw: any): Record<string, FileFieldSpec> | undefined {
|
|
36
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
37
|
+
const out: Record<string, FileFieldSpec> = {};
|
|
38
|
+
for (const [field, v] of Object.entries(raw as Record<string, any>)) {
|
|
39
|
+
const spec = parseFileSpec(v);
|
|
40
|
+
if (spec) out[field] = spec;
|
|
41
|
+
}
|
|
42
|
+
return Object.keys(out).length ? out : undefined;
|
|
43
|
+
}
|
package/src/api-repair.ts
CHANGED
|
@@ -23,6 +23,12 @@ export const apiRepair: RepairProvider = {
|
|
|
23
23
|
fix: 'An `@api:<name>` does not resolve in the catalog — fix the name, or add/import the endpoint into `api/apis.yaml` (`sungen api import`).' },
|
|
24
24
|
{ id: 'api-manual-automatable', match: /VIEWPOINT-API-MANUAL-AUTOMATABLE/i,
|
|
25
25
|
fix: 'A @manual scenario is automatable (its endpoint resolves) — drop @manual and use @api (+ @cases for error rows / @concurrent for idempotency). Reserve @manual for genuine judgment cases.' },
|
|
26
|
+
{ id: 'api-required-matrix', match: /VIEWPOINT-API-REQUIRED-MATRIX/i,
|
|
27
|
+
fix: 'A `required` field (from the catalog `fields:`) has no missing-field case — add a `@cases` row that omits the field and asserts a 4xx (`expect_status: 400`).' },
|
|
28
|
+
{ id: 'api-boundary', match: /VIEWPOINT-API-BOUNDARY/i,
|
|
29
|
+
fix: 'A `min/max/minLength/maxLength` field has no boundary case — add `@cases` rows at the edges (`min-1` / `min` / `max` / `max+1`), the out-of-range ones asserting a 4xx.' },
|
|
30
|
+
{ id: 'api-format', match: /VIEWPOINT-API-FORMAT/i,
|
|
31
|
+
fix: 'An enum/format field has no invalid-value case — add a `@cases` row with a value outside the allowed set (`values:`) asserting a 4xx.' },
|
|
26
32
|
|
|
27
33
|
// ── Runtime failures (from the Playwright test-result) ────────────────────────────────────
|
|
28
34
|
{ id: 'api-auth', match: /\b40[13]\b|unauthor|forbidden/i,
|
|
@@ -32,7 +38,7 @@ export const apiRepair: RepairProvider = {
|
|
|
32
38
|
{ id: 'api-status-mismatch', match: /expected.*status|status.*expected|toBe.*\b[45]\d\d\b|received.*\b[45]\d\d\b/i,
|
|
33
39
|
fix: 'Asserted status ≠ actual — reconcile the expectation against `apis.yaml`/spec (the catalog is the oracle). Never hand-edit the generated spec; fix the feature/test-data and re-`generate --api`.' },
|
|
34
40
|
{ id: 'api-timeout', match: /timeout|timed out|exceeded/i,
|
|
35
|
-
fix: 'Request timed out — raise the
|
|
41
|
+
fix: 'Request timed out — raise the timeout (`timeout_ms` on the catalog entry for just this endpoint, or on the datasource for all of them), or check the endpoint/path; for flows ensure prior steps bound the values the call needs.' },
|
|
36
42
|
{ id: 'api-flaky', match: /flak|intermitt|conflict|already exists|duplicate key/i,
|
|
37
43
|
fix: 'Likely state bleed — make the flow self-cleaning (delete what it created / `@cleanup`), isolate `@cases` rows, and cap `@concurrent`.' },
|
|
38
44
|
],
|
package/src/cli-import.ts
CHANGED
|
@@ -31,6 +31,22 @@ function writeCatalog(file: string, entries: Record<string, ImportedApiEntry>, m
|
|
|
31
31
|
fs.writeFileSync(file, CATALOG_HEADER + stringifyYaml(out));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const SCHEMAS_HEADER =
|
|
35
|
+
'# Response-body schema registry (API Driver) — generated by `sungen api import` from OpenAPI\n' +
|
|
36
|
+
'# `components.schemas`. Reference from a catalog entry via `schemaRef: <Name>`, then assert the\n' +
|
|
37
|
+
'# response shape with `expect {{<name>.body}} matches schema [<Name>]`.\n';
|
|
38
|
+
|
|
39
|
+
function writeSchemas(file: string, schemas: Record<string, unknown>, merge: boolean): void {
|
|
40
|
+
let out = schemas;
|
|
41
|
+
if (fs.existsSync(file)) {
|
|
42
|
+
if (!merge) return; // never clobber a reviewed schemas.yaml without --merge
|
|
43
|
+
const existing = parseYaml(fs.readFileSync(file, 'utf8')) || {};
|
|
44
|
+
out = { ...existing, ...schemas };
|
|
45
|
+
}
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
fs.writeFileSync(file, SCHEMAS_HEADER + stringifyYaml(out));
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
/** Read a source path or http(s) URL (a published Google-Sheet CSV) → text. */
|
|
35
51
|
async function readSource(source: string): Promise<string> {
|
|
36
52
|
if (/^https?:\/\//.test(source)) {
|
|
@@ -144,10 +160,16 @@ export function registerApiCommand(program: Command): void {
|
|
|
144
160
|
try {
|
|
145
161
|
const text = await readSource(source);
|
|
146
162
|
const { result, kind } = importAny(source, text, opts.datasource);
|
|
147
|
-
const { entries, count, skipped } = result;
|
|
163
|
+
const { entries, count, skipped, schemas, report } = result;
|
|
148
164
|
if (count === 0) throw new Error(`No endpoints imported (${skipped.join('; ') || 'empty source'}).`);
|
|
149
165
|
|
|
150
166
|
const written: string[] = [];
|
|
167
|
+
// Shared response-schema registry (feeds `expect {{name.body}} matches schema [Ref]`).
|
|
168
|
+
if (schemas && Object.keys(schemas).length) {
|
|
169
|
+
const schemaFile = path.join(process.cwd(), 'qa', 'api', 'schemas.yaml');
|
|
170
|
+
writeSchemas(schemaFile, schemas, !!opts.merge);
|
|
171
|
+
written.push(`${rel(schemaFile)} (${Object.keys(schemas).length} schema(s))`);
|
|
172
|
+
}
|
|
151
173
|
if (opts.screen) {
|
|
152
174
|
const f = path.join(process.cwd(), 'qa', 'screens', opts.screen, 'api', 'apis.yaml');
|
|
153
175
|
writeCatalog(f, entries, !!opts.merge);
|
|
@@ -177,6 +199,14 @@ export function registerApiCommand(program: Command): void {
|
|
|
177
199
|
|
|
178
200
|
console.log(`\n✓ Imported ${count} endpoint(s) from ${kind} source:`);
|
|
179
201
|
for (const w of written) console.log(` → ${w}`);
|
|
202
|
+
if (report) {
|
|
203
|
+
const bits: string[] = [];
|
|
204
|
+
if (report.fieldsPopulated) bits.push(`${report.fieldsPopulated} endpoint(s) with field metadata`);
|
|
205
|
+
if (report.queryParamsAdded) bits.push(`${report.queryParamsAdded} query/header param(s) recovered`);
|
|
206
|
+
if (report.authInferred) bits.push(`${report.authInferred} auth header(s) inferred`);
|
|
207
|
+
if (report.responseSchemas) bits.push(`${report.responseSchemas} response schema(s)`);
|
|
208
|
+
if (bits.length) console.log(`\n✓ Extracted: ${bits.join(', ')}.`);
|
|
209
|
+
}
|
|
180
210
|
if (skipped.length) console.log(`\n⚠ Skipped: ${skipped.slice(0, 10).join('; ')}${skipped.length > 10 ? ` … (+${skipped.length - 10})` : ''}`);
|
|
181
211
|
console.log('\nNext: wire the `datasource` base_url + auth in datasources.yaml, refine the scenarios,');
|
|
182
212
|
console.log('then `sungen generate --api <area>` → `npx playwright test`.\n');
|
package/src/csv-import.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* module only parses text).
|
|
8
8
|
*
|
|
9
9
|
* Columns (header row, case-insensitive; aliases accepted):
|
|
10
|
-
* name | area|tag | method | path | params | body | datasource|auth | expect_status | description
|
|
10
|
+
* name | area|tag | method | path | params | body | fields | datasource|auth | expect_status | description
|
|
11
11
|
* - params: separated by `;` or `|` (commas live inside quoted JSON, so don't use `,` here)
|
|
12
12
|
* - body: a JSON object string, values may be `:param` refs, e.g. {"item_id":":item_id"}
|
|
13
|
+
* - fields: a JSON object of field metadata, e.g. {"email":{"required":true,"format":"email"}}
|
|
13
14
|
* - expect_status: a number (e.g. 201)
|
|
14
15
|
*/
|
|
15
16
|
import type { ImportedApiEntry, ImportResult } from './openapi-import';
|
|
17
|
+
import { parseFields } from './api-field-spec';
|
|
16
18
|
|
|
17
19
|
/** RFC-4180-ish parser: handles quoted fields, embedded commas/newlines, and "" escapes. */
|
|
18
20
|
export function parseCsv(text: string): string[][] {
|
|
@@ -55,7 +57,8 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
|
|
|
55
57
|
const rows = parseCsv(text);
|
|
56
58
|
const entries: Record<string, ImportedApiEntry> = {};
|
|
57
59
|
const skipped: string[] = [];
|
|
58
|
-
|
|
60
|
+
const emptyReport = { queryParamsAdded: 0, authInferred: 0, fieldsPopulated: 0, responseSchemas: 0 };
|
|
61
|
+
if (rows.length < 2) return { entries, count: 0, skipped: ['CSV has no data rows (need a header + ≥1 row)'], schemas: {}, report: emptyReport };
|
|
59
62
|
|
|
60
63
|
const header = rows[0].map(norm);
|
|
61
64
|
const col = (r: string[], name: string): string => {
|
|
@@ -82,6 +85,11 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
|
|
|
82
85
|
if (bodyCell) {
|
|
83
86
|
try { body = JSON.parse(bodyCell); } catch { skipped.push(`row ${n + 1} (body is not valid JSON — kept no body)`); }
|
|
84
87
|
}
|
|
88
|
+
let fields;
|
|
89
|
+
const fieldsCell = col(r, 'fields');
|
|
90
|
+
if (fieldsCell) {
|
|
91
|
+
try { fields = parseFields(JSON.parse(fieldsCell)); } catch { skipped.push(`row ${n + 1} (fields is not valid JSON — kept no fields)`); }
|
|
92
|
+
}
|
|
85
93
|
const statusCell = col(r, 'expect_status');
|
|
86
94
|
const status = /^\d+$/.test(statusCell) ? Number(statusCell) : undefined;
|
|
87
95
|
const description = col(r, 'description') || undefined;
|
|
@@ -94,8 +102,10 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
|
|
|
94
102
|
path,
|
|
95
103
|
...(params.length ? { params } : {}),
|
|
96
104
|
...(body ? { body } : {}),
|
|
105
|
+
...(fields ? { fields } : {}),
|
|
97
106
|
...(status != null ? { expect: { status } } : {}),
|
|
98
107
|
};
|
|
108
|
+
if (fields) emptyReport.fieldsPopulated++;
|
|
99
109
|
}
|
|
100
|
-
return { entries, count: Object.keys(entries).length, skipped };
|
|
110
|
+
return { entries, count: Object.keys(entries).length, skipped, schemas: {}, report: emptyReport };
|
|
101
111
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// "not resolvable" install errors). Keeping core type-only makes the driver load with no core on disk.
|
|
19
19
|
import type { CapabilityRegistry, Sensor, GateInput } from '@sun-asterisk/sungen';
|
|
20
20
|
import { resolveApi, apiParamNames, lintApiCatalog } from './api-catalog';
|
|
21
|
+
import { fieldCoverageGaps, readUnitTestData } from './api-field-coverage';
|
|
22
|
+
import type { FieldSpec } from './api-field-spec';
|
|
21
23
|
import { registerApiCommand } from './cli-import';
|
|
22
24
|
import { apiViewpoints, apiGateProvider } from './api-harness';
|
|
23
25
|
import { apiDiscovery, apiContextMapper } from './api-context';
|
|
@@ -47,8 +49,11 @@ function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export { importOpenApi } from './openapi-import';
|
|
50
|
-
export type { ImportedApiEntry, ImportResult } from './openapi-import';
|
|
52
|
+
export type { ImportedApiEntry, ImportResult, ImportReport } from './openapi-import';
|
|
51
53
|
export { importCsv, parseCsv } from './csv-import';
|
|
54
|
+
export type { FieldSpec, FieldType, FieldIn } from './api-field-spec';
|
|
55
|
+
export { fieldCoverageGaps, readUnitTestData } from './api-field-coverage';
|
|
56
|
+
export type { FieldCoverageGap } from './api-field-coverage';
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
59
|
* API precondition codegen: `@api:<name>[(p={{v}},…)]` → run the catalog request and bind the
|
|
@@ -87,7 +92,14 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
|
|
|
87
92
|
const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
|
|
88
93
|
// request-body wire format (default json); `form` → x-www-form-urlencoded, `multipart` → form-data (#345).
|
|
89
94
|
const enc = entry.encoding ? `, encoding: ${JSON.stringify(entry.encoding)}` : '';
|
|
90
|
-
|
|
95
|
+
// multipart file fields; the runtime reads each fixture and builds a Playwright file payload. Its
|
|
96
|
+
// `:param` paths bind at runtime from the same params. Presence forces multipart in the runtime.
|
|
97
|
+
const files = entry.files && Object.keys(entry.files).length ? `, files: ${JSON.stringify(entry.files)}` : '';
|
|
98
|
+
// raw-binary body upload: the request body IS the fixture's bytes (e.g. application/octet-stream).
|
|
99
|
+
const bodyFile = entry.bodyFile ? `, bodyFile: ${JSON.stringify(entry.bodyFile)}` : '';
|
|
100
|
+
// per-endpoint timeout override (else the datasource timeout applies at runtime).
|
|
101
|
+
const timeout = entry.timeout_ms ? `, timeout: ${JSON.stringify(entry.timeout_ms)}` : '';
|
|
102
|
+
const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${enc}${files}${bodyFile}${hdr}${timeout}, datasource: ${ds} }`;
|
|
91
103
|
// @concurrent:N → callN (N parallel calls, aggregate result); else a single call. @hybrid adds
|
|
92
104
|
// the storageState opts so the request reuses the UI auth session.
|
|
93
105
|
const callExpr = concurrent
|
|
@@ -169,6 +181,26 @@ const apiViewpointSensor: Sensor<GateInput> = {
|
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
183
|
|
|
184
|
+
// Field-driven matrix coverage (REQUIRED/BOUNDARY/FORMAT) — applies to read + mutating endpoints
|
|
185
|
+
// that declare `fields:`. Advisory; fail-quiet when a `@cases` dataset can't be resolved.
|
|
186
|
+
const testData = readUnitTestData(screenName, cwd);
|
|
187
|
+
for (const name of refs) {
|
|
188
|
+
let fields: Record<string, FieldSpec> | undefined;
|
|
189
|
+
try { fields = resolveApi(name, screenName, cwd).fields; } catch { continue; }
|
|
190
|
+
if (!fields || !Object.keys(fields).length) continue;
|
|
191
|
+
const referencing = apiScenarios.filter((s) => (s.apiRefs ?? []).includes(name));
|
|
192
|
+
const datasetNames = [...new Set(referencing.filter((s) => s.casesDataset).map((s) => s.casesDataset as string))];
|
|
193
|
+
// Per-field coverage is measured from `@cases` rows (the matrix mechanism). If the endpoint has
|
|
194
|
+
// no `@cases` but DOES assert an inline 4xx, the author is testing errors another way — mirror
|
|
195
|
+
// api-error's "either @cases or an inline 4xx" acceptance and don't raise a false matrix finding.
|
|
196
|
+
if (!datasetNames.length && referencing.some((s) => /\b4\d\d\b/.test(s.stepsText ?? ''))) continue;
|
|
197
|
+
for (const gap of fieldCoverageGaps(name, fields, datasetNames, testData)) {
|
|
198
|
+
if (gap.kind === 'required') add('warn', `VIEWPOINT-API-REQUIRED-MATRIX: @api:${name} — required field(s) ${gap.fields.join(', ')} have no missing-field case — add a @cases row omitting each, asserting 4xx.`);
|
|
199
|
+
else if (gap.kind === 'boundary') add('warn', `VIEWPOINT-API-BOUNDARY: @api:${name} — field(s) ${gap.fields.join(', ')} have a min/max/length constraint but no boundary case — add @cases rows with out-of-range values → 4xx.`);
|
|
200
|
+
else add('warn', `VIEWPOINT-API-FORMAT: @api:${name} — enum field(s) ${gap.fields.join(', ')} have no invalid-value case — add a @cases row with a value outside the allowed set → 4xx.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
172
204
|
// Method-aware: error + idempotency only apply when a referenced endpoint MUTATES.
|
|
173
205
|
const mutating: string[] = [];
|
|
174
206
|
for (const name of refs) {
|
package/src/openapi-import.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Deterministically turns an OpenAPI/Swagger document into named-endpoint catalog entries, so QA
|
|
5
5
|
* doesn't hand-author the `api/apis.yaml`. Pure transform (no network): paths × methods → entries
|
|
6
|
-
* with method, `:param` path, declared params (path + JSON body props),
|
|
7
|
-
*
|
|
6
|
+
* with method, `:param` path, declared params (path + query + JSON body props), field metadata
|
|
7
|
+
* (required/type/enum/range → the `fields:` block), inferred auth headers (from securitySchemes), the
|
|
8
|
+
* expected 2xx status + its response schema, and a placeholder datasource. The Dev/lead reviews +
|
|
9
|
+
* wires the datasource; QA references by `@api:<name>`.
|
|
8
10
|
*/
|
|
11
|
+
import { fieldsFromSchema, fieldsFromParameters, mergeFields, paramToken, type FieldSpec } from './api-field-spec';
|
|
12
|
+
|
|
9
13
|
export interface ImportedApiEntry {
|
|
10
14
|
datasource: string;
|
|
11
15
|
area: string; // resource/tag group (OpenAPI tag, else first path segment)
|
|
@@ -14,7 +18,13 @@ export interface ImportedApiEntry {
|
|
|
14
18
|
path: string;
|
|
15
19
|
params?: string[];
|
|
16
20
|
body?: Record<string, string>;
|
|
21
|
+
files?: Record<string, { path: string; mimeType?: string; filename?: string; name?: string }>; // multipart/form-data binary props → file form fields
|
|
22
|
+
bodyFile?: { path: string; mimeType?: string; filename?: string; name?: string }; // raw-binary request body (application/octet-stream) → the body IS the file
|
|
17
23
|
encoding?: 'form' | 'multipart'; // set when the requestBody is form/multipart (json omitted = default)
|
|
24
|
+
headers?: Record<string, string>; // per-endpoint header templates (inferred auth, e.g. authorization: "Bearer :token")
|
|
25
|
+
fields?: Record<string, FieldSpec>; // field metadata (required/type/enum/range) for the error+boundary matrices
|
|
26
|
+
responseSchema?: unknown; // inline 2xx response body schema (when not a $ref)
|
|
27
|
+
schemaRef?: string; // component name of the 2xx response schema (when a $ref) → schemas registry
|
|
18
28
|
expect?: { status: number };
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -53,30 +63,170 @@ function expectedStatus(responses: Record<string, unknown> | undefined): number
|
|
|
53
63
|
return success ?? codes[0];
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
/** True for a schema property that carries raw file bytes — `format: binary` (a file), or an array of them. */
|
|
67
|
+
function isBinaryProp(schema: any): boolean {
|
|
68
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
69
|
+
if (schema.format === 'binary') return true;
|
|
70
|
+
return schema.type === 'array' && schema.items && typeof schema.items === 'object' && schema.items.format === 'binary';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Combine pathItem-level + operation-level `parameters` (op overrides by name+in). */
|
|
74
|
+
function collectParameters(pathItem: any, op: any): any[] {
|
|
75
|
+
const byKey = new Map<string, any>();
|
|
76
|
+
for (const src of [pathItem?.parameters, op?.parameters]) {
|
|
77
|
+
if (!Array.isArray(src)) continue;
|
|
78
|
+
for (const p of src) if (p && typeof p === 'object' && typeof p.name === 'string') byKey.set(`${p.in}:${p.name}`, p);
|
|
79
|
+
}
|
|
80
|
+
return [...byKey.values()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a request-body schema to its concrete `properties`+`required` — following a top-level
|
|
84
|
+
* `$ref` (`#/components/schemas/User`) and merging `allOf` members. Many real specs (Petstore, most
|
|
85
|
+
* generated ones) reference the body schema rather than inlining it; without this, `properties` is
|
|
86
|
+
* empty → no body template + no `fields:` matrix. Cycle-guarded; best-effort (unknown ref → {}). */
|
|
87
|
+
function resolveBodySchema(schema: any, components: Record<string, any>, seen: Set<string> = new Set()): any {
|
|
88
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
89
|
+
const refName = refComponentName(schema.$ref);
|
|
90
|
+
if (refName) {
|
|
91
|
+
if (seen.has(refName)) return {}; // circular → stop
|
|
92
|
+
seen.add(refName);
|
|
93
|
+
return resolveBodySchema(components[refName], components, seen);
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(schema.allOf)) {
|
|
96
|
+
const merged: any = { type: 'object', properties: { ...(schema.properties || {}) }, required: [...(Array.isArray(schema.required) ? schema.required : [])] };
|
|
97
|
+
for (const sub of schema.allOf) {
|
|
98
|
+
const r = resolveBodySchema(sub, components, seen);
|
|
99
|
+
if (r && typeof r === 'object') {
|
|
100
|
+
Object.assign(merged.properties, r.properties || {});
|
|
101
|
+
if (Array.isArray(r.required)) merged.required.push(...r.required);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
merged.required = [...new Set(merged.required)];
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
return schema;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Body property names → a `{ prop: ":prop" }` template, + the wire encoding (json | form | multipart)
|
|
111
|
+
* and any `files` (multipart binary props), best-effort from the requestBody content-type (#345).
|
|
112
|
+
* JSON is the default (encoding omitted). Also returns the picked request schema (for field metadata),
|
|
113
|
+
* resolving a `$ref`/`allOf` body against the doc's component schemas. */
|
|
114
|
+
function bodyTemplate(op: any, components: Record<string, any> = {}): { body?: Record<string, string>; files?: Record<string, { path: string }>; bodyFile?: { path: string; mimeType?: string }; props: string[]; encoding?: 'form' | 'multipart'; schema?: any } {
|
|
59
115
|
const content = op?.requestBody?.content ?? {};
|
|
116
|
+
// A raw binary body → the request body IS the file's bytes, NOT a multipart part (multipart would
|
|
117
|
+
// be rejected 415). This is `application/octet-stream` (Petstore `uploadImage`) OR a concrete media
|
|
118
|
+
// type carrying a binary schema (e.g. `image/png`, `application/pdf`). Only when there's no
|
|
119
|
+
// structured (json/form/multipart) body to prefer.
|
|
120
|
+
const structured = content['application/json'] || content['application/x-www-form-urlencoded'] || content['multipart/form-data'];
|
|
121
|
+
if (!structured) {
|
|
122
|
+
const rawCt = Object.keys(content).find((ct) => ct === 'application/octet-stream' || content[ct]?.schema?.format === 'binary');
|
|
123
|
+
if (rawCt) {
|
|
124
|
+
// octet-stream → runtime default Content-Type; a concrete media type → carry it as the mimeType.
|
|
125
|
+
return { bodyFile: rawCt === 'application/octet-stream' ? { path: ':file' } : { path: ':file', mimeType: rawCt }, props: ['file'] };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
60
128
|
// Prefer JSON; else fall back to form-urlencoded / multipart (those set encoding).
|
|
61
129
|
const pick = content['application/json'] ? { ct: 'application/json' as const, encoding: undefined }
|
|
62
130
|
: content['application/x-www-form-urlencoded'] ? { ct: 'application/x-www-form-urlencoded' as const, encoding: 'form' as const }
|
|
63
131
|
: content['multipart/form-data'] ? { ct: 'multipart/form-data' as const, encoding: 'multipart' as const }
|
|
64
132
|
: undefined;
|
|
65
133
|
if (!pick) return { props: [] };
|
|
66
|
-
const schema = content[pick.ct]?.schema;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
134
|
+
const schema = resolveBodySchema(content[pick.ct]?.schema, components);
|
|
135
|
+
const properties = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
|
|
136
|
+
const propNames = Object.keys(properties);
|
|
137
|
+
if (!propNames.length) return { props: [], encoding: pick.encoding, schema };
|
|
138
|
+
// multipart: split binary (file) props into `files`, the rest stay text `body`.
|
|
71
139
|
const body: Record<string, string> = {};
|
|
72
|
-
|
|
73
|
-
|
|
140
|
+
const files: Record<string, { path: string }> = {};
|
|
141
|
+
for (const p of propNames) {
|
|
142
|
+
if (pick.encoding === 'multipart' && isBinaryProp(properties[p])) files[p] = { path: `:${p}` };
|
|
143
|
+
else body[p] = `:${p}`;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...(Object.keys(body).length ? { body } : {}),
|
|
147
|
+
...(Object.keys(files).length ? { files } : {}),
|
|
148
|
+
props: propNames,
|
|
149
|
+
encoding: pick.encoding,
|
|
150
|
+
schema,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the auth requirement for an operation → a header template (+ the params it introduces).
|
|
156
|
+
* Reads the applicable `security` (op-level, else global) against `components.securitySchemes`.
|
|
157
|
+
* Supports http bearer/basic, apiKey (header/query), and oauth2 (bearer). Best-effort, additive.
|
|
158
|
+
*/
|
|
159
|
+
function authFor(op: any, doc: any): { headers?: Record<string, string>; headerParams?: string[]; queryParams?: Array<{ name: string; tok: string }>; queryFields?: Record<string, FieldSpec> } {
|
|
160
|
+
const schemes = doc?.components?.securitySchemes && typeof doc.components.securitySchemes === 'object' ? doc.components.securitySchemes : {};
|
|
161
|
+
const requirements: any[] = Array.isArray(op?.security) ? op.security : Array.isArray(doc?.security) ? doc.security : [];
|
|
162
|
+
const req = requirements.find((r) => r && typeof r === 'object' && Object.keys(r).length); // first non-empty requirement
|
|
163
|
+
if (!req) return {};
|
|
164
|
+
const headers: Record<string, string> = {};
|
|
165
|
+
const headerParams: string[] = [];
|
|
166
|
+
const queryParams: Array<{ name: string; tok: string }> = [];
|
|
167
|
+
const queryFields: Record<string, FieldSpec> = {};
|
|
168
|
+
for (const schemeName of Object.keys(req)) {
|
|
169
|
+
const s = schemes[schemeName];
|
|
170
|
+
if (!s || typeof s !== 'object') continue;
|
|
171
|
+
if (s.type === 'http' && String(s.scheme).toLowerCase() === 'bearer') {
|
|
172
|
+
headers['authorization'] = 'Bearer :token'; headerParams.push('token');
|
|
173
|
+
} else if (s.type === 'http' && String(s.scheme).toLowerCase() === 'basic') {
|
|
174
|
+
headers['authorization'] = 'Basic :basic_auth'; headerParams.push('basic_auth');
|
|
175
|
+
} else if (s.type === 'oauth2') {
|
|
176
|
+
headers['authorization'] = 'Bearer :token'; headerParams.push('token');
|
|
177
|
+
} else if (s.type === 'apiKey' && typeof s.name === 'string') {
|
|
178
|
+
const tok = paramToken(s.name);
|
|
179
|
+
if (s.in === 'header') { headers[s.name.toLowerCase()] = `:${tok}`; headerParams.push(tok); }
|
|
180
|
+
else if (s.in === 'query') { queryParams.push({ name: s.name, tok }); queryFields[tok] = { in: 'query', required: true }; }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
185
|
+
...(headerParams.length ? { headerParams } : {}),
|
|
186
|
+
...(queryParams.length ? { queryParams } : {}),
|
|
187
|
+
...(Object.keys(queryFields).length ? { queryFields } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Extract the 2xx JSON response schema for an operation → inline schema or a `#/…/schemas/<name>` ref. */
|
|
192
|
+
function responseSchemaFor(op: any): { responseSchema?: unknown; schemaRef?: string } {
|
|
193
|
+
const responses = op?.responses;
|
|
194
|
+
if (!responses || typeof responses !== 'object') return {};
|
|
195
|
+
const code = Object.keys(responses).filter((c) => /^\d+$/.test(c)).map(Number).find((c) => c >= 200 && c < 300);
|
|
196
|
+
if (code == null) return {};
|
|
197
|
+
const content = (responses as any)[String(code)]?.content;
|
|
198
|
+
const schema = content?.['application/json']?.schema;
|
|
199
|
+
if (!schema || typeof schema !== 'object') return {};
|
|
200
|
+
const refName = refComponentName(schema.$ref);
|
|
201
|
+
if (refName) return { schemaRef: refName };
|
|
202
|
+
// Unwrap a top-level array of a $ref (`{ type: array, items: { $ref } }`) → still ref the item.
|
|
203
|
+
if (schema.type === 'array') {
|
|
204
|
+
const itemRef = refComponentName(schema.items?.$ref);
|
|
205
|
+
if (itemRef) return { schemaRef: itemRef };
|
|
206
|
+
}
|
|
207
|
+
return { responseSchema: schema };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `#/components/schemas/User` → `User` (else undefined). */
|
|
211
|
+
function refComponentName(ref: unknown): string | undefined {
|
|
212
|
+
if (typeof ref !== 'string') return undefined;
|
|
213
|
+
const m = ref.match(/#\/components\/schemas\/([A-Za-z0-9_.-]+)$/);
|
|
214
|
+
return m ? m[1] : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ImportReport {
|
|
218
|
+
queryParamsAdded: number; // query/header params recovered from parameters[] (were dropped before)
|
|
219
|
+
authInferred: number; // endpoints that got an inferred auth header
|
|
220
|
+
fieldsPopulated: number; // endpoints with a fields: block
|
|
221
|
+
responseSchemas: number; // endpoints with a 2xx response schema / ref
|
|
74
222
|
}
|
|
75
223
|
|
|
76
224
|
export interface ImportResult {
|
|
77
225
|
entries: Record<string, ImportedApiEntry>;
|
|
78
226
|
count: number;
|
|
79
227
|
skipped: string[];
|
|
228
|
+
schemas: Record<string, unknown>; // component schemas → schemas.yaml (feeds `matches schema` assertions)
|
|
229
|
+
report: ImportReport;
|
|
80
230
|
}
|
|
81
231
|
|
|
82
232
|
/**
|
|
@@ -87,8 +237,10 @@ export interface ImportResult {
|
|
|
87
237
|
export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
|
|
88
238
|
const entries: Record<string, ImportedApiEntry> = {};
|
|
89
239
|
const skipped: string[] = [];
|
|
240
|
+
const report: ImportReport = { queryParamsAdded: 0, authInferred: 0, fieldsPopulated: 0, responseSchemas: 0 };
|
|
241
|
+
const componentSchemas = doc?.components?.schemas && typeof doc.components.schemas === 'object' ? doc.components.schemas : {};
|
|
90
242
|
const paths = doc && typeof doc === 'object' ? doc.paths : undefined;
|
|
91
|
-
if (!paths || typeof paths !== 'object') return { entries, count: 0, skipped: ['no `paths` object found'] };
|
|
243
|
+
if (!paths || typeof paths !== 'object') return { entries, count: 0, skipped: ['no `paths` object found'], schemas: {}, report };
|
|
92
244
|
|
|
93
245
|
for (const [rawPath, pathItem] of Object.entries(paths as Record<string, any>)) {
|
|
94
246
|
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
@@ -98,8 +250,44 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
|
|
|
98
250
|
|
|
99
251
|
const catalogPath = toCatalogPath(rawPath);
|
|
100
252
|
const pathParams = (catalogPath.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
|
|
101
|
-
const { body, props, encoding } = bodyTemplate(op);
|
|
102
|
-
|
|
253
|
+
const { body, files, bodyFile, props, encoding, schema: reqSchema } = bodyTemplate(op, componentSchemas);
|
|
254
|
+
|
|
255
|
+
// parameters[] — query params join `params` (a `?a=:a&b=:b` query string on the path); header
|
|
256
|
+
// params become header templates. Both were dropped before this. Auth (apiKey-in-query) also
|
|
257
|
+
// contributes a query key so it lands on the path (not just declared → would fail the lint).
|
|
258
|
+
const parameters = collectParameters(pathItem, op);
|
|
259
|
+
const auth = authFor(op, doc);
|
|
260
|
+
const declaredQuery = parameters.filter((p) => p.in === 'query').map((p) => ({ name: p.name, tok: paramToken(p.name) }));
|
|
261
|
+
const queryParams = [...declaredQuery, ...(auth.queryParams || [])];
|
|
262
|
+
// A header param whose name collides with an inferred auth header (e.g. an explicitly documented
|
|
263
|
+
// `Authorization`) is dropped in favour of the richer auth-scheme template — else its token would
|
|
264
|
+
// be a declared-but-unreferenced param (lint break) and the `Bearer :token` scheme would be lost.
|
|
265
|
+
const authHeaderKeys = new Set(Object.keys(auth.headers || {}));
|
|
266
|
+
const headerParamDefs = parameters.filter((p) => p.in === 'header' && typeof p.name === 'string' && !authHeaderKeys.has(String(p.name).toLowerCase()));
|
|
267
|
+
|
|
268
|
+
const paramFields = fieldsFromParameters(parameters);
|
|
269
|
+
const bodyFields = reqSchema ? fieldsFromSchema(reqSchema, 'body') : {};
|
|
270
|
+
const fields = mergeFields(bodyFields, paramFields, auth.queryFields);
|
|
271
|
+
|
|
272
|
+
// Assemble the request path: append recovered query params as `?a=:a&b=:b` (deterministic order).
|
|
273
|
+
let finalPath = catalogPath;
|
|
274
|
+
if (queryParams.length) {
|
|
275
|
+
const qs = queryParams.map((q) => `${q.name}=:${q.tok}`).join('&');
|
|
276
|
+
finalPath += (catalogPath.includes('?') ? '&' : '?') + qs;
|
|
277
|
+
}
|
|
278
|
+
report.queryParamsAdded += declaredQuery.length + headerParamDefs.length;
|
|
279
|
+
|
|
280
|
+
const headers: Record<string, string> = { ...(auth.headers || {}) };
|
|
281
|
+
for (const hp of headerParamDefs) headers[String(hp.name).toLowerCase()] = `:${paramToken(hp.name)}`;
|
|
282
|
+
|
|
283
|
+
const paramNames = [
|
|
284
|
+
...pathParams,
|
|
285
|
+
...queryParams.map((q) => q.tok),
|
|
286
|
+
...headerParamDefs.map((h) => paramToken(h.name)),
|
|
287
|
+
...props,
|
|
288
|
+
...(auth.headerParams || []),
|
|
289
|
+
];
|
|
290
|
+
const params = [...new Set(paramNames)];
|
|
103
291
|
|
|
104
292
|
let name = typeof op.operationId === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(op.operationId)
|
|
105
293
|
? op.operationId
|
|
@@ -108,18 +296,67 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
|
|
|
108
296
|
if (entries[name]) { skipped.push(`${method.toUpperCase()} ${rawPath} (duplicate name "${name}")`); continue; }
|
|
109
297
|
|
|
110
298
|
const status = expectedStatus(op.responses);
|
|
299
|
+
const { responseSchema, schemaRef } = responseSchemaFor(op);
|
|
300
|
+
if (Object.keys(headers).length && auth.headers) report.authInferred++;
|
|
301
|
+
if (fields) report.fieldsPopulated++;
|
|
302
|
+
if (responseSchema || schemaRef) report.responseSchemas++;
|
|
303
|
+
|
|
111
304
|
entries[name] = {
|
|
112
305
|
datasource,
|
|
113
306
|
area: areaOf(op, rawPath),
|
|
114
307
|
description: typeof op.summary === 'string' ? op.summary : undefined,
|
|
115
308
|
method: method.toUpperCase(),
|
|
116
|
-
path:
|
|
309
|
+
path: finalPath,
|
|
117
310
|
...(params.length ? { params } : {}),
|
|
311
|
+
...(files ? { files } : {}),
|
|
312
|
+
...(bodyFile ? { bodyFile } : {}),
|
|
118
313
|
...(body ? { body } : {}),
|
|
119
314
|
...(encoding ? { encoding } : {}),
|
|
315
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
316
|
+
...(fields ? { fields } : {}),
|
|
317
|
+
...(responseSchema ? { responseSchema } : {}),
|
|
318
|
+
...(schemaRef ? { schemaRef } : {}),
|
|
120
319
|
...(status != null ? { expect: { status } } : {}),
|
|
121
320
|
};
|
|
122
321
|
}
|
|
123
322
|
}
|
|
124
|
-
|
|
323
|
+
|
|
324
|
+
// Emit only the component schemas actually referenced by an entry (+ transitive $refs) so
|
|
325
|
+
// schemas.yaml stays lean and Ajv can resolve internal refs (phase: schema assertion).
|
|
326
|
+
const schemas = collectReferencedSchemas(entries, componentSchemas);
|
|
327
|
+
return { entries, count: Object.keys(entries).length, skipped, schemas, report };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Walk `schemaRef`s + their transitive `#/components/schemas/*` refs → the schemas to persist. */
|
|
331
|
+
function collectReferencedSchemas(entries: Record<string, ImportedApiEntry>, componentSchemas: Record<string, any>): Record<string, unknown> {
|
|
332
|
+
const out: Record<string, unknown> = {};
|
|
333
|
+
const queue: string[] = [];
|
|
334
|
+
for (const e of Object.values(entries)) {
|
|
335
|
+
if (e.schemaRef) queue.push(e.schemaRef);
|
|
336
|
+
// An inline 2xx response schema (envelope/pagination/array-of-inline) may still nest component
|
|
337
|
+
// $refs — walk them so the referenced components land in schemas.yaml (Ajv can then resolve them).
|
|
338
|
+
if (e.responseSchema) for (const ref of refsInObject(e.responseSchema)) { const n = refComponentName(ref); if (n) queue.push(n); }
|
|
339
|
+
}
|
|
340
|
+
const seen = new Set<string>();
|
|
341
|
+
while (queue.length) {
|
|
342
|
+
const name = queue.shift()!;
|
|
343
|
+
if (seen.has(name)) continue;
|
|
344
|
+
seen.add(name);
|
|
345
|
+
const schema = componentSchemas[name];
|
|
346
|
+
if (schema === undefined) continue;
|
|
347
|
+
out[name] = schema;
|
|
348
|
+
// find nested refs
|
|
349
|
+
for (const ref of refsInObject(schema)) { const n = refComponentName(ref); if (n && !seen.has(n)) queue.push(n); }
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** All `$ref` string values anywhere inside an object. */
|
|
355
|
+
function refsInObject(obj: any, acc: string[] = []): string[] {
|
|
356
|
+
if (!obj || typeof obj !== 'object') return acc;
|
|
357
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
358
|
+
if (k === '$ref' && typeof v === 'string') acc.push(v);
|
|
359
|
+
else if (v && typeof v === 'object') refsInObject(v, acc);
|
|
360
|
+
}
|
|
361
|
+
return acc;
|
|
125
362
|
}
|