@sungen/driver-api 3.1.2-beta.104 → 3.1.2-beta.106

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.104",
3
+ "version": "3.1.2-beta.106",
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.104",
16
+ "@sun-asterisk/sungen": "3.1.2-beta.106",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
package/src/cli-import.ts CHANGED
@@ -2,58 +2,183 @@ 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 commands (P3, Mode A).
9
- * For now: `import` an OpenAPI/Swagger doc into a reviewed `api/apis.yaml` catalog.
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 (import an OpenAPI spec into an apis.yaml catalog)');
57
+ const api = program.command('api').description('API Driver authoring init · add · import');
13
58
 
59
+ // ── init ─────────────────────────────────────────────────────────────────
14
60
  api
15
- .command('import <openapi>')
16
- .description('Generate api/apis.yaml entries from an OpenAPI/Swagger document')
17
- .option('--screen <name>', 'Write to qa/screens/<name>/api/apis.yaml (else shared qa/api/apis.yaml)')
18
- .option('--datasource <name>', 'Datasource name to assign to imported endpoints', 'app_api')
19
- .option('--merge', 'Merge into an existing apis.yaml instead of refusing to overwrite')
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
- if (!fs.existsSync(openapi)) throw new Error(`OpenAPI file not found: ${openapi}`);
23
- const text = fs.readFileSync(openapi, 'utf8');
24
- const doc = parseYaml(text); // YAML parser also accepts JSON
25
- const { entries, count, skipped } = importOpenApi(doc, opts.datasource);
26
- if (count === 0) throw new Error(`No operations imported (${skipped.join('; ') || 'empty paths'}).`);
27
-
28
- const outDir = opts.screen
29
- ? path.join(process.cwd(), 'qa', 'screens', opts.screen, 'api')
30
- : path.join(process.cwd(), 'qa', 'api');
31
- const outFile = path.join(outDir, 'apis.yaml');
32
-
33
- let merged = entries;
34
- if (fs.existsSync(outFile)) {
35
- if (!opts.merge) throw new Error(`${path.relative(process.cwd(), outFile)} already exists — pass --merge to combine.`);
36
- const existing = parseYaml(fs.readFileSync(outFile, 'utf8')) || {};
37
- merged = { ...existing, ...entries }; // imported entries win on name clash
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
- fs.mkdirSync(outDir, { recursive: true });
41
- const header = '# Named-endpoint catalog (API Driver) — generated from an OpenAPI spec by `sungen api import`.\n' +
42
- '# Review the SQL-free request shape, wire each `datasource` in datasources.yaml (kind: api),\n' +
43
- '# then reference an endpoint from a scenario with `@api:<name>`.\n';
44
- fs.writeFileSync(outFile, header + stringifyYaml(merged));
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
+ });
45
93
 
