@sungen/driver-api 3.1.2-beta.103 → 3.1.2-beta.105
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/cli-import.ts +167 -42
- package/src/csv-import.ts +101 -0
- package/src/index.ts +1 -0
- package/src/openapi-import.ts +16 -0
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.105",
|
|
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.105",
|
|
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
|
|
9
|
-
*
|
|
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
|
|
57
|
+
const api = program.command('api').description('API Driver authoring — init · add · import');
|
|
13
58
|
|
|
59
|
+
// ── init ─────────────────────────────────────────────────────────────────
|
|
14
60
|
api
|
|
15
|
-
.command('
|
|
16
|
-
.description('
|
|
17
|
-
.
|
|
18
|
-
.option('--datasource <name>', 'Datasource name
|
|
19
|
-
.
|
|
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
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.
|
|
56
|
-
|
|
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
|
package/src/openapi-import.ts
CHANGED
|
@@ -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,
|