@sungen/driver-api 3.2.4 → 3.2.6

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.4",
3
+ "version": "3.2.6",
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.4",
16
+ "@sun-asterisk/sungen": "3.2.6",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
@@ -17,6 +17,11 @@
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { parse as parseYaml } from 'yaml';
20
+ import { parseFileSpec, parseFiles, type FileFieldSpec } from './api-file-fields';
21
+ import { parseFields, type FieldSpec } from './api-field-spec';
22
+
23
+ export type { FileFieldSpec } from './api-file-fields';
24
+ export type { FieldSpec } from './api-field-spec';
20
25
 
21
26
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
22
27
  const METHODS = new Set<string>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
@@ -28,8 +33,14 @@ export interface ApiEntry {
28
33
  path: string;
29
34
  body?: unknown;
30
35
  encoding?: 'json' | 'form' | 'multipart'; // request-body wire format (default json); form = application/x-www-form-urlencoded
36
+ files?: Record<string, FileFieldSpec>; // multipart/form-data file fields (file as a form part) — presence forces multipart
37
+ bodyFile?: FileFieldSpec; // raw-binary body: the request body IS the file's bytes (Content-Type = mimeType, default application/octet-stream)
31
38
  headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
32
39
  params: string[];
40
+ timeout_ms?: number; // per-endpoint request timeout override (else the datasource timeout, default 15s)
41
+ fields?: Record<string, FieldSpec>; // optional field metadata (required/type/enum/range) — drives error+boundary matrices + the viewpoint gate
42
+ responseSchema?: unknown; // inline 2xx response body schema (JSON Schema) — for `matches schema` assertions
43
+ schemaRef?: string; // named schema (schemas.yaml key) for the 2xx response body
33
44
  expect?: { status?: number | string };
34
45
  description?: string;
35
46
  scope: 'screen' | 'shared';
@@ -70,12 +81,14 @@ function refsIn(text: string): string[] {
70
81
  return (text.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
71
82
  }
72
83
 
73
- /** All `:param` refs an entry binds — path + JSON body + header templates. */
84
+ /** All `:param` refs an entry binds — path + JSON body + header templates + file specs. */
74
85
  function entryRefs(entry: ApiEntry): string[] {
75
86
  const fromPath = refsIn(entry.path);
76
87
  const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
77
88
  const fromHeaders = entry.headers ? refsIn(JSON.stringify(entry.headers)) : [];
78
- return [...new Set([...fromPath, ...fromBody, ...fromHeaders])];
89
+ const fromFiles = entry.files ? refsIn(JSON.stringify(entry.files)) : [];
90
+ const fromBodyFile = entry.bodyFile ? refsIn(JSON.stringify(entry.bodyFile)) : [];
91
+ return [...new Set([...fromPath, ...fromBody, ...fromHeaders, ...fromFiles, ...fromBodyFile])];
79
92
  }
80
93
 
81
94
  function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, ApiEntry> {
@@ -91,8 +104,14 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
91
104
  path: v.path,
92
105
  body: v.body,
93
106
  encoding: v.encoding === 'form' || v.encoding === 'multipart' ? v.encoding : undefined, // default (undefined) = json
107
+ files: parseFiles(v.files),
108
+ bodyFile: parseFileSpec(v.bodyFile),
94
109
  headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
95
110
  params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
111
+ timeout_ms: typeof v.timeout_ms === 'number' && v.timeout_ms > 0 ? v.timeout_ms : undefined,
112
+ fields: parseFields(v.fields),
113
+ responseSchema: v.responseSchema,
114
+ schemaRef: typeof v.schemaRef === 'string' ? v.schemaRef : undefined,
96
115
  expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
97
116
  description: typeof v.description === 'string' ? v.description : undefined,
98
117
  scope,
@@ -150,6 +169,14 @@ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | nu
150
169
  const declared = new Set(entry.params);
151
170
  for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
152
171
  for (const d of declared) if (!refs.has(d)) errs.push(`api "${entry.name}": param "${d}" is declared but never used in path/body.`);
172
+ if (entry.files) {
173
+ for (const [field, spec] of Object.entries(entry.files)) if (!spec.path) errs.push(`api "${entry.name}": file field "${field}" has no path.`);
174
+ // files are always sent as multipart; an explicit json/form encoding contradicts that.
175
+ 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).`);
176
+ }
177
+ if (entry.bodyFile && !entry.bodyFile.path) errs.push(`api "${entry.name}": bodyFile has no path.`);
178
+ if (entry.bodyFile && entry.files) errs.push(`api "${entry.name}": use either files: (multipart form parts) or bodyFile: (raw binary body), not both.`);
179
+ if (entry.bodyFile && entry.body !== undefined) errs.push(`api "${entry.name}": bodyFile: (raw binary body) cannot combine with a body:.`);
153
180
  if (datasourceNames && entry.datasource && !datasourceNames.includes(entry.datasource)) {
154
181
  errs.push(`api "${entry.name}": datasource "${entry.datasource}" is not defined in datasources.yaml.`);
155
182
  }
@@ -11,8 +11,13 @@ import type { Context, DiscoveryProvider, ContextMapper, GenerationUnit } from '
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
  import { lintApiCatalog } from './api-catalog';
14
+ import type { FieldSpec } from './api-field-spec';
14
15
 
15
- interface EndpointFact { name: string; method: string; path: string; datasource?: string; expectStatus?: number | string }
16
+ interface EndpointFact {
17
+ name: string; method: string; path: string; datasource?: string; expectStatus?: number | string;
18
+ fields?: Record<string, FieldSpec>; // field metadata → the coverage model enumerates required/boundary/format matrices from it
19
+ schemaRef?: string; // response-schema name → the contract case can assert `matches schema`
20
+ }
16
21
 
17
22
  const MUTATING = /^(POST|PUT|PATCH|DELETE)$/;
18
23
 
@@ -25,6 +30,7 @@ export const apiDiscovery: DiscoveryProvider = {
25
30
  try {
26
31
  endpoints = lintApiCatalog(unitId, null, cwd).entries.map((e) => ({
27
32
  name: e.name, method: e.method, path: e.path, datasource: e.datasource, expectStatus: e.expect?.status,
33
+ ...(e.fields ? { fields: e.fields } : {}), ...(e.schemaRef ? { schemaRef: e.schemaRef } : {}),
28
34
  }));
29
35
  } catch { /* no catalog → the verification gate reports it */ }
30
36
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Field-driven matrix coverage — the deterministic backstop for the `fields:` block.
3
+ *
4
+ * The coverage model (skill) makes the generator enumerate the required / boundary / format matrix
5
+ * mechanically from `fields:`; this module is the AUDIT backstop that flags when it didn't. For each
6
+ * endpoint that declares `fields:`, it checks the `@cases` rows of the scenarios referencing it and
7
+ * reports any constrained field with no matching negative case:
8
+ * - REQUIRED — a `required` field with no row that omits/empties it asserting a 4xx
9
+ * - BOUNDARY — a `min/max/minLength/maxLength` field with no row whose value is out of range (4xx)
10
+ * - FORMAT — an enum field with no row whose value is outside its `values` (4xx)
11
+ *
12
+ * Advisory only (warn). It NEVER fires on a catalog without `fields:` (no false demands). When an
13
+ * endpoint's `@cases` rows can't be resolved from test-data (or there are none), that IS the honest
14
+ * "no negative case" signal → it flags. It never THROWS on a missing/unparseable file (fail-safe):
15
+ * an unreadable dataset degrades to "no rows", not a crash. (The sensor additionally suppresses the
16
+ * finding when the endpoint tests errors via an inline 4xx instead of `@cases` — see index.ts.)
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { parse as parseYaml } from 'yaml';
21
+ import type { FieldSpec } from './api-field-spec';
22
+
23
+ export interface FieldCoverageGap {
24
+ endpoint: string;
25
+ kind: 'required' | 'boundary' | 'format';
26
+ fields: string[]; // the uncovered field names
27
+ }
28
+
29
+ interface CaseRow { [col: string]: unknown }
30
+
31
+ /** Locate + read the test-data file for a unit, mirroring the api-catalog path rules. Undefined on miss. */
32
+ function readUnitTestData(screenName: string, cwd: string): Record<string, unknown> | undefined {
33
+ if (!screenName) return undefined;
34
+ const unitDir = screenName.startsWith('flows/') || screenName.startsWith('api/')
35
+ ? path.join(cwd, 'qa', screenName)
36
+ : path.join(cwd, 'qa', 'screens', screenName);
37
+ const base = path.basename(screenName);
38
+ const file = path.join(unitDir, 'test-data', `${base}.yaml`);
39
+ try {
40
+ if (!fs.existsSync(file)) return undefined;
41
+ const doc = parseYaml(fs.readFileSync(file, 'utf8'));
42
+ return doc && typeof doc === 'object' ? (doc as Record<string, unknown>) : undefined;
43
+ } catch { return undefined; }
44
+ }
45
+
46
+ /** Resolve a `@cases:<name>` dataset (a list of row objects) from the unit's test-data. */
47
+ function resolveDataset(testData: Record<string, unknown> | undefined, name: string): CaseRow[] | undefined {
48
+ if (!testData) return undefined;
49
+ const v = testData[name];
50
+ if (!Array.isArray(v)) return undefined;
51
+ return v.filter((r): r is CaseRow => !!r && typeof r === 'object' && !Array.isArray(r));
52
+ }
53
+
54
+ const is4xx = (row: CaseRow): boolean => /^4\d\d$/.test(String(row.expect_status ?? row.status ?? '').trim());
55
+ const missing = (row: CaseRow, col: string): boolean => !(col in row) || String(row[col] ?? '').trim() === '';
56
+ const numOf = (v: unknown): number | undefined => { const n = Number(String(v).trim()); return Number.isFinite(n) ? n : undefined; };
57
+
58
+ /** A row proves field `col` is out of its numeric/length range (→ expects 4xx). */
59
+ function violatesBoundary(row: CaseRow, col: string, spec: FieldSpec): boolean {
60
+ if (!(col in row) || String(row[col] ?? '').trim() === '') return false;
61
+ const raw = String(row[col]).trim();
62
+ if (spec.min != null || spec.max != null) {
63
+ const n = numOf(raw);
64
+ if (n != null && ((spec.min != null && n < spec.min) || (spec.max != null && n > spec.max))) return true;
65
+ }
66
+ if (spec.minLength != null || spec.maxLength != null) {
67
+ const len = raw.length;
68
+ if ((spec.minLength != null && len < spec.minLength) || (spec.maxLength != null && len > spec.maxLength)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** A row proves field `col` is outside its enum values (→ expects 4xx). */
74
+ function violatesEnum(row: CaseRow, col: string, spec: FieldSpec): boolean {
75
+ if (!spec.values || !(col in row)) return false;
76
+ const raw = String(row[col] ?? '').trim();
77
+ if (raw === '') return false;
78
+ return !spec.values.map((x) => String(x)).includes(raw);
79
+ }
80
+
81
+ /**
82
+ * Compute the field-coverage gaps for one endpoint given its `fields:` and the `@cases` datasets of
83
+ * the scenarios that reference it. Returns [] when there's nothing to say (fail-quiet on unresolved
84
+ * datasets: an endpoint with `fields:` but no resolvable rows yields the REQUIRED/BOUNDARY/FORMAT
85
+ * gaps for "no negative case at all", which is the honest signal).
86
+ */
87
+ export function fieldCoverageGaps(
88
+ endpoint: string,
89
+ fields: Record<string, FieldSpec>,
90
+ datasetNames: string[],
91
+ testData: Record<string, unknown> | undefined,
92
+ ): FieldCoverageGap[] {
93
+ const rows: CaseRow[] = [];
94
+ for (const name of datasetNames) { const ds = resolveDataset(testData, name); if (ds) rows.push(...ds); }
95
+ const errorRows = rows.filter(is4xx);
96
+
97
+ const requiredUncovered: string[] = [];
98
+ const boundaryUncovered: string[] = [];
99
+ const formatUncovered: string[] = [];
100
+
101
+ for (const [name, spec] of Object.entries(fields)) {
102
+ if (spec.required && !errorRows.some((r) => missing(r, name))) requiredUncovered.push(name);
103
+ const hasRange = spec.min != null || spec.max != null || spec.minLength != null || spec.maxLength != null;
104
+ if (hasRange && !errorRows.some((r) => violatesBoundary(r, name, spec))) boundaryUncovered.push(name);
105
+ if (spec.values && spec.values.length && !errorRows.some((r) => violatesEnum(r, name, spec))) formatUncovered.push(name);
106
+ }
107
+
108
+ const gaps: FieldCoverageGap[] = [];
109
+ if (requiredUncovered.length) gaps.push({ endpoint, kind: 'required', fields: requiredUncovered });
110
+ if (boundaryUncovered.length) gaps.push({ endpoint, kind: 'boundary', fields: boundaryUncovered });
111
+ if (formatUncovered.length) gaps.push({ endpoint, kind: 'format', fields: formatUncovered });
112
+ return gaps;
113
+ }
114
+
115
+ export { readUnitTestData };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Field metadata for the API catalog — the optional `fields:` block on an `apis.yaml` entry.
3
+ *
4
+ * The flat `params:` list says WHICH inputs an endpoint takes; `fields:` says WHAT each one is —
5
+ * required?, type/format, range/length, enum, pattern, uniqueness, and where it lives (body / query /
6
+ * header). This is the metadata the coverage model + the viewpoint gate need to enumerate the
7
+ * required / boundary / format matrices mechanically instead of the AI guessing them.
8
+ *
9
+ * Additive + optional: an entry with no `fields:` behaves exactly as before. Derivable from OpenAPI
10
+ * (`sungen api import` populates it from the request schema + declared parameters), so imported
11
+ * catalogs need no hand-authoring for standard specs.
12
+ */
13
+
14
+ export type FieldType = 'string' | 'integer' | 'number' | 'boolean' | 'enum' | 'array' | 'object';
15
+ export type FieldIn = 'body' | 'query' | 'header' | 'path';
16
+
17
+ export interface FieldSpec {
18
+ in?: FieldIn; // where the value travels (default body); drives how a negative case is built
19
+ required?: boolean;
20
+ type?: FieldType;
21
+ format?: string; // email | uuid | url | date | datetime | … (OpenAPI `format`)
22
+ minLength?: number;
23
+ maxLength?: number;
24
+ min?: number; // OpenAPI `minimum`
25
+ max?: number; // OpenAPI `maximum`
26
+ pattern?: string; // regex source
27
+ values?: Array<string | number | boolean>; // enum
28
+ unique?: boolean;
29
+ }
30
+
31
+ const NUM = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
32
+ const STR = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
33
+
34
+ /** A `:param` snake token for a query/header name (names may carry `-`, `.`, spaces). Shared with the
35
+ * importer so a field's key matches the `params`/path token it maps to (e.g. `X-Trace` → `x_trace`). */
36
+ export function paramToken(name: string): string {
37
+ return name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || name.toLowerCase();
38
+ }
39
+
40
+ /** Normalize an OpenAPI property/parameter schema type into a FieldType (enum wins when `enum` present). */
41
+ function fieldTypeOf(schema: any): FieldType | undefined {
42
+ if (schema && Array.isArray(schema.enum) && schema.enum.length) return 'enum';
43
+ const t = STR(schema?.type);
44
+ if (t === 'string' || t === 'integer' || t === 'number' || t === 'boolean' || t === 'array' || t === 'object') return t;
45
+ return undefined;
46
+ }
47
+
48
+ /** Map a single OpenAPI schema (a property schema or a parameter's `.schema`) → FieldSpec. */
49
+ export function fieldFromSchema(schema: any, opts: { in?: FieldIn; required?: boolean } = {}): FieldSpec {
50
+ const s = schema && typeof schema === 'object' ? schema : {};
51
+ const spec: FieldSpec = {};
52
+ if (opts.in && opts.in !== 'body') spec.in = opts.in; // body is the default → omit for terseness
53
+ if (opts.required) spec.required = true;
54
+ const type = fieldTypeOf(s);
55
+ if (type) spec.type = type;
56
+ const format = STR(s.format);
57
+ if (format) spec.format = format;
58
+ const minLength = NUM(s.minLength); if (minLength != null) spec.minLength = minLength;
59
+ const maxLength = NUM(s.maxLength); if (maxLength != null) spec.maxLength = maxLength;
60
+ const min = NUM(s.minimum); if (min != null) spec.min = min;
61
+ const max = NUM(s.maximum); if (max != null) spec.max = max;
62
+ const pattern = STR(s.pattern); if (pattern) spec.pattern = pattern;
63
+ if (Array.isArray(s.enum) && s.enum.length) spec.values = s.enum.filter((v: unknown) => ['string', 'number', 'boolean'].includes(typeof v));
64
+ if (s.uniqueItems === true || s['x-unique'] === true) spec.unique = true;
65
+ return spec;
66
+ }
67
+
68
+ /** Empty FieldSpec (no attributes) — used to decide whether carrying it adds any signal. */
69
+ function isEmpty(spec: FieldSpec): boolean {
70
+ return Object.keys(spec).length === 0 || (Object.keys(spec).length === 1 && spec.in != null);
71
+ }
72
+
73
+ /**
74
+ * Derive `fields:` from an OpenAPI request-body schema (its `properties` + `required[]`).
75
+ * `location` marks where these live (body by default). Skips fields that carry no useful metadata.
76
+ */
77
+ export function fieldsFromSchema(schema: any, location: FieldIn = 'body'): Record<string, FieldSpec> {
78
+ const out: Record<string, FieldSpec> = {};
79
+ const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object' ? schema.properties : null;
80
+ if (!props) return out;
81
+ const required = new Set<string>(Array.isArray(schema.required) ? schema.required.filter((r: unknown) => typeof r === 'string') : []);
82
+ for (const [name, propSchema] of Object.entries(props)) {
83
+ const spec = fieldFromSchema(propSchema, { in: location, required: required.has(name) });
84
+ if (!isEmpty(spec)) out[name] = spec;
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Derive `fields:` from an OpenAPI operation `parameters[]` (query + header; path is an identifier,
91
+ * carried as a param but not a validatable field). Each parameter carries `required` + a `.schema`.
92
+ */
93
+ export function fieldsFromParameters(parameters: any[]): Record<string, FieldSpec> {
94
+ const out: Record<string, FieldSpec> = {};
95
+ if (!Array.isArray(parameters)) return out;
96
+ for (const p of parameters) {
97
+ if (!p || typeof p !== 'object' || typeof p.name !== 'string') continue;
98
+ const loc = p.in === 'query' ? 'query' : p.in === 'header' ? 'header' : null;
99
+ if (!loc) continue; // skip path (identifier) + cookie
100
+ const spec = fieldFromSchema(p.schema ?? {}, { in: loc, required: p.required === true });
101
+ // A required query/header param carries signal even with no constraints (missing-field case).
102
+ // Key by the param TOKEN (not the raw name) so it matches the `params`/path token downstream.
103
+ if (!isEmpty(spec) || spec.required) out[paramToken(p.name)] = spec.required ? spec : { ...spec, in: loc };
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Merge field maps (later wins); drops nothing. */
109
+ export function mergeFields(...maps: Array<Record<string, FieldSpec> | undefined>): Record<string, FieldSpec> | undefined {
110
+ const out: Record<string, FieldSpec> = {};
111
+ for (const m of maps) if (m) for (const [k, v] of Object.entries(m)) out[k] = v;
112
+ return Object.keys(out).length ? out : undefined;
113
+ }
114
+
115
+ /** Parse + validate the `fields:` block from a loaded catalog entry. Undefined when absent/empty. */
116
+ export function parseFields(raw: any): Record<string, FieldSpec> | undefined {
117
+ if (!raw || typeof raw !== 'object') return undefined;
118
+ const out: Record<string, FieldSpec> = {};
119
+ for (const [name, v] of Object.entries(raw as Record<string, any>)) {
120
+ if (!v || typeof v !== 'object') continue;
121
+ const spec: FieldSpec = {};
122
+ if (v.in === 'query' || v.in === 'header' || v.in === 'path') spec.in = v.in;
123
+ if (v.required === true) spec.required = true;
124
+ if (typeof v.type === 'string') spec.type = v.type as FieldType;
125
+ if (typeof v.format === 'string') spec.format = v.format;
126
+ if (typeof v.minLength === 'number') spec.minLength = v.minLength;
127
+ if (typeof v.maxLength === 'number') spec.maxLength = v.maxLength;
128
+ if (typeof v.min === 'number') spec.min = v.min;
129
+ if (typeof v.max === 'number') spec.max = v.max;
130
+ if (typeof v.pattern === 'string') spec.pattern = v.pattern;
131
+ if (Array.isArray(v.values)) spec.values = v.values;
132
+ if (v.unique === true) spec.unique = true;
133
+ out[name] = spec;
134
+ }
135
+ return Object.keys(out).length ? out : undefined;
136
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * File-field normalization for the API catalog — the `files:` (multipart form parts) and `bodyFile:`
3
+ * (raw binary body) keys of an `apis.yaml` entry. Kept separate from api-catalog.ts (endpoint
4
+ * resolution) since file-upload declaration is a distinct concern with its own shorthand rules.
5
+ */
6
+
7
+ /**
8
+ * A file to upload. `path` is a fixture location (a literal or a `:param` bound at runtime); the
9
+ * runtime reads it into bytes. `mimeType`/`filename` are optional overrides (else inferred from the
10
+ * extension / basename); `name` overrides the multipart part name (defaults to the field key —
11
+ * unused by `bodyFile`, which has no part name).
12
+ */
13
+ export interface FileFieldSpec {
14
+ path: string;
15
+ mimeType?: string;
16
+ filename?: string;
17
+ name?: string;
18
+ }
19
+
20
+ /** Normalize a single file spec — a string value is shorthand for `{ path }`. Undefined if invalid. */
21
+ export function parseFileSpec(v: any): FileFieldSpec | undefined {
22
+ if (typeof v === 'string') return { path: v };
23
+ if (v && typeof v === 'object' && typeof v.path === 'string') {
24
+ return {
25
+ path: v.path,
26
+ mimeType: typeof v.mimeType === 'string' ? v.mimeType : undefined,
27
+ filename: typeof v.filename === 'string' ? v.filename : undefined,
28
+ name: typeof v.name === 'string' ? v.name : undefined,
29
+ };
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /** Normalize the `files:` block — each value via parseFileSpec. Undefined when no valid fields. */
35
+ export function parseFiles(raw: any): Record<string, FileFieldSpec> | undefined {
36
+ if (!raw || typeof raw !== 'object') return undefined;
37
+ const out: Record<string, FileFieldSpec> = {};
38
+ for (const [field, v] of Object.entries(raw as Record<string, any>)) {
39
+ const spec = parseFileSpec(v);
40
+ if (spec) out[field] = spec;
41
+ }
42
+ return Object.keys(out).length ? out : undefined;
43
+ }
package/src/api-repair.ts CHANGED
@@ -23,6 +23,12 @@ export const apiRepair: RepairProvider = {
23
23
  fix: 'An `@api:<name>` does not resolve in the catalog — fix the name, or add/import the endpoint into `api/apis.yaml` (`sungen api import`).' },
24
24
  { id: 'api-manual-automatable', match: /VIEWPOINT-API-MANUAL-AUTOMATABLE/i,
25
25
  fix: 'A @manual scenario is automatable (its endpoint resolves) — drop @manual and use @api (+ @cases for error rows / @concurrent for idempotency). Reserve @manual for genuine judgment cases.' },
26
+ { id: 'api-required-matrix', match: /VIEWPOINT-API-REQUIRED-MATRIX/i,
27
+ fix: 'A `required` field (from the catalog `fields:`) has no missing-field case — add a `@cases` row that omits the field and asserts a 4xx (`expect_status: 400`).' },
28
+ { id: 'api-boundary', match: /VIEWPOINT-API-BOUNDARY/i,
29
+ fix: 'A `min/max/minLength/maxLength` field has no boundary case — add `@cases` rows at the edges (`min-1` / `min` / `max` / `max+1`), the out-of-range ones asserting a 4xx.' },
30
+ { id: 'api-format', match: /VIEWPOINT-API-FORMAT/i,
31
+ fix: 'An enum/format field has no invalid-value case — add a `@cases` row with a value outside the allowed set (`values:`) asserting a 4xx.' },
26
32
 
27
33
  // ── Runtime failures (from the Playwright test-result) ────────────────────────────────────
28
34
  { id: 'api-auth', match: /\b40[13]\b|unauthor|forbidden/i,
@@ -32,7 +38,7 @@ export const apiRepair: RepairProvider = {
32
38
  { id: 'api-status-mismatch', match: /expected.*status|status.*expected|toBe.*\b[45]\d\d\b|received.*\b[45]\d\d\b/i,
33
39
  fix: 'Asserted status ≠ actual — reconcile the expectation against `apis.yaml`/spec (the catalog is the oracle). Never hand-edit the generated spec; fix the feature/test-data and re-`generate --api`.' },
34
40
  { id: 'api-timeout', match: /timeout|timed out|exceeded/i,
35
- fix: 'Request timed out — raise the datasource `timeout_ms`, or check the endpoint/path; for flows ensure prior steps bound the values the call needs.' },
41
+ fix: 'Request timed out — raise the timeout (`timeout_ms` on the catalog entry for just this endpoint, or on the datasource for all of them), or check the endpoint/path; for flows ensure prior steps bound the values the call needs.' },
36
42
  { id: 'api-flaky', match: /flak|intermitt|conflict|already exists|duplicate key/i,
37
43
  fix: 'Likely state bleed — make the flow self-cleaning (delete what it created / `@cleanup`), isolate `@cases` rows, and cap `@concurrent`.' },
38
44
  ],
package/src/cli-import.ts CHANGED
@@ -31,6 +31,22 @@ function writeCatalog(file: string, entries: Record<string, ImportedApiEntry>, m
31
31
  fs.writeFileSync(file, CATALOG_HEADER + stringifyYaml(out));
32
32
  }
33
33
 
34
+ const SCHEMAS_HEADER =
35
+ '# Response-body schema registry (API Driver) — generated by `sungen api import` from OpenAPI\n' +
36
+ '# `components.schemas`. Reference from a catalog entry via `schemaRef: <Name>`, then assert the\n' +
37
+ '# response shape with `expect {{<name>.body}} matches schema [<Name>]`.\n';
38
+
39
+ function writeSchemas(file: string, schemas: Record<string, unknown>, merge: boolean): void {
40
+ let out = schemas;
41
+ if (fs.existsSync(file)) {
42
+ if (!merge) return; // never clobber a reviewed schemas.yaml without --merge
43
+ const existing = parseYaml(fs.readFileSync(file, 'utf8')) || {};
44
+ out = { ...existing, ...schemas };
45
+ }
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ fs.writeFileSync(file, SCHEMAS_HEADER + stringifyYaml(out));
48
+ }
49
+
34
50
  /** Read a source path or http(s) URL (a published Google-Sheet CSV) → text. */
35
51
  async function readSource(source: string): Promise<string> {
36
52
  if (/^https?:\/\//.test(source)) {
@@ -144,10 +160,16 @@ export function registerApiCommand(program: Command): void {
144
160
  try {
145
161
  const text = await readSource(source);
146
162
  const { result, kind } = importAny(source, text, opts.datasource);
147
- const { entries, count, skipped } = result;
163
+ const { entries, count, skipped, schemas, report } = result;
148
164
  if (count === 0) throw new Error(`No endpoints imported (${skipped.join('; ') || 'empty source'}).`);
149
165
 
150
166
  const written: string[] = [];
167
+ // Shared response-schema registry (feeds `expect {{name.body}} matches schema [Ref]`).
168
+ if (schemas && Object.keys(schemas).length) {
169
+ const schemaFile = path.join(process.cwd(), 'qa', 'api', 'schemas.yaml');
170
+ writeSchemas(schemaFile, schemas, !!opts.merge);
171
+ written.push(`${rel(schemaFile)} (${Object.keys(schemas).length} schema(s))`);
172
+ }
151
173
  if (opts.screen) {
152
174
  const f = path.join(process.cwd(), 'qa', 'screens', opts.screen, 'api', 'apis.yaml');
153
175
  writeCatalog(f, entries, !!opts.merge);
@@ -177,6 +199,14 @@ export function registerApiCommand(program: Command): void {
177
199
 
178
200
  console.log(`\n✓ Imported ${count} endpoint(s) from ${kind} source:`);
179
201
  for (const w of written) console.log(` → ${w}`);
202
+ if (report) {
203
+ const bits: string[] = [];
204
+ if (report.fieldsPopulated) bits.push(`${report.fieldsPopulated} endpoint(s) with field metadata`);
205
+ if (report.queryParamsAdded) bits.push(`${report.queryParamsAdded} query/header param(s) recovered`);
206
+ if (report.authInferred) bits.push(`${report.authInferred} auth header(s) inferred`);
207
+ if (report.responseSchemas) bits.push(`${report.responseSchemas} response schema(s)`);
208
+ if (bits.length) console.log(`\n✓ Extracted: ${bits.join(', ')}.`);
209
+ }
180
210
  if (skipped.length) console.log(`\n⚠ Skipped: ${skipped.slice(0, 10).join('; ')}${skipped.length > 10 ? ` … (+${skipped.length - 10})` : ''}`);
181
211
  console.log('\nNext: wire the `datasource` base_url + auth in datasources.yaml, refine the scenarios,');
182
212
  console.log('then `sungen generate --api <area>` → `npx playwright test`.\n');
package/src/csv-import.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  * module only parses text).
8
8
  *
9
9
  * Columns (header row, case-insensitive; aliases accepted):
10
- * name | area|tag | method | path | params | body | datasource|auth | expect_status | description
10
+ * name | area|tag | method | path | params | body | fields | datasource|auth | expect_status | description
11
11
  * - params: separated by `;` or `|` (commas live inside quoted JSON, so don't use `,` here)
12
12
  * - body: a JSON object string, values may be `:param` refs, e.g. {"item_id":":item_id"}
13
+ * - fields: a JSON object of field metadata, e.g. {"email":{"required":true,"format":"email"}}
13
14
  * - expect_status: a number (e.g. 201)
14
15
  */
15
16
  import type { ImportedApiEntry, ImportResult } from './openapi-import';
17
+ import { parseFields } from './api-field-spec';
16
18
 
17
19
  /** RFC-4180-ish parser: handles quoted fields, embedded commas/newlines, and "" escapes. */
18
20
  export function parseCsv(text: string): string[][] {
@@ -55,7 +57,8 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
55
57
  const rows = parseCsv(text);
56
58
  const entries: Record<string, ImportedApiEntry> = {};
57
59
  const skipped: string[] = [];
58
- if (rows.length < 2) return { entries, count: 0, skipped: ['CSV has no data rows (need a header + ≥1 row)'] };
60
+ const emptyReport = { queryParamsAdded: 0, authInferred: 0, fieldsPopulated: 0, responseSchemas: 0 };
61
+ if (rows.length < 2) return { entries, count: 0, skipped: ['CSV has no data rows (need a header + ≥1 row)'], schemas: {}, report: emptyReport };
59
62
 
60
63
  const header = rows[0].map(norm);
61
64
  const col = (r: string[], name: string): string => {
@@ -82,6 +85,11 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
82
85
  if (bodyCell) {
83
86
  try { body = JSON.parse(bodyCell); } catch { skipped.push(`row ${n + 1} (body is not valid JSON — kept no body)`); }
84
87
  }
88
+ let fields;
89
+ const fieldsCell = col(r, 'fields');
90
+ if (fieldsCell) {
91
+ try { fields = parseFields(JSON.parse(fieldsCell)); } catch { skipped.push(`row ${n + 1} (fields is not valid JSON — kept no fields)`); }
92
+ }
85
93
  const statusCell = col(r, 'expect_status');
86
94
  const status = /^\d+$/.test(statusCell) ? Number(statusCell) : undefined;
87
95
  const description = col(r, 'description') || undefined;
@@ -94,8 +102,10 @@ export function importCsv(text: string, datasource = 'app_api'): ImportResult {
94
102
  path,
95
103
  ...(params.length ? { params } : {}),
96
104
  ...(body ? { body } : {}),
105
+ ...(fields ? { fields } : {}),
97
106
  ...(status != null ? { expect: { status } } : {}),
98
107
  };
108
+ if (fields) emptyReport.fieldsPopulated++;
99
109
  }
100
- return { entries, count: Object.keys(entries).length, skipped };
110
+ return { entries, count: Object.keys(entries).length, skipped, schemas: {}, report: emptyReport };
101
111
  }
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  // "not resolvable" install errors). Keeping core type-only makes the driver load with no core on disk.
19
19
  import type { CapabilityRegistry, Sensor, GateInput } from '@sun-asterisk/sungen';
20
20
  import { resolveApi, apiParamNames, lintApiCatalog } from './api-catalog';
21
+ import { fieldCoverageGaps, readUnitTestData } from './api-field-coverage';
22
+ import type { FieldSpec } from './api-field-spec';
21
23
  import { registerApiCommand } from './cli-import';
22
24
  import { apiViewpoints, apiGateProvider } from './api-harness';
23
25
  import { apiDiscovery, apiContextMapper } from './api-context';
@@ -47,8 +49,11 @@ function parseQueryOverrides(raw?: string): Record<string, string> {
47
49
  }
48
50
 
49
51
  export { importOpenApi } from './openapi-import';
50
- export type { ImportedApiEntry, ImportResult } from './openapi-import';
52
+ export type { ImportedApiEntry, ImportResult, ImportReport } from './openapi-import';
51
53
  export { importCsv, parseCsv } from './csv-import';
54
+ export type { FieldSpec, FieldType, FieldIn } from './api-field-spec';
55
+ export { fieldCoverageGaps, readUnitTestData } from './api-field-coverage';
56
+ export type { FieldCoverageGap } from './api-field-coverage';
52
57
 
53
58
  /**
54
59
  * API precondition codegen: `@api:<name>[(p={{v}},…)]` → run the catalog request and bind the
@@ -87,7 +92,14 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
87
92
  const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
88
93
  // request-body wire format (default json); `form` → x-www-form-urlencoded, `multipart` → form-data (#345).
89
94
  const enc = entry.encoding ? `, encoding: ${JSON.stringify(entry.encoding)}` : '';
90
- const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${enc}${hdr}, datasource: ${ds} }`;
95
+ // multipart file fields; the runtime reads each fixture and builds a Playwright file payload. Its
96
+ // `:param` paths bind at runtime from the same params. Presence forces multipart in the runtime.
97
+ const files = entry.files && Object.keys(entry.files).length ? `, files: ${JSON.stringify(entry.files)}` : '';
98
+ // raw-binary body upload: the request body IS the fixture's bytes (e.g. application/octet-stream).
99
+ const bodyFile = entry.bodyFile ? `, bodyFile: ${JSON.stringify(entry.bodyFile)}` : '';
100
+ // per-endpoint timeout override (else the datasource timeout applies at runtime).
101
+ const timeout = entry.timeout_ms ? `, timeout: ${JSON.stringify(entry.timeout_ms)}` : '';
102
+ 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} }`;
91
103
  // @concurrent:N → callN (N parallel calls, aggregate result); else a single call. @hybrid adds
92
104
  // the storageState opts so the request reuses the UI auth session.
93
105
  const callExpr = concurrent
@@ -169,6 +181,26 @@ const apiViewpointSensor: Sensor<GateInput> = {
169
181
  }
170
182
  }
171
183
 
184
+ // Field-driven matrix coverage (REQUIRED/BOUNDARY/FORMAT) — applies to read + mutating endpoints
185
+ // that declare `fields:`. Advisory; fail-quiet when a `@cases` dataset can't be resolved.
186
+ const testData = readUnitTestData(screenName, cwd);
187
+ for (const name of refs) {
188
+ let fields: Record<string, FieldSpec> | undefined;
189
+ try { fields = resolveApi(name, screenName, cwd).fields; } catch { continue; }
190
+ if (!fields || !Object.keys(fields).length) continue;
191
+ const referencing = apiScenarios.filter((s) => (s.apiRefs ?? []).includes(name));
192
+ const datasetNames = [...new Set(referencing.filter((s) => s.casesDataset).map((s) => s.casesDataset as string))];
193
+ // Per-field coverage is measured from `@cases` rows (the matrix mechanism). If the endpoint has
194
+ // no `@cases` but DOES assert an inline 4xx, the author is testing errors another way — mirror
195
+ // api-error's "either @cases or an inline 4xx" acceptance and don't raise a false matrix finding.
196
+ if (!datasetNames.length && referencing.some((s) => /\b4\d\d\b/.test(s.stepsText ?? ''))) continue;
197
+ for (const gap of fieldCoverageGaps(name, fields, datasetNames, testData)) {
198
+ if (gap.kind === 'required') add('warn', `VIEWPOINT-API-REQUIRED-MATRIX: @api:${name} — required field(s) ${gap.fields.join(', ')} have no missing-field case — add a @cases row omitting each, asserting 4xx.`);
199
+ else if (gap.kind === 'boundary') add('warn', `VIEWPOINT-API-BOUNDARY: @api:${name} — field(s) ${gap.fields.join(', ')} have a min/max/length constraint but no boundary case — add @cases rows with out-of-range values → 4xx.`);
200
+ else add('warn', `VIEWPOINT-API-FORMAT: @api:${name} — enum field(s) ${gap.fields.join(', ')} have no invalid-value case — add a @cases row with a value outside the allowed set → 4xx.`);
201
+ }
202
+ }
203
+
172
204
  // Method-aware: error + idempotency only apply when a referenced endpoint MUTATES.
173
205
  const mutating: string[] = [];
174
206
  for (const name of refs) {
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * Deterministically turns an OpenAPI/Swagger document into named-endpoint catalog entries, so QA
5
5
  * doesn't hand-author the `api/apis.yaml`. Pure transform (no network): paths × methods → entries
6
- * with method, `:param` path, declared params (path + JSON body props), expected status, and a
7
- * placeholder datasource. The Dev/lead reviews + wires the datasource; QA references by `@api:<name>`.
6
+ * with method, `:param` path, declared params (path + query + JSON body props), field metadata
7
+ * (required/type/enum/range the `fields:` block), inferred auth headers (from securitySchemes), the
8
+ * expected 2xx status + its response schema, and a placeholder datasource. The Dev/lead reviews +
9
+ * wires the datasource; QA references by `@api:<name>`.
8
10
  */
11
+ import { fieldsFromSchema, fieldsFromParameters, mergeFields, paramToken, type FieldSpec } from './api-field-spec';
12
+
9
13
  export interface ImportedApiEntry {
10
14
  datasource: string;
11
15
  area: string; // resource/tag group (OpenAPI tag, else first path segment)
@@ -14,7 +18,13 @@ export interface ImportedApiEntry {
14
18
  path: string;
15
19
  params?: string[];
16
20
  body?: Record<string, string>;
21
+ files?: Record<string, { path: string; mimeType?: string; filename?: string; name?: string }>; // multipart/form-data binary props → file form fields
22
+ bodyFile?: { path: string; mimeType?: string; filename?: string; name?: string }; // raw-binary request body (application/octet-stream) → the body IS the file
17
23
  encoding?: 'form' | 'multipart'; // set when the requestBody is form/multipart (json omitted = default)
24
+ headers?: Record<string, string>; // per-endpoint header templates (inferred auth, e.g. authorization: "Bearer :token")
25
+ fields?: Record<string, FieldSpec>; // field metadata (required/type/enum/range) for the error+boundary matrices
26
+ responseSchema?: unknown; // inline 2xx response body schema (when not a $ref)
27
+ schemaRef?: string; // component name of the 2xx response schema (when a $ref) → schemas registry
18
28
  expect?: { status: number };
19
29
  }
20
30
 
@@ -53,30 +63,170 @@ function expectedStatus(responses: Record<string, unknown> | undefined): number
53
63
  return success ?? codes[0];
54
64
  }
55
65
 
56
- /** Body property names a `{ prop: ":prop" }` template, + the wire encoding (json | form | multipart),
57
- * best-effort from the requestBody content-type (#345). JSON is the default (encoding omitted). */
58
- function bodyTemplate(op: any): { body?: Record<string, string>; props: string[]; encoding?: 'form' | 'multipart' } {
66
+ /** True for a schema property that carries raw file bytes — `format: binary` (a file), or an array of them. */
67
+ function isBinaryProp(schema: any): boolean {
68
+ if (!schema || typeof schema !== 'object') return false;
69
+ if (schema.format === 'binary') return true;
70
+ return schema.type === 'array' && schema.items && typeof schema.items === 'object' && schema.items.format === 'binary';
71
+ }
72
+
73
+ /** Combine pathItem-level + operation-level `parameters` (op overrides by name+in). */
74
+ function collectParameters(pathItem: any, op: any): any[] {
75
+ const byKey = new Map<string, any>();
76
+ for (const src of [pathItem?.parameters, op?.parameters]) {
77
+ if (!Array.isArray(src)) continue;
78
+ for (const p of src) if (p && typeof p === 'object' && typeof p.name === 'string') byKey.set(`${p.in}:${p.name}`, p);
79
+ }
80
+ return [...byKey.values()];
81
+ }
82
+
83
+ /** Resolve a request-body schema to its concrete `properties`+`required` — following a top-level
84
+ * `$ref` (`#/components/schemas/User`) and merging `allOf` members. Many real specs (Petstore, most
85
+ * generated ones) reference the body schema rather than inlining it; without this, `properties` is
86
+ * empty → no body template + no `fields:` matrix. Cycle-guarded; best-effort (unknown ref → {}). */
87
+ function resolveBodySchema(schema: any, components: Record<string, any>, seen: Set<string> = new Set()): any {
88
+ if (!schema || typeof schema !== 'object') return schema;
89
+ const refName = refComponentName(schema.$ref);
90
+ if (refName) {
91
+ if (seen.has(refName)) return {}; // circular → stop
92
+ seen.add(refName);
93
+ return resolveBodySchema(components[refName], components, seen);
94
+ }
95
+ if (Array.isArray(schema.allOf)) {
96
+ const merged: any = { type: 'object', properties: { ...(schema.properties || {}) }, required: [...(Array.isArray(schema.required) ? schema.required : [])] };
97
+ for (const sub of schema.allOf) {
98
+ const r = resolveBodySchema(sub, components, seen);
99
+ if (r && typeof r === 'object') {
100
+ Object.assign(merged.properties, r.properties || {});
101
+ if (Array.isArray(r.required)) merged.required.push(...r.required);
102
+ }
103
+ }
104
+ merged.required = [...new Set(merged.required)];
105
+ return merged;
106
+ }
107
+ return schema;
108
+ }
109
+
110
+ /** Body property names → a `{ prop: ":prop" }` template, + the wire encoding (json | form | multipart)
111
+ * and any `files` (multipart binary props), best-effort from the requestBody content-type (#345).
112
+ * JSON is the default (encoding omitted). Also returns the picked request schema (for field metadata),
113
+ * resolving a `$ref`/`allOf` body against the doc's component schemas. */
114
+ function bodyTemplate(op: any, components: Record<string, any> = {}): { body?: Record<string, string>; files?: Record<string, { path: string }>; bodyFile?: { path: string; mimeType?: string }; props: string[]; encoding?: 'form' | 'multipart'; schema?: any } {
59
115
  const content = op?.requestBody?.content ?? {};
116
+ // A raw binary body → the request body IS the file's bytes, NOT a multipart part (multipart would
117
+ // be rejected 415). This is `application/octet-stream` (Petstore `uploadImage`) OR a concrete media
118
+ // type carrying a binary schema (e.g. `image/png`, `application/pdf`). Only when there's no
119
+ // structured (json/form/multipart) body to prefer.
120
+ const structured = content['application/json'] || content['application/x-www-form-urlencoded'] || content['multipart/form-data'];
121
+ if (!structured) {
122
+ const rawCt = Object.keys(content).find((ct) => ct === 'application/octet-stream' || content[ct]?.schema?.format === 'binary');
123
+ if (rawCt) {
124
+ // octet-stream → runtime default Content-Type; a concrete media type → carry it as the mimeType.
125
+ return { bodyFile: rawCt === 'application/octet-stream' ? { path: ':file' } : { path: ':file', mimeType: rawCt }, props: ['file'] };
126
+ }
127
+ }
60
128
  // Prefer JSON; else fall back to form-urlencoded / multipart (those set encoding).
61
129
  const pick = content['application/json'] ? { ct: 'application/json' as const, encoding: undefined }
62
130
  : content['application/x-www-form-urlencoded'] ? { ct: 'application/x-www-form-urlencoded' as const, encoding: 'form' as const }
63
131
  : content['multipart/form-data'] ? { ct: 'multipart/form-data' as const, encoding: 'multipart' as const }
64
132
  : undefined;
65
133
  if (!pick) return { props: [] };
66
- const schema = content[pick.ct]?.schema;
67
- const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object'
68
- ? Object.keys(schema.properties)
69
- : [];
70
- if (!props.length) return { props: [], encoding: pick.encoding };
134
+ const schema = resolveBodySchema(content[pick.ct]?.schema, components);
135
+ const properties = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
136
+ const propNames = Object.keys(properties);
137
+ if (!propNames.length) return { props: [], encoding: pick.encoding, schema };
138
+ // multipart: split binary (file) props into `files`, the rest stay text `body`.
71
139
  const body: Record<string, string> = {};
72
- for (const p of props) body[p] = `:${p}`;
73
- return { body, props, encoding: pick.encoding };
140
+ const files: Record<string, { path: string }> = {};
141
+ for (const p of propNames) {
142
+ if (pick.encoding === 'multipart' && isBinaryProp(properties[p])) files[p] = { path: `:${p}` };
143
+ else body[p] = `:${p}`;
144
+ }
145
+ return {
146
+ ...(Object.keys(body).length ? { body } : {}),
147
+ ...(Object.keys(files).length ? { files } : {}),
148
+ props: propNames,
149
+ encoding: pick.encoding,
150
+ schema,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Resolve the auth requirement for an operation → a header template (+ the params it introduces).
156
+ * Reads the applicable `security` (op-level, else global) against `components.securitySchemes`.
157
+ * Supports http bearer/basic, apiKey (header/query), and oauth2 (bearer). Best-effort, additive.
158
+ */
159
+ function authFor(op: any, doc: any): { headers?: Record<string, string>; headerParams?: string[]; queryParams?: Array<{ name: string; tok: string }>; queryFields?: Record<string, FieldSpec> } {
160
+ const schemes = doc?.components?.securitySchemes && typeof doc.components.securitySchemes === 'object' ? doc.components.securitySchemes : {};
161
+ const requirements: any[] = Array.isArray(op?.security) ? op.security : Array.isArray(doc?.security) ? doc.security : [];
162
+ const req = requirements.find((r) => r && typeof r === 'object' && Object.keys(r).length); // first non-empty requirement
163
+ if (!req) return {};
164
+ const headers: Record<string, string> = {};
165
+ const headerParams: string[] = [];
166
+ const queryParams: Array<{ name: string; tok: string }> = [];
167
+ const queryFields: Record<string, FieldSpec> = {};
168
+ for (const schemeName of Object.keys(req)) {
169
+ const s = schemes[schemeName];
170
+ if (!s || typeof s !== 'object') continue;
171
+ if (s.type === 'http' && String(s.scheme).toLowerCase() === 'bearer') {
172
+ headers['authorization'] = 'Bearer :token'; headerParams.push('token');
173
+ } else if (s.type === 'http' && String(s.scheme).toLowerCase() === 'basic') {
174
+ headers['authorization'] = 'Basic :basic_auth'; headerParams.push('basic_auth');
175
+ } else if (s.type === 'oauth2') {
176
+ headers['authorization'] = 'Bearer :token'; headerParams.push('token');
177
+ } else if (s.type === 'apiKey' && typeof s.name === 'string') {
178
+ const tok = paramToken(s.name);
179
+ if (s.in === 'header') { headers[s.name.toLowerCase()] = `:${tok}`; headerParams.push(tok); }
180
+ else if (s.in === 'query') { queryParams.push({ name: s.name, tok }); queryFields[tok] = { in: 'query', required: true }; }
181
+ }
182
+ }
183
+ return {
184
+ ...(Object.keys(headers).length ? { headers } : {}),
185
+ ...(headerParams.length ? { headerParams } : {}),
186
+ ...(queryParams.length ? { queryParams } : {}),
187
+ ...(Object.keys(queryFields).length ? { queryFields } : {}),
188
+ };
189
+ }
190
+
191
+ /** Extract the 2xx JSON response schema for an operation → inline schema or a `#/…/schemas/<name>` ref. */
192
+ function responseSchemaFor(op: any): { responseSchema?: unknown; schemaRef?: string } {
193
+ const responses = op?.responses;
194
+ if (!responses || typeof responses !== 'object') return {};
195
+ const code = Object.keys(responses).filter((c) => /^\d+$/.test(c)).map(Number).find((c) => c >= 200 && c < 300);
196
+ if (code == null) return {};
197
+ const content = (responses as any)[String(code)]?.content;
198
+ const schema = content?.['application/json']?.schema;
199
+ if (!schema || typeof schema !== 'object') return {};
200
+ const refName = refComponentName(schema.$ref);
201
+ if (refName) return { schemaRef: refName };
202
+ // Unwrap a top-level array of a $ref (`{ type: array, items: { $ref } }`) → still ref the item.
203
+ if (schema.type === 'array') {
204
+ const itemRef = refComponentName(schema.items?.$ref);
205
+ if (itemRef) return { schemaRef: itemRef };
206
+ }
207
+ return { responseSchema: schema };
208
+ }
209
+
210
+ /** `#/components/schemas/User` → `User` (else undefined). */
211
+ function refComponentName(ref: unknown): string | undefined {
212
+ if (typeof ref !== 'string') return undefined;
213
+ const m = ref.match(/#\/components\/schemas\/([A-Za-z0-9_.-]+)$/);
214
+ return m ? m[1] : undefined;
215
+ }
216
+
217
+ export interface ImportReport {
218
+ queryParamsAdded: number; // query/header params recovered from parameters[] (were dropped before)
219
+ authInferred: number; // endpoints that got an inferred auth header
220
+ fieldsPopulated: number; // endpoints with a fields: block
221
+ responseSchemas: number; // endpoints with a 2xx response schema / ref
74
222
  }
75
223
 
76
224
  export interface ImportResult {
77
225
  entries: Record<string, ImportedApiEntry>;
78
226
  count: number;
79
227
  skipped: string[];
228
+ schemas: Record<string, unknown>; // component schemas → schemas.yaml (feeds `matches schema` assertions)
229
+ report: ImportReport;
80
230
  }
81
231
 
82
232
  /**
@@ -87,8 +237,10 @@ export interface ImportResult {
87
237
  export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
88
238
  const entries: Record<string, ImportedApiEntry> = {};
89
239
  const skipped: string[] = [];
240
+ const report: ImportReport = { queryParamsAdded: 0, authInferred: 0, fieldsPopulated: 0, responseSchemas: 0 };
241
+ const componentSchemas = doc?.components?.schemas && typeof doc.components.schemas === 'object' ? doc.components.schemas : {};
90
242
  const paths = doc && typeof doc === 'object' ? doc.paths : undefined;
91
- if (!paths || typeof paths !== 'object') return { entries, count: 0, skipped: ['no `paths` object found'] };
243
+ if (!paths || typeof paths !== 'object') return { entries, count: 0, skipped: ['no `paths` object found'], schemas: {}, report };
92
244
 
93
245
  for (const [rawPath, pathItem] of Object.entries(paths as Record<string, any>)) {
94
246
  if (!pathItem || typeof pathItem !== 'object') continue;
@@ -98,8 +250,44 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
98
250
 
99
251
  const catalogPath = toCatalogPath(rawPath);
100
252
  const pathParams = (catalogPath.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
101
- const { body, props, encoding } = bodyTemplate(op);
102
- const params = [...new Set([...pathParams, ...props])];
253
+ const { body, files, bodyFile, props, encoding, schema: reqSchema } = bodyTemplate(op, componentSchemas);
254
+
255
+ // parameters[] — query params join `params` (a `?a=:a&b=:b` query string on the path); header
256
+ // params become header templates. Both were dropped before this. Auth (apiKey-in-query) also
257
+ // contributes a query key so it lands on the path (not just declared → would fail the lint).
258
+ const parameters = collectParameters(pathItem, op);
259
+ const auth = authFor(op, doc);
260
+ const declaredQuery = parameters.filter((p) => p.in === 'query').map((p) => ({ name: p.name, tok: paramToken(p.name) }));
261
+ const queryParams = [...declaredQuery, ...(auth.queryParams || [])];
262
+ // A header param whose name collides with an inferred auth header (e.g. an explicitly documented
263
+ // `Authorization`) is dropped in favour of the richer auth-scheme template — else its token would
264
+ // be a declared-but-unreferenced param (lint break) and the `Bearer :token` scheme would be lost.
265
+ const authHeaderKeys = new Set(Object.keys(auth.headers || {}));
266
+ const headerParamDefs = parameters.filter((p) => p.in === 'header' && typeof p.name === 'string' && !authHeaderKeys.has(String(p.name).toLowerCase()));
267
+
268
+ const paramFields = fieldsFromParameters(parameters);
269
+ const bodyFields = reqSchema ? fieldsFromSchema(reqSchema, 'body') : {};
270
+ const fields = mergeFields(bodyFields, paramFields, auth.queryFields);
271
+
272
+ // Assemble the request path: append recovered query params as `?a=:a&b=:b` (deterministic order).
273
+ let finalPath = catalogPath;
274
+ if (queryParams.length) {
275
+ const qs = queryParams.map((q) => `${q.name}=:${q.tok}`).join('&');
276
+ finalPath += (catalogPath.includes('?') ? '&' : '?') + qs;
277
+ }
278
+ report.queryParamsAdded += declaredQuery.length + headerParamDefs.length;
279
+
280
+ const headers: Record<string, string> = { ...(auth.headers || {}) };
281
+ for (const hp of headerParamDefs) headers[String(hp.name).toLowerCase()] = `:${paramToken(hp.name)}`;
282
+
283
+ const paramNames = [
284
+ ...pathParams,
285
+ ...queryParams.map((q) => q.tok),
286
+ ...headerParamDefs.map((h) => paramToken(h.name)),
287
+ ...props,
288
+ ...(auth.headerParams || []),
289
+ ];
290
+ const params = [...new Set(paramNames)];
103
291
 
104
292
  let name = typeof op.operationId === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(op.operationId)
105
293
  ? op.operationId
@@ -108,18 +296,67 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
108
296
  if (entries[name]) { skipped.push(`${method.toUpperCase()} ${rawPath} (duplicate name "${name}")`); continue; }
109
297
 
110
298
  const status = expectedStatus(op.responses);
299
+ const { responseSchema, schemaRef } = responseSchemaFor(op);
300
+ if (Object.keys(headers).length && auth.headers) report.authInferred++;
301
+ if (fields) report.fieldsPopulated++;
302
+ if (responseSchema || schemaRef) report.responseSchemas++;
303
+
111
304
  entries[name] = {
112
305
  datasource,
113
306
  area: areaOf(op, rawPath),
114
307
  description: typeof op.summary === 'string' ? op.summary : undefined,
115
308
  method: method.toUpperCase(),
116
- path: catalogPath,
309
+ path: finalPath,
117
310
  ...(params.length ? { params } : {}),
311
+ ...(files ? { files } : {}),
312
+ ...(bodyFile ? { bodyFile } : {}),
118
313
  ...(body ? { body } : {}),
119
314
  ...(encoding ? { encoding } : {}),
315
+ ...(Object.keys(headers).length ? { headers } : {}),
316
+ ...(fields ? { fields } : {}),
317
+ ...(responseSchema ? { responseSchema } : {}),
318
+ ...(schemaRef ? { schemaRef } : {}),
120
319
  ...(status != null ? { expect: { status } } : {}),
121
320
  };
122
321
  }
123
322
  }
124
- return { entries, count: Object.keys(entries).length, skipped };
323
+
324
+ // Emit only the component schemas actually referenced by an entry (+ transitive $refs) so
325
+ // schemas.yaml stays lean and Ajv can resolve internal refs (phase: schema assertion).
326
+ const schemas = collectReferencedSchemas(entries, componentSchemas);
327
+ return { entries, count: Object.keys(entries).length, skipped, schemas, report };
328
+ }
329
+
330
+ /** Walk `schemaRef`s + their transitive `#/components/schemas/*` refs → the schemas to persist. */
331
+ function collectReferencedSchemas(entries: Record<string, ImportedApiEntry>, componentSchemas: Record<string, any>): Record<string, unknown> {
332
+ const out: Record<string, unknown> = {};
333
+ const queue: string[] = [];
334
+ for (const e of Object.values(entries)) {
335
+ if (e.schemaRef) queue.push(e.schemaRef);
336
+ // An inline 2xx response schema (envelope/pagination/array-of-inline) may still nest component
337
+ // $refs — walk them so the referenced components land in schemas.yaml (Ajv can then resolve them).
338
+ if (e.responseSchema) for (const ref of refsInObject(e.responseSchema)) { const n = refComponentName(ref); if (n) queue.push(n); }
339
+ }
340
+ const seen = new Set<string>();
341
+ while (queue.length) {
342
+ const name = queue.shift()!;
343
+ if (seen.has(name)) continue;
344
+ seen.add(name);
345
+ const schema = componentSchemas[name];
346
+ if (schema === undefined) continue;
347
+ out[name] = schema;
348
+ // find nested refs
349
+ for (const ref of refsInObject(schema)) { const n = refComponentName(ref); if (n && !seen.has(n)) queue.push(n); }
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /** All `$ref` string values anywhere inside an object. */
355
+ function refsInObject(obj: any, acc: string[] = []): string[] {
356
+ if (!obj || typeof obj !== 'object') return acc;
357
+ for (const [k, v] of Object.entries(obj)) {
358
+ if (k === '$ref' && typeof v === 'string') acc.push(v);
359
+ else if (v && typeof v === 'object') refsInObject(v, acc);
360
+ }
361
+ return acc;
125
362
  }