@sungen/driver-api 3.1.2-beta.99 → 3.2.0-beta.142
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 +18 -6
- package/src/api-context.ts +52 -0
- package/src/api-harness.ts +143 -0
- package/src/api-repair.ts +39 -0
- package/src/cli-import.ts +292 -42
- package/src/csv-import.ts +101 -0
- package/src/index.ts +127 -5
- package/src/openapi-import.ts +32 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0-beta.142",
|
|
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.
|
|
16
|
+
"@sun-asterisk/sungen": "3.2.0-beta.142",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
package/src/api-catalog.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ApiEntry {
|
|
|
27
27
|
method: HttpMethod;
|
|
28
28
|
path: string;
|
|
29
29
|
body?: unknown;
|
|
30
|
+
encoding?: 'json' | 'form' | 'multipart'; // request-body wire format (default json); form = application/x-www-form-urlencoded
|
|
31
|
+
headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
|
|
30
32
|
params: string[];
|
|
31
33
|
expect?: { status?: number | string };
|
|
32
34
|
description?: string;
|
|
@@ -51,7 +53,9 @@ const _cache = new Map<string, LoadedApiCatalog>();
|
|
|
51
53
|
export function findApiCatalogFiles(screenName: string, cwd: string = process.cwd()): ApiCatalogFiles {
|
|
52
54
|
const out: ApiCatalogFiles = {};
|
|
53
55
|
if (!screenName) return out;
|
|
54
|
-
|
|
56
|
+
// `screenName` is a unit id relative to qa/: `<screen>` (→ qa/screens/<screen>), `flows/<flow>`,
|
|
57
|
+
// or an api-first unit `api/<area>` / `api/flows/<flow>` (→ qa/<screenName>).
|
|
58
|
+
const screenDir = screenName.startsWith('flows/') || screenName.startsWith('api/')
|
|
55
59
|
? path.join(cwd, 'qa', screenName)
|
|
56
60
|
: path.join(cwd, 'qa', 'screens', screenName);
|
|
57
61
|
const screenFile = path.join(screenDir, 'api', 'apis.yaml');
|
|
@@ -66,6 +70,14 @@ function refsIn(text: string): string[] {
|
|
|
66
70
|
return (text.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** All `:param` refs an entry binds — path + JSON body + header templates. */
|
|
74
|
+
function entryRefs(entry: ApiEntry): string[] {
|
|
75
|
+
const fromPath = refsIn(entry.path);
|
|
76
|
+
const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
|
|
77
|
+
const fromHeaders = entry.headers ? refsIn(JSON.stringify(entry.headers)) : [];
|
|
78
|
+
return [...new Set([...fromPath, ...fromBody, ...fromHeaders])];
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, ApiEntry> {
|
|
70
82
|
const map = new Map<string, ApiEntry>();
|
|
71
83
|
const raw = parseYaml(fs.readFileSync(file, 'utf8')) || {};
|
|
@@ -78,6 +90,8 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
|
|
|
78
90
|
method: String(v.method).toUpperCase() as HttpMethod,
|
|
79
91
|
path: v.path,
|
|
80
92
|
body: v.body,
|
|
93
|
+
encoding: v.encoding === 'form' || v.encoding === 'multipart' ? v.encoding : undefined, // default (undefined) = json
|
|
94
|
+
headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
|
|
81
95
|
params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
|
|
82
96
|
expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
|
|
83
97
|
description: typeof v.description === 'string' ? v.description : undefined,
|
|
@@ -121,12 +135,10 @@ export function resolveApi(name: string, screenName: string, cwd: string = proce
|
|
|
121
135
|
return entry;
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
/** Ordered bound-param names for an entry (declared `params`, else the `:refs` in path+body). */
|
|
138
|
+
/** Ordered bound-param names for an entry (declared `params`, else the `:refs` in path+body+headers). */
|
|
125
139
|
export function apiParamNames(entry: ApiEntry): string[] {
|
|
126
140
|
if (entry.params.length) return entry.params;
|
|
127
|
-
|
|
128
|
-
const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
|
|
129
|
-
return [...new Set([...fromPath, ...fromBody])];
|
|
141
|
+
return entryRefs(entry);
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
/** Validate one entry. Returns human-readable problems (empty = clean). */
|
|
@@ -134,7 +146,7 @@ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | nu
|
|
|
134
146
|
const errs: string[] = [];
|
|
135
147
|
if (!METHODS.has(entry.method)) errs.push(`api "${entry.name}": method "${entry.method}" is not a valid HTTP method.`);
|
|
136
148
|
if (!entry.path.startsWith('/')) errs.push(`api "${entry.name}": path "${entry.path}" must start with "/".`);
|
|
137
|
-
const refs = new Set(
|
|
149
|
+
const refs = new Set(entryRefs(entry));
|
|
138
150
|
const declared = new Set(entry.params);
|
|
139
151
|
for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
|
|
140
152
|
for (const d of declared) if (!refs.has(d)) errs.push(`api "${entry.name}": param "${d}" is declared but never used in path/body.`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API discovery + context-mapping (AO-3) — the `api` capability's Discover → Contextualize hooks.
|
|
3
|
+
*
|
|
4
|
+
* Discovery reads the reviewed catalog (apis.yaml) + notes the spec source; context-mapping turns
|
|
5
|
+
* the endpoints into generation units by method-profile: every endpoint → a `matrix` unit (contract
|
|
6
|
+
* + error), each mutating endpoint → also an `async` unit (idempotency), and an api flow → a `flow`
|
|
7
|
+
* unit (the ordered journey). The orchestration loop (`sungen context` → `/sungen:design`) consumes
|
|
8
|
+
* these to know WHAT to author before writing scenarios. Type-only on core.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context, DiscoveryProvider, ContextMapper, GenerationUnit } from '@sun-asterisk/sungen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { lintApiCatalog } from './api-catalog';
|
|
14
|
+
|
|
15
|
+
interface EndpointFact { name: string; method: string; path: string; datasource?: string; expectStatus?: number | string }
|
|
16
|
+
|
|
17
|
+
const MUTATING = /^(POST|PUT|PATCH|DELETE)$/;
|
|
18
|
+
|
|
19
|
+
export const apiDiscovery: DiscoveryProvider = {
|
|
20
|
+
appliesTo: (t) => t.kind === 'api',
|
|
21
|
+
discover: async (target, scope) => {
|
|
22
|
+
const cwd = (scope as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
23
|
+
const unitId = target.id; // `api/<area>` | `api/flows/<flow>`
|
|
24
|
+
let endpoints: EndpointFact[] = [];
|
|
25
|
+
try {
|
|
26
|
+
endpoints = lintApiCatalog(unitId, null, cwd).entries.map((e) => ({
|
|
27
|
+
name: e.name, method: e.method, path: e.path, datasource: e.datasource, expectStatus: e.expect?.status,
|
|
28
|
+
}));
|
|
29
|
+
} catch { /* no catalog → the verification gate reports it */ }
|
|
30
|
+
|
|
31
|
+
const sources: Record<string, unknown> = {};
|
|
32
|
+
const specPath = path.join(cwd, 'qa', unitId, 'requirements', 'spec.md');
|
|
33
|
+
if (fs.existsSync(specPath)) sources.spec = path.relative(cwd, specPath);
|
|
34
|
+
|
|
35
|
+
return { target: { ...target, capability: 'api' }, sources, facts: { endpoints } };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const apiContextMapper: ContextMapper = {
|
|
40
|
+
decompose: (ctx: Context): GenerationUnit[] => {
|
|
41
|
+
const endpoints = ((ctx.facts ?? {}) as { endpoints?: EndpointFact[] }).endpoints ?? [];
|
|
42
|
+
const units: GenerationUnit[] = [];
|
|
43
|
+
const isFlow = typeof ctx.target.id === 'string' && ctx.target.id.includes('/flows/');
|
|
44
|
+
if (isFlow) units.push({ mode: 'flow', targetSlice: ctx.target }); // the ordered journey is one unit
|
|
45
|
+
for (const e of endpoints) {
|
|
46
|
+
units.push({ mode: 'matrix', targetSlice: e }); // contract + error matrix (70%)
|
|
47
|
+
if (MUTATING.test(e.method)) units.push({ mode: 'async', targetSlice: e }); // idempotency/concurrency (10%)
|
|
48
|
+
}
|
|
49
|
+
if (!units.length) units.push({ mode: 'matrix', targetSlice: ctx.target });
|
|
50
|
+
return units;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API harness — the `api` capability's score-bearing gate + viewpoint catalog (AO-2).
|
|
3
|
+
*
|
|
4
|
+
* The audit engine is capability-routed (AO-1): for an `api/<area>` unit it calls THIS capability's
|
|
5
|
+
* `viewpoints()` + `gateProvider()` instead of the UI ones, then assembles the score with the same
|
|
6
|
+
* `0.4·coverage + 0.3·businessDepth + 0.15·balance + 0.15·trace` formula. We compute API-native
|
|
7
|
+
* coverage + depth here (not the UI keyword model), so an API area is scored on what matters for an
|
|
8
|
+
* API: contract + failure coverage, and — the headline — `businessDepth`: do the business-critical
|
|
9
|
+
* (mutating) scenarios PROVE THE EFFECT (assert body / DB side-effect / concurrency invariant), or
|
|
10
|
+
* just the status?
|
|
11
|
+
*
|
|
12
|
+
* Type-only on core (like the rest of the driver) — the depth threshold + project context arrive via
|
|
13
|
+
* the gate input, so loading the driver never forces a core runtime resolve.
|
|
14
|
+
*/
|
|
15
|
+
import type { Catalog, GateResult, DepthResult } from '@sun-asterisk/sungen';
|
|
16
|
+
import { resolveApi } from './api-catalog';
|
|
17
|
+
|
|
18
|
+
/** A scenario as the audit parses it (the subset the API gate reads). */
|
|
19
|
+
interface ApiScenario {
|
|
20
|
+
name: string;
|
|
21
|
+
apiRefs?: string[];
|
|
22
|
+
queryRefs?: string[];
|
|
23
|
+
casesDataset?: string;
|
|
24
|
+
stepsText?: string; // lowercase steps text
|
|
25
|
+
manual?: boolean; // @manual — not automated, so excluded from automation businessDepth
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ApiGateInput {
|
|
29
|
+
scenarios: ApiScenario[];
|
|
30
|
+
focus: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
screenName: string; // catalog-resolution id, e.g. `api/<area>`
|
|
33
|
+
threshold: number; // required businessDepth for the focus (from core)
|
|
34
|
+
businessCriticalMethods?: string[]; // end-user override (qa/context.md) — default POST/PUT/PATCH/DELETE
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CRITICAL = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The API viewpoint catalog (display + the project-override seam). The gate computes coverage from
|
|
41
|
+
* the live `apis.yaml`, so this is intentionally light — one universal theme per viewpoint category.
|
|
42
|
+
*/
|
|
43
|
+
export function apiViewpoints(): Catalog {
|
|
44
|
+
const theme = (t: string, keywords: string[]) => ({ theme: t, keywords });
|
|
45
|
+
return {
|
|
46
|
+
page_types: {
|
|
47
|
+
// method-profiles (the API analog of UI page-types) — documented; the gate enforces them.
|
|
48
|
+
read: { detect_keywords: ['get', 'list', 'fetch'], must_cover: [theme('contract', ['status', 'body'])] },
|
|
49
|
+
mutating: {
|
|
50
|
+
detect_keywords: ['create', 'update', 'delete', 'post', 'put', 'patch'],
|
|
51
|
+
must_cover: [theme('contract', ['status', 'body']), theme('error', ['4', '5', 'invalid', 'cases'])],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
universal: [theme('contract', ['status']), theme('error', ['4', '5'])],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Score-bearing API gate: coverage (must-cover viewpoints per endpoint, by method-profile) + depth
|
|
60
|
+
* (mutating scenarios that prove the effect). Returns the same `{gate, depth}` shape the audit
|
|
61
|
+
* assembles into the score.
|
|
62
|
+
*/
|
|
63
|
+
export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth: DepthResult } {
|
|
64
|
+
const { scenarios, focus, cwd, screenName, threshold } = input;
|
|
65
|
+
// Business-critical (depth-required) HTTP methods — end-user customizable via qa/context.md
|
|
66
|
+
// (`business_critical_methods: …`), e.g. to mark GET on a read-sensitive resource as critical.
|
|
67
|
+
const criticalMethods = (input.businessCriticalMethods?.length ? input.businessCriticalMethods : DEFAULT_CRITICAL).map((m) => m.toUpperCase());
|
|
68
|
+
const apiScenarios = scenarios.filter((s) => (s.apiRefs ?? []).length);
|
|
69
|
+
|
|
70
|
+
// Resolve each referenced endpoint's HTTP method (best-effort; unresolved → the verification
|
|
71
|
+
// sensor already fails the gate, so we just skip it here).
|
|
72
|
+
const methodOf = new Map<string, string>();
|
|
73
|
+
for (const s of apiScenarios) {
|
|
74
|
+
for (const name of s.apiRefs ?? []) {
|
|
75
|
+
if (methodOf.has(name)) continue;
|
|
76
|
+
try { methodOf.set(name, resolveApi(name, screenName, cwd).method); } catch { /* unresolved */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const isMutating = (name: string) => criticalMethods.includes((methodOf.get(name) ?? '').toUpperCase());
|
|
80
|
+
|
|
81
|
+
// ── Coverage: must-cover viewpoints per resolved endpoint ──────────────────────────────────
|
|
82
|
+
// read (GET/HEAD): {contract}; mutating: {contract, error}. (auth/idempotency/side-effect are
|
|
83
|
+
// advisory via the api-viewpoints sensor + rewarded through businessDepth, not coverage-gating.)
|
|
84
|
+
const asserts = (s: ApiScenario, endpoint: string, vp: 'contract' | 'error'): boolean => {
|
|
85
|
+
if (!(s.apiRefs ?? []).includes(endpoint)) return false;
|
|
86
|
+
const t = s.stepsText ?? '';
|
|
87
|
+
if (vp === 'contract') return new RegExp(`\\b${endpoint.toLowerCase()}\\.(status|ok|body|headers|ok_count|status_counts)\\b`).test(t);
|
|
88
|
+
return !!s.casesDataset || /\b[45]\d\d\b/.test(t); // error: a @cases matrix or an asserted 4xx/5xx
|
|
89
|
+
};
|
|
90
|
+
const gaps: GateResult['gaps'] = [];
|
|
91
|
+
let covered = 0;
|
|
92
|
+
let themesTotal = 0;
|
|
93
|
+
for (const endpoint of methodOf.keys()) {
|
|
94
|
+
const vps: ('contract' | 'error')[] = isMutating(endpoint) ? ['contract', 'error'] : ['contract'];
|
|
95
|
+
for (const vp of vps) {
|
|
96
|
+
themesTotal++;
|
|
97
|
+
if (apiScenarios.some((s) => asserts(s, endpoint, vp))) covered++;
|
|
98
|
+
else gaps.push({ theme: `${endpoint}:${vp}`, keywords: [], status: 'missing' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const coverageRatio = themesTotal ? covered / themesTotal : 0;
|
|
102
|
+
const area = screenName.replace(/^api\//, '');
|
|
103
|
+
const gate: GateResult = {
|
|
104
|
+
pageType: `api:${area}`,
|
|
105
|
+
themesTotal,
|
|
106
|
+
themesCovered: covered,
|
|
107
|
+
coverageRatio,
|
|
108
|
+
gaps,
|
|
109
|
+
universalGaps: [],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── businessDepth: the SUCCESS path of a mutation must PROVE THE EFFECT, not just assert status ──
|
|
113
|
+
// An error scenario (a @cases matrix, or one asserting a 4xx/5xx) proves the failure CONTRACT —
|
|
114
|
+
// the status IS its correct assertion (the API analog of UI NAV), so it is not depth-required.
|
|
115
|
+
const errorLike = (s: ApiScenario) => !!s.casesDataset || /\bis\s+["']?[45]\d\d\b/.test(s.stepsText ?? '');
|
|
116
|
+
// businessDepth measures AUTOMATED proof — a @manual scenario isn't automated, so it can't count
|
|
117
|
+
// toward depth (else deferring an automatable mutation to @manual inflates the score). The
|
|
118
|
+
// api-viewpoints sensor separately flags @manual scenarios whose endpoint resolves (automatable).
|
|
119
|
+
const depthRequired = apiScenarios.filter((s) => !s.manual && (s.apiRefs ?? []).some(isMutating) && !errorLike(s));
|
|
120
|
+
const isDeep = (s: ApiScenario): boolean => {
|
|
121
|
+
const t = s.stepsText ?? '';
|
|
122
|
+
return /\b[a-z_][a-z0-9_]*\.body\b/.test(t) // asserts a response body field
|
|
123
|
+
|| /\b[a-z_][a-z0-9_]*\.(ok_count|status_counts)\b/.test(t) // a concurrency invariant
|
|
124
|
+
|| (s.queryRefs ?? []).length > 0; // a @query DB side-effect cross-check
|
|
125
|
+
};
|
|
126
|
+
const reqShallow = depthRequired.filter((s) => !isDeep(s));
|
|
127
|
+
const bcDepthRatio = depthRequired.length ? (depthRequired.length - reqShallow.length) / depthRequired.length : 1;
|
|
128
|
+
const verdict: DepthResult['verdict'] = bcDepthRatio < threshold ? (focus === 'smoke' ? 'warn' : 'fail') : 'pass';
|
|
129
|
+
const depth: DepthResult = {
|
|
130
|
+
total: apiScenarios.length,
|
|
131
|
+
shallowTotal: reqShallow.length,
|
|
132
|
+
shallowRatio: apiScenarios.length ? reqShallow.length / apiScenarios.length : 0,
|
|
133
|
+
businessCriticalTotal: depthRequired.length,
|
|
134
|
+
businessCriticalShallow: reqShallow.length,
|
|
135
|
+
bcDepthRatio,
|
|
136
|
+
shallowBusinessCritical: reqShallow.map((s) => ({ name: s.name, category: 'API' })),
|
|
137
|
+
focus,
|
|
138
|
+
threshold,
|
|
139
|
+
verdict,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { gate, depth };
|
|
143
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API repair catalog (#343) — the `api` capability's deterministic fix rules.
|
|
3
|
+
*
|
|
4
|
+
* `sungen repair --api <area>` matches each audit finding + runtime failure against these rules and
|
|
5
|
+
* proposes the concrete fix. Same catalog the `/sungen:run-test` API mode + `sungen-api-design`
|
|
6
|
+
* repair loop apply by hand — here it is capability-owned DATA, so it composes audit + runtime
|
|
7
|
+
* signals into one plan and a project/driver can extend it. Type-only on core.
|
|
8
|
+
*/
|
|
9
|
+
import type { RepairProvider } from '@sun-asterisk/sungen';
|
|
10
|
+
|
|
11
|
+
export const apiRepair: RepairProvider = {
|
|
12
|
+
rules: [
|
|
13
|
+
// ── Coverage / depth (from `sungen audit --api`) ──────────────────────────────────────────
|
|
14
|
+
{ id: 'api-contract', match: /VIEWPOINT-API-CONTRACT/i,
|
|
15
|
+
fix: 'The endpoint is invoked but its response is never asserted — add `expect {{name.status}} is …` plus a body check `{{name.body.<field>}}`.' },
|
|
16
|
+
{ id: 'api-error', match: /VIEWPOINT-API-ERROR/i,
|
|
17
|
+
fix: 'A mutating endpoint has no failure scenario — add a `@cases` error matrix (input → expected status) or an explicit 4xx assertion (the ~70% band).' },
|
|
18
|
+
{ id: 'api-idempotency', match: /VIEWPOINT-API-IDEMPOTENCY/i,
|
|
19
|
+
fix: 'A mutating endpoint has no race check — add `@concurrent:N` + assert `{{name.ok_count}} is 1`, cross-checked with a `@query` DB count (the ~10% band).' },
|
|
20
|
+
{ id: 'api-depth', match: /DEPTH-?FAIL|businessDepth|SHALLOW/i,
|
|
21
|
+
fix: 'A mutating success scenario only asserts the status — make it PROVE THE EFFECT: assert a response body field, a `@query` DB side-effect, or a `@concurrent` `ok_count` invariant.' },
|
|
22
|
+
{ id: 'api-verification', match: /VERIFICATION-FAIL|does not resolve|not found in/i,
|
|
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
|
+
{ id: 'api-manual-automatable', match: /VIEWPOINT-API-MANUAL-AUTOMATABLE/i,
|
|
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
|
+
|
|
27
|
+
// ── Runtime failures (from the Playwright test-result) ────────────────────────────────────
|
|
28
|
+
{ id: 'api-auth', match: /\b40[13]\b|unauthor|forbidden/i,
|
|
29
|
+
fix: 'Auth failure (401/403) — tag the scenario `@hybrid` + `@auth:<role>` to reuse the UI session, or wire a catalog `authorization: "Bearer :token"` header; capture a session with `sungen makeauth <role>`.' },
|
|
30
|
+
{ id: 'api-datasource', match: /base_url|ECONNREFUSED|ENOTFOUND|ENETUNREACH|getaddrinfo/i,
|
|
31
|
+
fix: 'The API was unreachable — set the datasource `${X_URL}` key in `.env.qa` (from `sungen api init`) and confirm the service is up / non-prod.' },
|
|
32
|
+
{ id: 'api-status-mismatch', match: /expected.*status|status.*expected|toBe.*\b[45]\d\d\b|received.*\b[45]\d\d\b/i,
|
|
33
|
+
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
|
+
{ id: 'api-timeout', match: /timeout|timed out|exceeded/i,
|
|
35
|
+
fix: 'Request timed out — raise the datasource `timeout_ms`, or check the endpoint/path; for flows ensure prior steps bound the values the call needs.' },
|
|
36
|
+
{ id: 'api-flaky', match: /flak|intermitt|conflict|already exists|duplicate key/i,
|
|
37
|
+
fix: 'Likely state bleed — make the flow self-cleaning (delete what it created / `@cleanup`), isolate `@cases` rows, and cap `@concurrent`.' },
|
|
38
|
+
],
|
|
39
|
+
};
|
package/src/cli-import.ts
CHANGED
|
@@ -2,58 +2,308 @@ import { Command } from 'commander';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
5
|
-
import { importOpenApi } from './openapi-import';
|
|
5
|
+
import { importOpenApi, type ImportedApiEntry, type ImportResult } from './openapi-import';
|
|
6
|
+
import { importCsv } from './csv-import';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* `sungen api …` — API Driver authoring
|
|
9
|
-
*
|
|
9
|
+
* `sungen api …` — API Driver authoring (Mode A).
|
|
10
|
+
* init — scaffold the API project (kind: api datasource + qa/api + .env key)
|
|
11
|
+
* add — scaffold one area (resource/tag): qa/api/<area>/…
|
|
12
|
+
* import — OpenAPI/Swagger OR CSV/Google-Sheet → apis.yaml, grouped into areas by tag
|
|
10
13
|
*/
|
|
14
|
+
|
|
15
|
+
const CATALOG_HEADER =
|
|
16
|
+
'# Named-endpoint catalog (API Driver) — generated by `sungen api import`.\n' +
|
|
17
|
+
'# Review the request shape, wire each `datasource` in datasources.yaml (kind: api),\n' +
|
|
18
|
+
'# then reference an endpoint from a scenario with `@api:<name>`.\n';
|
|
19
|
+
|
|
20
|
+
const slug = (s: string) => s.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
21
|
+
const rel = (p: string) => path.relative(process.cwd(), p);
|
|
22
|
+
|
|
23
|
+
function writeCatalog(file: string, entries: Record<string, ImportedApiEntry>, merge: boolean): void {
|
|
24
|
+
let out = entries;
|
|
25
|
+
if (fs.existsSync(file)) {
|
|
26
|
+
if (!merge) throw new Error(`${rel(file)} already exists — pass --merge to combine.`);
|
|
27
|
+
const existing = parseYaml(fs.readFileSync(file, 'utf8')) || {};
|
|
28
|
+
out = { ...existing, ...entries }; // imported entries win on name clash
|
|
29
|
+
}
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
fs.writeFileSync(file, CATALOG_HEADER + stringifyYaml(out));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a source path or http(s) URL (a published Google-Sheet CSV) → text. */
|
|
35
|
+
async function readSource(source: string): Promise<string> {
|
|
36
|
+
if (/^https?:\/\//.test(source)) {
|
|
37
|
+
const res = await fetch(source);
|
|
38
|
+
if (!res.ok) throw new Error(`fetch ${source} → ${res.status}`);
|
|
39
|
+
return await res.text();
|
|
40
|
+
}
|
|
41
|
+
if (!fs.existsSync(source)) throw new Error(`source not found: ${source}`);
|
|
42
|
+
return fs.readFileSync(source, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Detect OpenAPI vs CSV: a parseable doc with `paths`/`openapi`/`swagger` is OpenAPI; else CSV. */
|
|
46
|
+
function importAny(source: string, text: string, datasource: string): { result: ImportResult; kind: 'openapi' | 'csv' } {
|
|
47
|
+
if (/\.csv$/i.test(source)) return { result: importCsv(text, datasource), kind: 'csv' };
|
|
48
|
+
let doc: any;
|
|
49
|
+
try { doc = parseYaml(text); } catch { doc = undefined; } // yaml lib also parses JSON
|
|
50
|
+
if (doc && typeof doc === 'object' && (doc.paths || doc.openapi || doc.swagger)) {
|
|
51
|
+
return { result: importOpenApi(doc, datasource), kind: 'openapi' };
|
|
52
|
+
}
|
|
53
|
+
return { result: importCsv(text, datasource), kind: 'csv' };
|
|
54
|
+
}
|
|
55
|
+
|
|
11
56
|
export function registerApiCommand(program: Command): void {
|
|
12
|
-
const api = program.command('api').description('API Driver authoring
|
|
57
|
+
const api = program.command('api').description('API Driver authoring — init · add · import');
|
|
13
58
|
|
|
59
|
+
// ── init ─────────────────────────────────────────────────────────────────
|
|
14
60
|
api
|
|
15
|
-
.command('
|
|
16
|
-
.description('
|
|
17
|
-
.
|
|
18
|
-
.option('--datasource <name>', 'Datasource name
|
|
19
|
-
.
|
|
20
|
-
.action((openapi: string, opts: { screen?: string; datasource: string; merge?: boolean }) => {
|
|
61
|
+
.command('init')
|
|
62
|
+
.description('Scaffold an API project: a kind:api datasource + qa/api/ + the .env.qa URL key')
|
|
63
|
+
.requiredOption('--base-url <url>', 'API base URL (stored in .env.qa, referenced as ${<DS>_URL})')
|
|
64
|
+
.option('--datasource <name>', 'Datasource name', 'app_api')
|
|
65
|
+
.action((opts: { baseUrl: string; datasource: string }) => {
|
|
21
66
|
try {
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
67
|
+
const ds = opts.datasource;
|
|
68
|
+
const envKey = `${ds.toUpperCase()}_URL`;
|
|
69
|
+
fs.mkdirSync(path.join(process.cwd(), 'qa', 'api'), { recursive: true });
|
|
70
|
+
|
|
71
|
+
// datasources.yaml — merge (never clobber other datasources)
|
|
72
|
+
const dsFile =
|
|
73
|
+
[path.join(process.cwd(), 'qa', 'datasources.yaml'), path.join(process.cwd(), 'datasources.yaml')].find((f) => fs.existsSync(f)) ||
|
|
74
|
+
path.join(process.cwd(), 'qa', 'datasources.yaml');
|
|
75
|
+
const dsDoc: any = fs.existsSync(dsFile) ? parseYaml(fs.readFileSync(dsFile, 'utf8')) || {} : {};
|
|
76
|
+
dsDoc.datasources = dsDoc.datasources || {};
|
|
77
|
+
if (!dsDoc.datasources[ds]) {
|
|
78
|
+
dsDoc.datasources[ds] = { kind: 'api', base_url: `\${${envKey}}`, timeout_ms: 15000 };
|
|
79
|
+
fs.mkdirSync(path.dirname(dsFile), { recursive: true });
|
|
80
|
+
fs.writeFileSync(dsFile, stringifyYaml(dsDoc));
|
|
81
|
+
console.log(`✓ datasource "${ds}" (kind: api) → ${rel(dsFile)}`);
|
|
82
|
+
} else {
|
|
83
|
+
console.log(`• datasource "${ds}" already in ${rel(dsFile)} — left as is`);
|
|
38
84
|
}
|
|
39
85
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
// .env.qa (real value, gitignored) + .env.qa.example (key only)
|
|
87
|
+
upsertEnv(path.join(process.cwd(), '.env.qa'), envKey, opts.baseUrl);
|
|
88
|
+
upsertEnv(path.join(process.cwd(), '.env.qa.example'), envKey, '');
|
|
89
|
+
console.log(`✓ ${envKey} → .env.qa (value) + .env.qa.example (key)`);
|
|
90
|
+
console.log('\nNext: `sungen api import <openapi|csv|sheet-url>` or `sungen api add --area <name>`.\n');
|
|
91
|
+
} catch (e) { fail(e); }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── add ──────────────────────────────────────────────────────────────────
|
|
95
|
+
api
|
|
96
|
+
.command('add')
|
|
97
|
+
.description('Scaffold an API area (--area, one resource) or a CRUD/auth journey (--flow, ordered @api chain)')
|
|
98
|
+
.option('--area <name>', 'Area / resource name (e.g. orders, users, auth)')
|
|
99
|
+
.option('--flow <name>', 'CRUD/auth flow name — a response→request journey (e.g. user-lifecycle)')
|
|
100
|
+
.action((opts: { area?: string; flow?: string }) => {
|
|
101
|
+
try {
|
|
102
|
+
if (!!opts.area === !!opts.flow) throw new Error('pass exactly one of --area or --flow.');
|
|
103
|
+
|
|
104
|
+
if (opts.flow) {
|
|
105
|
+
const flow = slug(opts.flow);
|
|
106
|
+
if (!flow) throw new Error(`invalid --flow "${opts.flow}"`);
|
|
107
|
+
const base = path.join(process.cwd(), 'qa', 'api', 'flows', flow);
|
|
108
|
+
if (fs.existsSync(base)) throw new Error(`${rel(base)} already exists.`);
|
|
109
|
+
for (const d of ['features', 'test-data', 'requirements', 'api']) fs.mkdirSync(path.join(base, d), { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(base, 'features', `${flow}.feature`), flowFeatureStub(flow));
|
|
111
|
+
fs.writeFileSync(path.join(base, 'api', 'apis.yaml'), flowCatalogStub());
|
|
112
|
+
fs.writeFileSync(path.join(base, 'test-data', `${flow}.yaml`), flowTestDataStub());
|
|
113
|
+
fs.writeFileSync(path.join(base, 'requirements', 'spec.md'), `# ${flow} flow\n\n_Describe the ordered journey (auth → CRUD), the resource lifecycle, and the response fields each step threads forward._\n`);
|
|
114
|
+
console.log(`✓ flow "${flow}" → ${rel(base)}/`);
|
|
115
|
+
console.log(`\nResponse→request chaining: each @api binds {{name}}; a later call threads {{name.body.<field>}}`);
|
|
116
|
+
console.log(`via a path param or the catalog "Bearer :token" header. Review api/apis.yaml, wire the datasource,`);
|
|
117
|
+
console.log(`then assert each step's status/body. Self-clean by deleting what the flow created.\n`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const area = slug(opts.area!);
|
|
122
|
+
if (!area) throw new Error(`invalid --area "${opts.area}"`);
|
|
123
|
+
const base = path.join(process.cwd(), 'qa', 'api', area);
|
|
124
|
+
if (fs.existsSync(base)) throw new Error(`${rel(base)} already exists.`);
|
|
125
|
+
for (const d of ['features', 'test-data', 'requirements', 'api']) fs.mkdirSync(path.join(base, d), { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(base, 'features', `${area}.feature`), featureStub(area));
|
|
127
|
+
fs.writeFileSync(path.join(base, 'test-data', `${area}.yaml`), `# Test data for the ${area} API area.\n`);
|
|
128
|
+
fs.writeFileSync(path.join(base, 'requirements', 'spec.md'), `# ${area} API\n\n_Describe the ${area} endpoints, rules, and expected responses here (or import an OpenAPI/CSV)._\n`);
|
|
129
|
+
fs.writeFileSync(path.join(base, 'api', 'apis.yaml'), CATALOG_HEADER);
|
|
130
|
+
console.log(`✓ area "${area}" → ${rel(base)}/`);
|
|
131
|
+
console.log(`\nNext: import or hand-author ${rel(path.join(base, 'api', 'apis.yaml'))}, then add @api scenarios in features/${area}.feature.\n`);
|
|
132
|
+
} catch (e) { fail(e); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── import ───────────────────────────────────────────────────────────────
|
|
136
|
+
api
|
|
137
|
+
.command('import <source>')
|
|
138
|
+
.description('Generate apis.yaml from an OpenAPI/Swagger doc OR a CSV / published Google-Sheet URL')
|
|
139
|
+
.option('--datasource <name>', 'Default datasource for imported endpoints', 'app_api')
|
|
140
|
+
.option('--single', 'Write one shared qa/api/apis.yaml (default: one file per area)')
|
|
141
|
+
.option('--screen <name>', 'Legacy: write to qa/screens/<name>/api/apis.yaml')
|
|
142
|
+
.option('--merge', 'Merge into an existing catalog instead of refusing to overwrite')
|
|
143
|
+
.action(async (source: string, opts: { datasource: string; single?: boolean; screen?: string; merge?: boolean }) => {
|
|
144
|
+
try {
|
|
145
|
+
const text = await readSource(source);
|
|
146
|
+
const { result, kind } = importAny(source, text, opts.datasource);
|
|
147
|
+
const { entries, count, skipped } = result;
|
|
148
|
+
if (count === 0) throw new Error(`No endpoints imported (${skipped.join('; ') || 'empty source'}).`);
|
|
45
149
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
150
|
+
const written: string[] = [];
|
|
151
|
+
if (opts.screen) {
|
|
152
|
+
const f = path.join(process.cwd(), 'qa', 'screens', opts.screen, 'api', 'apis.yaml');
|
|
153
|
+
writeCatalog(f, entries, !!opts.merge);
|
|
154
|
+
written.push(rel(f));
|
|
155
|
+
} else if (opts.single) {
|
|
156
|
+
const f = path.join(process.cwd(), 'qa', 'api', 'apis.yaml');
|
|
157
|
+
writeCatalog(f, entries, !!opts.merge);
|
|
158
|
+
written.push(rel(f));
|
|
159
|
+
} else {
|
|
160
|
+
// group by area → qa/api/<area>/api/apis.yaml + a runnable contract-scenario stub.
|
|
161
|
+
const byArea: Record<string, Record<string, ImportedApiEntry>> = {};
|
|
162
|
+
for (const [name, e] of Object.entries(entries)) (byArea[e.area || 'root'] ||= {})[name] = e;
|
|
163
|
+
for (const [area, group] of Object.entries(byArea)) {
|
|
164
|
+
const f = path.join(process.cwd(), 'qa', 'api', area, 'api', 'apis.yaml');
|
|
165
|
+
writeCatalog(f, group, !!opts.merge);
|
|
166
|
+
written.push(`${rel(f)} (${Object.keys(group).length})`);
|
|
167
|
+
// Scaffold the feature so `sungen generate --api <area>` has something to compile — one
|
|
168
|
+
// happy-path contract scenario per endpoint (the 70% baseline). Don't clobber edits.
|
|
169
|
+
const featFile = path.join(process.cwd(), 'qa', 'api', area, 'features', `${area}.feature`);
|
|
170
|
+
if (!fs.existsSync(featFile)) {
|
|
171
|
+
fs.mkdirSync(path.dirname(featFile), { recursive: true });
|
|
172
|
+
fs.writeFileSync(featFile, importedFeatureStub(area, group));
|
|
173
|
+
written.push(`${rel(featFile)} (${Object.keys(group).length} contract scenario(s))`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
50
176
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.
|
|
56
|
-
|
|
57
|
-
}
|
|
177
|
+
|
|
178
|
+
console.log(`\n✓ Imported ${count} endpoint(s) from ${kind} source:`);
|
|
179
|
+
for (const w of written) console.log(` → ${w}`);
|
|
180
|
+
if (skipped.length) console.log(`\n⚠ Skipped: ${skipped.slice(0, 10).join('; ')}${skipped.length > 10 ? ` … (+${skipped.length - 10})` : ''}`);
|
|
181
|
+
console.log('\nNext: wire the `datasource` base_url + auth in datasources.yaml, refine the scenarios,');
|
|
182
|
+
console.log('then `sungen generate --api <area>` → `npx playwright test`.\n');
|
|
183
|
+
} catch (e) { fail(e); }
|
|
58
184
|
});
|
|
59
185
|
}
|
|
186
|
+
|
|
187
|
+
function upsertEnv(file: string, key: string, value: string): void {
|
|
188
|
+
const line = `${key}=${value}`;
|
|
189
|
+
if (!fs.existsSync(file)) { fs.writeFileSync(file, line + '\n'); return; }
|
|
190
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
191
|
+
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(content)) return; // already present — don't overwrite a real value
|
|
192
|
+
fs.writeFileSync(file, content.replace(/\n?$/, '\n') + line + '\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A runnable feature from imported endpoints: one happy-path contract scenario each (the 70% baseline). */
|
|
196
|
+
function importedFeatureStub(area: string, group: Record<string, ImportedApiEntry>): string {
|
|
197
|
+
const lines = [
|
|
198
|
+
`@parallel @area:${area}`,
|
|
199
|
+
`Feature: ${area} API`,
|
|
200
|
+
'',
|
|
201
|
+
' # Auto-scaffolded from the imported catalog — one contract check per endpoint. Refine these:',
|
|
202
|
+
' # add error cases with @cases, flows with ordered @api tags, idempotency with @concurrent:N.',
|
|
203
|
+
' # `sungen audit` flags missing error/idempotency coverage on mutating endpoints.',
|
|
204
|
+
'',
|
|
205
|
+
];
|
|
206
|
+
let i = 1;
|
|
207
|
+
for (const [name, e] of Object.entries(group)) {
|
|
208
|
+
const status = e.expect?.status ?? 200;
|
|
209
|
+
const seq = String(i++).padStart(3, '0');
|
|
210
|
+
lines.push(` @high @api:${name}`);
|
|
211
|
+
lines.push(` Scenario: VP-API-${seq} ${e.method} ${e.path} returns ${status}`);
|
|
212
|
+
lines.push(` Then expect {{${name}.status}} is ${status}`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
}
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function featureStub(area: string): string {
|
|
219
|
+
return [
|
|
220
|
+
`@area:${area}`,
|
|
221
|
+
`Feature: ${area} API`,
|
|
222
|
+
'',
|
|
223
|
+
' # Reference an endpoint from api/apis.yaml with @api:<name>; assert the bound response with',
|
|
224
|
+
' # expect {{<name>.status}} / {{<name>.body.<path>}}. Example:',
|
|
225
|
+
' #',
|
|
226
|
+
` # @api:get_${area}`,
|
|
227
|
+
` # Scenario: VP-API-001 Fetching ${area} returns 200`,
|
|
228
|
+
` # Then expect {{get_${area}.status}} is 200`,
|
|
229
|
+
'',
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** CRUD/auth flow scaffold: an ordered @api chain that threads a created resource through its lifecycle. */
|
|
234
|
+
function flowFeatureStub(flow: string): string {
|
|
235
|
+
return [
|
|
236
|
+
`@flow @area:${flow}`,
|
|
237
|
+
`Feature: ${flow} flow`,
|
|
238
|
+
'',
|
|
239
|
+
' # Response→request chaining: ordered @api tags run in declaration order; each binds {{name}},',
|
|
240
|
+
' # a later call threads a prior field forward. Tags carry no whitespace — pass single-token',
|
|
241
|
+
' # overrides; the auth scheme ("Bearer :token") lives in the catalog header, not the feature.',
|
|
242
|
+
' #',
|
|
243
|
+
' # auth precondition: @api:login binds {{login}}; authed calls pass token={{login.body.token}}',
|
|
244
|
+
' # → the catalog "Bearer :token" header. Self-clean by deleting what the flow created.',
|
|
245
|
+
' @high @api:login(email={{email}},password={{password}}) @api:create_item(name={{item_name}},token={{login.body.token}}) @api:get_item(id={{create_item.body.id}},token={{login.body.token}}) @api:delete_item(id={{create_item.body.id}},token={{login.body.token}})',
|
|
246
|
+
` Scenario: VP-API-FLOW-001 ${flow} — login, create, read back, then delete`,
|
|
247
|
+
' Then expect {{login.status}} is 200',
|
|
248
|
+
' And expect {{create_item.status}} is 201',
|
|
249
|
+
' And expect {{get_item.status}} is 200',
|
|
250
|
+
' And expect {{get_item.body.name}} is {{item_name}}',
|
|
251
|
+
' And expect {{delete_item.status}} is 204',
|
|
252
|
+
'',
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function flowCatalogStub(): string {
|
|
257
|
+
return CATALOG_HEADER + [
|
|
258
|
+
'',
|
|
259
|
+
'login:',
|
|
260
|
+
' datasource: app_api',
|
|
261
|
+
' method: POST',
|
|
262
|
+
' path: /auth/login',
|
|
263
|
+
' body: { email: ":email", password: ":password" }',
|
|
264
|
+
' params: [email, password]',
|
|
265
|
+
' expect: { status: 200 }',
|
|
266
|
+
'',
|
|
267
|
+
'create_item:',
|
|
268
|
+
' datasource: app_api',
|
|
269
|
+
' method: POST',
|
|
270
|
+
' path: /items',
|
|
271
|
+
' headers: { authorization: "Bearer :token" } # only :token is dynamic; the scheme is the contract',
|
|
272
|
+
' body: { name: ":name" }',
|
|
273
|
+
' params: [name, token]',
|
|
274
|
+
' expect: { status: 201 }',
|
|
275
|
+
'',
|
|
276
|
+
'get_item:',
|
|
277
|
+
' datasource: app_api',
|
|
278
|
+
' method: GET',
|
|
279
|
+
' path: /items/:id',
|
|
280
|
+
' headers: { authorization: "Bearer :token" }',
|
|
281
|
+
' params: [id, token]',
|
|
282
|
+
' expect: { status: 200 }',
|
|
283
|
+
'',
|
|
284
|
+
'delete_item:',
|
|
285
|
+
' datasource: app_api',
|
|
286
|
+
' method: DELETE',
|
|
287
|
+
' path: /items/:id',
|
|
288
|
+
' headers: { authorization: "Bearer :token" }',
|
|
289
|
+
' params: [id, token]',
|
|
290
|
+
' expect: { status: 204 }',
|
|
291
|
+
'',
|
|
292
|
+
].join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function flowTestDataStub(): string {
|
|
296
|
+
return [
|
|
297
|
+
'# Inputs for the flow. The created id + token are threaded from responses at runtime',
|
|
298
|
+
'# ({{create_item.body.id}}, {{login.body.token}}) — only the seed inputs live here.',
|
|
299
|
+
'email: "flow-user@example.com"',
|
|
300
|
+
'password: "change-me"',
|
|
301
|
+
'item_name: "Sample item"',
|
|
302
|
+
'',
|
|
303
|
+
].join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function fail(e: unknown): never {
|
|
307
|
+
console.error('Error:', e instanceof Error ? e.message : e);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV / Google-Sheet → apis.yaml importer (API Driver, AP-2).
|
|
3
|
+
*
|
|
4
|
+
* Many teams keep their endpoint list in a spreadsheet (Google Sheet → "Publish to web" CSV, or an
|
|
5
|
+
* exported .csv). This turns that flat table into the same `ImportedApiEntry` catalog shape as the
|
|
6
|
+
* OpenAPI importer. Deterministic — no AI, no network (the CLI fetches a published-CSV URL; this
|
|
7
|
+
* module only parses text).
|
|
8
|
+
*
|
|
9
|
+
* Columns (header row, case-insensitive; aliases accepted):
|
|
10
|
+
* name | area|tag | method | path | params | body | datasource|auth | expect_status | description
|
|
11
|
+
* - params: separated by `;` or `|` (commas live inside quoted JSON, so don't use `,` here)
|
|
12
|
+
* - body: a JSON object string, values may be `:param` refs, e.g. {"item_id":":item_id"}
|
|
13
|
+
* - expect_status: a number (e.g. 201)
|
|
14
|
+
*/
|
|
15
|
+
import type { ImportedApiEntry, ImportResult } from './openapi-import';
|
|
16
|
+
|
|
17
|
+
/** RFC-4180-ish parser: handles quoted fields, embedded commas/newlines, and "" escapes. */
|
|
18
|
+
export function parseCsv(text: string): string[][] {
|
|
19
|
+
const s = text.replace(/\r\n?/g, '\n');
|
|
20
|
+
const rows: string[][] = [];
|
|
21
|
+
let field = '';
|
|
22
|
+
let row: string[] = [];
|
|
23
|
+
let inQuotes = false;
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
const c = s[i];
|
|
26
|
+
if (inQuotes) {
|
|
27
|
+
if (c === '"') {
|
|
28
|
+
if (s[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
|
|
29
|
+
} else field += c;
|
|
30
|
+
} else if (c === '"') inQuotes = true;
|
|
31
|
+
else if (c === ',') { row.push(field); field = ''; }
|
|
32
|
+
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
|
33
|
+
else field += c;
|
|
34
|
+
}
|
|
35
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
36
|
+
return rows.filter((r) => r.some((cell) => cell.trim() !== '')); // drop blank lines
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ALIASES: Record<string, string> = {
|
|
40
|
+
tag: 'area', auth: 'datasource', status: 'expect_status', expectstatus: 'expect_status',
|
|
41
|
+
endpoint: 'path', url: 'path', verb: 'method', key: 'name', desc: 'description',
|
|
42
|
+
};
|
|
43
|
+
const norm = (h: string) => {
|
|
44
|
+
const k = h.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
45
|
+
return ALIASES[k.replace(/_/g, '')] || ALIASES[k] || k;
|
|
46
|
+
};
|
|
47
|
+
const slug = (s: string) => s.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Transform a CSV/Sheet table into catalog entries.
|
|
51
|
+
* @param text raw CSV
|
|
52
|
+
* @param datasource default datasource when a row omits one (default 'app_api')
|
|
53
|
+
*/
|
|
54
|
+
export function importCsv(text: string, datasource = 'app_api'): ImportResult {
|
|
55
|
+
const rows = parseCsv(text);
|
|
56
|
+
const entries: Record<string, ImportedApiEntry> = {};
|
|
57
|
+
const skipped: string[] = [];
|
|
58
|
+
if (rows.length < 2) return { entries, count: 0, skipped: ['CSV has no data rows (need a header + ≥1 row)'] };
|
|
59
|
+
|
|
60
|
+
const header = rows[0].map(norm);
|
|
61
|
+
const col = (r: string[], name: string): string => {
|
|
62
|
+
const i = header.indexOf(name);
|
|
63
|
+
return i >= 0 && i < r.length ? r[i].trim() : '';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (let n = 1; n < rows.length; n++) {
|
|
67
|
+
const r = rows[n];
|
|
68
|
+
const name = col(r, 'name');
|
|
69
|
+
const method = col(r, 'method').toUpperCase();
|
|
70
|
+
const path = col(r, 'path');
|
|
71
|
+
if (!name || !method || !path) { skipped.push(`row ${n + 1} (missing name/method/path)`); continue; }
|
|
72
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { skipped.push(`row ${n + 1} ("${name}" is not a valid catalog key)`); continue; }
|
|
73
|
+
if (entries[name]) { skipped.push(`row ${n + 1} (duplicate name "${name}")`); continue; }
|
|
74
|
+
|
|
75
|
+
const area = slug(col(r, 'area')) || slug(path.replace(/^\/+/, '').split('/')[0]) || 'root';
|
|
76
|
+
const pathParams = (path.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((x) => x.slice(1));
|
|
77
|
+
const declared = col(r, 'params').split(/[;|]/).map((p) => p.trim()).filter(Boolean);
|
|
78
|
+
const params = [...new Set([...pathParams, ...declared])];
|
|
79
|
+
|
|
80
|
+
let body: Record<string, string> | undefined;
|
|
81
|
+
const bodyCell = col(r, 'body');
|
|
82
|
+
if (bodyCell) {
|
|
83
|
+
try { body = JSON.parse(bodyCell); } catch { skipped.push(`row ${n + 1} (body is not valid JSON — kept no body)`); }
|
|
84
|
+
}
|
|
85
|
+
const statusCell = col(r, 'expect_status');
|
|
86
|
+
const status = /^\d+$/.test(statusCell) ? Number(statusCell) : undefined;
|
|
87
|
+
const description = col(r, 'description') || undefined;
|
|
88
|
+
|
|
89
|
+
entries[name] = {
|
|
90
|
+
datasource: col(r, 'datasource') || datasource,
|
|
91
|
+
area,
|
|
92
|
+
...(description ? { description } : {}),
|
|
93
|
+
method,
|
|
94
|
+
path,
|
|
95
|
+
...(params.length ? { params } : {}),
|
|
96
|
+
...(body ? { body } : {}),
|
|
97
|
+
...(status != null ? { expect: { status } } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { entries, count: Object.keys(entries).length, skipped };
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,43 @@
|
|
|
12
12
|
* template `specs-api.ts` remains in core's templates dir (emitted by the core generator via
|
|
13
13
|
* `runtimeHelpers`, resolved by filename) — consistent with the base/db templates staying in core.
|
|
14
14
|
*/
|
|
15
|
+
// Import ONLY types from core (erased at runtime) — never a runtime value. A runtime import of
|
|
16
|
+
// @sun-asterisk/sungen forces core to be resolved when this driver loads, which fails if a stale or
|
|
17
|
+
// wrong-version core is on the module path (the source of repeated "Cannot find …/dist/index.js" /
|
|
18
|
+
// "not resolvable" install errors). Keeping core type-only makes the driver load with no core on disk.
|
|
15
19
|
import type { CapabilityRegistry, Sensor, GateInput } from '@sun-asterisk/sungen';
|
|
16
|
-
import { parseQueryOverrides } from '@sun-asterisk/sungen';
|
|
17
20
|
import { resolveApi, apiParamNames, lintApiCatalog } from './api-catalog';
|
|
18
21
|
import { registerApiCommand } from './cli-import';
|
|
22
|
+
import { apiViewpoints, apiGateProvider } from './api-harness';
|
|
23
|
+
import { apiDiscovery, apiContextMapper } from './api-context';
|
|
24
|
+
import { apiRepair } from './api-repair';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse `name(a={{x}},b="lit",c=3)` annotation overrides → JS expressions. Inlined from core
|
|
28
|
+
* (was `parseQueryOverrides`) so this driver has NO runtime dependency on @sun-asterisk/sungen.
|
|
29
|
+
*/
|
|
30
|
+
function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
31
|
+
const out: Record<string, string> = {};
|
|
32
|
+
if (!raw) return out;
|
|
33
|
+
for (const part of raw.split(',')) {
|
|
34
|
+
const eq = part.indexOf('=');
|
|
35
|
+
if (eq < 0) continue;
|
|
36
|
+
const key = part.slice(0, eq).trim();
|
|
37
|
+
const val = part.slice(eq + 1).trim();
|
|
38
|
+
if (!key) continue;
|
|
39
|
+
const v = val.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
|
|
40
|
+
const q = val.match(/^["'](.*)["']$/);
|
|
41
|
+
if (v) out[key] = `testData.get(${JSON.stringify(v[1])})`;
|
|
42
|
+
else if (q) out[key] = JSON.stringify(q[1]);
|
|
43
|
+
else if (/^-?\d+(?:\.\d+)?$/.test(val)) out[key] = val;
|
|
44
|
+
else out[key] = JSON.stringify(val);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
19
48
|
|
|
20
49
|
export { importOpenApi } from './openapi-import';
|
|
21
50
|
export type { ImportedApiEntry, ImportResult } from './openapi-import';
|
|
51
|
+
export { importCsv, parseCsv } from './csv-import';
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
54
|
* API precondition codegen: `@api:<name>[(p={{v}},…)]` → run the catalog request and bind the
|
|
@@ -28,6 +58,18 @@ export type { ImportedApiEntry, ImportResult } from './openapi-import';
|
|
|
28
58
|
function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd: string }): Array<{ comment?: string; code: string; boundVars?: string[] }> {
|
|
29
59
|
const out: Array<{ comment?: string; code: string; boundVars?: string[] }> = [];
|
|
30
60
|
const TAG = /^@api:([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
|
|
61
|
+
// @concurrent:N (AP-5): fire each @api call N times in parallel and bind aggregates
|
|
62
|
+
// ({{name.ok_count}}, {{name.status_counts}}) — the idempotency/race oracle. Applies to the
|
|
63
|
+
// scenario's @api call(s); pair with @query to cross-check the DB ("exactly one charge").
|
|
64
|
+
const concMatch = input.tags.map((t) => t.match(/^@concurrent:(\d+)$/)).find(Boolean);
|
|
65
|
+
const concurrent = concMatch ? Math.max(1, parseInt(concMatch[1], 10)) : 0;
|
|
66
|
+
// @hybrid (AP-6): reuse the UI session — load the scenario's @auth:<role> storageState
|
|
67
|
+
// (specs/.auth/<role>.json, written by the auth global-setup before any test) so the API request
|
|
68
|
+
// carries the same authenticated cookies as the browser. No effect without an @auth role.
|
|
69
|
+
const authMatch = input.tags.map((t) => t.match(/^@auth:([A-Za-z0-9_-]+)$/)).find(Boolean);
|
|
70
|
+
const optsArg = input.tags.includes('@hybrid') && authMatch
|
|
71
|
+
? `, { storageState: ${JSON.stringify(`specs/.auth/${authMatch[1]}.json`)} }`
|
|
72
|
+
: '';
|
|
31
73
|
for (const tag of input.tags) {
|
|
32
74
|
const m = tag.match(TAG);
|
|
33
75
|
if (!m) continue;
|
|
@@ -39,10 +81,23 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
|
|
|
39
81
|
.join(', ');
|
|
40
82
|
const label = JSON.stringify(entry.description ? `api "${name}" — ${entry.description}` : `api "${name}"`);
|
|
41
83
|
const ds = entry.datasource ? JSON.stringify(entry.datasource) : 'undefined';
|
|
42
|
-
|
|
84
|
+
// `headers` (catalog templates, e.g. authorization: "Bearer :token") are embedded at compile time;
|
|
85
|
+
// their `:param` placeholders bind at runtime from the same params — this is the flow auth/token
|
|
86
|
+
// mechanism (the dynamic value comes via an override like `token={{login.body.token}}`).
|
|
87
|
+
const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
|
|
88
|
+
// request-body wire format (default json); `form` → x-www-form-urlencoded, `multipart` → form-data (#345).
|
|
89
|
+
const enc = entry.encoding ? `, encoding: ${JSON.stringify(entry.encoding)}` : '';
|
|
90
|
+
const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${enc}${hdr}, datasource: ${ds} }`;
|
|
91
|
+
// @concurrent:N → callN (N parallel calls, aggregate result); else a single call. @hybrid adds
|
|
92
|
+
// the storageState opts so the request reuses the UI auth session.
|
|
93
|
+
const callExpr = concurrent
|
|
94
|
+
? `await api.callN(${label}, ${req}, { ${paramsObj} }, ${concurrent}${optsArg})`
|
|
95
|
+
: `await api.call(${label}, ${req}, { ${paramsObj} }${optsArg})`;
|
|
43
96
|
out.push({
|
|
44
|
-
comment:
|
|
45
|
-
|
|
97
|
+
comment: concurrent
|
|
98
|
+
? `@api:${name} ×${concurrent} (@concurrent) → bind {{${name}}} aggregates (${entry.method} ${entry.path})`
|
|
99
|
+
: `@api:${name} → bind {{${name}}} (${entry.method} ${entry.path})`,
|
|
100
|
+
code: `testData.bind(${JSON.stringify(name)}, ${callExpr});`,
|
|
46
101
|
boundVars: [name],
|
|
47
102
|
});
|
|
48
103
|
}
|
|
@@ -74,6 +129,68 @@ const apiVerificationSensor: Sensor<GateInput> = {
|
|
|
74
129
|
},
|
|
75
130
|
};
|
|
76
131
|
|
|
132
|
+
/**
|
|
133
|
+
* API `viewpoints` gate sensor (AP-6): coverage feedback for the API surface, advisory (warn/info,
|
|
134
|
+
* never fails the gate). Classifies the feature's `@api` scenarios into the three API viewpoints and
|
|
135
|
+
* surfaces gaps — severity-weighted by method (read-only GET/HEAD surfaces don't expect error/idempotency):
|
|
136
|
+
* - `api-contract` — every invoked endpoint must have its response asserted ({{name.status|body}}).
|
|
137
|
+
* - `api-error` — a mutating endpoint without ANY failure scenario (@cases matrix or a 4xx/5xx) — the 70% band.
|
|
138
|
+
* - `api-idempotency` — a mutating endpoint without a @concurrent aggregate check ({{name.ok_count}}) — the 10% band.
|
|
139
|
+
* Reads the already-parsed scenarios (apiRefs + lowercase stepsText + casesDataset) — no re-parse.
|
|
140
|
+
*/
|
|
141
|
+
const apiViewpointSensor: Sensor<GateInput> = {
|
|
142
|
+
id: 'api-viewpoints',
|
|
143
|
+
capability: 'api',
|
|
144
|
+
kind: 'gate',
|
|
145
|
+
run: ({ screenName, scenarios, cwd }) => {
|
|
146
|
+
const findings = [] as ReturnType<Sensor['run']>;
|
|
147
|
+
const add = (severity: 'warn' | 'info', message: string) => findings.push({ sensorId: 'api-viewpoints', capability: 'api', message, severity });
|
|
148
|
+
const apiScenarios = scenarios.filter((s) => (s.apiRefs ?? []).length);
|
|
149
|
+
if (!apiScenarios.length) return findings;
|
|
150
|
+
|
|
151
|
+
const refs = new Set<string>();
|
|
152
|
+
for (const s of apiScenarios) for (const r of s.apiRefs ?? []) refs.add(r);
|
|
153
|
+
|
|
154
|
+
// api-contract — each invoked endpoint's response is asserted somewhere.
|
|
155
|
+
for (const name of refs) {
|
|
156
|
+
const re = new RegExp(`\\b${name.toLowerCase()}\\.(status|ok|body|headers|ok_count|status_counts)\\b`);
|
|
157
|
+
if (!apiScenarios.some((s) => re.test(s.stepsText ?? ''))) {
|
|
158
|
+
add('warn', `VIEWPOINT-API-CONTRACT: @api:${name} is invoked but its response is never asserted — add the contract check (expect {{${name}.status}} / {{${name}.body.…}}).`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// api-manual-automatable — a @manual scenario whose @api endpoint RESOLVES is automatable; flag it
|
|
163
|
+
// so automatable work isn't deferred to manual (a common run-test reflex when the call failed).
|
|
164
|
+
for (const s of apiScenarios) {
|
|
165
|
+
if (!s.manual) continue;
|
|
166
|
+
const automatable = (s.apiRefs ?? []).filter((r) => { try { resolveApi(r, screenName, cwd); return true; } catch { return false; } });
|
|
167
|
+
if (automatable.length) {
|
|
168
|
+
add('warn', `VIEWPOINT-API-MANUAL-AUTOMATABLE: "${s.name}" is @manual but its endpoint(s) ${automatable.join(', ')} resolve in the catalog — automate with @api (+ @cases for error rows); don't defer an automatable API check to manual.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Method-aware: error + idempotency only apply when a referenced endpoint MUTATES.
|
|
173
|
+
const mutating: string[] = [];
|
|
174
|
+
for (const name of refs) {
|
|
175
|
+
try { if (/^(POST|PUT|PATCH|DELETE)$/.test(resolveApi(name, screenName, cwd).method)) mutating.push(name); } catch { /* unresolved → the verification sensor reports it */ }
|
|
176
|
+
}
|
|
177
|
+
if (!mutating.length) return findings; // read-only surface — no error/idempotency expectation
|
|
178
|
+
|
|
179
|
+
// api-error — a failure scenario exists: a @cases matrix, or an asserted 4xx/5xx.
|
|
180
|
+
const hasError = apiScenarios.some((s) => s.casesDataset || /\b[45]\d\d\b/.test(s.stepsText ?? ''));
|
|
181
|
+
if (!hasError) {
|
|
182
|
+
add('warn', `VIEWPOINT-API-ERROR: mutating endpoint(s) ${mutating.join(', ')} have no failure scenario (4xx/auth/validation) — add a @cases error matrix or an explicit 4xx assertion (the ~70% success+failure band).`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// api-idempotency — a @concurrent aggregate check exists.
|
|
186
|
+
const hasIdem = apiScenarios.some((s) => /\b\w+\.(ok_count|status_counts)\b/.test(s.stepsText ?? ''));
|
|
187
|
+
if (!hasIdem) {
|
|
188
|
+
add('info', `VIEWPOINT-API-IDEMPOTENCY: no @concurrent idempotency/race check on a mutating endpoint — consider @concurrent:N + a @query DB cross-check (the ~10% band).`);
|
|
189
|
+
}
|
|
190
|
+
return findings;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
77
194
|
/** Register the API capability. */
|
|
78
195
|
export function register(registry: CapabilityRegistry): void {
|
|
79
196
|
registry.register({
|
|
@@ -81,7 +198,12 @@ export function register(registry: CapabilityRegistry): void {
|
|
|
81
198
|
annotations: ['@api'],
|
|
82
199
|
runtimeHelpers: [{ file: 'api.ts', template: 'specs-api.ts' }], // template resolved from core's templates dir
|
|
83
200
|
preconditionCodegen: apiPreconditionCodegen, // @api:<name> → bind {{name}}
|
|
84
|
-
sensors: [apiVerificationSensor],
|
|
201
|
+
sensors: [apiVerificationSensor, apiViewpointSensor],
|
|
202
|
+
viewpoints: apiViewpoints as () => unknown, // AO-2: API viewpoint catalog
|
|
203
|
+
gateProvider: apiGateProvider as (i: unknown) => unknown, // AO-2: API coverage + businessDepth
|
|
204
|
+
discovery: apiDiscovery, // AO-3: catalog/spec → Context slice
|
|
205
|
+
contextMapper: apiContextMapper, // AO-3: endpoints → matrix/async/flow generation units
|
|
206
|
+
repair: apiRepair, // #343: finding/failure → concrete fix catalog
|
|
85
207
|
cliCommands: [registerApiCommand as (program: unknown) => void], // `sungen api import <openapi>`
|
|
86
208
|
});
|
|
87
209
|
}
|
package/src/openapi-import.ts
CHANGED
|
@@ -8,16 +8,32 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export interface ImportedApiEntry {
|
|
10
10
|
datasource: string;
|
|
11
|
+
area: string; // resource/tag group (OpenAPI tag, else first path segment)
|
|
11
12
|
description?: string;
|
|
12
13
|
method: string;
|
|
13
14
|
path: string;
|
|
14
15
|
params?: string[];
|
|
15
16
|
body?: Record<string, string>;
|
|
17
|
+
encoding?: 'form' | 'multipart'; // set when the requestBody is form/multipart (json omitted = default)
|
|
16
18
|
expect?: { status: number };
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
20
22
|
|
|
23
|
+
/** A safe lower-snake slug for area/tag names. */
|
|
24
|
+
function slug(s: string): string {
|
|
25
|
+
return s.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Group an operation by its first OpenAPI tag (à la GitHub/Google/Swagger); fall back to the first
|
|
29
|
+
* path segment (`/orders/{id}` → `orders`). Deterministic — no AI. */
|
|
30
|
+
function areaOf(op: any, rawPath: string): string {
|
|
31
|
+
const tag = Array.isArray(op?.tags) && typeof op.tags[0] === 'string' ? slug(op.tags[0]) : '';
|
|
32
|
+
if (tag) return tag;
|
|
33
|
+
const seg = rawPath.replace(/^\/+/, '').split('/')[0] || '';
|
|
34
|
+
return slug(seg) || 'root';
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/** A safe catalog name from an OpenAPI path + method (fallback when there's no operationId). */
|
|
22
38
|
function fallbackName(method: string, path: string): string {
|
|
23
39
|
const slug = path.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
|
|
@@ -37,16 +53,24 @@ function expectedStatus(responses: Record<string, unknown> | undefined): number
|
|
|
37
53
|
return success ?? codes[0];
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
/** Body property names → a `{ prop: ":prop" }` template, + the wire encoding (json | form | multipart),
|
|
57
|
+
* best-effort from the requestBody content-type (#345). JSON is the default (encoding omitted). */
|
|
58
|
+
function bodyTemplate(op: any): { body?: Record<string, string>; props: string[]; encoding?: 'form' | 'multipart' } {
|
|
59
|
+
const content = op?.requestBody?.content ?? {};
|
|
60
|
+
// Prefer JSON; else fall back to form-urlencoded / multipart (those set encoding).
|
|
61
|
+
const pick = content['application/json'] ? { ct: 'application/json' as const, encoding: undefined }
|
|
62
|
+
: content['application/x-www-form-urlencoded'] ? { ct: 'application/x-www-form-urlencoded' as const, encoding: 'form' as const }
|
|
63
|
+
: content['multipart/form-data'] ? { ct: 'multipart/form-data' as const, encoding: 'multipart' as const }
|
|
64
|
+
: undefined;
|
|
65
|
+
if (!pick) return { props: [] };
|
|
66
|
+
const schema = content[pick.ct]?.schema;
|
|
43
67
|
const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object'
|
|
44
68
|
? Object.keys(schema.properties)
|
|
45
69
|
: [];
|
|
46
|
-
if (!props.length) return { props: [] };
|
|
70
|
+
if (!props.length) return { props: [], encoding: pick.encoding };
|
|
47
71
|
const body: Record<string, string> = {};
|
|
48
72
|
for (const p of props) body[p] = `:${p}`;
|
|
49
|
-
return { body, props };
|
|
73
|
+
return { body, props, encoding: pick.encoding };
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
export interface ImportResult {
|
|
@@ -74,7 +98,7 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
|
|
|
74
98
|
|
|
75
99
|
const catalogPath = toCatalogPath(rawPath);
|
|
76
100
|
const pathParams = (catalogPath.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
|
|
77
|
-
const { body, props } = bodyTemplate(op);
|
|
101
|
+
const { body, props, encoding } = bodyTemplate(op);
|
|
78
102
|
const params = [...new Set([...pathParams, ...props])];
|
|
79
103
|
|
|
80
104
|
let name = typeof op.operationId === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(op.operationId)
|
|
@@ -86,11 +110,13 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
|
|
|
86
110
|
const status = expectedStatus(op.responses);
|
|
87
111
|
entries[name] = {
|
|
88
112
|
datasource,
|
|
113
|
+
area: areaOf(op, rawPath),
|
|
89
114
|
description: typeof op.summary === 'string' ? op.summary : undefined,
|
|
90
115
|
method: method.toUpperCase(),
|
|
91
116
|
path: catalogPath,
|
|
92
117
|
...(params.length ? { params } : {}),
|
|
93
118
|
...(body ? { body } : {}),
|
|
119
|
+
...(encoding ? { encoding } : {}),
|
|
94
120
|
...(status != null ? { expect: { status } } : {}),
|
|
95
121
|
};
|
|
96
122
|
}
|