@sungen/driver-api 3.2.28 → 3.2.30
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-harness.ts +91 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.30",
|
|
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.30",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
package/src/api-harness.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* Type-only on core (like the rest of the driver) — the depth threshold + project context arrive via
|
|
13
13
|
* the gate input, so loading the driver never forces a core runtime resolve.
|
|
14
14
|
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
15
18
|
import type { Catalog, GateResult, DepthResult } from '@sun-asterisk/sungen';
|
|
16
|
-
import { resolveApi } from './api-catalog';
|
|
19
|
+
import { resolveApi, type ApiEntry } from './api-catalog';
|
|
17
20
|
|
|
18
21
|
/** A scenario as the audit parses it (the subset the API gate reads). */
|
|
19
22
|
interface ApiScenario {
|
|
@@ -67,16 +70,29 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
|
|
|
67
70
|
const criticalMethods = (input.businessCriticalMethods?.length ? input.businessCriticalMethods : DEFAULT_CRITICAL).map((m) => m.toUpperCase());
|
|
68
71
|
const apiScenarios = scenarios.filter((s) => (s.apiRefs ?? []).length);
|
|
69
72
|
|
|
70
|
-
// Resolve each referenced
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
+
// Resolve each referenced entry (best-effort; unresolved → the verification sensor already fails
|
|
74
|
+
// the gate, so we just skip it here — it contributes no theme).
|
|
75
|
+
const entryOf = new Map<string, ApiEntry>();
|
|
73
76
|
for (const s of apiScenarios) {
|
|
74
77
|
for (const name of s.apiRefs ?? []) {
|
|
75
|
-
if (
|
|
76
|
-
try {
|
|
78
|
+
if (entryOf.has(name)) continue;
|
|
79
|
+
try { entryOf.set(name, resolveApi(name, screenName, cwd)); } catch { /* unresolved */ }
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
|
-
const isMutating = (name: string) => criticalMethods.includes((
|
|
82
|
+
const isMutating = (name: string) => criticalMethods.includes((entryOf.get(name)?.method ?? '').toUpperCase());
|
|
83
|
+
|
|
84
|
+
// Alias groups: two catalog entries that are the same request under two names — the `<x>_control`
|
|
85
|
+
// clone an author adds so one scenario can bind two responses of one endpoint — are ONE endpoint.
|
|
86
|
+
// Counting the clone separately demanded its own error scenario, a gap nothing but a duplicate
|
|
87
|
+
// scenario could close. Entries on the same route that differ in any request/response detail
|
|
88
|
+
// (encoding, body, headers, expect, …) stay distinct contracts.
|
|
89
|
+
const defaultDatasource = defaultDatasourceName(cwd);
|
|
90
|
+
const aliasGroups = new Map<string, string[]>(); // fingerprint → alias names, first referenced = label
|
|
91
|
+
for (const [name, entry] of entryOf) {
|
|
92
|
+
const key = endpointFingerprint(entry, defaultDatasource);
|
|
93
|
+
const group = aliasGroups.get(key);
|
|
94
|
+
if (group) group.push(name); else aliasGroups.set(key, [name]);
|
|
95
|
+
}
|
|
80
96
|
|
|
81
97
|
// ── Coverage: must-cover viewpoints per resolved endpoint ──────────────────────────────────
|
|
82
98
|
// read (GET/HEAD): {contract}; mutating: {contract, error}. (auth/idempotency/side-effect are
|
|
@@ -90,12 +106,14 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
|
|
|
90
106
|
const gaps: GateResult['gaps'] = [];
|
|
91
107
|
let covered = 0;
|
|
92
108
|
let themesTotal = 0;
|
|
93
|
-
for (const
|
|
94
|
-
const
|
|
109
|
+
for (const aliases of aliasGroups.values()) {
|
|
110
|
+
const label = aliases[0];
|
|
111
|
+
const vps: ('contract' | 'error')[] = isMutating(label) ? ['contract', 'error'] : ['contract'];
|
|
95
112
|
for (const vp of vps) {
|
|
96
113
|
themesTotal++;
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
// A theme is covered when ANY alias of the endpoint asserts it.
|
|
115
|
+
if (apiScenarios.some((s) => aliases.some((alias) => asserts(s, alias, vp)))) covered++;
|
|
116
|
+
else gaps.push({ theme: `${label}:${vp}`, keywords: [], status: 'missing' });
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
const coverageRatio = themesTotal ? covered / themesTotal : 0;
|
|
@@ -142,3 +160,65 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
|
|
|
142
160
|
|
|
143
161
|
return { gate, depth };
|
|
144
162
|
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The datasource a request without an explicit `datasource` runs against — the same rule as the
|
|
166
|
+
* runtime client: the first `kind: api` entry of datasources.yaml (project root, then qa/), else the
|
|
167
|
+
* first entry. `undefined` when the project has no datasources.yaml (then only entries that both
|
|
168
|
+
* omit the field can match each other).
|
|
169
|
+
*/
|
|
170
|
+
function defaultDatasourceName(cwd: string): string | undefined {
|
|
171
|
+
const file = [path.join(cwd, 'datasources.yaml'), path.join(cwd, 'qa', 'datasources.yaml')].find((f) => fs.existsSync(f));
|
|
172
|
+
if (!file) return undefined;
|
|
173
|
+
try {
|
|
174
|
+
const doc = parseYaml(fs.readFileSync(file, 'utf8')) as { datasources?: Record<string, { kind?: string }> } | null;
|
|
175
|
+
const all = doc?.datasources ?? {};
|
|
176
|
+
const keys = Object.keys(all);
|
|
177
|
+
return keys.find((k) => (all[k]?.kind || 'api') === 'api') ?? keys[0];
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Identity of the request/response contract an entry describes — every field except how the entry
|
|
185
|
+
* is named (`name`, `description`) and where it lives (`scope`, `file`), with the effective
|
|
186
|
+
* datasource filled in. Canonical (sorted keys), so YAML key order never splits an alias.
|
|
187
|
+
*/
|
|
188
|
+
function endpointFingerprint(entry: ApiEntry, defaultDatasource: string | undefined): string {
|
|
189
|
+
const { name: _name, description: _description, scope: _scope, file: _file, ...contract } = entry;
|
|
190
|
+
const sorted = (xs: string[] | undefined) => (xs ? [...xs].sort() : undefined);
|
|
191
|
+
return canonicalJson({
|
|
192
|
+
...contract,
|
|
193
|
+
datasource: entry.datasource ?? defaultDatasource,
|
|
194
|
+
// Sets, not sequences: `params` is validated against the `:refs` in both directions with no order,
|
|
195
|
+
// `arrayPaths` is a membership list — a retyped clone must not split on order.
|
|
196
|
+
params: sorted(entry.params),
|
|
197
|
+
arrayPaths: sorted(entry.arrayPaths),
|
|
198
|
+
// The runtime treats header names case-insensitively — so does the identity.
|
|
199
|
+
headers: entry.headers ? Object.fromEntries(Object.entries(entry.headers).map(([k, v]) => [k.toLowerCase(), v])) : undefined,
|
|
200
|
+
// `status: 201` and `status: "201"` are the same expectation (every consumer reads it via String());
|
|
201
|
+
// an `expect:` block without a status is no expectation at all.
|
|
202
|
+
expect: entry.expect?.status != null ? { status: String(entry.expect.status) } : undefined,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Deterministic JSON: sorted object keys, `undefined` members dropped, cycles and non-finite numbers named. */
|
|
207
|
+
function canonicalJson(value: unknown, seen: WeakSet<object> = new WeakSet()): string {
|
|
208
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value); // NaN/Infinity ≠ null
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
if (seen.has(value)) return '"[cycle]"';
|
|
211
|
+
seen.add(value);
|
|
212
|
+
return `[${value.map((v) => canonicalJson(v, seen)).join(',')}]`;
|
|
213
|
+
}
|
|
214
|
+
if (value && typeof value === 'object') {
|
|
215
|
+
if (seen.has(value)) return '"[cycle]"'; // a self-referencing YAML anchor must not crash the audit
|
|
216
|
+
seen.add(value);
|
|
217
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
218
|
+
.filter(([, v]) => v !== undefined)
|
|
219
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
220
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v, seen)}`);
|
|
221
|
+
return `{${entries.join(',')}}`;
|
|
222
|
+
}
|
|
223
|
+
return JSON.stringify(value) ?? 'null';
|
|
224
|
+
}
|