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,333 @@
1
+ import { ZopiaError } from '../errors';
2
+ import type { OpenApiOperationIR } from './openapi-ir';
3
+ import type { OpenApiDocument } from './openapi';
4
+ import { decodeJsonPointerSegment, resolveOpenApiLocalRef } from './openapi-ref';
5
+
6
+ /** Normalized non-body operation parameter consumed by endpoint rendering. */
7
+ export interface OperationParameter {
8
+ /** Exact source parameter name. */
9
+ name: string;
10
+ /** Supported OpenAPI parameter location. */
11
+ in: 'path' | 'query' | 'header' | 'cookie';
12
+ /** Whether callers must provide the parameter. */
13
+ required: boolean;
14
+ /** Resolved JSON Schema for the parameter value, when present. */
15
+ schema?: unknown;
16
+ /** Reusable parameter component whose bare `$ref` declared this parameter (D-18); absent for inline or sibling-merged resolutions. */
17
+ reusable?: string;
18
+ }
19
+
20
+ /** Normalized request, parameter, and response contracts for one operation. */
21
+ export interface OperationContracts {
22
+ /** Resolved non-body operation parameters. */
23
+ parameters: OperationParameter[];
24
+ /** Primary request body contract, when the operation accepts a body. */
25
+ requestBody?: {
26
+ /** Selected request media type. */
27
+ contentType: string;
28
+ /** Resolved request JSON Schema, when present. */
29
+ schema?: unknown;
30
+ /** Whether callers must provide a request body. */
31
+ required: boolean;
32
+ /** Reusable parameter component whose bare `$ref` declared a Swagger `in: body` parameter (D-18). */
33
+ reusable?: string;
34
+ /** Reusable parameter component names for each bare-`$ref` formData property (D-18). */
35
+ formDataReusable?: Record<string, string>;
36
+ };
37
+ /** Response contracts in source-document order. */
38
+ responses: Array<{
39
+ /** Original response status key. */
40
+ status: string;
41
+ /** Required OpenAPI response description. */
42
+ description: string;
43
+ /** Selected response media type, when the response has content. */
44
+ contentType?: string;
45
+ /** Resolved response JSON Schema, when present. */
46
+ schema?: unknown;
47
+ /** Reusable response component whose bare `$ref` declared this status (D-18); absent for inline or sibling-merged resolutions. */
48
+ reusable?: string;
49
+ }>;
50
+ }
51
+
52
+ function firstContent(content: unknown): { contentType?: string; schema?: unknown } {
53
+ if (content === undefined) return {};
54
+ if (!content || typeof content !== 'object' || Array.isArray(content)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid content: expected an object');
55
+ const entries = Object.entries(content as Record<string, any>);
56
+ if (entries.length === 0) return {};
57
+ const [contentType, media] = entries.find(([type]) => type.toLowerCase() === 'application/json') ?? entries[0];
58
+ if (!contentType || !media || typeof media !== 'object' || Array.isArray(media)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid media type content: ${contentType}`);
59
+ return { contentType, schema: media.schema };
60
+ }
61
+
62
+ function primarySwaggerMediaType(value: unknown): string | undefined {
63
+ if (!Array.isArray(value)) return undefined;
64
+ const types = value.filter((item): item is string => typeof item === 'string' && item.length > 0);
65
+ return types.find((type) => type.toLowerCase() === 'application/json' || type.toLowerCase().endsWith('+json')) ?? types[0];
66
+ }
67
+
68
+ function assertSwaggerMediaTypes(value: unknown, field: 'consumes' | 'produces', at: string): void {
69
+ if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger ${field}: ${at}`);
70
+ }
71
+
72
+ const SWAGGER_SCHEMA_KEYS = new Set(['format', 'items', 'default', 'maximum', 'exclusiveMaximum', 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'maxItems', 'minItems', 'uniqueItems', 'enum', 'multipleOf']);
73
+ const SWAGGER_PARAMETER_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array']);
74
+
75
+ function isValidSwaggerItems(value: unknown, seen = new Set<object>()): boolean {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value) || seen.has(value)) return false;
77
+ seen.add(value);
78
+ const type = (value as Record<string, unknown>).type;
79
+ return SWAGGER_PARAMETER_TYPES.has(type as string) && (type !== 'array' || isValidSwaggerItems((value as Record<string, unknown>).items, seen));
80
+ }
81
+
82
+ /**
83
+ * Test whether a response key is valid for the selected source dialect.
84
+ *
85
+ * @param status Response-object key to validate.
86
+ * @param swagger Whether the target dialect is Swagger 2.0.
87
+ * @returns Whether the key is `default`, an exact HTTP status, or an OpenAPI range.
88
+ */
89
+ export function isValidResponseStatus(status: string, swagger: boolean): boolean {
90
+ return status === 'default' || /^[1-5]\d{2}$/.test(status) || !swagger && /^[1-5]XX$/.test(status);
91
+ }
92
+
93
+ function pathParameterNames(path: string): Set<string> {
94
+ return new Set([...path.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
95
+ }
96
+
97
+ /**
98
+ * Derive the effective schema of a Swagger 2.0 non-body/formData parameter.
99
+ *
100
+ * @param parameter Validated Swagger parameter object.
101
+ * @returns The parameter's top-level constraint keywords as a JSON Schema (`file` renders as `string`/`binary`).
102
+ */
103
+ export function swaggerParameterSchema(parameter: Record<string, any>): Record<string, unknown> {
104
+ return {
105
+ type: parameter.type === 'file' ? 'string' : parameter.type,
106
+ ...(parameter.type === 'file' ? { format: 'binary' } : {}),
107
+ ...Object.fromEntries(Object.entries(parameter).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key))),
108
+ };
109
+ }
110
+
111
+ /** Reusable non-schema component kinds emitted as standalone modules in components mode (D-18). */
112
+ export type ReusableComponentKind = 'parameter' | 'response';
113
+
114
+ /** Namespace table for reusable parameter/response references per source dialect (D-18). */
115
+ const REUSABLE_REF_ROOTS = {
116
+ parameter: { openapi: '#/components/parameters/', swagger: '#/parameters/' },
117
+ response: { openapi: '#/components/responses/', swagger: '#/responses/' },
118
+ } as const;
119
+
120
+ /**
121
+ * Detect a bare reusable parameter/response reference (`{ "$ref": <namespace>/<name> }` with no sibling keys).
122
+ *
123
+ * Sibling-merged references resolve inline and therefore never receive a component import (D-18).
124
+ *
125
+ * @param raw Raw source node (parameter or response object, before local-reference resolution).
126
+ * @param kind Reusable namespace to match.
127
+ * @param swagger Whether the source dialect is Swagger 2.0.
128
+ * @returns The decoded component name when `raw` is a bare namespace reference, otherwise `undefined`.
129
+ */
130
+ export function bareReusableReferenceName(raw: unknown, kind: ReusableComponentKind, swagger: boolean): string | undefined {
131
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
132
+ const entries = Object.entries(raw as Record<string, unknown>);
133
+ if (entries.length !== 1 || entries[0][0] !== '$ref' || typeof entries[0][1] !== 'string') return undefined;
134
+ const prefix = REUSABLE_REF_ROOTS[kind][swagger ? 'swagger' : 'openapi'];
135
+ if (!entries[0][1].startsWith(prefix)) return undefined;
136
+ const suffix = entries[0][1].slice(prefix.length);
137
+ return suffix.includes('/') || suffix === '' ? undefined : decodeJsonPointerSegment(suffix, entries[0][1]);
138
+ }
139
+
140
+ /**
141
+ * Read the reusable parameter/response declaration maps of a normalized document.
142
+ *
143
+ * @param document Validated Swagger/OpenAPI document.
144
+ * @returns Declaration maps keyed by component name (empty when the dialect container is absent).
145
+ */
146
+ export function reusableDeclarations(document: OpenApiDocument): Record<ReusableComponentKind, Record<string, unknown>> {
147
+ const swagger = document.swagger === '2.0';
148
+ return {
149
+ parameter: (swagger ? document.parameters : document.components?.parameters) ?? {},
150
+ response: (swagger ? document.responses : document.components?.responses) ?? {},
151
+ };
152
+ }
153
+
154
+ function resolveReusableDeclaration(document: OpenApiDocument, value: unknown, kind: ReusableComponentKind, name: string): Record<string, unknown> {
155
+ let current = value;
156
+ const seen = new Set<string>();
157
+ while (current && typeof current === 'object' && !Array.isArray(current) && typeof (current as Record<string, unknown>).$ref === 'string') {
158
+ const ref = (current as Record<string, unknown>).$ref as string;
159
+ if (seen.has(ref)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Circular reusable ${kind} $ref: ${ref}`, { at: `#/components/${kind}s/${name}`, hint: 'break the reusable reference cycle' });
160
+ seen.add(ref);
161
+ const resolved = resolveOpenApiLocalRef(document, ref);
162
+ if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid reusable ${kind}: ${name}`, { at: `#/components/${kind}s/${name}` });
163
+ current = { ...(resolved as Record<string, unknown>), ...Object.fromEntries(Object.entries(current as Record<string, unknown>).filter(([key]) => key !== '$ref')) };
164
+ }
165
+ if (!current || typeof current !== 'object' || Array.isArray(current)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid reusable ${kind}: ${name}`, { at: `#/components/${kind}s/${name}` });
166
+ return current as Record<string, unknown>;
167
+ }
168
+
169
+ /**
170
+ * Derive the JSON Schema rendered into a reusable parameter component module (D-18).
171
+ *
172
+ * @param document Validated Swagger/OpenAPI document (for reference chains).
173
+ * @param name Reusable parameter component name (used in diagnostics).
174
+ * @param declaration Raw declaration object; reference chains are resolved with sibling overrides.
175
+ * @returns The parameter's effective schema.
176
+ * @throws {@link ZopiaError} when the declaration has no derivable schema (`ZOPIA_SPEC_INVALID`).
177
+ */
178
+ export function deriveReusableParameterSchema(document: OpenApiDocument, name: string, declaration: unknown): unknown {
179
+ const parameter = resolveReusableDeclaration(document, declaration, 'parameter', name);
180
+ const swagger = document.swagger === '2.0';
181
+ if (swagger) {
182
+ if (typeof parameter.name !== 'string' || !parameter.name || typeof parameter.in !== 'string' || !parameter.in) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid reusable parameter: ${name}`, { at: `#/parameters/${name}`, hint: 'declare name and in on the reusable parameter' });
183
+ if (parameter.in === 'body') {
184
+ if (parameter.schema === undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger body parameter: ${name}`, { at: `#/parameters/${name}` });
185
+ return parameter.schema;
186
+ }
187
+ if (!SWAGGER_PARAMETER_TYPES.has(parameter.type as string) && parameter.type !== 'file') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger parameter type: ${name}`, { at: `#/parameters/${name}` });
188
+ return swaggerParameterSchema(parameter);
189
+ }
190
+ if (typeof parameter.name !== 'string' || !parameter.name || !['path', 'query', 'header', 'cookie'].includes(String(parameter.in))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid reusable parameter: ${name}`, { at: `#/components/parameters/${name}`, hint: 'declare name and a supported in on the reusable parameter' });
191
+ if (parameter.schema !== undefined && parameter.content !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter cannot define both schema and content: ${name}`, { at: `#/components/parameters/${name}` });
192
+ if (parameter.schema !== undefined) return parameter.schema;
193
+ if (parameter.content !== undefined) {
194
+ const media = firstContent(parameter.content);
195
+ if (media.schema === undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter requires schema or content: ${name}`, { at: `#/components/parameters/${name}` });
196
+ return media.schema;
197
+ }
198
+ throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter requires schema or content: ${name}`, { at: `#/components/parameters/${name}`, hint: 'add a schema or content field' });
199
+ }
200
+
201
+ /**
202
+ * Derive the JSON Schema rendered into a reusable response component module (D-18).
203
+ *
204
+ * @param document Validated Swagger/OpenAPI document (for reference chains).
205
+ * @param name Reusable response component name (used in diagnostics).
206
+ * @param declaration Raw declaration object; reference chains are resolved with sibling overrides.
207
+ * @returns The primary response schema, or `undefined` for schema-less responses (which render `z.void()`).
208
+ * @throws {@link ZopiaError} when the declaration is not an object (`ZOPIA_SPEC_INVALID`).
209
+ */
210
+ export function deriveReusableResponseSchema(document: OpenApiDocument, name: string, declaration: unknown): unknown {
211
+ const response = resolveReusableDeclaration(document, declaration, 'response', name);
212
+ const swagger = document.swagger === '2.0';
213
+ if (swagger) return response.schema;
214
+ if (response.content !== undefined) return firstContent(response.content).schema;
215
+ return undefined;
216
+ }
217
+
218
+ function resolveRef(value: Record<string, any>, ir: OpenApiOperationIR, context: string): Record<string, any> {
219
+ let current: any = value; const seen = new Set<string>();
220
+ while ('$ref' in current) {
221
+ if (typeof current.$ref !== 'string' || !current.$ref) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Invalid ${context} $ref`);
222
+ if (seen.has(current.$ref)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Circular ${context} $ref: ${current.$ref}`);
223
+ seen.add(current.$ref);
224
+ const resolved = resolveOpenApiLocalRef(ir.document, current.$ref);
225
+ if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Invalid resolved ${context} $ref: ${current.$ref}`);
226
+ const siblings = Object.fromEntries(Object.entries(current).filter(([key]) => key !== '$ref'));
227
+ current = Object.keys(siblings).length ? { ...(resolved as Record<string, any>), ...siblings } : resolved;
228
+ }
229
+ return current as Record<string, any>;
230
+ }
231
+
232
+ /**
233
+ * Extract request and response content without losing media-type metadata.
234
+ *
235
+ * @param ir Validated operation-level intermediate representation.
236
+ * @returns Resolved parameter, request-body, and response contracts.
237
+ * @throws {@link ZopiaError} when references or contract shapes are invalid.
238
+ */
239
+ export function extractOperationContracts(ir: OpenApiOperationIR): OperationContracts {
240
+ const operation = ir.operation;
241
+ const swagger = ir.document.swagger === '2.0';
242
+ if (swagger) for (const field of ['consumes', 'produces'] as const) {
243
+ assertSwaggerMediaTypes(ir.document[field], field, '#');
244
+ assertSwaggerMediaTypes(operation[field], field, `${ir.method} ${ir.path}`);
245
+ }
246
+ const resolvedParameters = ir.parameters.map((raw) => resolveRef(raw, ir, 'parameter'));
247
+ const rawReusableParameters = ir.parameters.map((raw) => bareReusableReferenceName(raw, 'parameter', swagger));
248
+ if (!swagger) {
249
+ const legacyParameter = resolvedParameters.find((parameter) => parameter.in === 'body' || parameter.in === 'formData');
250
+ if (legacyParameter) throw new ZopiaError('ZOPIA_SPEC_INVALID', `OpenAPI 3 does not support ${legacyParameter.in} parameters: ${ir.method} ${ir.path}`);
251
+ const swaggerShaped = resolvedParameters.find((parameter) => ['type', ...SWAGGER_SCHEMA_KEYS].some((key) => Object.prototype.hasOwnProperty.call(parameter, key)));
252
+ if (swaggerShaped) throw new ZopiaError('ZOPIA_SPEC_INVALID', `OpenAPI 3 parameter must place schema keywords under schema: ${String(swaggerShaped.name)}`);
253
+ } else {
254
+ if (operation.requestBody !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Swagger 2.0 does not support requestBody: ${ir.method} ${ir.path}`);
255
+ const schemaShaped = resolvedParameters.find((parameter) => parameter.in !== 'body' && (parameter.schema !== undefined || parameter.content !== undefined));
256
+ if (schemaShaped) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Swagger non-body parameter must use top-level type keywords: ${String(schemaShaped.name)}`);
257
+ }
258
+ const parameters = resolvedParameters.flatMap((parameter, parameterIndex) => {
259
+ if (parameter.in === 'body' || parameter.in === 'formData') return [];
260
+ if (!['path', 'query', 'header', 'cookie'].includes(parameter.in) || ir.document.swagger === '2.0' && parameter.in === 'cookie' || typeof parameter.name !== 'string' || !parameter.name) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid parameter: ${ir.method} ${ir.path}`);
261
+ if (parameter.required !== undefined && typeof parameter.required !== 'boolean') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid parameter.required: ${parameter.name}`);
262
+ if (parameter.in === 'path' && parameter.required !== true) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Path parameter must be required: ${parameter.name}`);
263
+ if (parameter.content !== undefined && parameter.schema !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter cannot define both schema and content: ${parameter.name}`);
264
+ if (parameter.content !== undefined && (!parameter.content || typeof parameter.content !== 'object' || Array.isArray(parameter.content) || Object.keys(parameter.content).length !== 1)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter content must contain exactly one media type: ${parameter.name}`);
265
+ const parameterContent = parameter.schema === undefined && parameter.content !== undefined ? firstContent(parameter.content) : undefined;
266
+ if (ir.document.swagger === '2.0' && parameter.type !== undefined && (!SWAGGER_PARAMETER_TYPES.has(parameter.type) || (parameter.type === 'array' && !isValidSwaggerItems(parameter.items)))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger parameter type: ${parameter.name}`);
267
+ const swaggerSchema = ir.document.swagger === '2.0' && parameter.type ? swaggerParameterSchema(parameter) : undefined;
268
+ const schema = parameter.schema ?? parameterContent?.schema ?? swaggerSchema;
269
+ if (schema === undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Parameter requires schema or content: ${parameter.name}`);
270
+ const reusable = rawReusableParameters[parameterIndex];
271
+ return [{ name: parameter.name, in: parameter.in, required: parameter.required === true || parameter.in === 'path', schema, ...(reusable === undefined ? {} : { reusable }) }];
272
+ });
273
+ const placeholders = pathParameterNames(ir.path);
274
+ const pathParameters = new Set(parameters.filter((parameter) => parameter.in === 'path').map((parameter) => parameter.name));
275
+ const missingPathParameter = [...placeholders].find((name) => !pathParameters.has(name));
276
+ if (missingPathParameter) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Path template parameter is not defined: ${missingPathParameter}`);
277
+ const unrelatedPathParameter = [...pathParameters].find((name) => !placeholders.has(name));
278
+ if (unrelatedPathParameter) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Path parameter is not present in the template: ${unrelatedPathParameter}`);
279
+ let body = operation.requestBody;
280
+ let bodyReusable: string | undefined;
281
+ let formDataReusable: Record<string, string> | undefined;
282
+ if (body === undefined && ir.document.swagger === '2.0') {
283
+ const bodyEntries = [...resolvedParameters.entries()].filter(([, parameter]) => parameter.in === 'body');
284
+ const bodyParameter = bodyEntries[0]?.[1];
285
+ const formEntries = [...resolvedParameters.entries()].filter(([, parameter]) => parameter.in === 'formData');
286
+ const formParameters = formEntries.map(([, parameter]) => parameter);
287
+ if (bodyEntries.length > 1) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Swagger operation cannot define multiple body parameters: ${ir.method} ${ir.path}`);
288
+ if (bodyParameter && formParameters.length) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Swagger operation cannot combine body and formData parameters: ${ir.method} ${ir.path}`);
289
+ if (bodyParameter) {
290
+ if (typeof bodyParameter !== 'object' || typeof bodyParameter.name !== 'string' || !bodyParameter.name || !bodyParameter.schema) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger body parameter: ${ir.method} ${ir.path}`);
291
+ if (bodyParameter.required !== undefined && typeof bodyParameter.required !== 'boolean') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger body parameter required: ${ir.method} ${ir.path}`);
292
+ const consumes = Array.isArray(operation.consumes) ? operation.consumes : Array.isArray(ir.document.consumes) ? ir.document.consumes : [];
293
+ body = { content: { [primarySwaggerMediaType(consumes) ?? 'application/json']: { schema: bodyParameter.schema } }, required: bodyParameter.required === true };
294
+ bodyReusable = rawReusableParameters[bodyEntries[0][0]];
295
+ } else if (formParameters.length) {
296
+ const properties: Record<string, any> = {}; const required: string[] = [];
297
+ for (const parameter of formParameters) { const validTypes = new Set([...SWAGGER_PARAMETER_TYPES, 'file']); if (typeof parameter.name !== 'string' || !parameter.name || !validTypes.has(parameter.type) || (parameter.type === 'array' && !isValidSwaggerItems(parameter.items))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger formData parameter: ${ir.method} ${ir.path}`); if (parameter.required !== undefined && typeof parameter.required !== 'boolean') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid Swagger formData required: ${parameter.name}`); properties[parameter.name] = swaggerParameterSchema(parameter); if (parameter.required === true) required.push(parameter.name); }
298
+ const consumes = Array.isArray(operation.consumes) ? operation.consumes : Array.isArray(ir.document.consumes) ? ir.document.consumes : [];
299
+ const contentType = consumes.find((value: unknown) => value === 'multipart/form-data' || value === 'application/x-www-form-urlencoded') ?? (formParameters.some((parameter: any) => parameter.type === 'file') ? 'multipart/form-data' : 'application/x-www-form-urlencoded');
300
+ body = { content: { [contentType]: { schema: { type: 'object', properties, ...(required.length ? { required } : {}) } } }, required: required.length > 0 };
301
+ const reusables = Object.fromEntries(formEntries.flatMap(([index, parameter]) => {
302
+ const reusable = rawReusableParameters[index];
303
+ return reusable === undefined ? [] : [[String(parameter.name), reusable]];
304
+ }));
305
+ if (Object.keys(reusables).length) formDataReusable = reusables;
306
+ }
307
+ }
308
+ const requestBody = body === undefined ? undefined : (() => {
309
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid requestBody: ${ir.method} ${ir.path}`);
310
+ const bodyObject = resolveRef(body, ir, 'requestBody');
311
+ if (bodyObject.required !== undefined && typeof bodyObject.required !== 'boolean') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid requestBody.required: ${ir.method} ${ir.path}`);
312
+ if (bodyObject.content === undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid requestBody.content: ${ir.method} ${ir.path}`);
313
+ const media = firstContent(bodyObject.content);
314
+ if (!media.contentType) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid requestBody.content: ${ir.method} ${ir.path}`);
315
+ return { ...media, contentType: media.contentType, required: bodyObject.required === true, ...(bodyReusable === undefined ? {} : { reusable: bodyReusable }), ...(formDataReusable === undefined ? {} : { formDataReusable }) };
316
+ })();
317
+ const responses = operation.responses;
318
+ if (!responses || typeof responses !== 'object' || Array.isArray(responses) || Object.keys(responses).length === 0) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid responses: ${ir.method} ${ir.path}`);
319
+ return { parameters, requestBody, responses: Object.entries(responses).map(([status, value]) => {
320
+ if (!isValidResponseStatus(status, ir.document.swagger === '2.0')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid response status: ${status}`);
321
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid response ${status}: ${ir.method} ${ir.path}`);
322
+ const response = resolveRef(value as Record<string, any>, ir, 'response');
323
+ if (typeof response.description !== 'string' || response.description.trim() === '') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid response description: ${status}`);
324
+ if (swagger && (response.content !== undefined || response.produces !== undefined)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Swagger response must use schema and operation-level produces: ${status}`);
325
+ if (!swagger && response.schema !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `OpenAPI 3 response must place schemas under content: ${status}`);
326
+ const media = firstContent(response.content);
327
+ const schema = media.schema === undefined && response.schema !== undefined ? { schema: response.schema } : {};
328
+ const swaggerProduces = swagger ? (Array.isArray(operation.produces) ? operation.produces : Array.isArray(ir.document.produces) ? ir.document.produces : []) : [];
329
+ const contentType = media.contentType ?? primarySwaggerMediaType(swaggerProduces);
330
+ const reusable = bareReusableReferenceName(value, 'response', swagger);
331
+ return { status, description: response.description, ...media, ...schema, ...(contentType ? { contentType } : {}), ...(reusable === undefined ? {} : { reusable }) };
332
+ }) };
333
+ }
@@ -0,0 +1,233 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, join, normalize } from 'node:path';
3
+ import { ZopiaError } from '../errors';
4
+ import { resolveOpenApiLocalRef } from './openapi-ref';
5
+ import type { OpenApiDocument } from './openapi';
6
+ import { parseYaml } from './yaml';
7
+
8
+ /** One normalized external reference target: a file in the spec folder plus a JSON Pointer. */
9
+ interface ExternalLocation {
10
+ /** Normalized filesystem key used for caching and cycle detection. */
11
+ fileKey: string;
12
+ /** Display name of the file (as written in the reference, without `./`). */
13
+ name: string;
14
+ /** Path used for reading. */
15
+ path: string;
16
+ /** Raw pointer text after `#` ('' = whole document) or undefined when the fragment is unsupported. */
17
+ pointer: string | undefined;
18
+ }
19
+
20
+ interface BundleContext {
21
+ /** Normalized key of the root input file; self-file references resolve into the root document. */
22
+ rootKey: string;
23
+ /** Root document (host of self-file references and pointer validation). */
24
+ root: OpenApiDocument;
25
+ /** Parsed-file cache; every external file is read and parsed exactly once. */
26
+ cache: Map<string, Promise<unknown>>;
27
+ /** Locations currently on the expansion stack (`fileKey|pointer`) for cycle detection. */
28
+ chain: string[];
29
+ /** Active expansion depth for the nesting guard. */
30
+ depth: number;
31
+ /** Folder of the root spec file; all supported external files live here. */
32
+ sourceDir: string;
33
+ }
34
+
35
+ /** Maximum external-reference expansion depth before failing deterministically (R-1006). */
36
+ const EXTERNAL_REF_DEPTH_LIMIT = 512;
37
+
38
+ /** Same-folder grammar: a plain file name (no separators, no `..`/`:`/NUL, no scheme/drive) with a spec extension. */
39
+ const EXTERNAL_FILE_PATTERN = /^(?:\.\/)?([^/\\]+\.(?:json|ya?ml))#?(.*)$/i;
40
+
41
+ const escapePointer = (value: string | number): string => String(value).replace(/~/g, '~0').replace(/\//g, '~1');
42
+ const childPointer = (at: string, value: string | number): string => `${at}/${escapePointer(value)}`;
43
+
44
+ /** Structural container keys whose children are nodes (kept in sync with the preflight reference walker). */
45
+ const STRUCTURAL_CONTAINER_KEYS = new Set(['paths', 'schemas', 'definitions', '$defs', 'properties', 'patternProperties', 'dependentSchemas', 'responses', 'content', 'headers', 'links', 'encoding', 'callbacks', 'parameters', 'requestBodies', 'securitySchemes', 'securityDefinitions', 'pathItems']);
46
+
47
+ /** Literal/demo positions that must not be interpreted as real references (kept in sync with the preflight walker). */
48
+ const LITERAL_VALUE_KEYS = new Set(['example', 'default', 'enum', 'const']);
49
+
50
+ type VisitMode = 'normal' | 'map' | 'example-map' | 'example-object';
51
+
52
+ /** Parse and validate a non-local `$ref` against the same-folder grammar (D-17). */
53
+ function classifyExternalRef(ref: string, refAt: string, sourceDir: string): ExternalLocation {
54
+ const match = EXTERNAL_FILE_PATTERN.exec(ref);
55
+ const name = match?.[1];
56
+ if (!match || !name || name === '.' || name === '..' || name.includes('\0') || name.includes(':')) {
57
+ throw new ZopiaError('ZOPIA_REF_EXTERNAL', `external reference is not supported outside the spec folder: ${ref}`, { at: refAt, hint: 'use a JSON/YAML file in the same folder as the spec' });
58
+ }
59
+ const path = join(sourceDir, name);
60
+ return { fileKey: normalize(path), name, path, pointer: match[2] ?? '' };
61
+ }
62
+
63
+ /** Read and parse a referenced file exactly once, mirroring the input pipeline's JSON/YAML selection. */
64
+ async function loadExternalFile(ctx: BundleContext, location: ExternalLocation, refAt: string): Promise<unknown> {
65
+ const cached = ctx.cache.get(location.fileKey);
66
+ if (cached) return cached;
67
+ const task = (async (): Promise<unknown> => {
68
+ let text: string;
69
+ try { text = await readFile(location.path, 'utf8'); }
70
+ catch (error) {
71
+ throw new ZopiaError('ZOPIA_REF_EXTERNAL', `unable to read external reference target: ${location.name}`, { at: refAt, hint: `check that ${location.name} exists next to the spec and is readable`, cause: error });
72
+ }
73
+ if (/\.ya?ml$/i.test(location.name)) {
74
+ try { return parseYaml(text); }
75
+ catch (error) {
76
+ if (error instanceof ZopiaError && error.code === 'ZOPIA_SPEC_INVALID_YAML') {
77
+ throw new ZopiaError('ZOPIA_SPEC_INVALID_YAML', error.message.slice(`${error.code}: `.length), { at: location.path, hint: error.hint, cause: error });
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ try { return JSON.parse(text) as unknown; }
83
+ catch (error) {
84
+ throw new ZopiaError('ZOPIA_SPEC_INVALID_JSON', `invalid JSON in external reference target ${location.name}: ${error instanceof Error ? error.message : String(error)}`, { at: location.path, hint: 'fix the JSON syntax', cause: error });
85
+ }
86
+ })();
87
+ ctx.cache.set(location.fileKey, task);
88
+ return task;
89
+ }
90
+
91
+ /** Resolve an optional pointer inside a parsed external file (or the root document). */
92
+ function resolveTarget(document: unknown, name: string, pointer: string | undefined, refAt: string): unknown {
93
+ if (pointer !== '' && pointer !== undefined && !pointer.startsWith('/')) {
94
+ throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `unsupported reference fragment: ${name}#${pointer}`, { at: refAt, hint: 'use a JSON Pointer after `#` (or omit it for the whole file)' });
95
+ }
96
+ if (!pointer) return document;
97
+ try { return resolveOpenApiLocalRef(document as OpenApiDocument, `#${pointer}`); }
98
+ catch (error) {
99
+ if (error instanceof ZopiaError && error.code === 'ZOPIA_REF_NOT_FOUND') {
100
+ throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `unresolved external reference: ${name}#${pointer}`, { at: refAt, hint: `check that ${name} contains the pointer ${pointer}`, cause: error });
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /** Deep-clone plain document content so spliced subtrees never share identity with their source file. */
107
+ function cloneSpecValue(value: unknown): unknown {
108
+ if (Array.isArray(value)) return value.map(cloneSpecValue);
109
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, cloneSpecValue(entry)]));
110
+ return value;
111
+ }
112
+
113
+ /** Walk data structures exactly like the preflight reference walker, expanding supported external refs. */
114
+ async function bundleSubtree(ctx: BundleContext, value: unknown, at: string, ownerFile: string | undefined, mode: VisitMode = 'normal'): Promise<unknown> {
115
+ if (!value || typeof value !== 'object') return value;
116
+ if (Array.isArray(value)) {
117
+ const items: unknown[] = [];
118
+ for (const [index, item] of value.entries()) items.push(await bundleSubtree(ctx, item, childPointer(at, index), ownerFile, 'normal'));
119
+ return items;
120
+ }
121
+ const object = value as Record<string, unknown>;
122
+ if ((mode === 'normal' || mode === 'example-object') && Object.prototype.hasOwnProperty.call(object, '$ref') && typeof object.$ref === 'string' && object.$ref.length > 0) {
123
+ let ref = object.$ref;
124
+ if (ref.startsWith('#')) {
125
+ if (ownerFile === undefined) return object; // host-document reference: stays local
126
+ ref = `${ownerFile}${ref}`; // a local reference inside bundled content belongs to its owning file
127
+ }
128
+ return bundleReference(ctx, object, ref, childPointer(at, '$ref'), at, ownerFile);
129
+ }
130
+ const result: Record<string, unknown> = {};
131
+ for (const [key, child] of Object.entries(object)) {
132
+ result[key] = await bundleChild(ctx, key, child, at, ownerFile, mode);
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /** Bundle one child value under the preflight walker's per-key mode rules (structural maps, literals, 2.0-vs-3.x examples). */
138
+ function bundleChild(ctx: BundleContext, key: string, child: unknown, at: string, ownerFile: string | undefined, mode: VisitMode): Promise<unknown> | unknown {
139
+ if (mode === 'map') return bundleSubtree(ctx, child, childPointer(at, key), ownerFile, 'normal');
140
+ if (mode === 'example-map') return bundleSubtree(ctx, child, childPointer(at, key), ownerFile, 'example-object');
141
+ if (mode === 'example-object' && key === 'value') return child;
142
+ if (LITERAL_VALUE_KEYS.has(key) || key.startsWith('x-')) return child;
143
+ if (key === 'examples') {
144
+ return ctx.root.swagger !== '2.0' && !Array.isArray(child)
145
+ ? bundleSubtree(ctx, child, childPointer(at, key), ownerFile, 'example-map')
146
+ : child;
147
+ }
148
+ return bundleSubtree(ctx, child, childPointer(at, key), ownerFile, STRUCTURAL_CONTAINER_KEYS.has(key) ? 'map' : 'normal');
149
+ }
150
+
151
+ function checkDepthLimit(ctx: BundleContext, at: string): void {
152
+ if (ctx.depth > EXTERNAL_REF_DEPTH_LIMIT) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `external reference expansion exceeds ${EXTERNAL_REF_DEPTH_LIMIT} levels at ${at}`, { at, hint: 'reduce external reference nesting' });
153
+ }
154
+
155
+ /** Expand one validated external reference: load, resolve, clone, bundle, then re-attach sibling keys. */
156
+ async function bundleReference(ctx: BundleContext, object: Record<string, unknown>, ref: string, refAt: string, at: string, ownerFile: string | undefined): Promise<unknown> {
157
+ const location = classifyExternalRef(ref, refAt, ctx.sourceDir);
158
+ const chainKey = `${location.fileKey}|${location.pointer ?? ''}`;
159
+ if (ctx.chain.includes(chainKey)) {
160
+ const display = [...ctx.chain, chainKey].map((entry) => entry.replace(ctx.sourceDir === '.' ? '' : `${ctx.sourceDir}/`, '').replace('|', '#'));
161
+ throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `circular external reference: ${display.join(' → ')}`, { at: refAt, hint: 'break the external reference cycle' });
162
+ }
163
+ const isRoot = location.fileKey === ctx.rootKey;
164
+ const targetDocument = isRoot ? ctx.root : await loadExternalFile(ctx, location, refAt);
165
+ const clone = cloneSpecValue(resolveTarget(targetDocument, location.name, location.pointer, refAt));
166
+ ctx.chain.push(chainKey);
167
+ ctx.depth += 1;
168
+ checkDepthLimit(ctx, at);
169
+ let bundled: unknown;
170
+ try { bundled = await bundleSubtree(ctx, clone, at, isRoot ? undefined : location.name); }
171
+ finally {
172
+ ctx.depth -= 1;
173
+ ctx.chain.pop();
174
+ }
175
+ const siblingEntries = Object.entries(object).filter(([key]) => key !== '$ref');
176
+ if (siblingEntries.length === 0) return bundled;
177
+ if (!bundled || typeof bundled !== 'object' || Array.isArray(bundled)) {
178
+ throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `external reference ${ref} with sibling keys must target an object`, { at: refAt, hint: 'remove the sibling keys or target a mapping' });
179
+ }
180
+ // Preflight parity: sibling keys are visited with the same per-key rules as any other node,
181
+ // so sibling-held external references resolve too (and sibling-held literals stay literal).
182
+ const siblings: Record<string, unknown> = {};
183
+ for (const [key, value] of siblingEntries) siblings[key] = await bundleChild(ctx, key, value, at, ownerFile, 'normal');
184
+ return { ...(bundled as Record<string, unknown>), ...siblings };
185
+ }
186
+
187
+ /**
188
+ * 🔗 Bundle same-folder external `$ref`s into a file-backed OpenAPI document (D-17).
189
+ *
190
+ * References of the form `other.yaml#/pointer` (including `other.json`,
191
+ * `other.yml`, and `./…` spellings) are resolved against the folder of the
192
+ * spec file path that produced `document`, read and parsed with the same
193
+ * JSON/YAML rules as the primary input, and spliced in place before
194
+ * normalization. Nested/cross-file references inside bundled content resolve
195
+ * against their owning file, while local references of the host document are
196
+ * untouched. Locations outside the spec folder (URLs, absolute paths, `../`,
197
+ * subdirectories, schemes/drives) fail with `ZOPIA_REF_EXTERNAL`; unreadable
198
+ * targets keep that code with the file as `cause`, unparsable targets surface
199
+ * `ZOPIA_SPEC_INVALID_JSON`/`ZOPIA_SPEC_INVALID_YAML` at the target path, and
200
+ * missing pointers or circular external chains fail with
201
+ * `ZOPIA_REF_NOT_FOUND`. Each referenced file is read exactly once (P-1),
202
+ * spliced content is deep-cloned so the returned document never shares object
203
+ * identity with another document, and traversal skips literal/example payload
204
+ * positions exactly like the preflight reference walker. Reverse conversion
205
+ * emits the bundled single-file document and does not re-split files (D-17).
206
+ *
207
+ * @param document Parsed OpenAPI document read from a spec file.
208
+ * @param sourceFile Path of the spec file the document was read from; its folder bounds resolution.
209
+ * @returns A new document with every supported external reference resolved inline.
210
+ * @throws {ZopiaError} `ZOPIA_REF_*` for unsupported/unreadable/circular targets; `ZOPIA_SPEC_INVALID_JSON`/`ZOPIA_SPEC_INVALID_YAML` for unparsable targets.
211
+ * @example
212
+ * ```ts
213
+ * import { readFile } from 'node:fs/promises';
214
+ * import { bundleExternalOpenApiRefs } from './src/conversions/openapi-external-ref';
215
+ *
216
+ * const document = JSON.parse(await readFile('spec/openapi.json', 'utf8')) as Record<string, unknown>;
217
+ * const bundled = await bundleExternalOpenApiRefs(document as never, 'spec/openapi.json');
218
+ * console.log(bundled);
219
+ * ```
220
+ * @see [docs/06-conversions.md → Engine ③](../../docs/06-conversions.md)
221
+ */
222
+ export async function bundleExternalOpenApiRefs(document: OpenApiDocument, sourceFile: string): Promise<OpenApiDocument> {
223
+ if (!document || typeof document !== 'object' || Array.isArray(document)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'expected a Swagger/OpenAPI document object', { at: '#', hint: 'provide a Swagger/OpenAPI document object' });
224
+ const ctx: BundleContext = {
225
+ rootKey: normalize(sourceFile),
226
+ root: document,
227
+ cache: new Map(),
228
+ chain: [],
229
+ depth: 0,
230
+ sourceDir: dirname(sourceFile),
231
+ };
232
+ return (await bundleSubtree(ctx, document, '#', undefined)) as OpenApiDocument;
233
+ }
@@ -0,0 +1,74 @@
1
+ import { ZopiaError } from '../errors';
2
+ import { collectOpenApiOperations, collectOpenApiWebhookOperations, type OpenApiOperation } from './openapi-to-api-docs';
3
+ import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
4
+
5
+ /** Validated operation-level intermediate representation used by Engine â‘¢. */
6
+ export interface OpenApiOperationIR {
7
+ /** Original OpenAPI path template. */
8
+ path: string;
9
+ /** Uppercase HTTP method emitted to km-api. */
10
+ method: Uppercase<OpenApiOperation['method']>;
11
+ /** km-api path shape, preserving OpenAPI parameter braces. */
12
+ pathShape: string;
13
+ /** Explicit or deterministically derived operation identifier. */
14
+ operationId: string;
15
+ /** Optional operation summary. */
16
+ summary?: string;
17
+ /** Optional operation description. */
18
+ description?: string;
19
+ /** km-api tags, normalized with `#` prefixes. */
20
+ tags: string[];
21
+ /** Whether the source operation is deprecated. */
22
+ deprecated: boolean;
23
+ /** Effective operation or document security requirements. */
24
+ security?: unknown[];
25
+ /** Original operation object. */
26
+ operation: Record<string, any>;
27
+ /** Merged path-level and operation-level parameters. */
28
+ parameters: any[];
29
+ /** Complete normalized source document used for local reference resolution. */
30
+ document: OpenApiDocument;
31
+ }
32
+
33
+ function security(value: unknown, context: string): unknown[] | undefined {
34
+ if (value === undefined) return undefined;
35
+ if (!Array.isArray(value) || !value.every((item) => item && typeof item === 'object' && !Array.isArray(item) && Object.entries(item).every(([name, scopes]) => name.length > 0 && Array.isArray(scopes) && scopes.every((scope) => typeof scope === 'string')))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid security requirements: ${context}`);
36
+ return value;
37
+ }
38
+
39
+ function tags(value: unknown): string[] {
40
+ if (value === undefined) return [];
41
+ if (!Array.isArray(value) || !value.every((tag) => typeof tag === 'string')) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid operation tags: expected an array of strings');
42
+ return value.map((tag) => tag.startsWith('#') ? tag : `#${tag}`);
43
+ }
44
+
45
+ /**
46
+ * Build the validated operation-level IR used by endpoint rendering.
47
+ *
48
+ * @param input Valid Swagger/OpenAPI object or JSON text.
49
+ * @returns Canonically ordered validated operation records.
50
+ * @throws {@link ZopiaError} when source operation metadata is invalid.
51
+ */
52
+ export function buildOpenApiOperationIR(input: OpenApiDocument | string): OpenApiOperationIR[] {
53
+ const { document } = normalizeOpenApiDocument(input);
54
+ return [...collectOpenApiOperations(document), ...collectOpenApiWebhookOperations(document)].map((entry) => {
55
+ const operation = entry.operation;
56
+ if (operation.summary !== undefined && typeof operation.summary !== 'string') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid summary: ${entry.method.toUpperCase()} ${entry.path}`);
57
+ if (operation.description !== undefined && typeof operation.description !== 'string') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid description: ${entry.method.toUpperCase()} ${entry.path}`);
58
+ if (operation.deprecated !== undefined && typeof operation.deprecated !== 'boolean') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid deprecated flag: ${entry.method.toUpperCase()} ${entry.path}`);
59
+ return {
60
+ path: entry.path,
61
+ method: entry.method.toUpperCase() as Uppercase<OpenApiOperation['method']>,
62
+ pathShape: entry.path,
63
+ operationId: entry.operationId,
64
+ summary: operation.summary,
65
+ description: operation.description,
66
+ tags: tags(operation.tags),
67
+ deprecated: operation.deprecated === true,
68
+ security: Object.prototype.hasOwnProperty.call(operation, 'security') ? security(operation.security, `${entry.method.toUpperCase()} ${entry.path}`) : security(document.security, 'document'),
69
+ operation,
70
+ parameters: entry.parameters,
71
+ document,
72
+ };
73
+ });
74
+ }