@sungen/driver-api 3.2.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-api",
3
- "version": "3.2.24",
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.24",
16
+ "@sun-asterisk/sungen": "3.2.25",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
@@ -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
- const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
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
- if (!entry.path.startsWith('/')) errs.push(`api "${entry.name}": path "${entry.path}" must start with "/".`);
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] = JSON.stringify(q[1]);
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