46
- console.log(`\n✓ Imported ${count} endpoint(s) → ${path.relative(process.cwd(), outFile)}`);
47
- for (const name of Object.keys(entries).slice(0, 12)) {
48
- const e = entries[name];
49
- console.log(` ${name}: ${e.method} ${e.path}${e.expect ? ` → ${e.expect.status}` : ''}`);
94
+ // ── add ──────────────────────────────────────────────────────────────────
95
+ api
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 }) => {
100
+ try {
101
+ const area = slug(opts.area);
102
+ if (!area) throw new Error(`invalid --area "${opts.area}"`);
103
+ const base = path.join(process.cwd(), 'qa', 'api', area);
104
+ if (fs.existsSync(base)) throw new Error(`${rel(base)} already exists.`);
105
+ for (const d of ['features', 'test-data', 'requirements', 'api']) fs.mkdirSync(path.join(base, d), { recursive: true });
106
+ fs.writeFileSync(path.join(base, 'features', `${area}.feature`), featureStub(area));
107
+ fs.writeFileSync(path.join(base, 'test-data', `${area}.yaml`), `# Test data for the ${area} API area.\n`);
108
+ 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`);
109
+ fs.writeFileSync(path.join(base, 'api', 'apis.yaml'), CATALOG_HEADER);
110
+ console.log(`✓ area "${area}" → ${rel(base)}/`);
111
+ console.log(`\nNext: import or hand-author ${rel(path.join(base, 'api', 'apis.yaml'))}, then add @api scenarios in features/${area}.feature.\n`);
112
+ } catch (e) { fail(e); }
113
+ });
114
+
115
+ // ── import ───────────────────────────────────────────────────────────────
116
+ api
117
+ .command('import <source>')
118
+ .description('Generate apis.yaml from an OpenAPI/Swagger doc OR a CSV / published Google-Sheet URL')
119
+ .option('--datasource <name>', 'Default datasource for imported endpoints', 'app_api')
120
+ .option('--single', 'Write one shared qa/api/apis.yaml (default: one file per area)')
121
+ .option('--screen <name>', 'Legacy: write to qa/screens/<name>/api/apis.yaml')
122
+ .option('--merge', 'Merge into an existing catalog instead of refusing to overwrite')
123
+ .action(async (source: string, opts: { datasource: string; single?: boolean; screen?: string; merge?: boolean }) => {
124
+ try {
125
+ const text = await readSource(source);
126
+ const { result, kind } = importAny(source, text, opts.datasource);
127
+ const { entries, count, skipped } = result;
128
+ if (count === 0) throw new Error(`No endpoints imported (${skipped.join('; ') || 'empty source'}).`);
129
+
130
+ const written: string[] = [];
131
+ if (opts.screen) {
132
+ const f = path.join(process.cwd(), 'qa', 'screens', opts.screen, 'api', 'apis.yaml');
133
+ writeCatalog(f, entries, !!opts.merge);
134
+ written.push(rel(f));
135
+ } else if (opts.single) {
136
+ const f = path.join(process.cwd(), 'qa', 'api', 'apis.yaml');
137
+ writeCatalog(f, entries, !!opts.merge);
138
+ written.push(rel(f));
139
+ } else {
140
+ // group by area → qa/api/<area>/api/apis.yaml
141
+ const byArea: Record<string, Record<string, ImportedApiEntry>> = {};
142
+ for (const [name, e] of Object.entries(entries)) (byArea[e.area || 'root'] ||= {})[name] = e;
143
+ for (const [area, group] of Object.entries(byArea)) {
144
+ const f = path.join(process.cwd(), 'qa', 'api', area, 'api', 'apis.yaml');
145
+ writeCatalog(f, group, !!opts.merge);
146
+ written.push(`${rel(f)} (${Object.keys(group).length})`);
147
+ }
50
148
  }
51
- if (Object.keys(entries).length > 12) console.log(` … and ${Object.keys(entries).length - 12} more`);
52
- if (skipped.length) console.log(`\n Skipped: ${skipped.join('; ')}`);
53
- console.log('\nNext: set the `datasource` base_url + auth in datasources.yaml, then `@api:<name>` in a scenario.\n');
54
- } catch (error) {
55
- console.error('Error:', error instanceof Error ? error.message : error);
56
- process.exit(1);
57
- }
149
+
150
+ console.log(`\n Imported ${count} endpoint(s) from ${kind} source:`);
151
+ for (const w of written) console.log(` → ${w}`);
152
+ if (skipped.length) console.log(`\n⚠ Skipped: ${skipped.slice(0, 10).join('; ')}${skipped.length > 10 ? ` … (+${skipped.length - 10})` : ''}`);
153
+ console.log('\nNext: wire the `datasource` base_url + auth in datasources.yaml, then `@api:<name>` in a scenario.\n');
154
+ } catch (e) { fail(e); }
58
155
  });
59
156
  }
157
+
158
+ function upsertEnv(file: string, key: string, value: string): void {
159
+ const line = `${key}=${value}`;
160
+ if (!fs.existsSync(file)) { fs.writeFileSync(file, line + '\n'); return; }
161
+ const content = fs.readFileSync(file, 'utf8');
162
+ if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(content)) return; // already present — don't overwrite a real value
163
+ fs.writeFileSync(file, content.replace(/\n?$/, '\n') + line + '\n');
164
+ }
165
+
166
+ function featureStub(area: string): string {
167
+ return [
168
+ `@area:${area}`,
169
+ `Feature: ${area} API`,
170
+ '',
171
+ ' # Reference an endpoint from api/apis.yaml with @api:<name>; assert the bound response with',
172
+ ' # expect {{<name>.status}} / {{<name>.body.<path>}}. Example:',
173
+ ' #',
174
+ ` # @api:get_${area}`,
175
+ ` # Scenario: VP-API-001 Fetching ${area} returns 200`,
176
+ ` # Then expect {{get_${area}.status}} is 200`,
177
+ '',
178
+ ].join('\n');
179
+ }
180
+
181
+ function fail(e: unknown): never {
182
+ console.error('Error:', e instanceof Error ? e.message : e);
183
+ process.exit(1);
184
+ }
@@ -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
@@ -19,6 +19,7 @@ import { registerApiCommand } from './cli-import';
19
19
 
20
20
  export { importOpenApi } from './openapi-import';
21
21
  export type { ImportedApiEntry, ImportResult } from './openapi-import';
22
+ export { importCsv, parseCsv } from './csv-import';
22
23
 
23
24
  /**
24
25
  * API precondition codegen: `@api:<name>[(p={{v}},…)]` → run the catalog request and bind the
@@ -8,6 +8,7 @@
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;
@@ -18,6 +19,20 @@ export interface ImportedApiEntry {
18
19
 
19
20
  const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
20
21
 
22
+ /** A safe lower-snake slug for area/tag names. */
23
+ function slug(s: string): string {
24
+ return s.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
25
+ }
26
+
27
+ /** Group an operation by its first OpenAPI tag (à la GitHub/Google/Swagger); fall back to the first
28
+ * path segment (`/orders/{id}` → `orders`). Deterministic — no AI. */
29
+ function areaOf(op: any, rawPath: string): string {
30
+ const tag = Array.isArray(op?.tags) && typeof op.tags[0] === 'string' ? slug(op.tags[0]) : '';
31
+ if (tag) return tag;
32
+ const seg = rawPath.replace(/^\/+/, '').split('/')[0] || '';
33
+ return slug(seg) || 'root';
34
+ }
35
+
21
36
  /** A safe catalog name from an OpenAPI path + method (fallback when there's no operationId). */
22
37
  function fallbackName(method: string, path: string): string {
23
38
  const slug = path.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
@@ -86,6 +101,7 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
86
101
  const status = expectedStatus(op.responses);
87
102
  entries[name] = {
88
103
  datasource,
104
+ area: areaOf(op, rawPath),
89
105
  description: typeof op.summary === 'string' ? op.summary : undefined,
90
106
  method: method.toUpperCase(),
91
107
  path: catalogPath,