zopia 0.3.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +354 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/bin/zopia.js +20 -0
  5. package/docs/01-overview.md +94 -0
  6. package/docs/02-targets.md +55 -0
  7. package/docs/03-roadmap.md +205 -0
  8. package/docs/04-architecture.md +345 -0
  9. package/docs/05-concepts.md +239 -0
  10. package/docs/06-conversions.md +493 -0
  11. package/docs/07-api-docs.md +337 -0
  12. package/docs/08-components.md +223 -0
  13. package/docs/09-configuration.md +167 -0
  14. package/docs/10-usage.md +208 -0
  15. package/docs/11-testing.md +267 -0
  16. package/docs/12-standards.md +242 -0
  17. package/docs/README.md +42 -0
  18. package/docs/publish-workflow.yml.example +48 -0
  19. package/package.json +77 -0
  20. package/src/api-docs-navigation.ts +353 -0
  21. package/src/cli-command.ts +537 -0
  22. package/src/cli.ts +4 -0
  23. package/src/config.ts +190 -0
  24. package/src/conversions/api-docs-facade.ts +42 -0
  25. package/src/conversions/api-docs-generate.ts +567 -0
  26. package/src/conversions/api-docs-layout.ts +39 -0
  27. package/src/conversions/api-docs-plan.ts +130 -0
  28. package/src/conversions/api-docs-presets.ts +246 -0
  29. package/src/conversions/json-schema-to-zod.ts +931 -0
  30. package/src/conversions/manifest-staleness.ts +211 -0
  31. package/src/conversions/manifest-to-openapi.ts +1861 -0
  32. package/src/conversions/manifest-writer.ts +778 -0
  33. package/src/conversions/openapi-contracts.ts +333 -0
  34. package/src/conversions/openapi-external-ref.ts +233 -0
  35. package/src/conversions/openapi-ir.ts +74 -0
  36. package/src/conversions/openapi-ref.ts +38 -0
  37. package/src/conversions/openapi-to-api-docs-public.ts +466 -0
  38. package/src/conversions/openapi-to-api-docs.ts +203 -0
  39. package/src/conversions/openapi.ts +80 -0
  40. package/src/conversions/reverse-security.ts +68 -0
  41. package/src/conversions/yaml.ts +876 -0
  42. package/src/conversions/zod-to-json-schema.ts +536 -0
  43. package/src/diff.ts +353 -0
  44. package/src/errors.ts +114 -0
  45. package/src/index.ts +80 -0
  46. package/src/validation.ts +299 -0
  47. package/src/warnings.ts +164 -0
