@sungen/driver-api 3.1.2-beta.100

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 ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@sungen/driver-api",
3
+ "version": "3.1.2-beta.100",
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
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "sungen": {
8
+ "capability": "api"
9
+ },
10
+ "scripts": {
11
+ "build": "rm -rf dist && tsc -p tsconfig.json"
12
+ },
13
+ "author": "eqe team (engineer & quality) — Sun Asterisk",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@sun-asterisk/sungen": "3.1.2-beta.100",
17
+ "commander": "^14.0.2",
18
+ "yaml": "^2.8.2"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ]
24
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * API Driver — named-endpoint catalog (P1, Mode B: `@api` in E2E).
3
+ *
4
+ * Mirrors the DB query catalog: endpoints live in a reviewed `api/apis.yaml`, referenced by name
5
+ * from the `@api:<name>` annotation. The single deterministic loader/resolver/linter, shared by the
6
+ * compiler (`preconditionCodegen` → embedded request) and the harness.
7
+ *
8
+ * Layout (per-capability subfolder — the §13 capability contract):
9
+ * qa/screens/<screen>/api/apis.yaml (screen-scoped)
10
+ * qa/flows/<flow>/api/apis.yaml (flow-scoped)
11
+ * qa/api/apis.yaml (optional shared/global)
12
+ *
13
+ * Resolution: screen/flow scope first, then shared; the same name in both is an error. Request
14
+ * shape (method/path/body) is *structure* (like SQL/selectors) → resolved at generate time and
15
+ * embedded; only `{{values}}` bind at runtime. Base URL + auth live in datasources.yaml + .env.qa.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { parse as parseYaml } from 'yaml';
20
+
21
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
22
+ const METHODS = new Set<string>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
23
+
24
+ export interface ApiEntry {
25
+ name: string;
26
+ datasource?: string;
27
+ method: HttpMethod;
28
+ path: string;
29
+ body?: unknown;
30
+ params: string[];
31
+ expect?: { status?: number | string };
32
+ description?: string;
33
+ scope: 'screen' | 'shared';
34
+ file: string;
35
+ }
36
+
37
+ export interface ApiCatalogFiles {
38
+ screen?: string;
39
+ shared?: string;
40
+ }
41
+
42
+ interface LoadedApiCatalog {
43
+ screen: Map<string, ApiEntry>;
44
+ shared: Map<string, ApiEntry>;
45
+ files: ApiCatalogFiles;
46
+ }
47
+
48
+ const _cache = new Map<string, LoadedApiCatalog>();
49
+
50
+ /** Locate the `api/apis.yaml` files visible to a screen/flow (resolved from the project root). */
51
+ export function findApiCatalogFiles(screenName: string, cwd: string = process.cwd()): ApiCatalogFiles {
52
+ const out: ApiCatalogFiles = {};
53
+ if (!screenName) return out;
54
+ const screenDir = screenName.startsWith('flows/')
55
+ ? path.join(cwd, 'qa', screenName)
56
+ : path.join(cwd, 'qa', 'screens', screenName);
57
+ const screenFile = path.join(screenDir, 'api', 'apis.yaml');
58
+ if (fs.existsSync(screenFile)) out.screen = screenFile;
59
+ const sharedFile = path.join(cwd, 'qa', 'api', 'apis.yaml');
60
+ if (fs.existsSync(sharedFile)) out.shared = sharedFile;
61
+ return out;
62
+ }
63
+
64
+ /** Bare `:param` references appearing in a path or stringified body. */
65
+ function refsIn(text: string): string[] {
66
+ return (text.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
67
+ }
68
+
69
+ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, ApiEntry> {
70
+ const map = new Map<string, ApiEntry>();
71
+ const raw = parseYaml(fs.readFileSync(file, 'utf8')) || {};
72
+ const root = (raw && typeof raw === 'object' && (raw as any).apis && typeof (raw as any).apis === 'object') ? (raw as any).apis : raw;
73
+ for (const [name, v] of Object.entries(root as Record<string, any>)) {
74
+ if (!v || typeof v !== 'object' || typeof v.method !== 'string' || typeof v.path !== 'string') continue;
75
+ map.set(name, {
76
+ name,
77
+ datasource: typeof v.datasource === 'string' ? v.datasource : undefined,
78
+ method: String(v.method).toUpperCase() as HttpMethod,
79
+ path: v.path,
80
+ body: v.body,
81
+ params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
82
+ expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
83
+ description: typeof v.description === 'string' ? v.description : undefined,
84
+ scope,
85
+ file,
86
+ });
87
+ }
88
+ return map;
89
+ }
90
+
91
+ function loadCatalog(screenName: string, cwd: string = process.cwd()): LoadedApiCatalog {
92
+ const key = `${cwd} ${screenName}`;
93
+ const cached = _cache.get(key);
94
+ if (cached) return cached;
95
+ const files = findApiCatalogFiles(screenName, cwd);
96
+ const loaded: LoadedApiCatalog = {
97
+ screen: files.screen ? parseEntries(files.screen, 'screen') : new Map(),
98
+ shared: files.shared ? parseEntries(files.shared, 'shared') : new Map(),
99
+ files,
100
+ };
101
+ _cache.set(key, loaded);
102
+ return loaded;
103
+ }
104
+
105
+ /** Resolve an `@api [name]` reference. Screen scope first, then shared; ambiguity → throw. */
106
+ export function resolveApi(name: string, screenName: string, cwd: string = process.cwd()): ApiEntry {
107
+ const { screen, shared, files } = loadCatalog(screenName, cwd);
108
+ const inScreen = screen.get(name);
109
+ const inShared = shared.get(name);
110
+ if (inScreen && inShared) {
111
+ throw new Error(`API Driver: api "${name}" is defined in BOTH the screen catalog (${rel(files.screen)}) and the shared catalog (${rel(files.shared)}) — names must be unambiguous. Rename one.`);
112
+ }
113
+ const entry = inScreen || inShared;
114
+ if (!entry) {
115
+ const known = [...screen.keys(), ...shared.keys()];
116
+ const where = files.screen || files.shared
117
+ ? `Known apis: ${known.length ? known.join(', ') : '(none)'}.`
118
+ : `No api/apis.yaml found for screen "${screenName}" (expected qa/screens/${screenName}/api/apis.yaml or qa/api/apis.yaml).`;
119
+ throw new Error(`API Driver: api "${name}" not found. ${where}`);
120
+ }
121
+ return entry;
122
+ }
123
+
124
+ /** Ordered bound-param names for an entry (declared `params`, else the `:refs` in path+body). */
125
+ export function apiParamNames(entry: ApiEntry): string[] {
126
+ if (entry.params.length) return entry.params;
127
+ const fromPath = refsIn(entry.path);
128
+ const fromBody = entry.body != null ? refsIn(JSON.stringify(entry.body)) : [];
129
+ return [...new Set([...fromPath, ...fromBody])];
130
+ }
131
+
132
+ /** Validate one entry. Returns human-readable problems (empty = clean). */
133
+ export function validateApiEntry(entry: ApiEntry, datasourceNames: string[] | null): string[] {
134
+ const errs: string[] = [];
135
+ if (!METHODS.has(entry.method)) errs.push(`api "${entry.name}": method "${entry.method}" is not a valid HTTP method.`);
136
+ if (!entry.path.startsWith('/')) errs.push(`api "${entry.name}": path "${entry.path}" must start with "/".`);
137
+ const refs = new Set([...refsIn(entry.path), ...(entry.body != null ? refsIn(JSON.stringify(entry.body)) : [])]);
138
+ const declared = new Set(entry.params);
139
+ for (const r of refs) if (!declared.has(r)) errs.push(`api "${entry.name}": uses :${r} but it is not in params:.`);
140
+ for (const d of declared) if (!refs.has(d)) errs.push(`api "${entry.name}": param "${d}" is declared but never used in path/body.`);
141
+ if (datasourceNames && entry.datasource && !datasourceNames.includes(entry.datasource)) {
142
+ errs.push(`api "${entry.name}": datasource "${entry.datasource}" is not defined in datasources.yaml.`);
143
+ }
144
+ return errs;
145
+ }
146
+
147
+ export interface ApiCatalogLint {
148
+ files: ApiCatalogFiles;
149
+ errors: string[];
150
+ entries: ApiEntry[];
151
+ }
152
+
153
+ /** Lint every api-catalog entry visible to a screen/flow (deterministic; used by audit + CI). */
154
+ export function lintApiCatalog(screenName: string, datasourceNames: string[] | null = null, cwd: string = process.cwd()): ApiCatalogLint {
155
+ const { screen, shared, files } = loadCatalog(screenName, cwd);
156
+ const errors: string[] = [];
157
+ const entries = [...screen.values(), ...shared.values()];
158
+ for (const e of entries) errors.push(...validateApiEntry(e, datasourceNames));
159
+ for (const name of screen.keys()) if (shared.has(name)) errors.push(`api "${name}": defined in both screen and shared catalogs — names must be unambiguous.`);
160
+ return { files, errors, entries };
161
+ }
162
+
163
+ function rel(p?: string): string {
164
+ if (!p) return '(unknown)';
165
+ return path.relative(process.cwd(), p) || p;
166
+ }
167
+
168
+ /** Clear the in-process cache (tests). */
169
+ export function _clearApiCatalogCache(): void {
170
+ _cache.clear();
171
+ }
@@ -0,0 +1,59 @@
1
+ import { Command } from 'commander';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
5
+ import { importOpenApi } from './openapi-import';
6
+
7
+ /**
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.
10
+ */
11
+ 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)');
13
+
14
+ 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 }) => {
21
+ 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
38
+ }
39
+
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));
45
+
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}` : ''}`);
50
+ }
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
+ }
58
+ });
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @sungen/driver-api — Sungen API capability.
3
+ *
4
+ * Owns the `@api` annotation (Mode B: API in E2E) and the API-first authoring tooling (Mode A):
5
+ * the named-endpoint catalog (apis.yaml), the precondition codegen that binds a response to
6
+ * `{{name}}`, the gate sensor that verifies referenced endpoints resolve, the OpenAPI importer, and
7
+ * the `sungen api import` CLI command. Plugs into core via the capability SPI; core discovers and
8
+ * calls `register()` at runtime (core never imports this package).
9
+ *
10
+ * Relocated from core in R5.6 (was `src/harness/api-catalog.ts`, `openapi-import.ts`,
11
+ * `src/cli/commands/api.ts`, and the api parts of `src/capabilities/builtins.ts`). The runtime-helper
12
+ * template `specs-api.ts` remains in core's templates dir (emitted by the core generator via
13
+ * `runtimeHelpers`, resolved by filename) — consistent with the base/db templates staying in core.
14
+ */
15
+ import type { CapabilityRegistry, Sensor, GateInput } from '@sun-asterisk/sungen';
16
+ import { parseQueryOverrides } from '@sun-asterisk/sungen';
17
+ import { resolveApi, apiParamNames, lintApiCatalog } from './api-catalog';
18
+ import { registerApiCommand } from './cli-import';
19
+
20
+ export { importOpenApi } from './openapi-import';
21
+ export type { ImportedApiEntry, ImportResult } from './openapi-import';
22
+
23
+ /**
24
+ * API precondition codegen: `@api:<name>[(p={{v}},…)]` → run the catalog request and bind the
25
+ * response { status, ok, body, headers } to `{{name}}`. Assert with `expect {{name.status}} is 201`
26
+ * / `{{name.body.<path>}}`.
27
+ */
28
+ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd: string }): Array<{ comment?: string; code: string; boundVars?: string[] }> {
29
+ const out: Array<{ comment?: string; code: string; boundVars?: string[] }> = [];
30
+ const TAG = /^@api:([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
31
+ for (const tag of input.tags) {
32
+ const m = tag.match(TAG);
33
+ if (!m) continue;
34
+ const name = m[1];
35
+ const overrides = parseQueryOverrides(m[2]); // same key=value override grammar
36
+ const entry = resolveApi(name, input.screenName, input.cwd); // throws (fail-fast) if missing/ambiguous
37
+ const paramsObj = apiParamNames(entry)
38
+ .map((p) => `${JSON.stringify(p)}: ${p in overrides ? overrides[p] : `testData.get(${JSON.stringify(p)})`}`)
39
+ .join(', ');
40
+ const label = JSON.stringify(entry.description ? `api "${name}" — ${entry.description}` : `api "${name}"`);
41
+ const ds = entry.datasource ? JSON.stringify(entry.datasource) : 'undefined';
42
+ const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}, datasource: ${ds} }`;
43
+ out.push({
44
+ comment: `@api:${name} → bind {{${name}}} (${entry.method} ${entry.path})`,
45
+ code: `testData.bind(${JSON.stringify(name)}, await api.call(${label}, ${req}, { ${paramsObj} }));`,
46
+ boundVars: [name],
47
+ });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * API `verification` gate sensor: every referenced `@api` must resolve in its catalog, and the
54
+ * catalog must lint clean. An unresolved/invalid reference is a gate-level error. Runs when the `api`
55
+ * capability is in scope (a scenario carries `@api`). Message prefix is unchanged from the former
56
+ * in-core verification sensor, so audit output is byte-identical.
57
+ */
58
+ const apiVerificationSensor: Sensor<GateInput> = {
59
+ id: 'api-verification',
60
+ capability: 'api',
61
+ kind: 'gate',
62
+ run: ({ screenName, scenarios, cwd }) => {
63
+ const findings = [] as ReturnType<Sensor['run']>;
64
+ const fail = (msg: string) => findings.push({ sensorId: 'api-verification', capability: 'api', message: `VERIFICATION-FAIL: ${msg}`, severity: 'error' });
65
+ const apiRefs = new Set<string>();
66
+ for (const s of scenarios) for (const r of s.apiRefs ?? []) apiRefs.add(r);
67
+ for (const name of apiRefs) {
68
+ try { resolveApi(name, screenName, cwd); } catch (e: any) { fail(e?.message || `api "${name}" does not resolve`); }
69
+ }
70
+ if (apiRefs.size) {
71
+ try { for (const err of lintApiCatalog(screenName, null, cwd).errors) fail(err); } catch { /* no catalog */ }
72
+ }
73
+ return findings;
74
+ },
75
+ };
76
+
77
+ /** Register the API capability. */
78
+ export function register(registry: CapabilityRegistry): void {
79
+ registry.register({
80
+ id: 'api',
81
+ annotations: ['@api'],
82
+ runtimeHelpers: [{ file: 'api.ts', template: 'specs-api.ts' }], // template resolved from core's templates dir
83
+ preconditionCodegen: apiPreconditionCodegen, // @api:<name> → bind {{name}}
84
+ sensors: [apiVerificationSensor],
85
+ cliCommands: [registerApiCommand as (program: unknown) => void], // `sungen api import <openapi>`
86
+ });
87
+ }
88
+
89
+ export const sungenDriver = { capability: 'api', register } as const;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * OpenAPI → apis.yaml importer (API Driver P3, Mode A keystone).
3
+ *
4
+ * Deterministically turns an OpenAPI/Swagger document into named-endpoint catalog entries, so QA
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>`.
8
+ */
9
+ export interface ImportedApiEntry {
10
+ datasource: string;
11
+ description?: string;
12
+ method: string;
13
+ path: string;
14
+ params?: string[];
15
+ body?: Record<string, string>;
16
+ expect?: { status: number };
17
+ }
18
+
19
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
20
+
21
+ /** A safe catalog name from an OpenAPI path + method (fallback when there's no operationId). */
22
+ function fallbackName(method: string, path: string): string {
23
+ const slug = path.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
24
+ return `${method.toLowerCase()}_${slug}`;
25
+ }
26
+
27
+ /** Convert an OpenAPI path (`/orders/{id}`) to the catalog form (`/orders/:id`). */
28
+ function toCatalogPath(path: string): string {
29
+ return path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ':$1');
30
+ }
31
+
32
+ /** First 2xx status declared for an operation (else the first numeric status, else 200). */
33
+ function expectedStatus(responses: Record<string, unknown> | undefined): number | undefined {
34
+ if (!responses) return undefined;
35
+ const codes = Object.keys(responses).filter((c) => /^\d+$/.test(c)).map(Number);
36
+ const success = codes.find((c) => c >= 200 && c < 300);
37
+ return success ?? codes[0];
38
+ }
39
+
40
+ /** JSON-body property names → a `{ prop: ":prop" }` template (best-effort from requestBody schema). */
41
+ function bodyTemplate(op: any): { body?: Record<string, string>; props: string[] } {
42
+ const schema = op?.requestBody?.content?.['application/json']?.schema;
43
+ const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object'
44
+ ? Object.keys(schema.properties)
45
+ : [];
46
+ if (!props.length) return { props: [] };
47
+ const body: Record<string, string> = {};
48
+ for (const p of props) body[p] = `:${p}`;
49
+ return { body, props };
50
+ }
51
+
52
+ export interface ImportResult {
53
+ entries: Record<string, ImportedApiEntry>;
54
+ count: number;
55
+ skipped: string[];
56
+ }
57
+
58
+ /**
59
+ * Transform an OpenAPI document into apis.yaml entries.
60
+ * @param doc parsed OpenAPI/Swagger object
61
+ * @param datasource placeholder datasource name to assign (default 'app_api')
62
+ */
63
+ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
64
+ const entries: Record<string, ImportedApiEntry> = {};
65
+ const skipped: string[] = [];
66
+ const paths = doc && typeof doc === 'object' ? doc.paths : undefined;
67
+ if (!paths || typeof paths !== 'object') return { entries, count: 0, skipped: ['no `paths` object found'] };
68
+
69
+ for (const [rawPath, pathItem] of Object.entries(paths as Record<string, any>)) {
70
+ if (!pathItem || typeof pathItem !== 'object') continue;
71
+ for (const method of METHODS) {
72
+ const op = (pathItem as any)[method];
73
+ if (!op || typeof op !== 'object') continue;
74
+
75
+ const catalogPath = toCatalogPath(rawPath);
76
+ const pathParams = (catalogPath.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
77
+ const { body, props } = bodyTemplate(op);
78
+ const params = [...new Set([...pathParams, ...props])];
79
+
80
+ let name = typeof op.operationId === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(op.operationId)
81
+ ? op.operationId
82
+ : fallbackName(method, rawPath);
83
+ if (entries[name]) name = fallbackName(method, rawPath); // operationId collision → disambiguate
84
+ if (entries[name]) { skipped.push(`${method.toUpperCase()} ${rawPath} (duplicate name "${name}")`); continue; }
85
+
86
+ const status = expectedStatus(op.responses);
87
+ entries[name] = {
88
+ datasource,
89
+ description: typeof op.summary === 'string' ? op.summary : undefined,
90
+ method: method.toUpperCase(),
91
+ path: catalogPath,
92
+ ...(params.length ? { params } : {}),
93
+ ...(body ? { body } : {}),
94
+ ...(status != null ? { expect: { status } } : {}),
95
+ };
96
+ }
97
+ }
98
+ return { entries, count: Object.keys(entries).length, skipped };
99
+ }