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.
- package/CHANGELOG.md +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ZopiaError } from '../errors';
|
|
2
|
+
import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
|
|
3
|
+
import { resolveOpenApiLocalRef } from './openapi-ref';
|
|
4
|
+
|
|
5
|
+
/** Canonical km-api/OpenAPI operation method order. */
|
|
6
|
+
export const OPENAPI_METHODS = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'] as const;
|
|
7
|
+
|
|
8
|
+
/** Supported lowercase OpenAPI operation method. */
|
|
9
|
+
export type OpenApiMethod = (typeof OPENAPI_METHODS)[number];
|
|
10
|
+
|
|
11
|
+
/** Collected source operation with merged parameters and a stable identifier. */
|
|
12
|
+
export interface OpenApiOperation {
|
|
13
|
+
/** Original OpenAPI path template. */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Lowercase HTTP method. */
|
|
16
|
+
method: OpenApiMethod;
|
|
17
|
+
/** Original operation object. */
|
|
18
|
+
operation: Record<string, any>;
|
|
19
|
+
/** Explicit or deterministically derived operation identifier. */
|
|
20
|
+
operationId: string;
|
|
21
|
+
/** Path-level and operation-level parameters after override merging. */
|
|
22
|
+
parameters: any[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pascalPath(path: string): string {
|
|
26
|
+
return path.split('/').filter(Boolean).map((segment) => segment.replace(/[{}]/g, '').split(/[^A-Za-z0-9]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')).join('') || 'Root';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Derive a stable operation identifier from a path and method.
|
|
30
|
+
*
|
|
31
|
+
* @param path OpenAPI path template beginning with `/`.
|
|
32
|
+
* @param method Supported lowercase HTTP method.
|
|
33
|
+
* @returns Method-prefixed camel-case operation identifier.
|
|
34
|
+
* @throws {@link ZopiaError} when the path or method is invalid.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveOperationId(path: string, method: OpenApiMethod): string {
|
|
37
|
+
if (typeof path !== 'string' || !path.startsWith('/')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid API path: ${String(path)}`, { at: 'path' });
|
|
38
|
+
if (!OPENAPI_METHODS.includes(method)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsupported HTTP method: ${String(method)}`, { at: 'method' });
|
|
39
|
+
return `${method}${pascalPath(path)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parameterIdentity(parameter: Record<string, any>, document: OpenApiDocument): string {
|
|
43
|
+
let current = parameter; const seen = new Set<string>();
|
|
44
|
+
while ('$ref' in current) {
|
|
45
|
+
if (typeof current.$ref !== 'string' || !current.$ref) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', 'Invalid parameter $ref');
|
|
46
|
+
if (seen.has(current.$ref)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Circular parameter $ref: ${current.$ref}`);
|
|
47
|
+
seen.add(current.$ref);
|
|
48
|
+
const target = resolveOpenApiLocalRef(document, current.$ref);
|
|
49
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Invalid parameter $ref target: ${current.$ref}`);
|
|
50
|
+
current = { ...(target as Record<string, any>), ...Object.fromEntries(Object.entries(current).filter(([key]) => key !== '$ref')) };
|
|
51
|
+
}
|
|
52
|
+
return `${String(current.in)}:${String(current.name)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collect path operations in the canonical km-api method order.
|
|
57
|
+
*
|
|
58
|
+
* @param input Valid Swagger/OpenAPI object or JSON text.
|
|
59
|
+
* @returns Operations with merged parameters and unique stable identifiers.
|
|
60
|
+
* @throws {@link ZopiaError} when the source, references, or operations are invalid.
|
|
61
|
+
*/
|
|
62
|
+
export function collectOpenApiOperations(input: OpenApiDocument | string): OpenApiOperation[] {
|
|
63
|
+
const { document } = normalizeOpenApiDocument(input); const operations: OpenApiOperation[] = []; const ids = new Set<string>(); const owners = new Map<string, OpenApiOperation>();
|
|
64
|
+
for (const path of Object.keys(document.paths)) {
|
|
65
|
+
if (path.startsWith('x-')) continue;
|
|
66
|
+
const item = document.paths[path];
|
|
67
|
+
let resolvedItem: any = item;
|
|
68
|
+
const seenPathRefs = new Set<string>();
|
|
69
|
+
while ('$ref' in resolvedItem) {
|
|
70
|
+
if (typeof resolvedItem.$ref !== 'string' || !resolvedItem.$ref) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid path-item $ref: ${path}`);
|
|
71
|
+
if (seenPathRefs.has(resolvedItem.$ref)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Circular path-item $ref: ${resolvedItem.$ref}`);
|
|
72
|
+
seenPathRefs.add(resolvedItem.$ref);
|
|
73
|
+
const target = resolveOpenApiLocalRef(document, resolvedItem.$ref);
|
|
74
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid path-item $ref: ${resolvedItem.$ref}`);
|
|
75
|
+
resolvedItem = { ...target, ...Object.fromEntries(Object.entries(resolvedItem).filter(([key]) => key !== '$ref')) };
|
|
76
|
+
}
|
|
77
|
+
for (const method of OPENAPI_METHODS) {
|
|
78
|
+
const operation = resolvedItem[method];
|
|
79
|
+
if (operation === undefined) continue;
|
|
80
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI operation: ${method.toUpperCase()} ${path}`);
|
|
81
|
+
if (operation.operationId !== undefined && (typeof operation.operationId !== 'string' || !operation.operationId.trim())) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid operationId: ${method.toUpperCase()} ${path}`);
|
|
82
|
+
const pathParameters = resolvedItem.parameters === undefined ? [] : resolvedItem.parameters;
|
|
83
|
+
if (!Array.isArray(pathParameters) || !pathParameters.every((parameter: any) => parameter && typeof parameter === 'object' && !Array.isArray(parameter))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid path parameters: ${path}`);
|
|
84
|
+
const operationParameters = operation.parameters === undefined ? [] : operation.parameters;
|
|
85
|
+
if (!Array.isArray(operationParameters) || !operationParameters.every((parameter: any) => parameter && typeof parameter === 'object' && !Array.isArray(parameter))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid operation parameters: ${method.toUpperCase()} ${path}`);
|
|
86
|
+
const identities = (parameters: any[], label: string): string[] => {
|
|
87
|
+
const keys = parameters.map((parameter) => parameterIdentity(parameter, document));
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
if (seen.has(key)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate ${label} parameter: ${key}`);
|
|
91
|
+
seen.add(key);
|
|
92
|
+
}
|
|
93
|
+
return keys;
|
|
94
|
+
};
|
|
95
|
+
const pathIdentities = identities(pathParameters, 'path-level');
|
|
96
|
+
const operationIdentities = identities(operationParameters, 'operation-level');
|
|
97
|
+
const mergedParameters = [...pathParameters]; const mergedIdentities = [...pathIdentities];
|
|
98
|
+
for (let parameterIndex = 0; parameterIndex < operationParameters.length; parameterIndex += 1) {
|
|
99
|
+
const parameter = operationParameters[parameterIndex]; const identity = operationIdentities[parameterIndex];
|
|
100
|
+
const index = mergedIdentities.indexOf(identity);
|
|
101
|
+
if (index >= 0) mergedParameters[index] = parameter;
|
|
102
|
+
else { mergedParameters.push(parameter); mergedIdentities.push(identity); }
|
|
103
|
+
}
|
|
104
|
+
let operationId: string;
|
|
105
|
+
if (operation.operationId !== undefined) {
|
|
106
|
+
operationId = operation.operationId;
|
|
107
|
+
const previous = owners.get(operationId);
|
|
108
|
+
if (previous) {
|
|
109
|
+
if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
|
|
110
|
+
let replacement = previous.operationId; let suffix = 1;
|
|
111
|
+
while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
|
|
112
|
+
ids.delete(previous.operationId); owners.delete(previous.operationId);
|
|
113
|
+
previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
const base = deriveOperationId(path, method);
|
|
117
|
+
operationId = base; let suffix = 1;
|
|
118
|
+
while (ids.has(operationId)) operationId = `${base}${++suffix}`;
|
|
119
|
+
}
|
|
120
|
+
const collected = { path, method, operation, operationId, parameters: mergedParameters };
|
|
121
|
+
ids.add(operationId); owners.set(operationId, collected); operations.push(collected);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return operations;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Collect every `webhooks` operation (OpenAPI 3.1) using the same operation rules as path operations.
|
|
129
|
+
*
|
|
130
|
+
* Webhook names are arbitrary identifiers rather than URL path templates, so no path-parameter
|
|
131
|
+
* template validation applies. `x-` names are preserved through the manifest overlays instead.
|
|
132
|
+
*
|
|
133
|
+
* @param document Normalized OpenAPI document.
|
|
134
|
+
* @returns Deterministically ordered webhook operations keyed by their webhook name.
|
|
135
|
+
* @throws {@link ZopiaError} when a webhook item, webhook-item `$ref` chain, or webhook operation is invalid.
|
|
136
|
+
*/
|
|
137
|
+
export function collectOpenApiWebhookOperations(document: OpenApiDocument): OpenApiOperation[] {
|
|
138
|
+
const operations: OpenApiOperation[] = []; const ids = new Set<string>(); const owners = new Map<string, OpenApiOperation>();
|
|
139
|
+
const webhooks = (document as { webhooks?: unknown }).webhooks;
|
|
140
|
+
if (webhooks === undefined) return operations;
|
|
141
|
+
if (!webhooks || typeof webhooks !== 'object' || Array.isArray(webhooks)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI webhooks', { at: '#/webhooks' });
|
|
142
|
+
for (const name of Object.keys(webhooks as Record<string, unknown>)) {
|
|
143
|
+
if (name.startsWith('x-')) continue;
|
|
144
|
+
let resolvedItem: any = (webhooks as Record<string, any>)[name];
|
|
145
|
+
if (!resolvedItem || typeof resolvedItem !== 'object' || Array.isArray(resolvedItem)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI webhook item: ${name}`);
|
|
146
|
+
const seenRefs = new Set<string>();
|
|
147
|
+
while ('$ref' in resolvedItem) {
|
|
148
|
+
if (typeof resolvedItem.$ref !== 'string' || !resolvedItem.$ref) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid webhook-item $ref: ${name}`);
|
|
149
|
+
if (seenRefs.has(resolvedItem.$ref)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Circular webhook-item $ref: ${resolvedItem.$ref}`);
|
|
150
|
+
seenRefs.add(resolvedItem.$ref);
|
|
151
|
+
const target = resolveOpenApiLocalRef(document, resolvedItem.$ref);
|
|
152
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid webhook-item $ref: ${resolvedItem.$ref}`);
|
|
153
|
+
resolvedItem = { ...target, ...Object.fromEntries(Object.entries(resolvedItem as Record<string, unknown>).filter(([key]) => key !== '$ref')) };
|
|
154
|
+
}
|
|
155
|
+
for (const method of OPENAPI_METHODS) {
|
|
156
|
+
const operation = resolvedItem[method];
|
|
157
|
+
if (operation === undefined) continue;
|
|
158
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI webhook operation: ${method.toUpperCase()} ${name}`);
|
|
159
|
+
if (operation.operationId !== undefined && (typeof operation.operationId !== 'string' || !operation.operationId.trim())) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid operationId: ${method.toUpperCase()} ${name}`);
|
|
160
|
+
const pathParameters = resolvedItem.parameters === undefined ? [] : resolvedItem.parameters;
|
|
161
|
+
if (!Array.isArray(pathParameters) || !pathParameters.every((parameter: any) => parameter && typeof parameter === 'object' && !Array.isArray(parameter))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid webhook-item parameters: ${name}`);
|
|
162
|
+
const operationParameters = operation.parameters === undefined ? [] : operation.parameters;
|
|
163
|
+
if (!Array.isArray(operationParameters) || !operationParameters.every((parameter: any) => parameter && typeof parameter === 'object' && !Array.isArray(parameter))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid webhook operation parameters: ${method.toUpperCase()} ${name}`);
|
|
164
|
+
const identities = (parameters: any[], label: string): string[] => {
|
|
165
|
+
const keys = parameters.map((parameter) => parameterIdentity(parameter, document));
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (seen.has(key)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate ${label} parameter: ${key}`);
|
|
169
|
+
seen.add(key);
|
|
170
|
+
}
|
|
171
|
+
return keys;
|
|
172
|
+
};
|
|
173
|
+
const pathIdentities = identities(pathParameters, 'webhook-item');
|
|
174
|
+
const operationIdentities = identities(operationParameters, 'operation-level');
|
|
175
|
+
const mergedParameters = [...pathParameters]; const mergedIdentities = [...pathIdentities];
|
|
176
|
+
for (let parameterIndex = 0; parameterIndex < operationParameters.length; parameterIndex += 1) {
|
|
177
|
+
const parameter = operationParameters[parameterIndex]; const identity = operationIdentities[parameterIndex];
|
|
178
|
+
const index = mergedIdentities.indexOf(identity);
|
|
179
|
+
if (index >= 0) mergedParameters[index] = parameter;
|
|
180
|
+
else { mergedParameters.push(parameter); mergedIdentities.push(identity); }
|
|
181
|
+
}
|
|
182
|
+
let operationId: string;
|
|
183
|
+
if (operation.operationId !== undefined) {
|
|
184
|
+
operationId = operation.operationId;
|
|
185
|
+
const previous = owners.get(operationId);
|
|
186
|
+
if (previous) {
|
|
187
|
+
if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
|
|
188
|
+
let replacement = previous.operationId; let suffix = 1;
|
|
189
|
+
while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
|
|
190
|
+
ids.delete(previous.operationId); owners.delete(previous.operationId);
|
|
191
|
+
previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
const base = `${method}${pascalPath(name)}`;
|
|
195
|
+
operationId = base; let suffix = 1;
|
|
196
|
+
while (ids.has(operationId)) operationId = `${base}${++suffix}`;
|
|
197
|
+
}
|
|
198
|
+
const collected = { path: name, method, operation, operationId, parameters: mergedParameters };
|
|
199
|
+
ids.add(operationId); owners.set(operationId, collected); operations.push(collected);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return operations;
|
|
203
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { asZopiaError, ZopiaError } from '../errors';
|
|
2
|
+
|
|
3
|
+
/** Mutable JSON-like Swagger/OpenAPI document accepted by normalization helpers. */
|
|
4
|
+
export type OpenApiDocument = Record<string, any>;
|
|
5
|
+
|
|
6
|
+
/** Supported normalized source dialect family. */
|
|
7
|
+
export type OpenApiVersion = '2.0' | '3.0' | '3.1';
|
|
8
|
+
|
|
9
|
+
/** Validated source envelope and its normalized dialect metadata. */
|
|
10
|
+
export interface NormalizedOpenApiDocument {
|
|
11
|
+
/** Validated source document. */
|
|
12
|
+
document: OpenApiDocument;
|
|
13
|
+
/** Detected Swagger/OpenAPI dialect family. */
|
|
14
|
+
version: OpenApiVersion;
|
|
15
|
+
/** Source `info.title`, when available after validation. */
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Source `info.version`, when available after validation. */
|
|
18
|
+
versionString?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pointerToken = (value: string): string => value.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
22
|
+
const PATH_ITEM_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse and validate the supported OpenAPI/Swagger document envelope.
|
|
26
|
+
*
|
|
27
|
+
* @param input Swagger/OpenAPI object or JSON text.
|
|
28
|
+
* @returns Validated document with detected dialect and info metadata.
|
|
29
|
+
* @throws {@link ZopiaError} when parsing or envelope validation fails.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeOpenApiDocument(input: OpenApiDocument | string): NormalizedOpenApiDocument {
|
|
32
|
+
let document: OpenApiDocument;
|
|
33
|
+
try { document = (typeof input === 'string' ? JSON.parse(input) : input) as OpenApiDocument; }
|
|
34
|
+
catch (error) { throw asZopiaError(error, 'ZOPIA_SPEC_INVALID_JSON', 'Invalid OpenAPI document', { at: '#', hint: 'fix the JSON syntax' }); }
|
|
35
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI document: expected an object', { at: '#' });
|
|
36
|
+
const hasSwaggerVersion = Object.prototype.hasOwnProperty.call(document, 'swagger');
|
|
37
|
+
const hasOpenApiVersion = Object.prototype.hasOwnProperty.call(document, 'openapi');
|
|
38
|
+
if (hasSwaggerVersion && hasOpenApiVersion) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI document: swagger and openapi version fields are mutually exclusive', { at: '#', hint: 'keep exactly one dialect version field' });
|
|
39
|
+
let version: OpenApiVersion;
|
|
40
|
+
if (document.swagger === '2.0') version = '2.0';
|
|
41
|
+
else if (typeof document.openapi === 'string' && /^3\.0(?:\.\d+)?$/.test(document.openapi)) version = '3.0';
|
|
42
|
+
else if (typeof document.openapi === 'string' && /^3\.1(?:\.\d+)?$/.test(document.openapi)) version = '3.1';
|
|
43
|
+
else throw new ZopiaError('ZOPIA_SPEC_UNSUPPORTED_VERSION', 'Unsupported OpenAPI document version; expected Swagger 2.0 or OpenAPI 3.0/3.1', { at: '#', hint: 'use Swagger 2.0, OpenAPI 3.0, or OpenAPI 3.1' });
|
|
44
|
+
const allowedRootFields = new Set(version === '2.0'
|
|
45
|
+
? ['swagger', 'info', 'host', 'basePath', 'schemes', 'consumes', 'produces', 'paths', 'definitions', 'parameters', 'responses', 'securityDefinitions', 'security', 'tags', 'externalDocs']
|
|
46
|
+
: ['openapi', 'info', 'servers', 'paths', 'components', 'security', 'tags', 'externalDocs', ...(version === '3.1' ? ['jsonSchemaDialect', 'webhooks'] : [])]);
|
|
47
|
+
const unsupportedRootField = Object.keys(document).find((key) => !allowedRootFields.has(key) && !key.startsWith('x-'));
|
|
48
|
+
if (unsupportedRootField) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: unsupported root field: ${unsupportedRootField}`, { at: `#/${pointerToken(unsupportedRootField)}`, hint: 'use a field supported by the selected dialect or an x- extension' });
|
|
49
|
+
if (version === '2.0') for (const field of ['consumes', 'produces'] as const) {
|
|
50
|
+
const value = document[field];
|
|
51
|
+
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: `#/${field}`, hint: `provide ${field} as an array of non-empty media-type strings` });
|
|
52
|
+
}
|
|
53
|
+
let schemas: unknown;
|
|
54
|
+
let schemasAt: string;
|
|
55
|
+
if (version === '2.0') {
|
|
56
|
+
schemas = document.definitions;
|
|
57
|
+
schemasAt = '#/definitions';
|
|
58
|
+
} else {
|
|
59
|
+
if (document.components !== undefined && (!document.components || typeof document.components !== 'object' || Array.isArray(document.components))) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI document: components must be an object', { at: '#/components', hint: 'provide an OpenAPI Components Object' });
|
|
60
|
+
schemas = document.components?.schemas;
|
|
61
|
+
schemasAt = '#/components/schemas';
|
|
62
|
+
}
|
|
63
|
+
if (schemas !== undefined) {
|
|
64
|
+
if (!schemas || typeof schemas !== 'object' || Array.isArray(schemas)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI document: schema components must be an object', { at: schemasAt, hint: 'provide a map of named schemas' });
|
|
65
|
+
for (const [name, schema] of Object.entries(schemas)) if (!name || typeof schema !== 'boolean' && (!schema || typeof schema !== 'object' || Array.isArray(schema))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid schema component: ${name || '(empty name)'}`, { at: `${schemasAt}/${pointerToken(name)}`, hint: 'provide a non-empty component name and an object or boolean schema' });
|
|
66
|
+
}
|
|
67
|
+
if (!document.info || typeof document.info !== 'object' || typeof document.info.title !== 'string' || document.info.title.trim() === '' || typeof document.info.version !== 'string' || document.info.version.trim() === '') throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI document: info.title and info.version are required', { at: '#/info', hint: 'provide non-empty info.title and info.version strings' });
|
|
68
|
+
if (!document.paths || typeof document.paths !== 'object' || Array.isArray(document.paths)) throw new ZopiaError('ZOPIA_SPEC_MISSING_PATHS', 'invalid OpenAPI document: paths must be an object', { at: '#/paths', hint: 'add a paths object to the API document' });
|
|
69
|
+
for (const [path, item] of Object.entries(document.paths)) {
|
|
70
|
+
if (!path.startsWith('/') && !path.startsWith('x-')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: path key must start with /: ${path}`, { at: `#/paths/${pointerToken(path)}`, hint: "start API path keys with '/'" });
|
|
71
|
+
if (path.startsWith('x-')) continue;
|
|
72
|
+
if (path.includes('?') || path.includes('#')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: path must not contain a query or fragment: ${path}`, { at: `#/paths/${pointerToken(path)}`, hint: 'move query values into parameter objects and remove URL fragments' });
|
|
73
|
+
if (/[{}]/.test(path) && !/^\/([^{}]|\{[A-Za-z0-9._-]+\})*$/.test(path)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: malformed path template: ${path}`, { at: `#/paths/${pointerToken(path)}`, hint: 'use balanced {parameter} path segments' });
|
|
74
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: path item must be an object: ${path}`, { at: `#/paths/${pointerToken(path)}`, hint: 'provide a Path Item object' });
|
|
75
|
+
const allowedPathItemFields = new Set(['$ref', 'parameters', ...PATH_ITEM_METHODS, ...(version === '2.0' ? [] : ['summary', 'description', 'servers'])]);
|
|
76
|
+
const unsupportedField = Object.keys(item).find((key) => !allowedPathItemFields.has(key) && !key.startsWith('x-'));
|
|
77
|
+
if (unsupportedField) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid OpenAPI document: unsupported path-item field: ${unsupportedField}`, { at: `#/paths/${pointerToken(path)}/${pointerToken(unsupportedField)}`, hint: 'use a supported HTTP method or an x- extension' });
|
|
78
|
+
}
|
|
79
|
+
return { document, version, title: document.info.title, versionString: document.info.version };
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ZopiaError } from '../errors';
|
|
2
|
+
/** One OpenAPI security-requirement alternative. */
|
|
3
|
+
export type OpenApiSecurityRequirement = Record<string, string[]>;
|
|
4
|
+
|
|
5
|
+
/** Result of selecting or creating zopia's deterministic fallback scheme. */
|
|
6
|
+
export interface ZopiaFallbackSecurityScheme {
|
|
7
|
+
/** Scheme name used by the synthesized operation requirement. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Security-scheme map including the fallback definition. */
|
|
10
|
+
schemes: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate and detach an OpenAPI security requirement list.
|
|
17
|
+
*
|
|
18
|
+
* @param value Candidate security-requirement array.
|
|
19
|
+
* @param context Human-readable field name used in failure messages.
|
|
20
|
+
* @returns Detached security-requirement alternatives in source order.
|
|
21
|
+
* @throws {@link ZopiaError} when the requirement shape is invalid.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeSecurityRequirements(value: unknown, context: string): OpenApiSecurityRequirement[] {
|
|
24
|
+
if (!Array.isArray(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid ${context}: expected an array`);
|
|
25
|
+
return value.map((alternative, index) => {
|
|
26
|
+
if (!isRecord(alternative)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid ${context} alternative ${index}: expected an object`);
|
|
27
|
+
const requirement: OpenApiSecurityRequirement = {};
|
|
28
|
+
for (const [name, scopes] of Object.entries(alternative)) {
|
|
29
|
+
if (!name || !Array.isArray(scopes) || !scopes.every((scope) => typeof scope === 'string')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid ${context} alternative ${index}: expected scheme names with string scope arrays`);
|
|
30
|
+
Object.defineProperty(requirement, name, { value: [...scopes], enumerable: true, configurable: true, writable: true });
|
|
31
|
+
}
|
|
32
|
+
return requirement;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCompatibleFallback(value: unknown, swagger: boolean): boolean {
|
|
37
|
+
if (!isRecord(value)) return false;
|
|
38
|
+
if (swagger) return value.type === 'apiKey' && value.in === 'header' && typeof value.name === 'string' && value.name.toLowerCase() === 'authorization';
|
|
39
|
+
return value.type === 'http' && typeof value.scheme === 'string' && value.scheme.toLowerCase() === 'bearer';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Add or reuse a collision-safe bearer fallback without replacing existing schemes.
|
|
44
|
+
*
|
|
45
|
+
* @param input Existing security-scheme map to copy and extend.
|
|
46
|
+
* @param swagger Whether to emit a Swagger 2.0 API-key-compatible fallback.
|
|
47
|
+
* @returns Selected fallback name and a detached security-scheme map.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureFallbackSecurityScheme(input: Record<string, unknown>, swagger: boolean): ZopiaFallbackSecurityScheme {
|
|
50
|
+
const schemes = { ...input };
|
|
51
|
+
let suffix = 1;
|
|
52
|
+
while (true) {
|
|
53
|
+
const name = suffix === 1 ? 'bearerAuth' : `bearerAuth${suffix}`;
|
|
54
|
+
if (!Object.prototype.hasOwnProperty.call(schemes, name)) {
|
|
55
|
+
Object.defineProperty(schemes, name, {
|
|
56
|
+
value: swagger
|
|
57
|
+
? { type: 'apiKey', name: 'Authorization', in: 'header' }
|
|
58
|
+
: { type: 'http', scheme: 'bearer' },
|
|
59
|
+
enumerable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
writable: true,
|
|
62
|
+
});
|
|
63
|
+
return { name, schemes };
|
|
64
|
+
}
|
|
65
|
+
if (isCompatibleFallback(schemes[name], swagger)) return { name, schemes };
|
|
66
|
+
suffix += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|