@@ -0,0 +1,1861 @@
1
+ import { asZopiaError, ZopiaError, type ZopiaErrorCode } from '../errors';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile, realpath, stat } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { zodSchemasToJsonSchema, zodToJsonSchema } from './zod-to-json-schema';
7
+ import { jsonSchemaToZod } from './json-schema-to-zod';
8
+ import { decodeJsonPointerSegment } from './openapi-ref';
9
+ import { extractOperationContracts, isValidResponseStatus } from './openapi-contracts';
10
+ import { buildOpenApiOperationIR } from './openapi-ir';
11
+ import { webhookRuntimePath } from './api-docs-plan';
12
+ import { OPENAPI_METHODS } from './openapi-to-api-docs';
13
+ import { ZopiaWarningCollector, type ZopiaWarning } from '../warnings';
14
+ import { ZOPIA_MANIFEST_FILE, ZOPIA_MANIFEST_SCHEMA, type ZopiaManifest } from './manifest-writer';
15
+ import { ensureFallbackSecurityScheme, normalizeSecurityRequirements } from './reverse-security';
16
+ export type { ZopiaManifest } from './manifest-writer';
17
+
18
+ /** Options for selecting the OpenAPI dialect emitted by reverse conversion. */
19
+ export interface ZopiaReverseOptions {
20
+ /**
21
+ * OpenAPI version to emit. Low-level manifest helpers preserve the source dialect when omitted.
22
+ * @default '3.1' for `apiDocsToOpenApi`; preserved source dialect for manifest helpers
23
+ */
24
+ version?: '2.0' | '3.0' | '3.1';
25
+ /**
26
+ * Receive each deterministic structured warning emitted during reverse conversion.
27
+ *
28
+ * @param warning Normalized warning emitted in deterministic order.
29
+ * @returns Nothing.
30
+ * @default undefined
31
+ */
32
+ onWarning?: (warning: ZopiaWarning) => void;
33
+ }
34
+
35
+ /** Result returned by the directory-level reverse-conversion API. */
36
+ export interface ZopiaReverseResult {
37
+ /** Reconstructed OpenAPI document. */
38
+ openapi: Record<string, unknown>;
39
+ /** Non-fatal structured conversion warnings. */
40
+ warnings: ZopiaWarning[];
41
+ }
42
+
43
+ type EndpointConfig = Record<string, any>;
44
+ type ComponentSchema = Parameters<typeof zodToJsonSchema>[0];
45
+ interface ImportedComponents { schemas: Map<number, unknown>; references: Array<readonly [string, ComponentSchema]>; }
46
+
47
+ const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace']);
48
+ const pointerToken = (value: string): string => value.replace(/~/g, '~0').replace(/\//g, '~1');
49
+
50
+ function isRecord(value: unknown): value is Record<string, any> { return value !== null && typeof value === 'object' && !Array.isArray(value); }
51
+
52
+ function isEndpointConfig(value: unknown): value is EndpointConfig {
53
+ return isRecord(value) && typeof value.method === 'string' && typeof value.pathShape === 'string' && isRecord(value.request) && isRecord(value.response);
54
+ }
55
+
56
+ function isFileWithinRoot(root: string, file: string): boolean {
57
+ const fromRoot = relative(root, file);
58
+ return Boolean(fromRoot) && fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
59
+ }
60
+
61
+ function isMissingFileError(error: unknown): boolean {
62
+ return isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
63
+ }
64
+
65
+ function manifestMismatch(message: string, at?: string): ZopiaError {
66
+ return new ZopiaError('ZOPIA_DOCS_MANIFEST_MISMATCH', message, {
67
+ at,
68
+ hint: 'regenerate the api-docs tree or restore the generated file',
69
+ });
70
+ }
71
+
72
+ async function validateManifestFiles(manifest: ZopiaManifest, root: string): Promise<void> {
73
+ const entries: Array<{ file: string; kind: 'endpoint' | 'component' }> = [];
74
+ for (const api of manifest.apis) {
75
+ if (!isRecord(api) || typeof api.file !== 'string' || !api.file) throw manifestMismatch(`manifest API entry does not list a generated file: ${String((api as any)?.path)} ${String((api as any)?.method)}`);
76
+ entries.push({ file: api.file, kind: 'endpoint' });
77
+ }
78
+ for (const component of manifest.components ?? []) if (component.file !== undefined && component.file !== null) entries.push({ file: component.file, kind: 'component' });
79
+
80
+ for (const { file, kind } of entries) {
81
+ const manifestKind = kind === 'endpoint' ? 'API' : 'component';
82
+ if (isAbsolute(file)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
83
+ const requested = resolve(root, file);
84
+ if (!isFileWithinRoot(root, requested)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
85
+ let generatedFile: string;
86
+ try { generatedFile = await realpath(requested); }
87
+ catch (error) {
88
+ if (isMissingFileError(error)) throw manifestMismatch(`generated ${kind} file is missing or renamed: ${file}`, file);
89
+ throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unable to resolve generated ${kind} file ${file}: ${error instanceof Error ? error.message : String(error)}`, { at: file, cause: error });
90
+ }
91
+ if (!isFileWithinRoot(root, generatedFile)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
92
+ let metadata;
93
+ try { metadata = await stat(generatedFile); }
94
+ catch (error) {
95
+ if (isMissingFileError(error)) throw manifestMismatch(`generated ${kind} file is missing or renamed: ${file}`, file);
96
+ throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unable to inspect generated ${kind} file ${file}: ${error instanceof Error ? error.message : String(error)}`, { at: file, cause: error });
97
+ }
98
+ if (!metadata.isFile()) throw manifestMismatch(`generated ${kind} file is missing or renamed: ${file}`, file);
99
+ }
100
+ }
101
+
102
+ function isComponentSchema(value: unknown): value is ComponentSchema {
103
+ return isRecord(value) && isRecord(value._zod) && typeof value._zod.run === 'function' && typeof value.parse === 'function';
104
+ }
105
+
106
+ function selectEndpointConfig(module: Record<string, unknown>, operationId: string | undefined, file: string): EndpointConfig {
107
+ const candidates = [...new Set(Object.values(module).filter(isEndpointConfig))];
108
+ const matching = operationId === undefined ? [] : candidates.filter((candidate) => candidate.operationId === operationId);
109
+ if (matching.length === 1) return matching[0];
110
+ if (matching.length > 1) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Generated endpoint module exports multiple km-api configs for operationId ${operationId}: ${file}`, { at: file });
111
+ if (isEndpointConfig(module.default)) return module.default;
112
+ if (candidates.length === 1) return candidates[0];
113
+ throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Generated endpoint module does not export a unique km-api config: ${file}`, { at: file });
114
+ }
115
+
116
+ function selectComponentSchema(module: Record<string, unknown>, file: string): ComponentSchema {
117
+ if (isComponentSchema(module.default)) return module.default;
118
+ const candidates = [...new Set(Object.values(module).filter(isComponentSchema))];
119
+ if (candidates.length === 1) return candidates[0];
120
+ throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Generated component module does not export a unique Zod schema: ${file}`, { at: file });
121
+ }
122
+
123
+ async function importGeneratedModule(root: string, file: string, kind: 'endpoint' | 'component', modules: Map<string, Record<string, unknown>>, cacheBust = true): Promise<Record<string, unknown>> {
124
+ const manifestKind = kind === 'endpoint' ? 'API' : 'component';
125
+ if (isAbsolute(file)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
126
+ const requested = resolve(root, file);
127
+ if (!isFileWithinRoot(root, requested)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
128
+ let generatedFile: string;
129
+ try { generatedFile = await realpath(requested); }
130
+ catch (error) { throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unable to resolve generated ${kind} file ${file}: ${error instanceof Error ? error.message : String(error)}`, { at: file, cause: error }); }
131
+ if (!isFileWithinRoot(root, generatedFile)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsafe manifest ${manifestKind} file: ${file}`);
132
+ const moduleKey = `${generatedFile}\0${cacheBust ? 'fresh' : 'shared'}`;
133
+ let generatedModule = modules.get(moduleKey);
134
+ if (!generatedModule) {
135
+ try {
136
+ const url = pathToFileURL(generatedFile);
137
+ if (cacheBust) {
138
+ const digest = createHash('sha256').update(await readFile(generatedFile)).digest('hex');
139
+ url.searchParams.set('zopia-reverse', digest);
140
+ }
141
+ const importUrl = url.href.replace(/%7B/gi, '{').replace(/%7D/gi, '}').replace(/%7E/gi, '~');
142
+ generatedModule = await import(importUrl) as Record<string, unknown>;
143
+ } catch (error) { throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Unable to import generated ${kind} file ${file}: ${error instanceof Error ? error.message : String(error)}`, { at: file, cause: error }); }
144
+ modules.set(moduleKey, generatedModule);
145
+ }
146
+ return generatedModule;
147
+ }
148
+
149
+ async function importEndpointConfigs(manifest: ZopiaManifest, root: string, modules: Map<string, Record<string, unknown>>): Promise<Map<number, EndpointConfig>> {
150
+ const configs = new Map<number, EndpointConfig>();
151
+ for (const [index, api] of manifest.apis.entries()) {
152
+ if (!isRecord(api) || typeof api.file !== 'string' || !api.file) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Manifest API file is required: ${String((api as any)?.path)} ${String((api as any)?.method)}`);
153
+ const endpointModule = await importGeneratedModule(root, api.file, 'endpoint', modules);
154
+ configs.set(index, selectEndpointConfig(endpointModule, api.operationId, api.file));
155
+ }
156
+ return configs;
157
+ }
158
+
159
+ async function importWebhookConfigs(manifest: ZopiaManifest, root: string, modules: Map<string, Record<string, unknown>>): Promise<Map<number, EndpointConfig>> {
160
+ const configs = new Map<number, EndpointConfig>();
161
+ for (const [index, webhook] of (manifest.webhooks ?? []).entries()) {
162
+ if (!isRecord(webhook) || typeof webhook.file !== 'string' || !webhook.file) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Manifest webhook API file is required: ${String((webhook as any)?.name)} ${String((webhook as any)?.method)}`);
163
+ const endpointModule = await importGeneratedModule(root, webhook.file, 'endpoint', modules);
164
+ configs.set(index, selectEndpointConfig(endpointModule, webhook.operationId, webhook.file));
165
+ }
166
+ return configs;
167
+ }
168
+
169
+ async function importComponentSchemas(manifest: ZopiaManifest, root: string, modules: Map<string, Record<string, unknown>>, warnings?: ZopiaWarningCollector): Promise<ImportedComponents> {
170
+ const shared = new Map<number, ComponentSchema>();
171
+ const imported = new Map<number, ComponentSchema>();
172
+ for (const [index, component] of (manifest.components ?? []).entries()) {
173
+ if (component.file === undefined || component.file === null) continue;
174
+ const sharedModule = await importGeneratedModule(root, component.file, 'component', modules, false);
175
+ shared.set(index, selectComponentSchema(sharedModule, component.file));
176
+ const freshModule = await importGeneratedModule(root, component.file, 'component', modules);
177
+ imported.set(index, selectComponentSchema(freshModule, component.file));
178
+ }
179
+ if (imported.size === 0) return { schemas: new Map(), references: [] };
180
+
181
+ const indexByName = new Map((manifest.components ?? []).map((component, index) => [component.name, index]));
182
+ const aliases = new Set<number>();
183
+ for (const [index, schema] of imported) {
184
+ const target = componentRefTarget(manifest.components![index].schema);
185
+ const targetIndex = target === undefined ? undefined : indexByName.get(target);
186
+ if (targetIndex !== undefined && (schema === shared.get(targetIndex) || schema === imported.get(targetIndex))) aliases.add(index);
187
+ }
188
+
189
+ const namedSchemas: Array<readonly [string, ComponentSchema]> = [];
190
+ for (const [index, schema] of shared) if (!aliases.has(index)) namedSchemas.push([manifest.components![index].name, schema]);
191
+ for (const [index, schema] of imported) if (!aliases.has(index)) namedSchemas.push([manifest.components![index].name, schema]);
192
+ const target = manifest.source.kind === 'swagger-2.0' ? 'draft-4' : manifest.source.kind === 'openapi-3.0' ? 'openapi-3.0' : 'openapi-3.1';
193
+ const referenceRoot = manifest.source.kind === 'swagger-2.0' ? '#/definitions/' : '#/components/schemas/';
194
+ let converted: Record<string, Record<string, unknown>>;
195
+ try { converted = zodSchemasToJsonSchema(namedSchemas, { target, $schema: false, onWarning: (warning) => warnings?.addRebased([warning], manifest.source.kind === 'swagger-2.0' ? '#/definitions' : '#/components/schemas') }, (name) => `${referenceRoot}${name.replace(/~/g, '~0').replace(/\//g, '~1')}`); }
196
+ catch (error) { throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Unable to convert generated component files: ${error instanceof Error ? error.message : String(error)}`, { at: '#/components/schemas', cause: error }); }
197
+
198
+ const schemas = new Map<number, unknown>();
199
+ for (const [index] of imported) {
200
+ const component = manifest.components![index];
201
+ if (aliases.has(index)) schemas.set(index, component.schema);
202
+ else {
203
+ const schema = converted[component.name];
204
+ if (!schema) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Unable to convert generated component file ${String(component.file)}`);
205
+ const normalized = restoreSourceSchemaStructure(normalizeRuntimeSchema(schema, manifest.source.kind), component.schema);
206
+ schemas.set(index, applySchemaOverlayList(normalized, component.overlay, 'component schema', component.schema, manifest.source.kind));
207
+ }
208
+ }
209
+ const references: Array<readonly [string, ComponentSchema]> = [];
210
+ for (const [index, schema] of shared) if (!aliases.has(index) && manifest.components![index].kind !== 'parameter' && manifest.components![index].kind !== 'response') references.push([manifest.components![index].name, schema]);
211
+ return { schemas, references };
212
+ }
213
+
214
+ function componentRefTarget(schema: unknown): string | undefined {
215
+ if (!isRecord(schema) || typeof schema.$ref !== 'string') return undefined;
216
+ const prefix = schema.$ref.startsWith('#/components/schemas/') ? '#/components/schemas/' : schema.$ref.startsWith('#/definitions/') ? '#/definitions/' : undefined;
217
+ if (!prefix) return undefined;
218
+ const suffix = schema.$ref.slice(prefix.length);
219
+ return suffix.includes('/') ? undefined : decodeJsonPointerSegment(suffix, schema.$ref);
220
+ }
221
+
222
+ function sourceSchemaGeneratesAny(schema: unknown, manifest: ZopiaManifest, seen = new Set<string>()): boolean {
223
+ const target = componentRefTarget(schema);
224
+ if (target !== undefined && isRecord(schema)) {
225
+ const nonValidationKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', '$comment', 'title', 'description', 'examples', 'example', 'readOnly', 'writeOnly', 'deprecated', 'discriminator', 'xml', 'externalDocs']);
226
+ if (Object.keys(schema).every((key) => nonValidationKeys.has(key) || key.startsWith('x-')) && !seen.has(target)) {
227
+ const component = (manifest.components ?? []).find((entry) => entry.name === target);
228
+ if (component !== undefined) return sourceSchemaGeneratesAny(component.schema, manifest, new Set(seen).add(target));
229
+ }
230
+ return false;
231
+ }
232
+ if (typeof schema !== 'boolean' && !isRecord(schema)) return false;
233
+ try { return schemaKind(jsonSchemaToZod(schema as any).schema) === 'any'; }
234
+ catch { return false; }
235
+ }
236
+
237
+ type OpenApiReverseVersion = '2.0' | '3.0' | '3.1';
238
+
239
+ function reverseVersion(options: ZopiaReverseOptions | undefined): OpenApiReverseVersion | undefined {
240
+ if (options === undefined) return undefined;
241
+ if (!isRecord(options)) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'reverse options must be an object', { at: 'options', hint: 'pass an options object or omit it' });
242
+ const unknown = Object.keys(options).find((key) => key !== 'version' && key !== 'onWarning');
243
+ if (unknown) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unknown reverse option: ${unknown}`, { at: unknown, hint: 'remove the unsupported option' });
244
+ if (options.version !== undefined && options.version !== '2.0' && options.version !== '3.0' && options.version !== '3.1') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `reverse version must be '2.0', '3.0', or '3.1': ${String(options.version)}`, { at: 'version', hint: "use '2.0', '3.0', or '3.1'" });
245
+ if (options.onWarning !== undefined && typeof options.onWarning !== 'function') throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'reverse onWarning must be a function', { at: 'onWarning', hint: 'provide a warning callback or omit it' });
246
+ return options.version;
247
+ }
248
+
249
+ function emitReverseWarnings(options: ZopiaReverseOptions | undefined, collector: ZopiaWarningCollector): ZopiaWarning[] {
250
+ const warnings = collector.toArray();
251
+ for (const warning of warnings) options?.onWarning?.(warning);
252
+ return warnings;
253
+ }
254
+
255
+ function rewriteSchemaVersion(value: unknown, sourceKind: string, version: OpenApiReverseVersion): unknown {
256
+ if (Array.isArray(value)) return value.map((item) => rewriteSchemaVersion(item, sourceKind, version));
257
+ if (!isRecord(value)) return value;
258
+ const literalKeywords = new Set(['const', 'default', 'enum', 'example', 'examples']);
259
+ const result = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, literalKeywords.has(key) || key.startsWith('x-') ? child : rewriteSchemaVersion(child, sourceKind, version)])) as Record<string, any>;
260
+ if (typeof result.$ref === 'string' && result.$ref.startsWith('#/definitions/')) result.$ref = `#/components/schemas/${result.$ref.slice('#/definitions/'.length)}`;
261
+ if (result.type === 'file') { result.type = 'string'; result.format = 'binary'; }
262
+ const nullable = result.nullable === true || result['x-nullable'] === true;
263
+ delete result['x-nullable'];
264
+ if (version === '3.1') {
265
+ delete result.nullable;
266
+ if (nullable) {
267
+ if (typeof result.type === 'string') result.type = [result.type, 'null'];
268
+ else if (Array.isArray(result.type) && !result.type.includes('null')) result.type = [...result.type, 'null'];
269
+ else if (Array.isArray(result.anyOf)) {
270
+ if (!result.anyOf.some((branch: unknown) => isRecord(branch) && branch.type === 'null')) result.anyOf = [...result.anyOf, { type: 'null' }];
271
+ } else {
272
+ const annotations = new Set(['title', 'description', 'default', 'deprecated', 'readOnly', 'writeOnly', 'examples']);
273
+ const branch = Object.fromEntries(Object.entries(result).filter(([key]) => !annotations.has(key)));
274
+ for (const key of Object.keys(result)) if (!annotations.has(key)) delete result[key];
275
+ result.anyOf = [branch, { type: 'null' }];
276
+ }
277
+ }
278
+ if ((sourceKind === 'swagger-2.0' || sourceKind === 'openapi-3.0') && typeof result.exclusiveMinimum === 'boolean') {
279
+ if (result.exclusiveMinimum === true && typeof result.minimum === 'number') { result.exclusiveMinimum = result.minimum; delete result.minimum; }
280
+ else delete result.exclusiveMinimum;
281
+ }
282
+ if ((sourceKind === 'swagger-2.0' || sourceKind === 'openapi-3.0') && typeof result.exclusiveMaximum === 'boolean') {
283
+ if (result.exclusiveMaximum === true && typeof result.maximum === 'number') { result.exclusiveMaximum = result.maximum; delete result.maximum; }
284
+ else delete result.exclusiveMaximum;
285
+ }
286
+ } else {
287
+ const nullableKeyword = version === '2.0' ? 'x-nullable' : 'nullable';
288
+ if (nullable) result[nullableKeyword] = true;
289
+ if (result.type === 'null') { result.type = 'string'; result.enum = [null]; result[nullableKeyword] = true; }
290
+ if (Array.isArray(result.type) && result.type.includes('null')) {
291
+ const nonNull = result.type.filter((type: unknown) => type !== 'null');
292
+ if (nonNull.length === 1) { result.type = nonNull[0]; result[nullableKeyword] = true; }
293
+ else { delete result.type; result.anyOf = nonNull.map((type: unknown) => ({ type })); result[nullableKeyword] = true; }
294
+ }
295
+ if (Object.prototype.hasOwnProperty.call(result, 'const')) { result.enum = [result.const]; delete result.const; }
296
+ if ((sourceKind === 'openapi-3.1' || version === '2.0') && typeof result.exclusiveMinimum === 'number') {
297
+ const exclusive = result.exclusiveMinimum;
298
+ if (typeof result.minimum !== 'number' || exclusive >= result.minimum) { result.minimum = exclusive; result.exclusiveMinimum = true; }
299
+ else delete result.exclusiveMinimum;
300
+ }
301
+ if ((sourceKind === 'openapi-3.1' || version === '2.0') && typeof result.exclusiveMaximum === 'number') {
302
+ const exclusive = result.exclusiveMaximum;
303
+ if (typeof result.maximum !== 'number' || exclusive <= result.maximum) { result.maximum = exclusive; result.exclusiveMaximum = true; }
304
+ else delete result.exclusiveMaximum;
305
+ }
306
+ }
307
+ if (version === '2.0') {
308
+ if (typeof result.$ref === 'string') {
309
+ if (result.$ref.startsWith('#/components/schemas/')) result.$ref = `#/definitions/${result.$ref.slice('#/components/schemas/'.length)}`;
310
+ else if (result.$ref.startsWith('#/components/parameters/')) result.$ref = `#/parameters/${result.$ref.slice('#/components/parameters/'.length)}`;
311
+ else if (result.$ref.startsWith('#/components/responses/')) result.$ref = `#/responses/${result.$ref.slice('#/components/responses/'.length)}`;
312
+ else if (result.$ref.startsWith('#/components/')) delete result.$ref;
313
+ }
314
+ if (Array.isArray(result.type) && !result.type.includes('null') && result.type.every((type: unknown) => typeof type === 'string')) {
315
+ result.anyOf = result.type.map((type: unknown) => ({ type }));
316
+ delete result.type;
317
+ }
318
+ if (Array.isArray(result.anyOf)) {
319
+ const pruned = result.anyOf.filter((branch: unknown) => !(isRecord(branch) && branch.type === 'null' && Object.keys(branch).length === 1));
320
+ if (pruned.length !== result.anyOf.length) {
321
+ result['x-nullable'] = true;
322
+ if (pruned.length === 0) delete result.anyOf;
323
+ else if (pruned.length === 1 && isRecord(pruned[0]) && pruned[0].$ref === undefined) {
324
+ const branch = pruned[0];
325
+ delete result.anyOf;
326
+ for (const key of Object.keys(result)) if (!['title', 'description', 'default', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'x-nullable'].includes(key) && !key.startsWith('x-')) delete result[key];
327
+ for (const [key, value] of Object.entries(branch)) if (!Object.prototype.hasOwnProperty.call(result, key)) result[key] = value;
328
+ } else result.anyOf = pruned;
329
+ }
330
+ }
331
+ for (const keyword of ['allOf', 'anyOf', 'oneOf'] as const) {
332
+ const branches = result[keyword];
333
+ if (!Array.isArray(branches) || branches.length !== 1) continue;
334
+ const branch = branches[0];
335
+ if (!isRecord(branch) || typeof branch.$ref !== 'string' || Object.keys(branch).length !== 1) continue;
336
+ if (Object.keys(result).some((key) => key !== keyword && !['title', 'description', 'readOnly', 'writeOnly', 'deprecated', 'examples', 'x-nullable'].includes(key) && !key.startsWith('x-'))) continue;
337
+ delete result[keyword];
338
+ result.$ref = branch.$ref;
339
+ }
340
+ if (isRecord(result.$defs)) {
341
+ result.definitions = { ...(isRecord(result.definitions) ? result.definitions : {}), ...result.$defs };
342
+ delete result.$defs;
343
+ }
344
+ if (Array.isArray(result.prefixItems)) {
345
+ const items = result.prefixItems;
346
+ delete result.prefixItems;
347
+ result.items = items;
348
+ if (result.additionalItems === undefined && items.length > 0) result.additionalItems = true;
349
+ }
350
+ for (const key of ['$schema', '$id', '$anchor', '$dynamicAnchor', '$dynamicRef', '$comment']) delete result[key];
351
+ }
352
+ return result;
353
+ }
354
+
355
+ function rewriteOperationSchemas(value: unknown, sourceKind: string, version: OpenApiReverseVersion): unknown {
356
+ if (Array.isArray(value)) return value.map((item) => rewriteOperationSchemas(item, sourceKind, version));
357
+ if (!isRecord(value)) return value;
358
+ const literalKeys = new Set(['example', 'examples', 'externalValue', 'value']);
359
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, literalKeys.has(key) || key.startsWith('x-') ? child : key === 'schema' ? rewriteSchemaVersion(child, sourceKind, version) : rewriteOperationSchemas(child, sourceKind, version)]));
360
+ }
361
+
362
+ const SWAGGER_SCHEMA_KEYS = new Set(['type', 'format', 'items', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'pattern', 'enum', 'default', 'multipleOf', 'minItems', 'maxItems', 'uniqueItems']);
363
+
364
+ function swaggerInlineSchema(value: Record<string, any>, sourceKind: string, version: OpenApiReverseVersion): Record<string, any> {
365
+ return rewriteSchemaVersion(Object.fromEntries(Object.entries(value).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key))), sourceKind, version) as Record<string, any>;
366
+ }
367
+
368
+ function swaggerParameterToOpenApi(parameter: Record<string, any>, version: OpenApiReverseVersion): Record<string, any> {
369
+ if (typeof parameter.$ref === 'string' && parameter.$ref.startsWith('#/parameters/')) return { $ref: `#/components/parameters/${parameter.$ref.slice('#/parameters/'.length)}` };
370
+ const metadata = Object.fromEntries(Object.entries(parameter).filter(([key]) => !SWAGGER_SCHEMA_KEYS.has(key) && key !== 'schema' && key !== 'collectionFormat'));
371
+ return { ...metadata, schema: rewriteSchemaVersion(parameter.schema ?? swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version) };
372
+ }
373
+
374
+ function swaggerPathsOverlayToOpenApi(pathsOverlay: Record<string, unknown> | undefined, version: OpenApiReverseVersion): Record<string, unknown> | undefined {
375
+ if (pathsOverlay === undefined) return undefined;
376
+ return Object.fromEntries(Object.entries(pathsOverlay).map(([path, metadata]) => {
377
+ if (!isRecord(metadata) || !Array.isArray(metadata.parameters)) return [path, metadata];
378
+ return [path, { ...metadata, parameters: metadata.parameters.map((parameter: unknown) => isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version) : parameter) }];
379
+ }));
380
+ }
381
+
382
+ function swaggerHeaderToOpenApi(header: unknown, version: OpenApiReverseVersion): unknown {
383
+ if (!isRecord(header)) return header;
384
+ const metadata = Object.fromEntries(Object.entries(header).filter(([key]) => !SWAGGER_SCHEMA_KEYS.has(key)));
385
+ return { ...metadata, schema: swaggerInlineSchema(header, 'swagger-2.0', version) };
386
+ }
387
+
388
+ function swaggerResponseToOpenApi(response: unknown, contentTypes: string[], version: OpenApiReverseVersion): unknown {
389
+ if (!isRecord(response)) return response;
390
+ if (typeof response.$ref === 'string' && response.$ref.startsWith('#/responses/')) return { $ref: `#/components/responses/${response.$ref.slice('#/responses/'.length)}` };
391
+ const headers = isRecord(response.headers) ? Object.fromEntries(Object.entries(response.headers).map(([name, header]) => [name, swaggerHeaderToOpenApi(header, version)])) : undefined;
392
+ const examples = isRecord(response.examples) ? response.examples : {};
393
+ const schema = response.schema === undefined ? undefined : rewriteSchemaVersion(response.schema, 'swagger-2.0', version);
394
+ const mediaTypes = new Set<string>(Object.keys(examples));
395
+ if (schema !== undefined) for (const contentType of contentTypes.length ? contentTypes : ['application/json']) mediaTypes.add(contentType);
396
+ const content = schema === undefined && mediaTypes.size === 0 ? undefined : Object.fromEntries([...mediaTypes].map((type) => [type, { ...(Object.prototype.hasOwnProperty.call(examples, type) ? { example: examples[type] } : {}), ...(schema === undefined ? {} : { schema }) }]));
397
+ return { ...Object.fromEntries(Object.entries(response).filter(([key]) => !['schema', 'examples', 'headers'].includes(key))), ...(headers === undefined ? {} : { headers }), ...(content === undefined ? {} : { content }) };
398
+ }
399
+
400
+ function swaggerSecuritySchemeToOpenApi(scheme: unknown): unknown {
401
+ if (!isRecord(scheme)) return scheme;
402
+ if (scheme.type === 'basic') return { type: 'http', scheme: 'basic', ...Object.fromEntries(Object.entries(scheme).filter(([key]) => !['type'].includes(key))) };
403
+ if (scheme.type !== 'oauth2') return { ...scheme };
404
+ const flow = scheme.flow === 'accessCode' ? 'authorizationCode' : scheme.flow === 'application' ? 'clientCredentials' : scheme.flow;
405
+ const flowValue = { ...(scheme.authorizationUrl === undefined ? {} : { authorizationUrl: scheme.authorizationUrl }), ...(scheme.tokenUrl === undefined ? {} : { tokenUrl: scheme.tokenUrl }), scopes: isRecord(scheme.scopes) ? scheme.scopes : {} };
406
+ return { type: 'oauth2', flows: { [flow]: flowValue }, ...Object.fromEntries(Object.entries(scheme).filter(([key]) => !['flow', 'authorizationUrl', 'tokenUrl', 'scopes'].includes(key))) };
407
+ }
408
+
409
+ const MANIFEST_REF_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions', 'responses', 'content', 'headers', 'links', 'encoding', 'callbacks']);
410
+ function collectManifestRefs(value: unknown, at = '', mapEntries = false): Array<{ at: string; ref: string; component?: string }> {
411
+ const refs: Array<{ at: string; ref: string; component?: string }> = [];
412
+ if (Array.isArray(value)) value.forEach((child, index) => refs.push(...collectManifestRefs(child, `${at}/${index}`)));
413
+ else if (isRecord(value)) for (const [key, child] of Object.entries(value)) {
414
+ const location = `${at}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`;
415
+ if (mapEntries) refs.push(...collectManifestRefs(child, location));
416
+ else if (['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-')) continue;
417
+ else if (key === '$ref' && typeof child === 'string') refs.push({ at: location, ref: child, ...(componentRefTarget({ $ref: child }) ? { component: componentRefTarget({ $ref: child }) } : {}) });
418
+ else refs.push(...collectManifestRefs(child, location, MANIFEST_REF_MAP_KEYS.has(key)));
419
+ }
420
+ return refs;
421
+ }
422
+
423
+ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: ZopiaManifest, version: OpenApiReverseVersion): Record<string, any> {
424
+ const consumes = Array.isArray(operation.consumes) ? operation.consumes : manifest.swaggerConsumes ?? [];
425
+ const produces = Array.isArray(operation.produces) ? operation.produces : manifest.swaggerProduces ?? [];
426
+ const requestTypes = consumes.length ? consumes : ['application/json'];
427
+ const responseTypes = produces.length ? produces : ['application/json'];
428
+ const parameters = (Array.isArray(operation.parameters) ? operation.parameters : []).filter(isRecord).map((raw) => ({ raw, resolved: resolveParameter(raw, manifest) }));
429
+ const body = parameters.find(({ resolved }) => resolved.in === 'body')?.resolved;
430
+ const form = parameters.filter(({ resolved }) => resolved.in === 'formData').map(({ resolved }) => resolved);
431
+ const ordinary = parameters.filter(({ resolved }) => resolved.in !== 'body' && resolved.in !== 'formData').map(({ raw, resolved }) => swaggerParameterToOpenApi(typeof raw.$ref === 'string' ? raw : resolved, version));
432
+ let requestBody: Record<string, any> | undefined;
433
+ if (body) {
434
+ const schema = rewriteSchemaVersion(body.schema ?? {}, 'swagger-2.0', version);
435
+ requestBody = { ...(body.description === undefined ? {} : { description: body.description }), required: body.required === true, content: Object.fromEntries(requestTypes.map((type) => [type, { schema }])) };
436
+ }
437
+ else if (form.length) {
438
+ const properties = Object.fromEntries(form.map((parameter) => [parameter.name, rewriteSchemaVersion(parameter.type === 'file' ? { type: 'string', format: 'binary' } : swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version)]));
439
+ const required = form.filter((parameter) => parameter.required === true).map((parameter) => parameter.name);
440
+ const schema = { type: 'object', properties, ...(required.length ? { required } : {}) };
441
+ requestBody = { required: required.length > 0, content: Object.fromEntries(requestTypes.map((type) => [type, { schema }])) };
442
+ }
443
+ const responses = Object.fromEntries(Object.entries(operation.responses ?? {}).map(([status, response]) => [status, swaggerResponseToOpenApi(response, responseTypes, version)]));
444
+ return { ...Object.fromEntries(Object.entries(operation).filter(([key]) => !['parameters', 'responses', 'consumes', 'produces', 'schemes'].includes(key))), ...(ordinary.length ? { parameters: ordinary } : {}), ...(requestBody === undefined ? {} : { requestBody }), responses };
445
+ }
446
+
447
+ const SWAGGER_REQUEST_FORM_TYPES = new Set(['application/x-www-form-urlencoded', 'multipart/form-data']);
448
+
449
+ function openApiInlineConstraintsToSwagger(schema: Record<string, any>, sourceKind: string, at: string, warnings?: ZopiaWarningCollector): Record<string, any> {
450
+ const rewritten = rewriteSchemaVersion(schema, sourceKind, '2.0') as Record<string, any>;
451
+ const type = rewritten.type;
452
+ if (rewritten.$ref !== undefined || !(type === undefined || type === 'string' || type === 'integer' || type === 'number' || type === 'boolean' || type === 'array') || Object.keys(rewritten).some((key) => ['allOf', 'anyOf', 'oneOf', 'not', 'properties', 'patternProperties', '$defs', 'definitions'].includes(key))) {
453
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/schema`, message: 'parameter/header constraint shapes beyond scalar swagger fields degrade to type string in OpenAPI 2.0 output' });
454
+ return { type: 'string' };
455
+ }
456
+ const constraints = Object.fromEntries(Object.entries(rewritten).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key)));
457
+ if (constraints.type === 'array' && !isRecord(constraints.items)) constraints.items = { type: 'string' };
458
+ return constraints;
459
+ }
460
+
461
+ function openApiParameterToSwagger(parameter: unknown, sourceKind: string, at: string, warnings?: ZopiaWarningCollector): Record<string, any> | undefined {
462
+ if (!isRecord(parameter)) return parameter as Record<string, any>;
463
+ if (typeof parameter.$ref === 'string') {
464
+ if (parameter.$ref.startsWith('#/components/parameters/')) return { $ref: `#/parameters/${parameter.$ref.slice('#/components/parameters/'.length)}` };
465
+ return parameter as Record<string, any>;
466
+ }
467
+ if (parameter.in === 'cookie') {
468
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: 'cookie parameters are omitted because OpenAPI 2.0 cannot represent them' });
469
+ return undefined;
470
+ }
471
+ const metadata = Object.fromEntries(Object.entries(parameter).filter(([key]) => !['schema', 'content', 'style', 'explode', 'example', 'examples'].includes(key)));
472
+ let constraints: Record<string, any>;
473
+ if (isRecord(parameter.schema)) constraints = openApiInlineConstraintsToSwagger(parameter.schema, sourceKind, at, warnings);
474
+ else if (isRecord(parameter.content)) {
475
+ const media = Object.keys(parameter.content)[0];
476
+ const mediaValue = media === undefined ? undefined : parameter.content[media];
477
+ constraints = openApiInlineConstraintsToSwagger(isRecord(mediaValue) && isRecord(mediaValue.schema) ? mediaValue.schema : {}, sourceKind, `${at}/content`, warnings);
478
+ } else constraints = { type: 'string' };
479
+ return { ...metadata, ...constraints };
480
+ }
481
+
482
+ function openApiResponseHeaderToSwagger(header: unknown, sourceKind: string, at: string, warnings?: ZopiaWarningCollector): Record<string, any> | undefined {
483
+ if (!isRecord(header)) return undefined;
484
+ if (typeof header.$ref === 'string') {
485
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: 'reusable header references are omitted because OpenAPI 2.0 headers cannot reference component objects' });
486
+ return undefined;
487
+ }
488
+ const metadata = Object.fromEntries(Object.entries(header).filter(([key]) => !['schema', 'content', 'style', 'explode', 'example', 'examples'].includes(key)));
489
+ const constraints = isRecord(header.schema) ? openApiInlineConstraintsToSwagger(header.schema, sourceKind, at, warnings) : { type: 'string' };
490
+ return { ...metadata, ...constraints };
491
+ }
492
+
493
+ function openApiResponseToSwagger(response: unknown, sourceKind: string, at: string, warnings?: ZopiaWarningCollector): Record<string, any> {
494
+ if (!isRecord(response)) return response as Record<string, any>;
495
+ if (typeof response.$ref === 'string') {
496
+ if (response.$ref.startsWith('#/components/responses/')) return { $ref: `#/responses/${response.$ref.slice('#/components/responses/'.length)}` };
497
+ return response as Record<string, any>;
498
+ }
499
+ const content = isRecord(response.content) ? response.content : {};
500
+ const contentTypes = Object.keys(content);
501
+ const mediaType = contentTypes[0];
502
+ const primaryMedia = mediaType === undefined ? undefined : content[mediaType];
503
+ const schema = isRecord(primaryMedia) && primaryMedia.schema !== undefined ? rewriteSchemaVersion(primaryMedia.schema, sourceKind, '2.0') : undefined;
504
+ if (contentTypes.length > 1) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/content`, message: 'additional response media types are reflected only through examples in OpenAPI 2.0 output' });
505
+ const examples: Record<string, unknown> = {};
506
+ for (const [type, mediaValue] of Object.entries(content)) {
507
+ if (!isRecord(mediaValue)) continue;
508
+ if (Object.prototype.hasOwnProperty.call(mediaValue, 'example')) examples[type] = mediaValue.example;
509
+ else if (isRecord(mediaValue.examples)) {
510
+ const name = Object.keys(mediaValue.examples)[0];
511
+ const value = name === undefined ? undefined : mediaValue.examples[name];
512
+ if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'value')) examples[type] = value.value;
513
+ }
514
+ }
515
+ const headers = isRecord(response.headers)
516
+ ? Object.fromEntries(Object.entries(response.headers).flatMap(([name, header]) => {
517
+ const converted = openApiResponseHeaderToSwagger(header, sourceKind, `${at}/headers/${pointerToken(name)}`, warnings);
518
+ return converted === undefined ? [] : [[name, converted]];
519
+ }))
520
+ : undefined;
521
+ if (response.links !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/links`, message: 'response links are omitted because OpenAPI 2.0 cannot represent them' });
522
+ const metadata = Object.fromEntries(Object.entries(response).filter(([key]) => !['content', 'headers', 'links'].includes(key)));
523
+ return {
524
+ ...metadata,
525
+ description: typeof response.description === 'string' ? response.description : '',
526
+ ...(headers !== undefined ? { headers } : {}),
527
+ ...(schema === undefined ? {} : { schema }),
528
+ ...(Object.keys(examples).length ? { examples } : {}),
529
+ };
530
+ }
531
+
532
+ function openApiOperationToSwagger(operation: Record<string, any>, manifest: ZopiaManifest, warnings?: ZopiaWarningCollector, operationAt?: string): Record<string, any> {
533
+ const sourceKind = manifest.source.kind;
534
+ const at = operationAt ?? '#';
535
+ const requestBody = isRecord(operation.requestBody) ? operation.requestBody : undefined;
536
+ const content = requestBody !== undefined && isRecord(requestBody.content) ? requestBody.content : {};
537
+ const contentTypes = Object.keys(content);
538
+ const mediaType = contentTypes[0];
539
+ const formType = mediaType !== undefined && SWAGGER_REQUEST_FORM_TYPES.has(mediaType) ? mediaType : undefined;
540
+ const parameters = (Array.isArray(operation.parameters) ? operation.parameters : [])
541
+ .map((parameter, index) => openApiParameterToSwagger(parameter, sourceKind, `${at}/parameters/${index}`, warnings))
542
+ .filter((parameter): parameter is Record<string, any> => parameter !== undefined);
543
+ if (requestBody !== undefined) {
544
+ if (formType !== undefined) {
545
+ const mediaValue = content[formType];
546
+ const schema = isRecord(mediaValue) && isRecord(mediaValue.schema) ? rewriteSchemaVersion(mediaValue.schema, sourceKind, '2.0') as Record<string, any> : {};
547
+ if (schema.type === 'object' || isRecord(schema.properties)) {
548
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === 'string') : []);
549
+ const properties = isRecord(schema.properties) ? schema.properties : {};
550
+ for (const [name, value] of Object.entries(properties)) {
551
+ const constraints = isRecord(value) ? openApiInlineConstraintsToSwagger(value, sourceKind, `${at}/requestBody/content`, warnings) : { type: 'string' };
552
+ parameters.push({ name, in: 'formData', ...(required.has(name) ? { required: true } : {}), ...constraints });
553
+ }
554
+ } else {
555
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/requestBody`, message: 'form request bodies without an object schema degrade to a body parameter in OpenAPI 2.0 output' });
556
+ parameters.push({ name: 'body', in: 'body', ...(requestBody.required === true ? { required: true } : {}), schema: rewriteSchemaVersion(isRecord(mediaValue) && mediaValue.schema !== undefined ? mediaValue.schema : {}, sourceKind, '2.0') });
557
+ }
558
+ } else if (mediaType !== undefined) {
559
+ if (contentTypes.length > 1) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/requestBody/content`, message: 'additional request body media types are reflected only through consumes in OpenAPI 2.0 output' });
560
+ const mediaValue = content[mediaType];
561
+ parameters.push({
562
+ name: 'body',
563
+ in: 'body',
564
+ ...(requestBody.description === undefined ? {} : { description: requestBody.description }),
565
+ ...(requestBody.required === true ? { required: true } : {}),
566
+ schema: rewriteSchemaVersion(isRecord(mediaValue) && mediaValue.schema !== undefined ? mediaValue.schema : {}, sourceKind, '2.0'),
567
+ });
568
+ } else {
569
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/requestBody`, message: 'empty request bodies are omitted from OpenAPI 2.0 output' });
570
+ }
571
+ }
572
+ const responses = Object.fromEntries((isRecord(operation.responses) ? Object.entries(operation.responses) : []).map(([status, response]) => [status, openApiResponseToSwagger(response, sourceKind, `${at}/responses/${pointerToken(status)}`, warnings)]));
573
+ if (operation.callbacks !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${at}/callbacks`, message: 'operation callbacks are omitted because OpenAPI 2.0 cannot represent them' });
574
+ return { ...Object.fromEntries(Object.entries(operation).filter(([key]) => !['parameters', 'requestBody', 'responses', 'callbacks'].includes(key))), ...(parameters.length ? { parameters } : {}), responses };
575
+ }
576
+
577
+ function openApiSecuritySchemeToSwagger(scheme: unknown, at: string, warnings?: ZopiaWarningCollector): Record<string, any> | undefined {
578
+ if (!isRecord(scheme)) return undefined;
579
+ if (typeof scheme.$ref === 'string') {
580
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: 'security scheme references are omitted from OpenAPI 2.0 output' });
581
+ return undefined;
582
+ }
583
+ if (scheme.type === 'basic') return { type: 'basic', ...Object.fromEntries(Object.entries(scheme).filter(([key]) => key !== 'type')) };
584
+ if (scheme.type === 'http') {
585
+ if (scheme.scheme === 'basic') return { type: 'basic', ...Object.fromEntries(Object.entries(scheme).filter(([key]) => !['type', 'scheme'].includes(key))) };
586
+ if (scheme.scheme === 'bearer') { const { type: _type, scheme: _scheme, ...metadata } = scheme; return { type: 'apiKey', name: 'Authorization', in: 'header', ...metadata }; }
587
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: `http security scheme '${String(scheme.scheme)}' is omitted because OpenAPI 2.0 cannot represent it` });
588
+ return undefined;
589
+ }
590
+ if (scheme.type === 'apiKey') return { ...scheme };
591
+ if (scheme.type === 'oauth2' && isRecord(scheme.flows)) {
592
+ const flowNames: Record<string, string> = { implicit: 'implicit', password: 'password', clientCredentials: 'application', authorizationCode: 'accessCode' };
593
+ const names = Object.keys(scheme.flows).filter((name) => flowNames[name] !== undefined).sort((left, right) => flowNames[left] < flowNames[right] ? -1 : flowNames[left] > flowNames[right] ? 1 : 0);
594
+ if (names.length === 0) { warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: 'oauth2 security scheme is omitted because no supported flow remains in OpenAPI 2.0 output' }); return undefined; }
595
+ if (names.length > 1) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: 'additional oauth2 flows are omitted because OpenAPI 2.0 supports a single flow' });
596
+ const flow = isRecord(scheme.flows[names[0]]) ? scheme.flows[names[0]] : {};
597
+ return { type: 'oauth2', flow: flowNames[names[0]], ...(flow.authorizationUrl === undefined ? {} : { authorizationUrl: flow.authorizationUrl }), ...(flow.tokenUrl === undefined ? {} : { tokenUrl: flow.tokenUrl }), scopes: isRecord(flow.scopes) ? flow.scopes : {}, ...Object.fromEntries(Object.entries(scheme).filter(([key]) => !['type', 'flows'].includes(key))) };
598
+ }
599
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at, message: `security scheme '${String(scheme.type)}' is omitted because OpenAPI 2.0 cannot represent it` });
600
+ return undefined;
601
+ }
602
+
603
+ function rewriteOverlaysVersion(overlay: unknown, sourceKind: string, version: OpenApiReverseVersion): unknown {
604
+ if (!Array.isArray(overlay)) return overlay;
605
+ return overlay.map((entry) => {
606
+ if (!isRecord(entry) || typeof entry.key === 'string') return entry;
607
+ let set = isRecord(entry.set) ? rewriteSchemaVersion(entry.set, sourceKind, version) as Record<string, any> : undefined;
608
+ if (set && version === '3.1' && sourceKind !== 'openapi-3.1' && (Object.prototype.hasOwnProperty.call(entry.set, 'nullable') || Object.prototype.hasOwnProperty.call(entry.set, 'x-nullable'))) {
609
+ set = { ...set }; delete set.nullable; delete set['x-nullable']; delete set.anyOf;
610
+ }
611
+ return {
612
+ ...entry,
613
+ ...(set === undefined ? {} : { set }),
614
+ ...(Object.prototype.hasOwnProperty.call(entry, 'node') ? { node: rewriteSchemaVersion(entry.node, sourceKind, version) } : {}),
615
+ };
616
+ });
617
+ }
618
+
619
+ /** Whether a path-item `$ref` points into a container removed by the target dialect. */
620
+ function isDroppedDialectRef(ref: unknown): ref is string {
621
+ return typeof ref === 'string' && (ref.startsWith('#/components/pathItems/') || ref.startsWith('#/webhooks/'));
622
+ }
623
+
624
+ function manifestToSwaggerDialect(manifest: ZopiaManifest, warnings?: ZopiaWarningCollector): ZopiaManifest {
625
+ const sourceKind = manifest.source.kind;
626
+ if ((manifest.webhooks?.length ?? 0) > 0 || manifest.documentOverlay?.webhooks !== undefined) warnings?.add({ code: 'ZOPIA_WARN_WEBHOOKS', at: '#/webhooks', message: 'webhooks are omitted because OpenAPI 2.0 cannot represent them' });
627
+ if (manifest.documentOverlay?.jsonSchemaDialect !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/jsonSchemaDialect', message: 'jsonSchemaDialect is omitted from OpenAPI 2.0 output' });
628
+ const sourceServers = manifest.servers ?? [];
629
+ let host: string | undefined;
630
+ let schemes: string[] | undefined;
631
+ let basePath = '/';
632
+ if (sourceServers.length > 0) {
633
+ const first = sourceServers[0];
634
+ const rawUrl = isRecord(first) && typeof first.url === 'string' ? first.url : typeof first === 'string' ? first : '/';
635
+ if (sourceServers.length > 1) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/servers', message: 'only the first server entry is kept because OpenAPI 2.0 supports a single host/basePath anchor' });
636
+ let urlText = rawUrl;
637
+ const variables = isRecord(first) && isRecord(first.variables) ? first.variables : {};
638
+ for (const [name, variable] of Object.entries(variables)) {
639
+ const value = isRecord(variable) && typeof variable.default === 'string' ? variable.default : undefined;
640
+ if (value !== undefined) urlText = urlText.split(`{${name}}`).join(value);
641
+ }
642
+ if (/\{[^{}]+\}/.test(urlText) || Object.keys(variables).length > 0) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/servers/0', message: 'server variables are substituted with defaults because OpenAPI 2.0 cannot represent them' });
643
+ try {
644
+ const parsed = new URL(urlText);
645
+ schemes = [parsed.protocol.replace(/:$/, '')];
646
+ host = parsed.host;
647
+ basePath = parsed.pathname === '' ? '/' : parsed.pathname;
648
+ } catch {
649
+ schemes = undefined;
650
+ host = undefined;
651
+ basePath = urlText.startsWith('/') ? urlText : `/${urlText}`;
652
+ if (urlText !== basePath) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/servers/0', message: 'relative server URLs become basePath values because OpenAPI 2.0 cannot represent protocol-relative anchors' });
653
+ }
654
+ }
655
+ const documentOverlay = Object.fromEntries(Object.entries(manifest.documentOverlay ?? {}).filter(([key]) => key !== 'webhooks' && key !== 'jsonSchemaDialect'));
656
+ const overlay = manifest.componentsOverlay ?? {};
657
+ const dropComponentGroups = Object.keys(overlay).filter((key) => !['parameters', 'responses', 'requestBodies'].includes(key) && !key.startsWith('x-'));
658
+ for (const key of dropComponentGroups) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `#/components/${pointerToken(key)}`, message: `components.${key} is omitted because OpenAPI 2.0 cannot represent it` });
659
+ if (overlay.requestBodies !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/components/requestBodies', message: 'components.requestBodies become body parameters in OpenAPI 2.0 output' });
660
+ const swaggerParameters = {
661
+ ...Object.fromEntries(Object.entries(isRecord(overlay.parameters) ? overlay.parameters : {}).flatMap(([name, parameter]) => {
662
+ const converted = openApiParameterToSwagger(parameter, sourceKind, `#/components/parameters/${pointerToken(name)}`, warnings);
663
+ return converted === undefined ? [] : [[name, converted]];
664
+ })),
665
+ ...Object.fromEntries(Object.entries(isRecord(overlay.requestBodies) ? overlay.requestBodies : {}).flatMap(([name, requestBody]) => {
666
+ if (!isRecord(requestBody)) return [];
667
+ const content = isRecord(requestBody.content) ? requestBody.content : {};
668
+ const type = Object.keys(content)[0];
669
+ const media = type === undefined ? undefined : content[type];
670
+ return [[name, {
671
+ name,
672
+ in: 'body',
673
+ ...(requestBody.description === undefined ? {} : { description: requestBody.description }),
674
+ ...(requestBody.required === true ? { required: true } : {}),
675
+ schema: rewriteSchemaVersion(isRecord(media) && media.schema !== undefined ? media.schema : {}, sourceKind, '2.0'),
676
+ }]];
677
+ })),
678
+ };
679
+ const swaggerResponses = Object.fromEntries(Object.entries(isRecord(overlay.responses) ? overlay.responses : {}).map(([name, response]) => [name, openApiResponseToSwagger(response, sourceKind, `#/components/responses/${pointerToken(name)}`, warnings)]));
680
+ // Expand path-item $refs whose targets have no 2.0 home (components.pathItems
681
+ // and webhooks are both omitted) so the output never dangles.
682
+ const expandPaths = new Set(Object.entries(manifest.pathsOverlay ?? {}).filter(([, metadata]) => isRecord(metadata) && isDroppedDialectRef(metadata.$ref)).map(([path]) => path));
683
+ const apis = manifest.apis.map((api) => {
684
+ const apiAt = `#/paths/${pointerToken(api.path)}/${api.method}`;
685
+ const sourceOperation = api.sourceOperation === undefined ? undefined : openApiOperationToSwagger(api.sourceOperation, manifest, warnings, apiAt);
686
+ const responseOverlay = sourceOperation === undefined ? api.responseOverlay : Object.entries(sourceOperation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []);
687
+ return { ...api, ...(expandPaths.has(api.path) && api.pathItemRef === true ? { pathItemRef: false } : {}), sourceOperation, refs: sourceOperation === undefined ? api.refs : collectManifestRefs(sourceOperation), overlay: rewriteOverlaysVersion(api.overlay, sourceKind, '2.0'), responseOverlay };
688
+ });
689
+ const consumes = new Set<string>();
690
+ const produces = new Set<string>();
691
+ for (const api of manifest.apis) {
692
+ const operation = isRecord(api.sourceOperation) ? api.sourceOperation : {};
693
+ const requestContent = isRecord(operation.requestBody) && isRecord(operation.requestBody.content) ? operation.requestBody.content : {};
694
+ for (const type of Object.keys(requestContent)) consumes.add(type);
695
+ const responses = isRecord(operation.responses) ? operation.responses : {};
696
+ for (const response of Object.values(responses)) if (isRecord(response) && isRecord(response.content)) for (const type of Object.keys(response.content)) produces.add(type);
697
+ }
698
+ const sortedConsumes = [...consumes].sort();
699
+ const sortedProduces = [...produces].sort();
700
+ const components = (manifest.components ?? []).flatMap((component) => {
701
+ if (component.kind === 'parameter') {
702
+ const converted = openApiParameterToSwagger(component.schema, sourceKind, `#/components/parameters/${pointerToken(component.name)}`, warnings);
703
+ return converted === undefined ? [] : [{ ...component, schema: converted, overlay: rewriteOverlaysVersion(component.overlay, sourceKind, '2.0') }];
704
+ }
705
+ if (component.kind === 'response') return [{ ...component, schema: openApiResponseToSwagger(component.schema, sourceKind, `#/components/responses/${pointerToken(component.name)}`, warnings), overlay: rewriteOverlaysVersion(component.overlay, sourceKind, '2.0') }];
706
+ return [{ ...component, schema: rewriteSchemaVersion(component.schema, sourceKind, '2.0'), overlay: rewriteOverlaysVersion(component.overlay, sourceKind, '2.0') }];
707
+ });
708
+ const securitySchemes = Object.fromEntries(Object.entries(manifest.securitySchemes ?? {}).flatMap(([name, scheme]) => {
709
+ const converted = openApiSecuritySchemeToSwagger(scheme, `#/components/securitySchemes/${pointerToken(name)}`, warnings);
710
+ return converted === undefined ? [] : [[name, converted]];
711
+ }));
712
+ const pathsOverlay = manifest.pathsOverlay === undefined ? undefined : Object.fromEntries(Object.entries(manifest.pathsOverlay).map(([path, metadata]) => {
713
+ if (!isRecord(metadata)) return [path, metadata];
714
+ const rewritten: Record<string, unknown> = { ...Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'parameters' && !(key === '$ref' && expandPaths.has(path)))) };
715
+ if (Array.isArray(metadata.parameters)) {
716
+ const parameters = metadata.parameters.map((parameter, index) => openApiParameterToSwagger(parameter, sourceKind, `#/paths/${pointerToken(path)}/parameters/${index}`, warnings)).filter((parameter): parameter is Record<string, any> => parameter !== undefined);
717
+ if (parameters.length) rewritten.parameters = parameters;
718
+ }
719
+ // An expanded `$ref` entry must not fall back to the un-stripped metadata when
720
+ // stripping emptied the overlay — an empty object restores just the inline operation.
721
+ return [path, Object.keys(rewritten).length || expandPaths.has(path) ? rewritten : metadata];
722
+ }));
723
+ return {
724
+ ...manifest,
725
+ source: { ...manifest.source, kind: 'swagger-2.0', openapiVersion: '2.0' },
726
+ documentOverlay,
727
+ pathsOverlay,
728
+ ...(host === undefined && basePath === '/' ? { servers: undefined } : { servers: [basePath] }),
729
+ ...(host === undefined ? {} : { swaggerHost: host }),
730
+ ...(schemes === undefined ? {} : { swaggerSchemes: schemes }),
731
+ ...(sortedConsumes.length ? { swaggerConsumes: sortedConsumes } : { swaggerConsumes: ['application/json'] }),
732
+ ...(sortedProduces.length ? { swaggerProduces: sortedProduces } : { swaggerProduces: ['application/json'] }),
733
+ ...(Object.keys(swaggerParameters).length ? { swaggerParameters } : {}),
734
+ ...(Object.keys(swaggerResponses).length ? { swaggerResponses } : {}),
735
+ components,
736
+ componentsOverlay: undefined,
737
+ ...(Object.keys(securitySchemes).length ? { securitySchemes } : { securitySchemes: undefined }),
738
+ apis,
739
+ webhooks: undefined,
740
+ webhookOrder: undefined,
741
+ webhooksOverlay: undefined,
742
+ dialectDowngraded: true,
743
+ };
744
+ }
745
+
746
+ function manifestForOutputVersion(manifest: ZopiaManifest, version: OpenApiReverseVersion | undefined, warnings?: ZopiaWarningCollector): ZopiaManifest {
747
+ if (version === undefined) return manifest;
748
+ const sourceKind = manifest.source.kind;
749
+ if (version === '2.0') return sourceKind === 'swagger-2.0' ? manifest : manifestToSwaggerDialect(manifest, warnings);
750
+ if (version === '3.0' && sourceKind === 'openapi-3.1') {
751
+ if ((manifest.webhooks?.length ?? 0) > 0 || manifest.documentOverlay?.webhooks !== undefined) warnings?.add({ code: 'ZOPIA_WARN_WEBHOOKS', at: '#/webhooks', message: 'webhooks are omitted because OpenAPI 3.0 cannot represent them' });
752
+ if (manifest.documentOverlay?.jsonSchemaDialect !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/jsonSchemaDialect', message: 'jsonSchemaDialect is omitted from OpenAPI 3.0 output' });
753
+ if (manifest.componentsOverlay?.pathItems !== undefined) warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: '#/components/pathItems', message: 'components.pathItems is omitted from OpenAPI 3.0 output' });
754
+ }
755
+ const targetKind = version === '3.0' ? 'openapi-3.0' : 'openapi-3.1';
756
+ const components = (manifest.components ?? []).map((component) => ({
757
+ ...component,
758
+ schema: rewriteSchemaVersion(component.schema, sourceKind, version),
759
+ overlay: rewriteOverlaysVersion(component.overlay, sourceKind, version),
760
+ }));
761
+ const documentOverlay = version === '3.0' ? Object.fromEntries(Object.entries(manifest.documentOverlay ?? {}).filter(([key]) => key !== 'webhooks' && key !== 'jsonSchemaDialect')) : manifest.documentOverlay;
762
+ if (sourceKind !== 'swagger-2.0') {
763
+ const rewrittenComponents = manifest.componentsOverlay === undefined ? undefined : rewriteOperationSchemas(manifest.componentsOverlay, sourceKind, version) as Record<string, unknown>;
764
+ const componentsOverlay = version === '3.0' && rewrittenComponents ? Object.fromEntries(Object.entries(rewrittenComponents).filter(([key]) => key !== 'pathItems')) : rewrittenComponents;
765
+ const rewrittenPaths = manifest.pathsOverlay === undefined ? undefined : rewriteOperationSchemas(manifest.pathsOverlay, sourceKind, version) as Record<string, unknown>;
766
+ // A path-item $ref into a container OpenAPI 3.0 drops (`components.pathItems`,
767
+ // or the whole `webhooks` section) must expand in place; refs into surviving
768
+ // containers (for example another path) stay verbatim for fidelity.
769
+ const expandPaths = new Set(version === '3.0' && sourceKind === 'openapi-3.1'
770
+ ? Object.entries(manifest.pathsOverlay ?? {}).filter(([, metadata]) => isRecord(metadata) && isDroppedDialectRef(metadata.$ref)).map(([path]) => path)
771
+ : []);
772
+ const pathsOverlay = rewrittenPaths ? Object.fromEntries(Object.entries(rewrittenPaths).map(([path, metadata]) => [path, isRecord(metadata) && expandPaths.has(path) ? Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== '$ref')) : metadata])) : rewrittenPaths;
773
+ const dropWebhooks = version === '3.0' && sourceKind === 'openapi-3.1';
774
+ return {
775
+ ...manifest,
776
+ source: { ...manifest.source, kind: targetKind, openapiVersion: `${version}.0` },
777
+ documentOverlay,
778
+ pathsOverlay,
779
+ components,
780
+ componentsOverlay,
781
+ apis: manifest.apis.map((api) => ({
782
+ ...api,
783
+ ...(expandPaths.has(api.path) && api.pathItemRef === true ? { pathItemRef: false } : {}),
784
+ sourceOperation: api.sourceOperation === undefined ? undefined : rewriteOperationSchemas(api.sourceOperation, sourceKind, version) as Record<string, any>,
785
+ overlay: rewriteOverlaysVersion(api.overlay, sourceKind, version),
786
+ })),
787
+ ...(dropWebhooks ? { webhooks: undefined, webhookOrder: undefined, webhooksOverlay: undefined } : {
788
+ ...(manifest.webhooks === undefined ? {} : {
789
+ webhooks: manifest.webhooks.map((webhook) => ({
790
+ ...webhook,
791
+ sourceOperation: webhook.sourceOperation === undefined ? undefined : rewriteOperationSchemas(webhook.sourceOperation, sourceKind, version) as Record<string, any>,
792
+ overlay: rewriteOverlaysVersion(webhook.overlay, sourceKind, version),
793
+ })),
794
+ }),
795
+ ...(manifest.webhooksOverlay === undefined ? {} : { webhooksOverlay: rewriteOperationSchemas(manifest.webhooksOverlay, sourceKind, version) as Record<string, unknown> }),
796
+ }),
797
+ };
798
+ }
799
+ const basePath = typeof manifest.servers?.[0] === 'string' ? manifest.servers[0] : '/';
800
+ const host = manifest.swaggerHost;
801
+ const schemes = manifest.swaggerSchemes?.length ? manifest.swaggerSchemes : ['https'];
802
+ const servers = host ? schemes.map((scheme) => ({ url: `${scheme}://${host}${basePath === '/' ? '' : basePath}` })) : [{ url: basePath }];
803
+ const reusableParameters = Object.fromEntries(Object.entries(manifest.swaggerParameters ?? {}).filter(([, parameter]) => !isRecord(parameter) || parameter.in !== 'body' && parameter.in !== 'formData').map(([name, parameter]) => [name, isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version) : parameter]));
804
+ const responseTypes = manifest.swaggerProduces?.length ? manifest.swaggerProduces : ['application/json'];
805
+ const reusableResponses = Object.fromEntries(Object.entries(manifest.swaggerResponses ?? {}).map(([name, response]) => [name, swaggerResponseToOpenApi(response, responseTypes, version)]));
806
+ const componentsOverlay = { ...(manifest.componentsOverlay ?? {}), ...(Object.keys(reusableParameters).length ? { parameters: reusableParameters } : {}), ...(Object.keys(reusableResponses).length ? { responses: reusableResponses } : {}) };
807
+ const apis = manifest.apis.map((api) => {
808
+ const sourceOperation = api.sourceOperation === undefined ? undefined : swaggerOperationToOpenApi(api.sourceOperation, manifest, version);
809
+ const responseOverlay = sourceOperation === undefined ? api.responseOverlay : Object.entries(sourceOperation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []);
810
+ return { ...api, sourceOperation, refs: sourceOperation === undefined ? api.refs : collectManifestRefs(sourceOperation), overlay: rewriteOverlaysVersion(api.overlay, sourceKind, version), responseOverlay };
811
+ });
812
+ return {
813
+ ...manifest,
814
+ source: { ...manifest.source, kind: targetKind, openapiVersion: `${version}.0` },
815
+ documentOverlay,
816
+ pathsOverlay: swaggerPathsOverlayToOpenApi(manifest.pathsOverlay, version),
817
+ servers,
818
+ components,
819
+ componentsOverlay,
820
+ securitySchemes: Object.fromEntries(Object.entries(manifest.securitySchemes ?? {}).map(([name, scheme]) => [name, swaggerSecuritySchemeToOpenApi(scheme)])),
821
+ apis,
822
+ };
823
+ }
824
+
825
+ function sweepSwaggerDowngradeDocument(document: Record<string, any>): void {
826
+ const rewrite = (schema: unknown): unknown => rewriteSchemaVersion(schema, 'openapi-3.1', '2.0');
827
+ const sweepParameter = (parameter: unknown): void => {
828
+ if (!isRecord(parameter) || typeof parameter.$ref === 'string') return;
829
+ if (parameter.schema !== undefined) parameter.schema = rewrite(parameter.schema);
830
+ };
831
+ const sweepResponses = (responses: unknown): void => {
832
+ if (!isRecord(responses)) return;
833
+ for (const response of Object.values(responses)) {
834
+ if (!isRecord(response) || typeof response.$ref === 'string') continue;
835
+ if (response.schema !== undefined) response.schema = rewrite(response.schema);
836
+ }
837
+ };
838
+ if (isRecord(document.definitions)) for (const name of Object.keys(document.definitions)) document.definitions[name] = rewrite(document.definitions[name]);
839
+ if (isRecord(document.paths)) for (const pathItem of Object.values(document.paths)) {
840
+ if (!isRecord(pathItem)) continue;
841
+ for (const value of Object.values(pathItem)) {
842
+ if (!isRecord(value)) continue;
843
+ if (Array.isArray(value.parameters)) for (const parameter of value.parameters) sweepParameter(parameter);
844
+ sweepResponses(value.responses);
845
+ }
846
+ }
847
+ if (isRecord(document.parameters)) for (const parameter of Object.values(document.parameters)) sweepParameter(parameter);
848
+ sweepResponses(isRecord(document.responses) ? document.responses : undefined);
849
+ }
850
+
851
+ async function manifestFileToOpenApiInternal(file: string, version: OpenApiReverseVersion | undefined, warnings: ZopiaWarningCollector): Promise<Record<string, unknown>> {
852
+ if (typeof file !== 'string' || !file) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'manifest file path is required', { at: 'file', hint: 'provide the .zopia-manifest.json path' });
853
+ let source: string;
854
+ try { source = await readFile(file, 'utf8'); }
855
+ catch (error) {
856
+ if (isMissingFileError(error)) throw new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `manifest file not found: ${file}`, { at: file, hint: 'generate api docs first or pass the manifest path' });
857
+ throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to read manifest file', { at: file, hint: 'check that the manifest is readable JSON' });
858
+ }
859
+ let parsed: unknown;
860
+ try { parsed = JSON.parse(source); }
861
+ catch (error) { throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'Invalid manifest file', { at: file, hint: 'fix the manifest JSON syntax or regenerate the tree' }); }
862
+ const manifest = parsed as ZopiaManifest;
863
+ reconstructOpenApi(manifest, undefined, undefined, undefined, warnings);
864
+ const outputManifest = manifestForOutputVersion(manifest, version, warnings);
865
+ const root = await realpath(dirname(resolve(file)));
866
+ await validateManifestFiles(manifest, root);
867
+ const modules = new Map<string, Record<string, unknown>>();
868
+ const endpointConfigs = await importEndpointConfigs(outputManifest, root, modules);
869
+ const webhookConfigs = await importWebhookConfigs(outputManifest, root, modules);
870
+ const components = await importComponentSchemas(outputManifest, root, modules, warnings);
871
+ const document = reconstructOpenApi(outputManifest, endpointConfigs, components.schemas, components.references, warnings, webhookConfigs);
872
+ if (outputManifest.dialectDowngraded === true) sweepSwaggerDowngradeDocument(document);
873
+ return document;
874
+ }
875
+
876
+ /**
877
+ * Read a manifest, import its generated modules, and reconstruct the API document.
878
+ *
879
+ * @param file Filesystem path to a generated Zopia manifest.
880
+ * @param options Target dialect and warning handling configuration.
881
+ * @returns Reconstructed Swagger/OpenAPI document.
882
+ * @throws {@link ZopiaError} when the manifest or a generated module is invalid.
883
+ */
884
+ export async function manifestFileToOpenApi(file: string, options?: ZopiaReverseOptions): Promise<Record<string, unknown>> {
885
+ try {
886
+ const version = reverseVersion(options);
887
+ const warnings = new ZopiaWarningCollector();
888
+ const openapi = await manifestFileToOpenApiInternal(file, version, warnings);
889
+ emitReverseWarnings(options, warnings);
890
+ return openapi;
891
+ } catch (error) {
892
+ throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to reconstruct API from manifest', { at: typeof file === 'string' ? file : 'file' });
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Reconstruct an API document from in-memory manifest snapshots without importing files.
898
+ *
899
+ * @param manifest Valid Zopia manifest containing source snapshots.
900
+ * @param options Target dialect and warning handling configuration.
901
+ * @returns Reconstructed Swagger/OpenAPI document.
902
+ * @throws {@link ZopiaError} when the manifest or target version is invalid.
903
+ */
904
+ export function manifestToOpenApi(manifest: ZopiaManifest, options?: ZopiaReverseOptions): Record<string, unknown> {
905
+ try {
906
+ const version = reverseVersion(options);
907
+ const warnings = new ZopiaWarningCollector();
908
+ const rewritten = manifestForOutputVersion(manifest, version, warnings);
909
+ const openapi = reconstructOpenApi(rewritten, undefined, undefined, undefined, warnings);
910
+ if (rewritten.dialectDowngraded === true) sweepSwaggerDowngradeDocument(openapi);
911
+ emitReverseWarnings(options, warnings);
912
+ return openapi;
913
+ } catch (error) {
914
+ throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to reconstruct API from manifest', { at: '#' });
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Convert a generated api-docs directory or manifest path to OpenAPI.
920
+ *
921
+ * @param path Generated api-docs directory or manifest JSON path.
922
+ * @param options Target dialect and warning handling configuration.
923
+ * @returns Reconstructed document and deterministically ordered warnings.
924
+ * @throws {@link ZopiaError} when the generated tree cannot be reconstructed.
925
+ */
926
+ export async function apiDocsToOpenApi(path: string, options: ZopiaReverseOptions = {}): Promise<ZopiaReverseResult> {
927
+ try { return await apiDocsToOpenApiInternal(path, options); }
928
+ catch (error) { throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to reconstruct API from api-docs', { at: typeof path === 'string' ? path : 'path' }); }
929
+ }
930
+
931
+ async function apiDocsToOpenApiInternal(path: string, options: ZopiaReverseOptions): Promise<ZopiaReverseResult> {
932
+ if (typeof path !== 'string' || !path) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'api-docs path is required', { at: 'path', hint: 'provide an api-docs directory or manifest path' });
933
+ const version = reverseVersion(options) ?? '3.1';
934
+ let manifestFile = path;
935
+ try { if ((await stat(path)).isDirectory()) manifestFile = resolve(path, ZOPIA_MANIFEST_FILE); }
936
+ catch (error) {
937
+ if (!isMissingFileError(error)) throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to inspect api-docs path', { at: path, hint: 'check that the path is readable' });
938
+ if (!path.toLowerCase().endsWith('.json')) manifestFile = resolve(path, ZOPIA_MANIFEST_FILE);
939
+ }
940
+ const warnings = new ZopiaWarningCollector();
941
+ const openapi = await manifestFileToOpenApiInternal(manifestFile, version, warnings);
942
+ const emitted = emitReverseWarnings(options, warnings);
943
+ return { openapi, warnings: emitted };
944
+ }
945
+
946
+ function schemaKind(schema: ComponentSchema): unknown { return (schema as any)?._zod?.def?.type; }
947
+
948
+ function referenceUri(manifest: ZopiaManifest, name: string, kind?: "schema" | "parameter" | "response"): string {
949
+ const root = kind === 'parameter' ? (manifest.source.kind === 'swagger-2.0' ? '#/parameters/' : '#/components/parameters/')
950
+ : kind === 'response' ? (manifest.source.kind === 'swagger-2.0' ? '#/responses/' : '#/components/responses/')
951
+ : manifest.source.kind === 'swagger-2.0' ? '#/definitions/' : '#/components/schemas/';
952
+ return `${root}${name.replace(/~/g, '~0').replace(/\//g, '~1')}`;
953
+ }
954
+
955
+ function convertRuntimeSchema(schema: unknown, io: 'input' | 'output', manifest: ZopiaManifest, references: Array<readonly [string, ComponentSchema]>, context: string, warningAt: string, warnings?: ZopiaWarningCollector): Record<string, any> {
956
+ if (!isComponentSchema(schema)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint ${context} schema`);
957
+ const direct = references.find(([, candidate]) => candidate === schema);
958
+ if (direct) return { $ref: referenceUri(manifest, direct[0]) };
959
+ let name = '__zopia_runtime_schema__';
960
+ const names = new Set(references.map(([candidate]) => candidate));
961
+ while (names.has(name)) name += '_';
962
+ const target = manifest.source.kind === 'swagger-2.0' ? 'draft-4' : manifest.source.kind === 'openapi-3.0' ? 'openapi-3.0' : 'openapi-3.1';
963
+ try {
964
+ const runtimePointer = `#/${pointerToken(name)}`;
965
+ const converted = zodSchemasToJsonSchema([...references, [name, schema]], { target, $schema: false, io, onWarning: (warning) => {
966
+ if (warning.at === undefined) warnings?.addRebased([warning], warningAt);
967
+ else if (warning.at === runtimePointer || warning.at.startsWith(`${runtimePointer}/`)) {
968
+ const suffix = warning.at.slice(runtimePointer.length);
969
+ warnings?.addRebased([{ ...warning, at: suffix ? `#${suffix}` : '#' }], warningAt);
970
+ }
971
+ } }, (component) => referenceUri(manifest, component));
972
+ const result = converted[name];
973
+ if (!result) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'schema conversion produced no output');
974
+ return normalizeRuntimeSchema(result, manifest.source.kind);
975
+ } catch (error) {
976
+ throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Unable to convert generated endpoint ${context} schema: ${error instanceof Error ? error.message : String(error)}`, { at: warningAt, cause: error });
977
+ }
978
+ }
979
+
980
+ function normalizeRuntimeSchema(value: Record<string, any>, outputKind?: string): Record<string, any> {
981
+ const visit = (node: unknown): unknown => {
982
+ if (Array.isArray(node)) return node.map(visit);
983
+ if (!isRecord(node)) return node;
984
+ const normalized = Object.fromEntries(Object.entries(node).map(([key, child]) => [key, visit(child)]));
985
+ for (const keyword of ['anyOf', 'oneOf'] as const) {
986
+ const variants = normalized[keyword];
987
+ if (Array.isArray(variants) && variants.length > 0 && variants.every((variant) => isRecord(variant) && Object.prototype.hasOwnProperty.call(variant, 'const'))) {
988
+ normalized.enum = variants.map((variant) => variant.const);
989
+ delete normalized[keyword];
990
+ }
991
+ }
992
+ if (Array.isArray(normalized.allOf) && normalized.allOf.length > 0 && normalized.allOf.every(isRecord)) {
993
+ const branches = normalized.allOf as Record<string, unknown>[];
994
+ const branchTypes = new Set(branches.map((branch) => branch.type).filter((type): type is string => typeof type === 'string'));
995
+ const scalarType = branchTypes.size === 1 && ['string', 'number', 'integer', 'boolean', 'null'].includes([...branchTypes][0]);
996
+ if (scalarType) {
997
+ const merged: Record<string, unknown> = {};
998
+ let compatible = true;
999
+ for (const branch of branches) for (const [key, child] of Object.entries(branch)) {
1000
+ if (Object.prototype.hasOwnProperty.call(merged, key) && !sameSchema(merged[key], child)) { compatible = false; break; }
1001
+ merged[key] = child;
1002
+ }
1003
+ if (compatible) { delete normalized.allOf; Object.assign(normalized, merged); }
1004
+ }
1005
+ }
1006
+ if (outputKind === 'openapi-3.0' && Array.isArray(normalized.anyOf) && normalized.anyOf.length === 2) {
1007
+ const nullIndex = normalized.anyOf.findIndex((variant: unknown) => isRecord(variant) && (variant.type === 'null' || variant.nullable === true && Array.isArray(variant.enum) && variant.enum.length === 1 && variant.enum[0] === null));
1008
+ if (nullIndex >= 0) {
1009
+ const nonNull = normalized.anyOf[nullIndex === 0 ? 1 : 0];
1010
+ if (isRecord(nonNull)) {
1011
+ delete normalized.anyOf;
1012
+ if (typeof nonNull.$ref === 'string') {
1013
+ const { $ref, ...siblings } = nonNull;
1014
+ Object.assign(normalized, siblings, { allOf: [{ $ref }], nullable: true });
1015
+ } else Object.assign(normalized, nonNull, { nullable: true });
1016
+ }
1017
+ }
1018
+ }
1019
+ if (normalized.minimum === -9007199254740991) delete normalized.minimum;
1020
+ if (normalized.maximum === 9007199254740991) delete normalized.maximum;
1021
+ return normalized;
1022
+ };
1023
+ return visit(value) as Record<string, any>;
1024
+ }
1025
+
1026
+ function manifestPointerTokens(pointer: unknown, kind: string): string[] {
1027
+ if (typeof pointer !== 'string' || pointer !== '' && !pointer.startsWith('/')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${kind} pointer: ${String(pointer)}`);
1028
+ if (pointer === '') return [];
1029
+ return pointer.slice(1).split('/').map((token) => decodeJsonPointerSegment(token, pointer));
1030
+ }
1031
+
1032
+ function legacyPointerTokens(root: unknown, pointer: string): string[] | undefined {
1033
+ if (!pointer.startsWith('/')) return undefined;
1034
+ const parts = pointer.slice(1).split('/').map((token) => decodeJsonPointerSegment(token, pointer));
1035
+ const visit = (value: unknown, index: number): string[] | undefined => {
1036
+ if (index === parts.length) return [];
1037
+ if (Array.isArray(value)) {
1038
+ const token = parts[index];
1039
+ if (!/^(?:0|[1-9]\d*)$/.test(token) || Number(token) >= value.length) return undefined;
1040
+ const tail = visit(value[Number(token)], index + 1);
1041
+ return tail === undefined ? undefined : [token, ...tail];
1042
+ }
1043
+ if (!isRecord(value)) return undefined;
1044
+ for (let end = parts.length; end > index; end -= 1) {
1045
+ const key = parts.slice(index, end).join('/');
1046
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1047
+ const tail = visit(value[key], end);
1048
+ if (tail !== undefined) return [key, ...tail];
1049
+ }
1050
+ return undefined;
1051
+ };
1052
+ const tokens = visit(root, 0);
1053
+ if (tokens === undefined) return undefined;
1054
+ return tokens;
1055
+ }
1056
+
1057
+ function pointerValue(root: unknown, tokens: string[]): unknown {
1058
+ let value = root;
1059
+ for (const token of tokens) {
1060
+ if (Array.isArray(value)) {
1061
+ if (!/^(?:0|[1-9]\d*)$/.test(token) || Number(token) >= value.length) return undefined;
1062
+ value = value[Number(token)];
1063
+ } else if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, token)) value = value[token];
1064
+ else return undefined;
1065
+ }
1066
+ return value;
1067
+ }
1068
+
1069
+ function setPointerValue(root: unknown, tokens: string[], value: unknown): boolean {
1070
+ if (tokens.length === 0) return false;
1071
+ const parent = pointerValue(root, tokens.slice(0, -1));
1072
+ const key = tokens[tokens.length - 1];
1073
+ if (Array.isArray(parent)) {
1074
+ if (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= parent.length) return false;
1075
+ parent[Number(key)] = value;
1076
+ return true;
1077
+ }
1078
+ if (!isRecord(parent)) return false;
1079
+ Object.defineProperty(parent, key, { value, enumerable: true, configurable: true, writable: true });
1080
+ return true;
1081
+ }
1082
+
1083
+ function pointerStartsWith(tokens: string[], prefix: string[]): boolean {
1084
+ return prefix.length <= tokens.length && prefix.every((token, index) => tokens[index] === token);
1085
+ }
1086
+
1087
+ function applyManifestRefs(operation: Record<string, any>, sourceOperation: Record<string, any>, api: ZopiaManifest['apis'][number], manifest: ZopiaManifest): string[][] {
1088
+ if (api.refs === undefined) return [];
1089
+ if (!Array.isArray(api.refs)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest refs');
1090
+ const skipped: string[][] = [];
1091
+ for (const entry of api.refs) {
1092
+ if (!isRecord(entry)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest ref entry');
1093
+ let tokens = manifestPointerTokens(entry.at, 'ref');
1094
+ if (typeof entry.at === 'string' && pointerValue(sourceOperation, tokens) === undefined) tokens = legacyPointerTokens(sourceOperation, entry.at) ?? tokens;
1095
+ const ref = typeof entry.ref === 'string' && entry.ref
1096
+ ? entry.ref
1097
+ : typeof entry.component === 'string' && entry.component ? referenceUri(manifest, entry.component) : undefined;
1098
+ if (ref === undefined || ref !== '#' && !ref.startsWith('#/')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ref: ${String(entry.ref ?? entry.component)}`);
1099
+ if (ref.startsWith('#/')) manifestPointerTokens(ref.slice(1), 'ref target');
1100
+ const nodeTokens = tokens[tokens.length - 1] === '$ref' ? tokens.slice(0, -1) : tokens;
1101
+ if (nodeTokens.length === 0) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ref pointer: ${String(entry.at)}`);
1102
+ const runtimeNode = pointerValue(operation, nodeTokens);
1103
+ if (!isRecord(runtimeNode)) continue;
1104
+ if (typeof runtimeNode.$ref === 'string' && runtimeNode.$ref !== ref) { skipped.push(nodeTokens); continue; }
1105
+ const sourceNode = pointerValue(sourceOperation, nodeTokens);
1106
+ const replacement = isRecord(sourceNode) && sourceNode.$ref === ref ? { ...sourceNode } : { $ref: ref };
1107
+ setPointerValue(operation, nodeTokens, replacement);
1108
+ }
1109
+ return skipped;
1110
+ }
1111
+
1112
+ function overlayMatchesEditableStructure(target: unknown, source: unknown, frozenNode = false, sourceKind?: string, io?: 'input' | 'output'): boolean {
1113
+ if (isRecord(source) && source.type === 'array' && Array.isArray(source.items)) {
1114
+ if (!isRecord(target)) return false;
1115
+ const generatedTuple = Array.isArray(target.prefixItems) ? target.prefixItems : Array.isArray(target.items) ? target.items : undefined;
1116
+ if (generatedTuple === undefined) return false;
1117
+ const restoredPrefix = restoreSourceSchemaStructure(JSON.parse(JSON.stringify(generatedTuple)), source.items);
1118
+ if (!sameSchema(restoredPrefix, source.items)) return false;
1119
+ }
1120
+ if (!frozenNode || !isRecord(source) || sourceKind === undefined) return true;
1121
+ const containsReference = (value: unknown): boolean => Array.isArray(value)
1122
+ ? value.some(containsReference)
1123
+ : isRecord(value) && (typeof value.$ref === 'string' || Object.entries(value).some(([key, child]) => !['example', 'examples', 'default', 'enum', 'const'].includes(key) && !key.startsWith('x-') && containsReference(child)));
1124
+ if (containsReference(source)) return true;
1125
+ try {
1126
+ const targetDialect = sourceKind === 'swagger-2.0' ? 'draft-4' : sourceKind === 'openapi-3.0' ? 'openapi-3.0' : 'openapi-3.1';
1127
+ const runtime = jsonSchemaToZod(source as any).schema;
1128
+ const baseline = zodSchemasToJsonSchema([['__zopia_overlay_baseline__', runtime]], { target: targetDialect, $schema: false, ...(io === undefined ? {} : { io }) }).__zopia_overlay_baseline__;
1129
+ if (!baseline) return true;
1130
+ return sameSchema(restoreSourceSchemaStructure(normalizeRuntimeSchema(baseline, sourceKind), source), target);
1131
+ } catch { return true; }
1132
+ }
1133
+
1134
+ function applySchemaOverlayList(value: unknown, overlay: unknown, context: string, source?: unknown, sourceKind?: string): unknown {
1135
+ if (overlay === undefined) return value;
1136
+ if (!Array.isArray(overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${context} overlays`);
1137
+ let result = value;
1138
+ for (const entry of overlay) {
1139
+ if (!isRecord(entry) || typeof entry.at !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${context} overlay entry`);
1140
+ const tokens = manifestPointerTokens(entry.at, `${context} overlay`);
1141
+ const overlayTarget = tokens.length === 0 ? result : pointerValue(result, tokens);
1142
+ const sourceTarget = tokens.length === 0 ? source : pointerValue(source, tokens);
1143
+ const hasNode = Object.prototype.hasOwnProperty.call(entry, 'node');
1144
+ if (!overlayMatchesEditableStructure(overlayTarget, sourceTarget, hasNode, sourceKind)) continue;
1145
+ if (entry.set !== undefined && !isRecord(entry.set)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${context} overlay set`);
1146
+ if (entry.remove !== undefined && (!Array.isArray(entry.remove) || !entry.remove.every((key: unknown) => typeof key === 'string'))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${context} overlay remove`);
1147
+ if (!hasNode && entry.set === undefined && entry.remove === undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest ${context} overlay entry`);
1148
+ if (hasNode) {
1149
+ if (tokens.length === 0) result = entry.node;
1150
+ else setPointerValue(result, tokens, entry.node);
1151
+ continue;
1152
+ }
1153
+ const target = tokens.length === 0 ? result : pointerValue(result, tokens);
1154
+ if (!isRecord(target)) continue;
1155
+ for (const key of entry.remove ?? []) delete target[key];
1156
+ for (const [key, replacement] of Object.entries(entry.set ?? {})) Object.defineProperty(target, key, { value: replacement, enumerable: true, configurable: true, writable: true });
1157
+ }
1158
+ return result;
1159
+ }
1160
+
1161
+ function applyManifestSchemaOverlays(operation: Record<string, any>, api: ZopiaManifest['apis'][number], skippedRefs: string[][], sourceKind: string): Record<string, any> {
1162
+ if (api.overlay === undefined) return operation;
1163
+ if (!Array.isArray(api.overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema overlays');
1164
+ let result = operation;
1165
+ for (const entry of api.overlay) {
1166
+ if (!isRecord(entry)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema overlay entry');
1167
+ if (typeof entry.key === 'string' && Object.prototype.hasOwnProperty.call(entry, 'value')) {
1168
+ if (!['callbacks', 'servers', 'externalDocs', 'links'].includes(entry.key)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest operation overlay key: ${entry.key}`);
1169
+ Object.defineProperty(result, entry.key, { value: entry.value, enumerable: true, configurable: true, writable: true });
1170
+ continue;
1171
+ }
1172
+ const tokens = manifestPointerTokens(entry.at, 'schema overlay');
1173
+ if (skippedRefs.some((prefix) => pointerStartsWith(tokens, prefix))) continue;
1174
+ const hasNode = Object.prototype.hasOwnProperty.call(entry, 'node');
1175
+ const io = tokens[0] === 'responses' ? 'output' : 'input';
1176
+ if (!overlayMatchesEditableStructure(pointerValue(result, tokens), pointerValue(api.sourceOperation, tokens), hasNode, sourceKind, io)) continue;
1177
+ if (entry.set !== undefined && !isRecord(entry.set)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema overlay set');
1178
+ if (entry.remove !== undefined && (!Array.isArray(entry.remove) || !entry.remove.every((key: unknown) => typeof key === 'string'))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema overlay remove');
1179
+ if (!hasNode && entry.set === undefined && entry.remove === undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema overlay entry');
1180
+ if (hasNode) {
1181
+ if (tokens.length === 0) {
1182
+ if (!isRecord(entry.node)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest root schema overlay node');
1183
+ result = { ...entry.node };
1184
+ } else setPointerValue(result, tokens, entry.node);
1185
+ continue;
1186
+ }
1187
+ const target = pointerValue(result, tokens);
1188
+ if (!isRecord(target)) continue;
1189
+ for (const key of entry.remove ?? []) delete target[key];
1190
+ for (const [key, value] of Object.entries(entry.set ?? {})) Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
1191
+ }
1192
+ return result;
1193
+ }
1194
+
1195
+ function applyManifestResponseOverlays(operation: Record<string, any>, api: ZopiaManifest['apis'][number]): void {
1196
+ if (api.responseOverlay === undefined) return;
1197
+ const entries: Array<{ status: string; overlay: Record<string, any> }> = [];
1198
+ if (Array.isArray(api.responseOverlay)) {
1199
+ for (const entry of api.responseOverlay) {
1200
+ if (!isRecord(entry) || typeof entry.status !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest response overlay entry');
1201
+ const overlay = isRecord(entry.response) ? entry.response : Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'status'));
1202
+ entries.push({ status: entry.status, overlay });
1203
+ }
1204
+ } else if (isRecord(api.responseOverlay)) {
1205
+ for (const [status, overlay] of Object.entries(api.responseOverlay)) {
1206
+ if (!isRecord(overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest response overlay entry');
1207
+ entries.push({ status, overlay });
1208
+ }
1209
+ } else throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest response overlays');
1210
+ if (!isRecord(operation.responses)) return;
1211
+ for (const { status, overlay } of entries) {
1212
+ if (!Object.prototype.hasOwnProperty.call(operation.responses, status)) continue;
1213
+ const response = operation.responses[status];
1214
+ if (!isRecord(response)) continue;
1215
+ for (const [key, value] of Object.entries(overlay)) {
1216
+ if (key === '$ref' || key === 'schema' || key === 'content' || key === 'examples') continue;
1217
+ Object.defineProperty(response, key, { value, enumerable: true, configurable: true, writable: true });
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ function resolveParameter(parameter: Record<string, any>, manifest: ZopiaManifest, seen = new Set<string>()): Record<string, any> {
1223
+ if (typeof parameter.$ref !== 'string') return parameter;
1224
+ const openApiPrefix = '#/components/parameters/';
1225
+ const swaggerPrefix = '#/parameters/';
1226
+ const source = parameter.$ref.startsWith(openApiPrefix)
1227
+ ? (manifest.componentsOverlay as any)?.parameters?.[decodeJsonPointerSegment(parameter.$ref.slice(openApiPrefix.length), parameter.$ref)]
1228
+ : parameter.$ref.startsWith(swaggerPrefix) ? manifest.swaggerParameters?.[decodeJsonPointerSegment(parameter.$ref.slice(swaggerPrefix.length), parameter.$ref)] : undefined;
1229
+ if (!isRecord(source)) return parameter;
1230
+ const merged = { ...source, ...Object.fromEntries(Object.entries(parameter).filter(([key]) => key !== '$ref')) };
1231
+ // Declaration chains (`#/components/parameters/A` → `B` → concrete) resolve fully, with a cycle guard.
1232
+ if (typeof merged.$ref !== 'string' || seen.has(merged.$ref)) return merged;
1233
+ return resolveParameter(merged, manifest, new Set([...seen, parameter.$ref]));
1234
+ }
1235
+
1236
+ function parameterIdentity(parameter: unknown, manifest: ZopiaManifest): string | undefined {
1237
+ if (!isRecord(parameter)) return undefined;
1238
+ const resolved = resolveParameter(parameter, manifest);
1239
+ return typeof resolved.name === 'string' && typeof resolved.in === 'string' ? `${resolved.in}\0${resolved.name}` : undefined;
1240
+ }
1241
+
1242
+ function removeInheritedPathParameters(operation: Record<string, any>, sourceOperation: Record<string, any>, pathItem: Record<string, any> | undefined, manifest: ZopiaManifest): void {
1243
+ if (!pathItem || !Array.isArray(pathItem.parameters) || !Array.isArray(operation.parameters)) return;
1244
+ const inherited = new Set(pathItem.parameters.map((parameter: unknown) => parameterIdentity(parameter, manifest)).filter((identity): identity is string => identity !== undefined));
1245
+ const declared = new Set((Array.isArray(sourceOperation.parameters) ? sourceOperation.parameters : []).map((parameter: unknown) => parameterIdentity(parameter, manifest)).filter((identity): identity is string => identity !== undefined));
1246
+ operation.parameters = operation.parameters.filter((parameter: unknown) => {
1247
+ const identity = parameterIdentity(parameter, manifest);
1248
+ return identity === undefined || !inherited.has(identity) || declared.has(identity);
1249
+ });
1250
+ if (operation.parameters.length === 0) delete operation.parameters;
1251
+ }
1252
+
1253
+ const SWAGGER_PARAMETER_SCHEMA_KEYS = new Set(['schema', 'content', 'type', 'format', 'items', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'pattern', 'enum', 'default', 'multipleOf', 'minItems', 'maxItems', 'uniqueItems']);
1254
+
1255
+ function swaggerParameterShape(schema: unknown, previous: Record<string, any> = {}, context = 'parameter', downgrade?: { at: string; warnings?: ZopiaWarningCollector }): Record<string, unknown> {
1256
+ const shape = isRecord(schema) ? { ...schema } : {};
1257
+ if (previous.type === 'file' && shape.type === 'string') { shape.type = 'file'; delete shape.format; }
1258
+ if (!['string', 'number', 'integer', 'boolean', 'array', 'file'].includes(String(shape.type)) || Object.prototype.hasOwnProperty.call(shape, '$ref')) {
1259
+ if (downgrade !== undefined) {
1260
+ downgrade.warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: downgrade.at, message: `${context}s degrade to type string because OpenAPI 2.0 parameters cannot represent their schema shape` });
1261
+ return { type: 'string' };
1262
+ }
1263
+ throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Swagger 2.0 ${context} must serialize to a primitive, array, or file schema`);
1264
+ }
1265
+ return shape;
1266
+ }
1267
+
1268
+ function serializeParameters(operation: Record<string, any>, config: EndpointConfig, manifest: ZopiaManifest, references: Array<readonly [string, ComponentSchema]>, operationAt: string, warnings?: ZopiaWarningCollector): Record<string, any>[] {
1269
+ const isSwagger = manifest.source.kind === 'swagger-2.0';
1270
+ const locations = [['path', 'params'], ['query', 'query'], ['header', 'headers'], ['cookie', 'cookies']] as const;
1271
+ const groups = new Map<string, { properties: Record<string, any>; required: Set<string> }>();
1272
+ for (const [location, field] of locations) {
1273
+ if (!isComponentSchema(config.request[field]) || schemaKind(config.request[field]) !== 'object') throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint ${location} parameters schema`);
1274
+ const schema = convertRuntimeSchema(config.request[field], 'input', manifest, references, `${location} parameters`, `${operationAt}/parameters/${location}`, warnings);
1275
+ const properties = isRecord(schema.properties) ? schema.properties : {};
1276
+ groups.set(location, { properties, required: new Set(Array.isArray(schema.required) ? schema.required : []) });
1277
+ }
1278
+ if (isSwagger && Object.keys(groups.get('cookie')!.properties).length > 0) {
1279
+ if (manifest.dialectDowngraded === true) {
1280
+ warnings?.add({ code: 'ZOPIA_WARN_DIALECT_DOWNGRADE', at: `${operationAt}/parameters`, message: 'cookie parameters are omitted because OpenAPI 2.0 cannot represent them' });
1281
+ groups.get('cookie')!.properties = {};
1282
+ groups.get('cookie')!.required = new Set();
1283
+ } else throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', 'Swagger 2.0 does not support cookie parameters');
1284
+ }
1285
+ const used = new Map<string, Set<string>>(locations.map(([location]) => [location, new Set()]));
1286
+ const parameters: Record<string, any>[] = [];
1287
+ for (const raw of Array.isArray(operation.parameters) ? operation.parameters : []) {
1288
+ if (!isRecord(raw)) continue;
1289
+ const parameter = resolveParameter(raw, manifest);
1290
+ if (parameter.in === 'body' || parameter.in === 'formData') continue;
1291
+ if (!['path', 'query', 'header', 'cookie'].includes(parameter.in) || typeof parameter.name !== 'string') { parameters.push(raw); continue; }
1292
+ const group = groups.get(parameter.in)!;
1293
+ if (!Object.prototype.hasOwnProperty.call(group.properties, parameter.name)) continue;
1294
+ let schema = group.properties[parameter.name];
1295
+ const sourceParameterSchema = isSwagger
1296
+ ? parameter
1297
+ : isRecord(parameter.content)
1298
+ ? (Object.values(parameter.content).find(isRecord) as Record<string, any> | undefined)?.schema
1299
+ : parameter.schema;
1300
+ schema = restoreSourceSchemaStructure(schema, sourceParameterSchema);
1301
+ used.get(parameter.in)!.add(parameter.name);
1302
+ const required = parameter.in === 'path' || group.required.has(parameter.name);
1303
+ const requiredField = required ? { required: true } : Object.prototype.hasOwnProperty.call(parameter, 'required') ? { required: false } : {};
1304
+ if (isSwagger) {
1305
+ const metadata = Object.fromEntries(Object.entries(parameter).filter(([key]) => !SWAGGER_PARAMETER_SCHEMA_KEYS.has(key) && key !== '$ref'));
1306
+ parameters.push({ ...metadata, name: parameter.name, in: parameter.in, ...requiredField, ...swaggerParameterShape(schema, parameter, `${parameter.in} parameter ${parameter.name}`, manifest.dialectDowngraded === true ? { at: `${operationAt}/parameters`, warnings } : undefined) });
1307
+ } else if (isRecord(parameter.content) && Object.keys(parameter.content).length) {
1308
+ const [contentType, media] = Object.entries(parameter.content)[0];
1309
+ const mediaValue = isRecord(media) ? media : {};
1310
+ const serializedMedia = !Object.prototype.hasOwnProperty.call(mediaValue, 'schema') && Object.keys(schema).length === 0 ? mediaValue : { ...mediaValue, schema };
1311
+ parameters.push({ ...parameter, name: parameter.name, in: parameter.in, ...requiredField, content: { ...parameter.content, [contentType]: serializedMedia } });
1312
+ } else parameters.push({ ...parameter, name: parameter.name, in: parameter.in, ...requiredField, schema });
1313
+ }
1314
+ for (const [location] of locations) {
1315
+ const group = groups.get(location)!;
1316
+ for (const [name, schema] of Object.entries(group.properties)) if (!used.get(location)!.has(name)) {
1317
+ if (isSwagger) parameters.push({ name, in: location, required: location === 'path' || group.required.has(name), ...swaggerParameterShape(schema, {}, `${location} parameter ${name}`, manifest.dialectDowngraded === true ? { at: `${operationAt}/parameters`, warnings } : undefined) });
1318
+ else parameters.push({ name, in: location, required: location === 'path' || group.required.has(name), schema });
1319
+ }
1320
+ }
1321
+ return parameters;
1322
+ }
1323
+
1324
+ function validateReconstructedPathParameters(path: string, parameters: Record<string, any>[], code: ZopiaErrorCode, subject: string): void {
1325
+ const placeholders = new Set([...path.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
1326
+ const pathParameters = new Map(parameters
1327
+ .filter((parameter) => parameter.in === 'path' && typeof parameter.name === 'string')
1328
+ .map((parameter) => [parameter.name, parameter]));
1329
+ const missing = [...placeholders].find((name) => !pathParameters.has(name));
1330
+ if (missing) throw new ZopiaError(code, `${subject} path parameter is not defined: ${missing}`);
1331
+ const unrelated = [...pathParameters.keys()].find((name) => !placeholders.has(name));
1332
+ if (unrelated) throw new ZopiaError(code, `${subject} path parameter is not present in the template: ${unrelated}`);
1333
+ const optional = [...pathParameters.values()].find((parameter) => parameter.required !== true);
1334
+ if (optional) throw new ZopiaError(code, `${subject} path parameter must be required: ${optional.name}`);
1335
+ }
1336
+
1337
+ function canonicalJson(value: unknown): string | undefined {
1338
+ try {
1339
+ return JSON.stringify(value, (_key, child) => child && typeof child === 'object' && !Array.isArray(child)
1340
+ ? Object.fromEntries(Object.entries(child).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
1341
+ : child);
1342
+ } catch { return undefined; }
1343
+ }
1344
+
1345
+ function sameSchema(left: unknown, right: unknown): boolean {
1346
+ const leftJson = canonicalJson(left);
1347
+ return leftJson !== undefined && leftJson === canonicalJson(right);
1348
+ }
1349
+
1350
+ /** Restore source-only schema structure while leaving semantic developer edits authoritative. */
1351
+ function restoreSourceSchemaStructure(generated: unknown, source: unknown): unknown {
1352
+ if (source === true && isRecord(generated) && Object.keys(generated).length === 0) return true;
1353
+ if (source === false && isRecord(generated) && isRecord(generated.not) && Object.keys(generated).length === 1 && Object.keys(generated.not).length === 0) return false;
1354
+ if (Array.isArray(generated)) {
1355
+ if (Array.isArray(source)) generated.forEach((child, index) => { generated[index] = restoreSourceSchemaStructure(child, source[index]); });
1356
+ return generated;
1357
+ }
1358
+ if (!isRecord(generated) || !isRecord(source)) return generated;
1359
+ if (Array.isArray(source.required) && source.required.every((key: unknown) => typeof key === 'string')) {
1360
+ const generatedRequired = Array.isArray(generated.required) && generated.required.every((key: unknown) => typeof key === 'string') ? generated.required as string[] : [];
1361
+ const sourceRequired = source.required as string[];
1362
+ const sortedGenerated = [...generatedRequired].sort();
1363
+ const sortedSource = [...sourceRequired].sort();
1364
+ if (sortedGenerated.length === sortedSource.length && sortedGenerated.every((key, index) => key === sortedSource[index])) {
1365
+ Object.defineProperty(generated, 'required', { value: [...sourceRequired], enumerable: true, configurable: true, writable: true });
1366
+ }
1367
+ }
1368
+ if (source.type === 'object') {
1369
+ const generatedAdditional = generated.additionalProperties;
1370
+ const generatedIsPassThrough = isRecord(generatedAdditional) && Object.keys(generatedAdditional).length === 0;
1371
+ if (!Object.prototype.hasOwnProperty.call(source, 'additionalProperties') && generatedIsPassThrough) delete generated.additionalProperties;
1372
+ else if (source.additionalProperties === true && generatedIsPassThrough) generated.additionalProperties = true;
1373
+ if (isRecord(source.properties) && Object.keys(source.properties).length === 0 && generated.properties === undefined) generated.properties = {};
1374
+ }
1375
+ for (const keyword of ['$defs', 'definitions'] as const) {
1376
+ if (!isRecord(source[keyword])) continue;
1377
+ if (!isRecord(generated[keyword])) generated[keyword] = {};
1378
+ for (const [name, definition] of Object.entries(source[keyword])) {
1379
+ if (!Object.prototype.hasOwnProperty.call(generated[keyword], name)) generated[keyword][name] = JSON.parse(JSON.stringify(definition));
1380
+ }
1381
+ }
1382
+ for (const [key, child] of Object.entries(generated)) {
1383
+ if (Object.prototype.hasOwnProperty.call(source, key)) generated[key] = restoreSourceSchemaStructure(child, source[key]);
1384
+ }
1385
+ return generated;
1386
+ }
1387
+
1388
+ function mediaExamples(media: Record<string, any>): Record<string, unknown> | undefined {
1389
+ if (isRecord(media.examples)) return media.examples;
1390
+ if (Object.prototype.hasOwnProperty.call(media, 'example')) return { default: { value: media.example } };
1391
+ return undefined;
1392
+ }
1393
+
1394
+ function legacyResponseExamples(examples: unknown): Record<string, unknown> | undefined {
1395
+ return isRecord(examples) ? Object.fromEntries(Object.entries(examples).map(([contentType, value]) => [contentType, { value }])) : undefined;
1396
+ }
1397
+
1398
+ function resolvedRequestBody(operation: Record<string, any>, manifest: ZopiaManifest): Record<string, any> {
1399
+ if (!isRecord(operation.requestBody)) return {};
1400
+ if (typeof operation.requestBody.$ref !== 'string') return operation.requestBody;
1401
+ const prefix = '#/components/requestBodies/';
1402
+ const source = operation.requestBody.$ref.startsWith(prefix) ? (manifest.componentsOverlay as any)?.requestBodies?.[decodeJsonPointerSegment(operation.requestBody.$ref.slice(prefix.length), operation.requestBody.$ref)] : undefined;
1403
+ return isRecord(source) ? { ...source, ...Object.fromEntries(Object.entries(operation.requestBody).filter(([key]) => key !== '$ref')) } : {};
1404
+ }
1405
+
1406
+ function serializeRequestBody(operation: Record<string, any>, config: EndpointConfig, manifest: ZopiaManifest, references: Array<readonly [string, ComponentSchema]>, parameters: Record<string, any>[], operationAt: string, warnings?: ZopiaWarningCollector): void {
1407
+ const isSwagger = manifest.source.kind === 'swagger-2.0';
1408
+ const body = config.request.body;
1409
+ if (!isComponentSchema(body)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', 'Invalid generated endpoint body schema');
1410
+ const originalParameters = isSwagger && Array.isArray(operation.parameters) ? operation.parameters.filter(isRecord) : [];
1411
+ const sourceHasBody = isSwagger
1412
+ ? originalParameters.some((parameter) => ['body', 'formData'].includes(String(resolveParameter(parameter, manifest).in)))
1413
+ : isRecord(operation.requestBody);
1414
+ const previousRequestBody = isSwagger ? {} : resolvedRequestBody(operation, manifest);
1415
+ const sourceContent = isRecord(previousRequestBody.content) ? previousRequestBody.content : {};
1416
+ const sourceType = Object.keys(sourceContent)[0];
1417
+ const sourceMedia = sourceType !== undefined && isRecord(sourceContent[sourceType]) ? sourceContent[sourceType] : undefined;
1418
+ const swaggerBody = isSwagger ? originalParameters.map((parameter) => resolveParameter(parameter, manifest)).find((parameter) => parameter.in === 'body') : undefined;
1419
+ const sourceSchema = isSwagger ? swaggerBody?.schema : sourceMedia?.schema;
1420
+ if (schemaKind(body) === 'any') {
1421
+ if (!sourceHasBody) { delete operation.requestBody; operation.parameters = parameters; return; }
1422
+ if (!isSwagger && sourceMedia !== undefined && !Object.prototype.hasOwnProperty.call(sourceMedia, 'schema')) {
1423
+ operation.requestBody = previousRequestBody;
1424
+ operation.parameters = parameters;
1425
+ return;
1426
+ }
1427
+ if (!sourceSchemaGeneratesAny(sourceSchema, manifest)) { delete operation.requestBody; operation.parameters = parameters; return; }
1428
+ }
1429
+ const warningAt = manifest.source.kind === 'swagger-2.0' ? `${operationAt}/parameters/body/schema` : `${operationAt}/requestBody/schema`;
1430
+ const schema = restoreSourceSchemaStructure(convertRuntimeSchema(body, 'input', manifest, references, 'request body', warningAt, warnings), sourceSchema) as Record<string, any>;
1431
+ const contentType = typeof config.requestContentType === 'string' && config.requestContentType ? config.requestContentType : undefined;
1432
+ if (isSwagger) {
1433
+ const original = (Array.isArray(operation.parameters) ? operation.parameters : []).filter(isRecord);
1434
+ const form = original.filter((parameter) => parameter.in === 'formData');
1435
+ const baselineContentType = (Array.isArray(operation.consumes) ? operation.consumes[0] : undefined) ?? manifest.swaggerConsumes?.[0];
1436
+ const contentTypeEdited = contentType !== undefined && typeof baselineContentType === 'string' && contentType !== baselineContentType;
1437
+ const formContentType = contentType === 'multipart/form-data' || contentType === 'application/x-www-form-urlencoded';
1438
+ if (contentTypeEdited ? formContentType : form.length > 0 || formContentType) {
1439
+ const properties = isRecord((schema as any).properties) ? (schema as any).properties : {};
1440
+ const required = new Set(Array.isArray((schema as any).required) ? (schema as any).required : []);
1441
+ for (const [name, property] of Object.entries(properties)) {
1442
+ const previous = form.find((parameter) => parameter.name === name) ?? {};
1443
+ const metadata = Object.fromEntries(Object.entries(previous).filter(([key]) => !SWAGGER_PARAMETER_SCHEMA_KEYS.has(key)));
1444
+ const requiredField = required.has(name) ? { required: true } : Object.prototype.hasOwnProperty.call(previous, 'required') ? { required: false } : {};
1445
+ parameters.push({ ...metadata, name, in: 'formData', ...requiredField, ...swaggerParameterShape(property, previous, `formData parameter ${name}`, manifest.dialectDowngraded === true ? { at: `${operationAt}/parameters`, warnings } : undefined) });
1446
+ }
1447
+ } else {
1448
+ const previous = original.find((parameter) => parameter.in === 'body') ?? {};
1449
+ const requiredField = previous.required === true ? { required: true } : Object.prototype.hasOwnProperty.call(previous, 'required') ? { required: false } : {};
1450
+ parameters.push({ ...previous, name: typeof previous.name === 'string' ? previous.name : 'body', in: 'body', ...requiredField, schema });
1451
+ }
1452
+ operation.parameters = parameters;
1453
+ if (contentType && (Object.prototype.hasOwnProperty.call(operation, 'consumes') || contentTypeEdited)) {
1454
+ const retainedConsumes = Array.isArray(operation.consumes) ? operation.consumes.filter((value: unknown) => value !== contentType && (!contentTypeEdited || value !== baselineContentType)) : [];
1455
+ operation.consumes = [contentType, ...retainedConsumes];
1456
+ }
1457
+ return;
1458
+ }
1459
+ const previous = resolvedRequestBody(operation, manifest);
1460
+ const previousContent = isRecord(previous.content) ? previous.content : {};
1461
+ const previousType = Object.keys(previousContent)[0];
1462
+ const selectedType = contentType ?? previousType ?? 'application/json';
1463
+ const contentTypeEdited = contentType !== undefined && previousType !== undefined && contentType !== previousType;
1464
+ const primaryMedia = previousType !== undefined && isRecord(previousContent[previousType]) ? previousContent[previousType] : {};
1465
+ const retainedContent = Object.fromEntries(Object.entries(previousContent).filter(([type]) => !contentTypeEdited || type !== previousType).map(([type, media]) => [type, isRecord(media) && type !== selectedType && sameSchema(media.schema, primaryMedia.schema) ? { ...media, schema } : media]));
1466
+ const previousMedia = isRecord(previousContent[selectedType]) ? previousContent[selectedType] : {};
1467
+ const configuredExamples = isRecord(config.examples?.request) ? config.examples.request : undefined;
1468
+ const examplesUnchanged = sameSchema(configuredExamples, mediaExamples(previousMedia));
1469
+ const retainedMedia = examplesUnchanged ? previousMedia : Object.fromEntries(Object.entries(previousMedia).filter(([key]) => key !== 'example' && key !== 'examples'));
1470
+ const examples = !examplesUnchanged && configuredExamples !== undefined ? { examples: configuredExamples } : {};
1471
+ operation.requestBody = { ...previous, content: { ...retainedContent, [selectedType]: { ...retainedMedia, ...examples, schema } } };
1472
+ operation.parameters = parameters;
1473
+ }
1474
+
1475
+ function resolveResponse(response: Record<string, any>, manifest: ZopiaManifest): Record<string, any> {
1476
+ if (typeof response.$ref !== 'string') return response;
1477
+ const openApiPrefix = '#/components/responses/';
1478
+ const swaggerPrefix = '#/responses/';
1479
+ const source = response.$ref.startsWith(openApiPrefix)
1480
+ ? (manifest.componentsOverlay as any)?.responses?.[decodeJsonPointerSegment(response.$ref.slice(openApiPrefix.length), response.$ref)]
1481
+ : response.$ref.startsWith(swaggerPrefix) ? manifest.swaggerResponses?.[decodeJsonPointerSegment(response.$ref.slice(swaggerPrefix.length), response.$ref)] : undefined;
1482
+ return isRecord(source) ? { ...source, ...Object.fromEntries(Object.entries(response).filter(([key]) => key !== '$ref')) } : {};
1483
+ }
1484
+
1485
+ function serializeResponses(operation: Record<string, any>, config: EndpointConfig, manifest: ZopiaManifest, references: Array<readonly [string, ComponentSchema]>, operationAt: string, warnings?: ZopiaWarningCollector): void {
1486
+ const isSwagger = manifest.source.kind === 'swagger-2.0';
1487
+ const original = isRecord(operation.responses) ? operation.responses : {};
1488
+ const responses: Record<string, any> = {};
1489
+ const contentType = typeof config.responseContentType === 'string' && config.responseContentType ? config.responseContentType : undefined;
1490
+ const baselineContentType = isSwagger
1491
+ ? (Array.isArray(operation.produces) ? operation.produces[0] : undefined) ?? manifest.swaggerProduces?.[0]
1492
+ : Object.values(original).map((value) => isRecord(value) && isRecord(value.content) ? Object.keys(value.content)[0] : undefined).find((value) => value !== undefined);
1493
+ const contentTypeEdited = contentType !== undefined && typeof baselineContentType === 'string' && contentType !== baselineContentType;
1494
+ if (Object.keys(config.response).length === 0) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', 'Generated endpoint must define at least one response');
1495
+ for (const [status, runtimeSchema] of Object.entries(config.response)) {
1496
+ if (!isValidResponseStatus(status, isSwagger)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint response status: ${status}`);
1497
+ if (!isComponentSchema(runtimeSchema)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint response schema: ${status}`);
1498
+ const previous = isRecord(original[status]) ? resolveResponse(original[status], manifest) : {};
1499
+ const response: Record<string, any> = { ...previous, description: typeof previous.description === 'string' && previous.description ? previous.description : 'Generated response' };
1500
+ const configuredExamples = config.examples?.response?.[status];
1501
+ if (isSwagger && !sameSchema(configuredExamples, legacyResponseExamples(previous.examples))) {
1502
+ if (isRecord(configuredExamples)) response.examples = Object.fromEntries(Object.entries(configuredExamples).map(([type, example]) => [type, isRecord(example) && Object.prototype.hasOwnProperty.call(example, 'value') ? example.value : example]));
1503
+ else delete response.examples;
1504
+ }
1505
+ if (schemaKind(runtimeSchema) === 'void') {
1506
+ const sourceContent = isRecord(previous.content) ? previous.content : undefined;
1507
+ const sourceType = sourceContent === undefined ? undefined : Object.keys(sourceContent)[0];
1508
+ const sourceMedia = sourceType === undefined || sourceContent === undefined ? undefined : sourceContent[sourceType];
1509
+ const sourceHadSchemaLessContent = sourceContent !== undefined && (sourceType === undefined || isRecord(sourceMedia) && !Object.prototype.hasOwnProperty.call(sourceMedia, 'schema'));
1510
+ if (!sourceHadSchemaLessContent) delete response.content;
1511
+ delete response.schema;
1512
+ responses[status] = response;
1513
+ continue;
1514
+ }
1515
+ const warningAt = manifest.source.kind === 'swagger-2.0' ? `${operationAt}/responses/${pointerToken(status)}/schema` : `${operationAt}/responses/${pointerToken(status)}/content/schema`;
1516
+ let schema: any = convertRuntimeSchema(runtimeSchema, 'output', manifest, references, `response ${status}`, warningAt, warnings);
1517
+ if (isSwagger) {
1518
+ schema = restoreSourceSchemaStructure(schema, previous.schema);
1519
+ response.schema = schema;
1520
+ } else {
1521
+ const previousContent = isRecord(previous.content) ? previous.content : {};
1522
+ const previousType = Object.keys(previousContent)[0];
1523
+ const selectedType = contentTypeEdited ? contentType! : previousType ?? contentType ?? 'application/json';
1524
+ const primaryMedia = previousType !== undefined && isRecord(previousContent[previousType]) ? previousContent[previousType] : {};
1525
+ const retainedContent = Object.fromEntries(Object.entries(previousContent).filter(([type]) => !contentTypeEdited || type !== previousType || previousType === selectedType).map(([type, media]) => [type, isRecord(media) && type !== selectedType && sameSchema(media.schema, primaryMedia.schema) ? { ...media, schema } : media]));
1526
+ const previousMedia = isRecord(previousContent[selectedType]) ? previousContent[selectedType] : {};
1527
+ schema = restoreSourceSchemaStructure(schema, previousMedia.schema);
1528
+ const examplesUnchanged = sameSchema(isRecord(configuredExamples) ? configuredExamples : undefined, mediaExamples(previousMedia));
1529
+ const retainedMedia = examplesUnchanged ? previousMedia : Object.fromEntries(Object.entries(previousMedia).filter(([key]) => key !== 'example' && key !== 'examples'));
1530
+ const runtimeExamples = !examplesUnchanged && isRecord(configuredExamples) ? { examples: configuredExamples } : {};
1531
+ response.content = { ...retainedContent, [selectedType]: { ...retainedMedia, ...runtimeExamples, schema } };
1532
+ }
1533
+ responses[status] = response;
1534
+ }
1535
+ operation.responses = responses;
1536
+ if (isSwagger && contentType && (Object.prototype.hasOwnProperty.call(operation, 'produces') || contentTypeEdited)) {
1537
+ const retainedProduces = Array.isArray(operation.produces) ? operation.produces.filter((value: unknown) => value !== contentType && (!contentTypeEdited || value !== baselineContentType)) : [];
1538
+ operation.produces = [contentType, ...retainedProduces];
1539
+ }
1540
+ }
1541
+
1542
+ function runtimeOperation(api: ZopiaManifest['apis'][number], sourceOperation: Record<string, any>, pathItem: Record<string, any> | undefined, config: EndpointConfig, manifest: ZopiaManifest, references: Array<readonly [string, ComponentSchema]>, warnings?: ZopiaWarningCollector): { path: string; method: string; operation: Record<string, any> } {
1543
+ const method = config.method.toLowerCase();
1544
+ if (!HTTP_METHODS.has(method)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint method: ${String(config.method)}`);
1545
+ let path: unknown;
1546
+ try { path = typeof config.makeOpenApiPathShape === 'function' ? config.makeOpenApiPathShape() : config.pathShape.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}'); }
1547
+ catch (error) { throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Unable to read generated endpoint path ${api.file}: ${error instanceof Error ? error.message : String(error)}`, { at: api.file, cause: error }); }
1548
+ if (typeof path !== 'string' || !path.startsWith('/') || path.includes('?') || path.includes('#') || /[{}]/.test(path) && !/^\/([^{}]|\{[A-Za-z0-9._-]+\})*$/.test(path)) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint path: ${String(path)}`);
1549
+ if (config.operationId !== undefined && (typeof config.operationId !== 'string' || !config.operationId.trim())) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint operationId: ${String(config.operationId)}`);
1550
+ for (const field of ['summary', 'description'] as const) if (config[field] !== undefined && typeof config[field] !== 'string') throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint ${field}: ${String(config[field])}`);
1551
+ if (config.tags !== undefined && (!Array.isArray(config.tags) || !config.tags.every((tag: unknown) => typeof tag === 'string'))) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', 'Invalid generated endpoint tags');
1552
+ if (config.deprecated !== undefined && config.deprecated !== 'YES' && config.deprecated !== 'NO') throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint deprecated status: ${String(config.deprecated)}`);
1553
+ if (config.auth !== undefined && config.auth !== 'YES' && config.auth !== 'NO') throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint auth status: ${String(config.auth)}`);
1554
+
1555
+ let operation = { ...sourceOperation };
1556
+ if (Object.prototype.hasOwnProperty.call(sourceOperation, 'operationId') || config.operationId !== api.operationId) {
1557
+ if (config.operationId === undefined) delete operation.operationId;
1558
+ else operation.operationId = config.operationId;
1559
+ }
1560
+ for (const field of ['summary', 'description'] as const) {
1561
+ const value = config[field];
1562
+ if (Object.prototype.hasOwnProperty.call(sourceOperation, field) || value !== undefined && value !== '') {
1563
+ if (value === undefined) delete operation[field];
1564
+ else operation[field] = value;
1565
+ }
1566
+ }
1567
+ const tags = config.tags?.map((tag: string) => tag.startsWith('#') ? tag.slice(1) : tag);
1568
+ if (Object.prototype.hasOwnProperty.call(sourceOperation, 'tags') || tags?.length) operation.tags = tags ?? [];
1569
+ else delete operation.tags;
1570
+ const deprecated = config.deprecated === 'YES';
1571
+ if (Object.prototype.hasOwnProperty.call(sourceOperation, 'deprecated') || deprecated) operation.deprecated = deprecated;
1572
+ else delete operation.deprecated;
1573
+ for (const field of ['requestContentType', 'responseContentType'] as const) if (config[field] !== undefined && (typeof config[field] !== 'string' || !config[field])) throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `Invalid generated endpoint ${field}: ${String(config[field])}`);
1574
+ const operationAt = `#/paths/${pointerToken(path)}/${method}`;
1575
+ const parameters = serializeParameters(operation, config, manifest, references, operationAt, warnings);
1576
+ validateReconstructedPathParameters(path, parameters, 'ZOPIA_DOCS_IMPORT_FAILED', 'Generated endpoint');
1577
+ serializeRequestBody(operation, config, manifest, references, parameters, operationAt, warnings);
1578
+ serializeResponses(operation, config, manifest, references, operationAt, warnings);
1579
+ if (Array.isArray(operation.parameters) && operation.parameters.length === 0) delete operation.parameters;
1580
+ removeInheritedPathParameters(operation, sourceOperation, pathItem, manifest);
1581
+ const skippedRefs = applyManifestRefs(operation, sourceOperation, api, manifest);
1582
+ operation = applyManifestSchemaOverlays(operation, api, skippedRefs, manifest.source.kind);
1583
+ applyManifestResponseOverlays(operation, api);
1584
+ return { path, method, operation };
1585
+ }
1586
+
1587
+ /** Refresh one reusable parameter/response declaration with the schema converted from its current module (D-18). */
1588
+ function refreshReusableDeclaration(kind: 'parameter' | 'response', container: unknown, name: string, schema: unknown, downgrade?: { at: string; warnings?: ZopiaWarningCollector }): void {
1589
+ if (!isRecord(container)) return;
1590
+ const item = container[name];
1591
+ if (!isRecord(item) || schema === undefined) return;
1592
+ if (typeof item.$ref === 'string' && !Object.prototype.hasOwnProperty.call(item, 'schema') && !Object.prototype.hasOwnProperty.call(item, 'content')) return; // declaration chains stay verbatim; the chain end is refreshed
1593
+ const primaryMedia = isRecord(item.content) ? Object.entries(item.content).find(([, value]) => isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'schema')) : undefined;
1594
+ if (Object.prototype.hasOwnProperty.call(item, 'schema')) { item.schema = schema; return; }
1595
+ if (kind === 'response') {
1596
+ if (primaryMedia) item.content = { ...(item.content as Record<string, unknown>), [primaryMedia[0]]: { ...(primaryMedia[1] as Record<string, unknown>), schema } };
1597
+ return;
1598
+ }
1599
+ if (primaryMedia) { item.content = { ...(item.content as Record<string, unknown>), [primaryMedia[0]]: { ...(primaryMedia[1] as Record<string, unknown>), schema } }; return; }
1600
+ // Swagger 2.0 parameter shape: schema constraints live directly on the parameter object.
1601
+ if (!isRecord(schema)) return;
1602
+ const constraints = downgrade === undefined ? schema : openApiInlineConstraintsToSwagger(schema, 'openapi-3.1', downgrade.at, downgrade.warnings);
1603
+ const next: Record<string, unknown> = {};
1604
+ for (const [key, value] of Object.entries(item)) if (!SWAGGER_PARAMETER_SCHEMA_KEYS.has(key) && key !== 'schema' && key !== 'content') next[key] = value;
1605
+ for (const [key, value] of Object.entries(constraints)) next[key] = value;
1606
+ for (const key of Object.keys(item)) delete item[key];
1607
+ Object.assign(item, next);
1608
+ }
1609
+
1610
+ function reconstructOpenApi(manifest: ZopiaManifest, endpointConfigs = new Map<number, EndpointConfig>(), componentSchemas = new Map<number, unknown>, componentReferences: Array<readonly [string, ComponentSchema]> = [], warnings?: ZopiaWarningCollector, webhookConfigs = new Map<number, EndpointConfig>()): Record<string, unknown> {
1611
+ if (!isRecord(manifest) || manifest.$schema !== ZOPIA_MANIFEST_SCHEMA || !isRecord(manifest.source) || !Array.isArray(manifest.apis)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest', { at: '#' });
1612
+ if (!['swagger-2.0', 'openapi-3.0', 'openapi-3.1'].includes(manifest.source.kind)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Unsupported manifest source kind: ${manifest.source.kind}`);
1613
+ if (manifest.source.openapiVersion !== undefined && (typeof manifest.source.openapiVersion !== 'string' || (manifest.source.kind === 'swagger-2.0' ? manifest.source.openapiVersion !== '2.0' : !/^3\.[01](?:\.\d+)?$/.test(manifest.source.openapiVersion)))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest OpenAPI version: ${String(manifest.source.openapiVersion)}`);
1614
+ if (manifest.source.title !== undefined && (typeof manifest.source.title !== 'string' || !manifest.source.title.trim()) || manifest.source.version !== undefined && (typeof manifest.source.version !== 'string' || !manifest.source.version.trim())) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest source title or version');
1615
+ const title = manifest.source.title ?? 'Zopia API';
1616
+ const sourceVersion = manifest.source.version ?? '0.0.0';
1617
+ if (manifest.source.title === undefined) warnings?.add({ code: 'ZOPIA_WARN_DEFAULT_INFO', at: '#/info/title', message: 'manifest source title is missing; using Zopia API' });
1618
+ if (manifest.source.version === undefined) warnings?.add({ code: 'ZOPIA_WARN_DEFAULT_INFO', at: '#/info/version', message: 'manifest source version is missing; using 0.0.0' });
1619
+ if (manifest.infoOverlay !== undefined && !isRecord(manifest.infoOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest infoOverlay');
1620
+ if (manifest.documentOverlay !== undefined && !isRecord(manifest.documentOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest documentOverlay');
1621
+ if (manifest.pathsOverlay !== undefined && (!isRecord(manifest.pathsOverlay) || Object.entries(manifest.pathsOverlay).some(([path, metadata]) => !path.startsWith('/') && !path.startsWith('x-') || path.startsWith('/') && (!isRecord(metadata) || Object.keys(metadata).some((key) => HTTP_METHODS.has(key)))))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest pathsOverlay');
1622
+ if (manifest.componentsOverlay !== undefined && !isRecord(manifest.componentsOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest componentsOverlay');
1623
+ if (manifest.swaggerParameters !== undefined && !isRecord(manifest.swaggerParameters)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest swaggerParameters');
1624
+ if (manifest.swaggerResponses !== undefined && !isRecord(manifest.swaggerResponses)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest swaggerResponses');
1625
+ if (manifest.components !== undefined && !Array.isArray(manifest.components)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest components');
1626
+ if (manifest.pathOrder !== undefined && (!Array.isArray(manifest.pathOrder) || manifest.pathOrder.some((path) => typeof path !== 'string' || !path.startsWith('/') && !path.startsWith('x-')) || new Set(manifest.pathOrder).size !== manifest.pathOrder.length)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest path order');
1627
+ if (manifest.schemaComponentsPresent !== undefined && typeof manifest.schemaComponentsPresent !== 'boolean') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest schema-component presence');
1628
+ const componentNames = new Set<string>();
1629
+ for (const component of manifest.components ?? []) {
1630
+ if (!isRecord(component) || component.kind !== undefined && component.kind !== 'schema' && component.kind !== 'parameter' && component.kind !== 'response' || typeof component.name !== 'string' || !component.name || !Object.prototype.hasOwnProperty.call(component, 'schema') || component.file !== undefined && component.file !== null && (typeof component.file !== 'string' || !component.file)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest component');
1631
+ const identity = `${component.kind === 'parameter' || component.kind === 'response' ? component.kind : 'schema'}:${component.name}`;
1632
+ if (componentNames.has(identity)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate manifest component: ${identity}`);
1633
+ componentNames.add(identity);
1634
+ }
1635
+ if (manifest.componentsOverlay && ('schemas' in manifest.componentsOverlay || 'securitySchemes' in manifest.componentsOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest componentsOverlay: schemas and securitySchemes are reserved');
1636
+ const reservedInfoKeys = new Set(['title', 'version', 'description']);
1637
+ const invalidInfoKey = Object.keys(manifest.infoOverlay ?? {}).find((key) => reservedInfoKeys.has(key));
1638
+ if (invalidInfoKey) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest infoOverlay key: ${invalidInfoKey}`);
1639
+ const allowedDocumentKey = (key: string): boolean => key === 'externalDocs' || key === 'webhooks' || key === 'jsonSchemaDialect' || key.startsWith('x-');
1640
+ const invalidDocumentKey = Object.keys(manifest.documentOverlay ?? {}).find((key) => !allowedDocumentKey(key));
1641
+ if (invalidDocumentKey) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest documentOverlay key: ${invalidDocumentKey}`);
1642
+ const isSwagger = manifest.source.kind === 'swagger-2.0';
1643
+ const document: Record<string, any> = isSwagger
1644
+ ? { swagger: '2.0', info: { title, version: sourceVersion, ...(manifest.source.description === undefined ? {} : { description: manifest.source.description }) }, paths: {} }
1645
+ : { openapi: manifest.source.openapiVersion ?? (manifest.source.kind === 'openapi-3.0' ? '3.0.0' : '3.1.0'), info: { title, version: sourceVersion, ...(manifest.source.description === undefined ? {} : { description: manifest.source.description }) }, paths: {} };
1646
+ const assignOverlay = (target: Record<string, unknown>, overlay: Record<string, unknown>): void => {
1647
+ for (const [key, value] of Object.entries(overlay)) Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
1648
+ };
1649
+ if (manifest.infoOverlay) assignOverlay(document.info, manifest.infoOverlay);
1650
+ if (manifest.documentOverlay) assignOverlay(document, manifest.documentOverlay);
1651
+ const sourceApiPaths = new Set(manifest.apis.map((api) => isRecord(api) && typeof api.path === 'string' ? api.path : '').filter(Boolean));
1652
+ const operationOnlyPathPlaceholders = new Set<string>();
1653
+ for (const path of manifest.pathOrder ?? []) {
1654
+ const hasOverlay = manifest.pathsOverlay !== undefined && Object.prototype.hasOwnProperty.call(manifest.pathsOverlay, path);
1655
+ const metadata = hasOverlay ? manifest.pathsOverlay![path] : {};
1656
+ Object.defineProperty(document.paths, path, { value: metadata, enumerable: true, configurable: true, writable: true });
1657
+ if (!hasOverlay && sourceApiPaths.has(path)) operationOnlyPathPlaceholders.add(path);
1658
+ }
1659
+ if (manifest.pathsOverlay) for (const [path, metadata] of Object.entries(manifest.pathsOverlay)) if (!Object.prototype.hasOwnProperty.call(document.paths, path)) Object.defineProperty(document.paths, path, { value: metadata, enumerable: true, configurable: true, writable: true });
1660
+ if (manifest.servers !== undefined && !isSwagger) document.servers = manifest.servers;
1661
+ if (manifest.servers !== undefined && isSwagger && typeof manifest.servers[0] === 'string') document.basePath = manifest.servers[0];
1662
+ if (isSwagger) { if (manifest.swaggerHost !== undefined) document.host = manifest.swaggerHost; if (manifest.swaggerSchemes !== undefined) document.schemes = manifest.swaggerSchemes; if (manifest.swaggerConsumes !== undefined) document.consumes = manifest.swaggerConsumes; if (manifest.swaggerProduces !== undefined) document.produces = manifest.swaggerProduces; if (manifest.swaggerParameters !== undefined) document.parameters = manifest.swaggerParameters; if (manifest.swaggerResponses !== undefined) document.responses = manifest.swaggerResponses; }
1663
+ else if (manifest.componentsOverlay !== undefined) document.components = { ...manifest.componentsOverlay };
1664
+ if (manifest.tags !== undefined) document.tags = manifest.tags;
1665
+ if (manifest.securitySchemes !== undefined && (!isRecord(manifest.securitySchemes)
1666
+ || Object.entries(manifest.securitySchemes).some(([name, scheme]) => !name || !isRecord(scheme)))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest securitySchemes');
1667
+ let securitySchemes: Record<string, unknown> = { ...(manifest.securitySchemes ?? {}) };
1668
+ const writeSecuritySchemes = (): void => {
1669
+ if (isSwagger) document.securityDefinitions = securitySchemes;
1670
+ else document.components = { ...(document.components ?? {}), securitySchemes };
1671
+ };
1672
+ if (manifest.securitySchemes !== undefined) writeSecuritySchemes();
1673
+ const defaultSecurity = manifest.defaultSecurity === undefined
1674
+ ? undefined
1675
+ : normalizeSecurityRequirements(manifest.defaultSecurity, 'manifest defaultSecurity');
1676
+ if (defaultSecurity !== undefined) document.security = defaultSecurity;
1677
+ for (const [index, component] of (manifest.components ?? []).entries()) {
1678
+ if ((component.kind !== 'parameter' && component.kind !== 'response') || !componentSchemas.has(index)) continue;
1679
+ const container: unknown = isSwagger
1680
+ ? (component.kind === 'parameter' ? document.parameters : document.responses)
1681
+ : isRecord(document.components)
1682
+ ? document.components[component.kind === 'parameter' ? 'parameters' : 'responses']
1683
+ : undefined;
1684
+ refreshReusableDeclaration(component.kind, container, component.name, componentSchemas.get(index), manifest.dialectDowngraded === true ? { at: `#/${manifest.source.kind === 'swagger-2.0' ? '' : 'components/'}${component.kind === 'parameter' ? 'parameters' : 'responses'}/${pointerToken(component.name)}`, warnings } : undefined);
1685
+ }
1686
+ const schemas = Object.fromEntries((manifest.components ?? []).flatMap((component, index) => component.kind !== 'parameter' && component.kind !== 'response' ? [[component.name, componentSchemas.has(index) ? componentSchemas.get(index) : component.schema]] as const : []));
1687
+ const schemaComponentsPresent = manifest.schemaComponentsPresent ?? Object.keys(schemas).length > 0;
1688
+ if (schemaComponentsPresent) {
1689
+ if (isSwagger) document.definitions = schemas;
1690
+ else document.components = { ...(document.components ?? {}), schemas };
1691
+ }
1692
+ const operationIds = new Map<string, string>();
1693
+ const pathIndexes = new Map((manifest.pathOrder ?? []).map((path, index) => [path, index]));
1694
+ const methodIndexes = new Map([...HTTP_METHODS].map((method, index) => [method, index]));
1695
+ const apiEntries = [...manifest.apis.entries()].sort(([leftIndex, left], [rightIndex, right]) => {
1696
+ const leftPath = isRecord(left) && typeof left.path === 'string' ? pathIndexes.get(left.path) : undefined;
1697
+ const rightPath = isRecord(right) && typeof right.path === 'string' ? pathIndexes.get(right.path) : undefined;
1698
+ if (leftPath !== rightPath) return (leftPath ?? Number.MAX_SAFE_INTEGER) - (rightPath ?? Number.MAX_SAFE_INTEGER);
1699
+ const leftMethod = isRecord(left) && typeof left.method === 'string' ? methodIndexes.get(left.method) : undefined;
1700
+ const rightMethod = isRecord(right) && typeof right.method === 'string' ? methodIndexes.get(right.method) : undefined;
1701
+ if (leftMethod !== rightMethod) return (leftMethod ?? Number.MAX_SAFE_INTEGER) - (rightMethod ?? Number.MAX_SAFE_INTEGER);
1702
+ return leftIndex - rightIndex;
1703
+ });
1704
+ for (const [index, api] of apiEntries) {
1705
+ if (!isRecord(api) || typeof api.path !== 'string' || !api.path.startsWith('/') || api.path.includes('?') || api.path.includes('#') || typeof api.method !== 'string' || !HTTP_METHODS.has(api.method)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest API: ${String((api as any)?.path)} ${String((api as any)?.method)}`);
1706
+ if (api.sourceOperation !== undefined && !isRecord(api.sourceOperation)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest source operation: ${api.path} ${api.method}`);
1707
+ if (api.pathItemRef !== undefined && typeof api.pathItemRef !== 'boolean') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest path-item reference flag: ${api.path} ${api.method}`);
1708
+ const sourceOperation: Record<string, any> = api.sourceOperation ? { ...api.sourceOperation } : { operationId: api.operationId, responses: { default: { description: 'Generated from manifest' } } };
1709
+ const runtime = endpointConfigs.get(index);
1710
+ const sourcePathItem = isRecord(manifest.pathsOverlay?.[api.path]) ? manifest.pathsOverlay[api.path] as Record<string, any> : undefined;
1711
+ let reconstructed: { path: string; method: string; operation: Record<string, any> };
1712
+ try { reconstructed = runtime ? runtimeOperation(api, sourceOperation, sourcePathItem, runtime, manifest, componentReferences, warnings) : { path: api.path, method: api.method, operation: sourceOperation }; }
1713
+ catch (error) {
1714
+ if (error instanceof ZopiaError && error.at === undefined) {
1715
+ const message = error.message.slice(`${error.code}: `.length);
1716
+ throw new ZopiaError(error.code, message, { at: api.file ?? `#/apis/${index}`, hint: error.hint, cause: error.cause ?? error });
1717
+ }
1718
+ throw asZopiaError(error, 'ZOPIA_DOCS_IMPORT_FAILED', 'unable to reconstruct generated endpoint', { at: api.file ?? `#/apis/${index}` });
1719
+ }
1720
+ if (!runtime && api.pathItemRef !== true) {
1721
+ const pathParameters = sourcePathItem?.parameters;
1722
+ const operationParameters = reconstructed.operation.parameters;
1723
+ if ((pathParameters !== undefined && !Array.isArray(pathParameters)) || (operationParameters !== undefined && !Array.isArray(operationParameters))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid reconstructed parameters: ${api.path} ${api.method}`);
1724
+ const rawParameters = [...(Array.isArray(pathParameters) ? pathParameters : []), ...(Array.isArray(operationParameters) ? operationParameters : [])];
1725
+ if (!rawParameters.every(isRecord)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid reconstructed parameters: ${api.path} ${api.method}`);
1726
+ validateReconstructedPathParameters(reconstructed.path, rawParameters.map((parameter) => resolveParameter(parameter, manifest)), 'ZOPIA_MANIFEST_INVALID', 'Manifest operation');
1727
+ const responses = reconstructed.operation.responses;
1728
+ if (!isRecord(responses) || Object.keys(responses).length === 0 || Object.entries(responses).some(([status, response]) => {
1729
+ if (!isValidResponseStatus(status, isSwagger) || !isRecord(response)) return true;
1730
+ const resolved = resolveResponse(response, manifest);
1731
+ return typeof resolved.description !== 'string' || !resolved.description.trim();
1732
+ })) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid reconstructed responses: ${api.path} ${api.method}`);
1733
+ }
1734
+ const operationId = reconstructed.operation.operationId;
1735
+ if (operationId !== undefined && (typeof operationId !== 'string' || !operationId.trim())) {
1736
+ throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `Invalid reconstructed operationId: ${String(operationId)}`, { at: runtime ? api.file : `#/apis/${index}/sourceOperation/operationId` });
1737
+ }
1738
+ if (typeof operationId === 'string') {
1739
+ const previous = operationIds.get(operationId);
1740
+ if (previous !== undefined) throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `Duplicate reconstructed operationId: ${operationId}`, { at: runtime ? api.file : `#/apis/${index}/sourceOperation/operationId` });
1741
+ operationIds.set(operationId, api.file ?? `#/apis/${index}`);
1742
+ }
1743
+ const operationSecurity = api.security === undefined
1744
+ ? undefined
1745
+ : normalizeSecurityRequirements(api.security, `manifest security for ${api.path} ${api.method}`);
1746
+ const legacyOperationSecurity = operationSecurity === undefined
1747
+ && Object.prototype.hasOwnProperty.call(sourceOperation, 'security')
1748
+ ? normalizeSecurityRequirements(sourceOperation.security, `legacy manifest security for ${api.path} ${api.method}`)
1749
+ : undefined;
1750
+ if (operationSecurity !== undefined) reconstructed.operation.security = operationSecurity;
1751
+ else if (legacyOperationSecurity !== undefined) reconstructed.operation.security = legacyOperationSecurity;
1752
+ else if (runtime?.auth === 'YES' && defaultSecurity === undefined) {
1753
+ const fallback = ensureFallbackSecurityScheme(securitySchemes, isSwagger);
1754
+ securitySchemes = fallback.schemes;
1755
+ writeSecuritySchemes();
1756
+ reconstructed.operation.security = [{ [fallback.name]: [] }];
1757
+ warnings?.add({
1758
+ code: 'ZOPIA_WARN_DEFAULT_SECURITY',
1759
+ at: `#/paths/${pointerToken(reconstructed.path)}/${reconstructed.method}/security`,
1760
+ message: `auth is YES but the manifest has no security requirement; using ${fallback.name}`,
1761
+ });
1762
+ }
1763
+ if (api.pathItemRef === true && reconstructed.path === api.path && reconstructed.method === api.method && sameSchema(reconstructed.operation, sourceOperation)) continue;
1764
+ const pathItem = Object.prototype.hasOwnProperty.call(document.paths, reconstructed.path) ? document.paths[reconstructed.path] : {};
1765
+ if (Object.prototype.hasOwnProperty.call(pathItem, reconstructed.method)) {
1766
+ throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `${runtime ? 'Duplicate reconstructed endpoint' : 'Duplicate manifest API'}: ${reconstructed.path} ${reconstructed.method}`, { at: runtime ? api.file : `#/apis/${index}` });
1767
+ }
1768
+ Object.defineProperty(document.paths, reconstructed.path, { value: { ...pathItem, [reconstructed.method]: reconstructed.operation }, enumerable: true, configurable: true, writable: true });
1769
+ }
1770
+ for (const path of operationOnlyPathPlaceholders) if (isRecord(document.paths[path]) && Object.keys(document.paths[path]).length === 0) delete document.paths[path];
1771
+ const looseWebhooks = (manifest as ZopiaManifest & { webhooks?: unknown }).webhooks;
1772
+ if (looseWebhooks !== undefined && !Array.isArray(looseWebhooks)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest webhooks');
1773
+ const webhookEntries: Array<readonly [number, Record<string, any>]> = (Array.isArray(looseWebhooks) ? looseWebhooks : []).map((webhook, webhookIndex) => [webhookIndex, webhook] as const);
1774
+ const hasWebhookState = webhookEntries.length > 0
1775
+ || Array.isArray((manifest as { webhookOrder?: unknown }).webhookOrder) && ((manifest as { webhookOrder?: unknown }).webhookOrder as unknown[]).length > 0
1776
+ || isRecord((manifest as { webhooksOverlay?: unknown }).webhooksOverlay) && Object.keys((manifest as { webhooksOverlay?: Record<string, unknown> }).webhooksOverlay as Record<string, unknown>).length > 0;
1777
+ if (hasWebhookState) {
1778
+ if (manifest.source.kind !== 'openapi-3.1') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid manifest webhooks for this source kind');
1779
+ document.webhooks = {} as Record<string, any>;
1780
+ const declaredOrder = Array.isArray((manifest as { webhookOrder?: unknown }).webhookOrder) ? ((manifest as { webhookOrder?: unknown }).webhookOrder as unknown[]).filter((name): name is string => typeof name === 'string' && name.length > 0) : [];
1781
+ const seenOrder = new Set<string>();
1782
+ const orderedNames = declaredOrder.filter((name) => { if (seenOrder.has(name)) return false; seenOrder.add(name); return true; });
1783
+ for (const name of [...new Set(webhookEntries.map(([, webhook]) => isRecord(webhook) ? String((webhook as any).name) : ''))]) if (name && !seenOrder.has(name)) { orderedNames.push(name); seenOrder.add(name); }
1784
+ const methodOrder = new Map(OPENAPI_METHODS.map((method, methodIndex) => [method, methodIndex]));
1785
+ const overlayItems = isRecord((manifest as { webhooksOverlay?: unknown }).webhooksOverlay) ? (manifest as { webhooksOverlay?: Record<string, unknown> }).webhooksOverlay as Record<string, unknown> : {};
1786
+ for (const name of orderedNames) {
1787
+ if (name.startsWith('x-')) {
1788
+ // Extension entries keep their verbatim value; an empty extension item (an x-
1789
+ // name present in the order but absent from the overlay) must still restore.
1790
+ document.webhooks[name] = isRecord(overlayItems[name])
1791
+ ? { ...(overlayItems[name] as Record<string, unknown>) }
1792
+ : overlayItems[name] ?? {};
1793
+ continue;
1794
+ }
1795
+ const entries = webhookEntries
1796
+ .filter(([, webhook]) => isRecord(webhook) && (webhook as any).name === name)
1797
+ .sort((left, right) => (methodOrder.get((left[1] as any).method) ?? 99) - (methodOrder.get((right[1] as any).method) ?? 99) || left[0] - right[0]);
1798
+ const item: Record<string, any> = isRecord(overlayItems[name]) ? { ...(overlayItems[name] as Record<string, any>) } : {};
1799
+ document.webhooks[name] = item;
1800
+ for (const [webhookIndex, webhook] of entries) {
1801
+ if (!isRecord(webhook) || typeof webhook.name !== 'string' || !webhook.name || typeof webhook.method !== 'string' || !HTTP_METHODS.has(webhook.method)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest webhook API: ${String((webhook as any)?.name)} ${String((webhook as any)?.method)}`);
1802
+ if (webhook.sourceOperation !== undefined && !isRecord(webhook.sourceOperation)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid manifest source operation: webhook ${webhook.name} ${webhook.method}`);
1803
+ const sourceOperation: Record<string, any> = webhook.sourceOperation ? { ...webhook.sourceOperation } : { operationId: webhook.operationId, responses: { default: { description: 'Generated from manifest' } } };
1804
+ const runtime = webhookConfigs.get(webhookIndex);
1805
+ const sourceWebhookItem = isRecord(overlayItems[name]) ? overlayItems[name] as Record<string, any> : undefined;
1806
+ // The runtime parity rule deletes `operationId` when it merely derivates from the
1807
+ // manifest value and the source operation never declared one; webhooks need the
1808
+ // derived identity to survive exactly like path operations do, so omit the hook.
1809
+ const { operationId: _webhookManifestOperationId, ...webhookWithoutManifestOperationId } = webhook;
1810
+ const apiView = { ...webhookWithoutManifestOperationId, path: webhookRuntimePath(webhook.name) } as unknown as ZopiaManifest['apis'][number];
1811
+ let reconstructed: { path: string; method: string; operation: Record<string, any> };
1812
+ try { reconstructed = runtime ? runtimeOperation(apiView, sourceOperation, sourceWebhookItem, runtime, manifest, componentReferences, warnings) : { path: apiView.path, method: webhook.method, operation: sourceOperation }; }
1813
+ catch (error) {
1814
+ if (error instanceof ZopiaError && error.at === undefined) {
1815
+ const message = error.message.slice(`${error.code}: `.length);
1816
+ throw new ZopiaError(error.code, message, { at: webhook.file ?? `#/webhooks/${webhookIndex}`, hint: error.hint, cause: error.cause ?? error });
1817
+ }
1818
+ throw asZopiaError(error, 'ZOPIA_DOCS_IMPORT_FAILED', 'unable to reconstruct generated webhook endpoint', { at: webhook.file ?? `#/webhooks/${webhookIndex}` });
1819
+ }
1820
+ const operationId = reconstructed.operation.operationId;
1821
+ if (operationId !== undefined && (typeof operationId !== 'string' || !operationId.trim())) {
1822
+ throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `Invalid reconstructed operationId: ${String(operationId)}`, { at: runtime ? webhook.file : `#/webhooks/${webhookIndex}/sourceOperation/operationId` });
1823
+ }
1824
+ if (typeof operationId === 'string') {
1825
+ const previous = operationIds.get(operationId);
1826
+ if (previous !== undefined) throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `Duplicate reconstructed operationId: ${operationId}`, { at: runtime ? webhook.file : `#/webhooks/${webhookIndex}/sourceOperation/operationId` });
1827
+ operationIds.set(operationId, webhook.file ?? `#/webhooks/${webhookIndex}`);
1828
+ }
1829
+ const webhookSecurity = webhook.security === undefined ? undefined : normalizeSecurityRequirements(webhook.security, `manifest security for webhook ${webhook.name} ${webhook.method}`);
1830
+ const legacyWebhookSecurity = webhookSecurity === undefined
1831
+ && Object.prototype.hasOwnProperty.call(sourceOperation, 'security')
1832
+ ? normalizeSecurityRequirements(sourceOperation.security, `legacy manifest security for webhook ${webhook.name} ${webhook.method}`)
1833
+ : undefined;
1834
+ if (webhookSecurity !== undefined) reconstructed.operation.security = webhookSecurity;
1835
+ else if (legacyWebhookSecurity !== undefined) reconstructed.operation.security = legacyWebhookSecurity;
1836
+ else if (runtime?.auth === 'YES' && defaultSecurity === undefined) {
1837
+ const fallback = ensureFallbackSecurityScheme(securitySchemes, isSwagger);
1838
+ securitySchemes = fallback.schemes;
1839
+ writeSecuritySchemes();
1840
+ reconstructed.operation.security = [{ [fallback.name]: [] }];
1841
+ warnings?.add({
1842
+ code: 'ZOPIA_WARN_DEFAULT_SECURITY',
1843
+ at: `#/webhooks/${pointerToken(webhook.name)}/${reconstructed.method}/security`,
1844
+ message: `auth is YES but the manifest has no security requirement; using ${fallback.name}`,
1845
+ });
1846
+ }
1847
+ if (webhook.webhookItemRef === true && reconstructed.method === webhook.method && sameSchema(reconstructed.operation, sourceOperation)) continue;
1848
+ if (Object.prototype.hasOwnProperty.call(item, webhook.method)) throw new ZopiaError(runtime ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID', `${runtime ? 'Duplicate reconstructed webhook endpoint' : 'Duplicate manifest webhook API'}: ${webhook.name} ${webhook.method}`, { at: runtime ? webhook.file : `#/webhooks/${webhookIndex}` });
1849
+ item[webhook.method] = reconstructed.operation;
1850
+ }
1851
+ }
1852
+ }
1853
+ try {
1854
+ for (const operation of buildOpenApiOperationIR(document)) extractOperationContracts(operation);
1855
+ } catch (error) {
1856
+ const code = endpointConfigs.size ? 'ZOPIA_DOCS_IMPORT_FAILED' : 'ZOPIA_MANIFEST_INVALID';
1857
+ const detail = error instanceof Error ? error.message.replace(/^ZOPIA_[A-Z_]+: /, '') : String(error);
1858
+ throw new ZopiaError(code, `Invalid reconstructed OpenAPI document: ${detail}`, { cause: error });
1859
+ }
1860
+ return document;
1861
+ }