@sungen/driver-api 3.1.2-beta.110 → 3.1.2-beta.111
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 +13 -5
- package/src/cli-import.ts +97 -4
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.1.2-beta.
|
|
3
|
+
"version": "3.1.2-beta.111",
|
|
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.
|
|
16
|
+
"@sun-asterisk/sungen": "3.1.2-beta.111",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
package/src/api-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
98
|
-
.
|
|
99
|
-
.
|
|
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
|
-
|
|
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
|
@@ -66,7 +66,11 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
|
|
|
66
66
|
.join(', ');
|
|
67
67
|
const label = JSON.stringify(entry.description ? `api "${name}" — ${entry.description}` : `api "${name}"`);
|
|
68
68
|
const ds = entry.datasource ? JSON.stringify(entry.datasource) : 'undefined';
|
|
69
|
-
|
|
69
|
+
// `headers` (catalog templates, e.g. authorization: "Bearer :token") are embedded at compile time;
|
|
70
|
+
// their `:param` placeholders bind at runtime from the same params — this is the flow auth/token
|
|
71
|
+
// mechanism (the dynamic value comes via an override like `token={{login.body.token}}`).
|
|
72
|
+
const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
|
|
73
|
+
const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${hdr}, datasource: ${ds} }`;
|
|
70
74
|
out.push({
|
|
71
75
|
comment: `@api:${name} → bind {{${name}}} (${entry.method} ${entry.path})`,
|
|
72
76
|
code: `testData.bind(${JSON.stringify(name)}, await api.call(${label}, ${req}, { ${paramsObj} }));`,
|