@sungen/driver-api 3.1.2-beta.110 → 3.1.2-beta.112

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.1.2-beta.110",
3
+ "version": "3.1.2-beta.112",
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.1.2-beta.110",
16
+ "@sun-asterisk/sungen": "3.1.2-beta.112",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
@@ -27,6 +27,7 @@ export interface ApiEntry {
27
27
  method: HttpMethod;
28
28
  path: string;
29
29
  body?: unknown;
30
+ headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
30
31
  params: string[];
31
32
  expect?: { status?: number | string };
32
33
  description?: string;
@@ -66,6 +67,14 @@ function refsIn(text: string): string[] {
66
67
  return (text.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
67
68
  }
68
69
 
70
+ /** All `:param` refs an entry binds — path + JSON body + header templates. */
71
+ function entryRefs(entry: ApiEntry): string[] {
72
+ const fromPath = refsIn(entry.path);
73
+ const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
74
+ const fromHeaders = entry.headers ? refsIn(JSON.stringify(entry.headers)) : [];
75
+ return [...new Set([...fromPath, ...fromBody, ...fromHeaders])];
76
+ }
77
+
69
78
  function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, ApiEntry> {
70
79
  const map = new Map<string, ApiEntry>();
71
80
  const raw = parseYaml(fs.readFileSync(file, 'utf8')) || {};
@@ -78,6 +87,7 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
78
87
  method: String(v.method).toUpperCase() as HttpMethod,
79
88
  path: v.path,
80
89
  body: v.body,
90
+ headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
81
91
  params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
82
92
  expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
83
93
  description: typeof v.description === 'string' ? v.description : undefined,
@@ -121,12 +131,10 @@ export function resolveApi(name: string, screenName: string, cwd: string = proce
121
131
  return entry;
122
132
  }
123
133
 
124
- /** Ordered bound-param names for an entry (declared `params`, else the `:refs` in path+body). */
134
+ /** Ordered bound-param names for an entry (declared `params`, else the `:refs` in path+body+headers). */
125
135
  export function apiParamNames(entry: ApiEntry): string[] {
126
136
  if (entry.params.length) return entry.params;
127
- const fromPath = refsIn(entry.path);
128
- const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
129
- return [...new Set([...fromPath, ...fromBody])];
137
+ return entryRefs(entry);
130
138
  }
131
139
 
132
140
  /** Validate one entry. Returns human-readable problems (empty = clean). */
@@ -134,7 +142,7 @@ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | nu
134
142
  const errs: string[] = [];
135
143
  if (!METHODS.has(entry.method)) errs.push(`api "${entry.name}": method "${entry.method}" is not a valid HTTP method.`);
136
144
  if (!entry.path.startsWith('/')) errs.push(`api "${entry.name}": path "${entry.path}" must start with "/".`);
137
- const refs = new Set([...refsIn(entry.path), ...(entry.body != null ? refsIn(JSON.stringify(entry.body)) : [])]);
145
+ const refs = new Set(entryRefs(entry));
138
146
  const declared = new Set(entry.params);
139
147
  for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
140
148
  for (const d of declared) if (!refs.has(d)) errs.push(`api "${entry.name}": param "${d}" is declared but never used in path/body.`);
package/src/cli-import.ts CHANGED
@@ -94,11 +94,31 @@ export function registerApiCommand(program: Command): void {
94
94
  // ── add ──────────────────────────────────────────────────────────────────
95
95
  api
96
96
  .command('add')
97
- .description('Scaffold an API area (resource/tag): qa/api/<area>/{features,test-data,requirements,api}')
98
- .requiredOption('--area <name>', 'Area / resource name (e.g. orders, users, auth)')
99
- .action((opts: { area: string }) => {
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 }) => {
100
101
  try {
101
- const area = slug(opts.area);
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!);
102
122
  if (!area) throw new Error(`invalid --area "${opts.area}"`);
103
123
  const base = path.join(process.cwd(), 'qa', 'api', area);
104
124
  if (fs.existsSync(base)) throw new Error(`${rel(base)} already exists.`);
@@ -178,6 +198,79 @@ function featureStub(area: string): string {
178
198
  ].join('\n');
179
199
  }
180
200
 
201
+ /** CRUD/auth flow scaffold: an ordered @api chain that threads a created resource through its lifecycle. */
202
+ function flowFeatureStub(flow: string): string {
203
+ return [
204
+ `@flow @area:${flow}`,
205
+ `Feature: ${flow} flow`,
206
+ '',
207
+ ' # Response→request chaining: ordered @api tags run in declaration order; each binds {{name}},',
208
+ ' # a later call threads a prior field forward. Tags carry no whitespace — pass single-token',
209
+ ' # overrides; the auth scheme ("Bearer :token") lives in the catalog header, not the feature.',
210
+ ' #',
211
+ ' # auth precondition: @api:login binds {{login}}; authed calls pass token={{login.body.token}}',
212
+ ' # → the catalog "Bearer :token" header. Self-clean by deleting what the flow created.',
213
+ ' @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}})',
214
+ ` Scenario: VP-API-FLOW-001 ${flow} — login, create, read back, then delete`,
215
+ ' Then expect {{login.status}} is 200',
216
+ ' And expect {{create_item.status}} is 201',
217
+ ' And expect {{get_item.status}} is 200',
218
+ ' And expect {{get_item.body.name}} is {{item_name}}',
219
+ ' And expect {{delete_item.status}} is 204',
220
+ '',
221
+ ].join('\n');
222
+ }
223
+
224
+ function flowCatalogStub(): string {
225
+ return CATALOG_HEADER + [
226
+ '',
227
+ 'login:',
228
+ ' datasource: app_api',
229
+ ' method: POST',
230
+ ' path: /auth/login',
231
+ ' body: { email: ":email", password: ":password" }',
232
+ ' params: [email, password]',
233
+ ' expect: { status: 200 }',
234
+ '',
235
+ 'create_item:',
236
+ ' datasource: app_api',
237
+ ' method: POST',
238
+ ' path: /items',
239
+ ' headers: { authorization: "Bearer :token" } # only :token is dynamic; the scheme is the contract',
240
+ ' body: { name: ":name" }',
241
+ ' params: [name, token]',
242
+ ' expect: { status: 201 }',
243
+ '',
244
+ 'get_item:',
245
+ ' datasource: app_api',
246
+ ' method: GET',
247
+ ' path: /items/:id',
248
+ ' headers: { authorization: "Bearer :token" }',
249
+ ' params: [id, token]',
250
+ ' expect: { status: 200 }',
251
+ '',
252
+ 'delete_item:',
253
+ ' datasource: app_api',
254
+ ' method: DELETE',
255
+ ' path: /items/:id',
256
+ ' headers: { authorization: "Bearer :token" }',
257
+ ' params: [id, token]',
258
+ ' expect: { status: 204 }',
259
+ '',
260
+ ].join('\n');
261
+ }
262
+
263
+ function flowTestDataStub(): string {
264
+ return [
265
+ '# Inputs for the flow. The created id + token are threaded from responses at runtime',
266
+ '# ({{create_item.body.id}}, {{login.body.token}}) — only the seed inputs live here.',
267
+ 'email: "flow-user@example.com"',
268
+ 'password: "change-me"',
269
+ 'item_name: "Sample item"',
270
+ '',
271
+ ].join('\n');
272
+ }
273
+
181
274
  function fail(e: unknown): never {
182
275
  console.error('Error:', e instanceof Error ? e.message : e);
183
276
  process.exit(1);
package/src/index.ts CHANGED
@@ -55,6 +55,11 @@ export { importCsv, parseCsv } from './csv-import';
55
55
  function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd: string }): Array<{ comment?: string; code: string; boundVars?: string[] }> {
56
56
  const out: Array<{ comment?: string; code: string; boundVars?: string[] }> = [];
57
57
  const TAG = /^@api:([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
58
+ // @concurrent:N (AP-5): fire each @api call N times in parallel and bind aggregates
59
+ // ({{name.ok_count}}, {{name.status_counts}}) — the idempotency/race oracle. Applies to the
60
+ // scenario's @api call(s); pair with @query to cross-check the DB ("exactly one charge").
61
+ const concMatch = input.tags.map((t) => t.match(/^@concurrent:(\d+)$/)).find(Boolean);
62
+ const concurrent = concMatch ? Math.max(1, parseInt(concMatch[1], 10)) : 0;
58
63
  for (const tag of input.tags) {
59
64
  const m = tag.match(TAG);
60
65
  if (!m) continue;
@@ -66,10 +71,20 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
66
71
  .join(', ');
67
72
  const label = JSON.stringify(entry.description ? `api "${name}" — ${entry.description}` : `api "${name}"`);
68
73
  const ds = entry.datasource ? JSON.stringify(entry.datasource) : 'undefined';
69
- const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}, datasource: ${ds} }`;
74
+ // `headers` (catalog templates, e.g. authorization: "Bearer :token") are embedded at compile time;
75
+ // their `:param` placeholders bind at runtime from the same params — this is the flow auth/token
76
+ // mechanism (the dynamic value comes via an override like `token={{login.body.token}}`).
77
+ const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
78
+ const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${hdr}, datasource: ${ds} }`;
79
+ // @concurrent:N → callN (N parallel calls, aggregate result); else a single call.
80
+ const callExpr = concurrent
81
+ ? `await api.callN(${label}, ${req}, { ${paramsObj} }, ${concurrent})`
82
+ : `await api.call(${label}, ${req}, { ${paramsObj} })`;
70
83
  out.push({
71
- comment: `@api:${name} → bind {{${name}}} (${entry.method} ${entry.path})`,
72
- code: `testData.bind(${JSON.stringify(name)}, await api.call(${label}, ${req}, { ${paramsObj} }));`,
84
+ comment: concurrent
85
+ ? `@api:${name} ×${concurrent} (@concurrent) → bind {{${name}}} aggregates (${entry.method} ${entry.path})`
86
+ : `@api:${name} → bind {{${name}}} (${entry.method} ${entry.path})`,
87
+ code: `testData.bind(${JSON.stringify(name)}, ${callExpr});`,
73
88
  boundVars: [name],
74
89
  });
75
90
  }