@sungen/driver-api 3.2.24-beta.6 → 3.2.25
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 +49 -4
- package/src/index.ts +32 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.25",
|
|
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.25",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
package/src/api-catalog.ts
CHANGED
|
@@ -32,7 +32,8 @@ export interface ApiEntry {
|
|
|
32
32
|
method: HttpMethod;
|
|
33
33
|
path: string;
|
|
34
34
|
body?: unknown;
|
|
35
|
-
encoding?: 'json' | 'form' | 'multipart'; // request-body wire format (default json); form = application/x-www-form-urlencoded
|
|
35
|
+
encoding?: 'json' | 'form' | 'multipart' | 'xml'; // request-body wire format (default json); form = application/x-www-form-urlencoded; xml = raw string template
|
|
36
|
+
arrayPaths?: string[]; // xml only: dot jpaths (incl. root element) that are ALWAYS arrays — XML can't tell one item from a list of one
|
|
36
37
|
files?: Record<string, FileFieldSpec | FileFieldSpec[]>; // multipart/form-data file fields — array = multiple files under one field name; presence forces multipart
|
|
37
38
|
bodyFile?: FileFieldSpec; // raw-binary body: the request body IS the file's bytes (Content-Type = mimeType, default application/octet-stream)
|
|
38
39
|
headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
|
|
@@ -84,7 +85,13 @@ function refsIn(text: string): string[] {
|
|
|
84
85
|
/** All `:param` refs an entry binds — path + JSON body + header templates + file specs. */
|
|
85
86
|
function entryRefs(entry: ApiEntry): string[] {
|
|
86
87
|
const fromPath = refsIn(entry.path);
|
|
87
|
-
|
|
88
|
+
// An XML body is a raw string full of `:`-prefixed tokens that are NOT params
|
|
89
|
+
// (namespace prefixes like soapenv:Envelope) — a generic scan would force those
|
|
90
|
+
// into params:. XML params are DECLARED, so the body contributes only declared
|
|
91
|
+
// names it actually uses (word-boundary match, so :id never claims :id_2).
|
|
92
|
+
const fromBody = entry.encoding === 'xml' && typeof entry.body === 'string'
|
|
93
|
+
? entry.params.filter((p) => new RegExp(`:${p}(?![A-Za-z0-9_])`).test(entry.body as string))
|
|
94
|
+
: entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
|
|
88
95
|
const fromHeaders = entry.headers ? refsIn(JSON.stringify(entry.headers)) : [];
|
|
89
96
|
const fromFiles = entry.files ? refsIn(JSON.stringify(entry.files)) : [];
|
|
90
97
|
const fromBodyFile = entry.bodyFile ? refsIn(JSON.stringify(entry.bodyFile)) : [];
|
|
@@ -103,7 +110,8 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
|
|
|
103
110
|
method: String(v.method).toUpperCase() as HttpMethod,
|
|
104
111
|
path: v.path,
|
|
105
112
|
body: v.body,
|
|
106
|
-
encoding: v.encoding === 'form' || v.encoding === 'multipart' ? v.encoding : undefined, // default (undefined) = json
|
|
113
|
+
encoding: v.encoding === 'form' || v.encoding === 'multipart' || v.encoding === 'xml' ? v.encoding : undefined, // default (undefined) = json
|
|
114
|
+
arrayPaths: Array.isArray(v.arrayPaths) ? v.arrayPaths.map((p: any) => String(p)) : undefined,
|
|
107
115
|
files: parseFiles(v.files),
|
|
108
116
|
bodyFile: parseFileSpec(v.bodyFile),
|
|
109
117
|
headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
|
|
@@ -164,7 +172,23 @@ export function apiParamNames(entry: ApiEntry): string[] {
|
|
|
164
172
|
export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | null): string[] {
|
|
165
173
|
const errs: string[] = [];
|
|
166
174
|
if (!METHODS.has(entry.method)) errs.push(`api "${entry.name}": method "${entry.method}" is not a valid HTTP method.`);
|
|
167
|
-
|
|
175
|
+
// Param names feed RegExp construction (runtime whitelist bind, exporter cells) —
|
|
176
|
+
// a name like "a(b" would THROW out of the binder instead of reporting, and a "."
|
|
177
|
+
// would silently match any character. Identifier-only, checked before any regex use.
|
|
178
|
+
for (const p of entry.params) {
|
|
179
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(p)) {
|
|
180
|
+
errs.push(`api "${entry.name}": param "${p}" is not a valid name — use letters, digits and underscores, starting with a letter or underscore.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (entry.params.some((p) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(p))) {
|
|
184
|
+
return errs; // don't feed the invalid names into the regex-based checks below
|
|
185
|
+
}
|
|
186
|
+
// Three valid path forms: a base_url-relative path, a single ":param" token (an
|
|
187
|
+
// absolute URL bound at runtime from a prior response), or a literal http(s) URL.
|
|
188
|
+
const pathOk = entry.path.startsWith('/')
|
|
189
|
+
|| /^:[A-Za-z_][A-Za-z0-9_]*$/.test(entry.path)
|
|
190
|
+
|| /^https?:\/\//i.test(entry.path);
|
|
191
|
+
if (!pathOk) errs.push(`api "${entry.name}": path "${entry.path}" must start with "/", be a single ":param" token, or be an absolute http(s) URL.`);
|
|
168
192
|
const refs = new Set(entryRefs(entry));
|
|
169
193
|
const declared = new Set(entry.params);
|
|
170
194
|
for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
|
|
@@ -176,6 +200,14 @@ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | nu
|
|
|
176
200
|
// files are always sent as multipart; an explicit json/form encoding contradicts that.
|
|
177
201
|
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).`);
|
|
178
202
|
}
|
|
203
|
+
// XML entries: the body is a raw string template (any shape); arrayPaths belongs to
|
|
204
|
+
// the XML response mapping only.
|
|
205
|
+
if (entry.encoding === 'xml' && entry.body !== undefined && typeof entry.body !== 'string') {
|
|
206
|
+
errs.push(`api "${entry.name}": encoding: xml requires body: to be a string template (use a YAML block scalar holding the raw XML).`);
|
|
207
|
+
}
|
|
208
|
+
if (entry.arrayPaths?.length && entry.encoding !== 'xml') {
|
|
209
|
+
errs.push(`api "${entry.name}": arrayPaths: applies only to encoding: xml responses.`);
|
|
210
|
+
}
|
|
179
211
|
// A GET/HEAD request may not carry a body — fetch/undici refuses one, so the runtime would have to
|
|
180
212
|
// drop it. Catch the contradiction HERE, in the catalog the author wrote, rather than let a request
|
|
181
213
|
// go out silently shorter than the entry says it is.
|
|
@@ -205,6 +237,19 @@ export function lintApiCatalog(screenName: string, datasourceNames: string[] | n
|
|
|
205
237
|
const entries = [...screen.values(), ...shared.values()];
|
|
206
238
|
for (const e of entries) errors.push(...validateApiEntry(e, datasourceNames));
|
|
207
239
|
for (const name of screen.keys()) if (shared.has(name)) errors.push(`api "${name}": defined in both screen and shared catalogs — names must be unambiguous.`);
|
|
240
|
+
// XML runtime needs fast-xml-parser IN THE PROJECT (the generated specs/api.ts
|
|
241
|
+
// lazy-requires it). Projects initialized before the capability existed don't have
|
|
242
|
+
// it, and generate overwrites specs/api.ts — warn HERE so the author installs the
|
|
243
|
+
// dep before the suite dies mid-run on the first XML response.
|
|
244
|
+
if (entries.some((e) => e.encoding === 'xml')) {
|
|
245
|
+
try {
|
|
246
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
247
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
248
|
+
if (!deps['fast-xml-parser']) {
|
|
249
|
+
console.warn('⚠ apis.yaml declares encoding: xml but "fast-xml-parser" is not in this project\'s package.json — run: npm install --save-dev fast-xml-parser');
|
|
250
|
+
}
|
|
251
|
+
} catch { /* no readable package.json — project init installs the dep */ }
|
|
252
|
+
}
|
|
208
253
|
return { files, errors, entries };
|
|
209
254
|
}
|
|
210
255
|
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,33 @@ import { apiRepair } from './api-repair';
|
|
|
32
32
|
* `testData.raw()` (native type, still interpolates embedded refs) rather than `get()`
|
|
33
33
|
* (Gherkin-text stringify) — a numeric override otherwise reaches the JSON body as a string.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* `{{var}}` EMBEDDED in a quoted override value — a quoted value can mix static
|
|
37
|
+
* text with refs (`user_email="pre-{{id}}-x"`); shipping the braces verbatim
|
|
38
|
+
* would POST literal `{{...}}` text in a live request body. `{{$...}}` dynamic
|
|
39
|
+
* tokens have no test-data key and stay literal. Twin of the core helper —
|
|
40
|
+
* this driver deliberately has no runtime dependency on @sun-asterisk/sungen.
|
|
41
|
+
*/
|
|
42
|
+
const RE_EMBEDDED_VAR = /\{\{\s*([\w.[\]*-]+?)\s*\}\}/g;
|
|
43
|
+
|
|
44
|
+
/** Escape static literal text for a template-literal context. */
|
|
45
|
+
const escapeForTemplate = (s: string): string =>
|
|
46
|
+
s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
47
|
+
|
|
48
|
+
/** Render a quoted override value's BODY as a JS expression (string intent → get()). */
|
|
49
|
+
function literalExpr(text: string): string {
|
|
50
|
+
const vars = [...text.matchAll(RE_EMBEDDED_VAR)];
|
|
51
|
+
if (vars.length === 0) return JSON.stringify(text);
|
|
52
|
+
if (vars.length === 1 && vars[0][0] === text) return `testData.get(${JSON.stringify(vars[0][1])})`;
|
|
53
|
+
let out = '';
|
|
54
|
+
let last = 0;
|
|
55
|
+
for (const m of vars) {
|
|
56
|
+
out += escapeForTemplate(text.slice(last, m.index!)) + `\${testData.get(${JSON.stringify(m[1])})}`;
|
|
57
|
+
last = m.index! + m[0].length;
|
|
58
|
+
}
|
|
59
|
+
return `\`${out + escapeForTemplate(text.slice(last))}\``;
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
36
63
|
const out: Record<string, string> = {};
|
|
37
64
|
if (!raw) return out;
|
|
@@ -44,7 +71,7 @@ function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
|
44
71
|
const v = val.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
|
|
45
72
|
const q = val.match(/^["'](.*)["']$/);
|
|
46
73
|
if (v) out[key] = `testData.raw(${JSON.stringify(v[1])})`;
|
|
47
|
-
else if (q) out[key] =
|
|
74
|
+
else if (q) out[key] = literalExpr(q[1]);
|
|
48
75
|
else if (/^-?\d+(?:\.\d+)?$/.test(val)) out[key] = val;
|
|
49
76
|
else out[key] = JSON.stringify(val);
|
|
50
77
|
}
|
|
@@ -98,6 +125,9 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
|
|
|
98
125
|
const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
|
|
99
126
|
// request-body wire format (default json); `form` → x-www-form-urlencoded, `multipart` → form-data (#345).
|
|
100
127
|
const enc = entry.encoding ? `, encoding: ${JSON.stringify(entry.encoding)}` : '';
|
|
128
|
+
// xml response mapping: catalog-declared paths that are ALWAYS arrays (XML can't
|
|
129
|
+
// tell one repeated element from a list of one — `[0]` access must not break).
|
|
130
|
+
const arrayPaths = entry.arrayPaths?.length ? `, arrayPaths: ${JSON.stringify(entry.arrayPaths)}` : '';
|
|
101
131
|
// multipart file fields; the runtime reads each fixture and builds a Playwright file payload. Its
|
|
102
132
|
// `:param` paths bind at runtime from the same params. Presence forces multipart in the runtime.
|
|
103
133
|
// A field's value may be an array → multiple files under one field name (JSON.stringify carries it).
|
|
@@ -106,7 +136,7 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
|
|
|
106
136
|
const bodyFile = entry.bodyFile ? `, bodyFile: ${JSON.stringify(entry.bodyFile)}` : '';
|
|
107
137
|
// per-endpoint timeout override (else the datasource timeout applies at runtime).
|
|
108
138
|
const timeout = entry.timeout_ms ? `, timeout: ${JSON.stringify(entry.timeout_ms)}` : '';
|
|
109
|
-
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} }`;
|
|
139
|
+
const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${enc}${arrayPaths}${files}${bodyFile}${hdr}${timeout}, datasource: ${ds} }`;
|
|
110
140
|
// @concurrent:N → callN (N parallel calls, aggregate result); else a single call. @hybrid adds
|
|
111
141
|
// the storageState opts so the request reuses the UI auth session.
|
|
112
142
|
const callExpr = concurrent
|