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,38 @@
|
|
|
1
|
+
import { ZopiaError } from '../errors';
|
|
2
|
+
import type { OpenApiDocument } from './openapi';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decode one RFC 6901 JSON Pointer segment.
|
|
6
|
+
*
|
|
7
|
+
* @param segment Escaped JSON Pointer path segment.
|
|
8
|
+
* @param ref Complete reference used in error diagnostics.
|
|
9
|
+
* @returns Decoded property name.
|
|
10
|
+
* @throws {@link ZopiaError} when `segment` contains an invalid escape.
|
|
11
|
+
*/
|
|
12
|
+
export function decodeJsonPointerSegment(segment: string, ref = segment): string {
|
|
13
|
+
if (typeof segment !== 'string') throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `invalid JSON Pointer segment: ${String(segment)}`, { at: String(ref), hint: 'use string JSON Pointer segments' });
|
|
14
|
+
let decoded: string;
|
|
15
|
+
try { decoded = decodeURIComponent(segment); }
|
|
16
|
+
catch (error) { throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Invalid percent encoding in OpenAPI reference: ${ref}`, { at: ref, hint: 'fix the local URI fragment encoding', cause: error }); }
|
|
17
|
+
if (/~(?![01])/.test(decoded)) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Invalid JSON Pointer escape in OpenAPI reference: ${ref}`, { at: ref, hint: 'fix the local JSON Pointer escape' });
|
|
18
|
+
return decoded.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a local JSON Pointer reference in an OpenAPI document.
|
|
23
|
+
*
|
|
24
|
+
* @param document Swagger/OpenAPI document containing the target.
|
|
25
|
+
* @param ref Local reference beginning with `#`.
|
|
26
|
+
* @returns Referenced value, including explicit `null` values.
|
|
27
|
+
* @throws {@link ZopiaError} when the document, reference, or target is invalid.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveOpenApiLocalRef(document: OpenApiDocument, ref: string): unknown {
|
|
30
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'invalid OpenAPI document: expected an object', { at: '#', hint: 'pass a Swagger/OpenAPI document object' });
|
|
31
|
+
if (typeof ref !== 'string' || (!ref.startsWith('#/') && ref !== '#')) throw new ZopiaError('ZOPIA_REF_EXTERNAL', `Only local OpenAPI references are supported: ${String(ref)}`, { at: String(ref), hint: "use a local reference beginning with '#'" });
|
|
32
|
+
const value = ref === '#' ? document : ref.slice(2).split('/').map((part) => decodeJsonPointerSegment(part, ref)).reduce<any>((current, key) => {
|
|
33
|
+
if (current === null || (typeof current !== 'object' && typeof current !== 'function') || !Object.prototype.hasOwnProperty.call(current, key)) return undefined;
|
|
34
|
+
return current[key];
|
|
35
|
+
}, document);
|
|
36
|
+
if (value === undefined) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `Unresolved OpenAPI reference: ${ref}`, { at: ref, hint: 'check that the local JSON Pointer target exists' });
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ZopiaError } from '../errors';
|
|
3
|
+
import { ZopiaWarningCollector, type ZopiaWarning } from '../warnings';
|
|
4
|
+
import { generateApiDocsFiles } from './api-docs-generate';
|
|
5
|
+
import type { ApiDocsMode } from './api-docs-layout';
|
|
6
|
+
import { bundleExternalOpenApiRefs } from './openapi-external-ref';
|
|
7
|
+
import { extractOperationContracts } from './openapi-contracts';
|
|
8
|
+
import { buildOpenApiOperationIR } from './openapi-ir';
|
|
9
|
+
import { collectOpenApiWebhookOperations } from './openapi-to-api-docs';
|
|
10
|
+
import { resolveOpenApiLocalRef } from './openapi-ref';
|
|
11
|
+
import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
|
|
12
|
+
import { parseYaml } from './yaml';
|
|
13
|
+
import { jsonSchemaToZod, type JsonSchema } from './json-schema-to-zod';
|
|
14
|
+
import { hashOpenApiDocument, ZOPIA_MANIFEST_FILE } from './manifest-writer';
|
|
15
|
+
import { formatManifestStaleness, inspectZopiaManifestStaleness } from './manifest-staleness';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { planPresetBuckets, ZOPIA_GENERATE_PRESETS, type ZopiaGeneratePreset, type ZopiaPresetTree } from './api-docs-presets';
|
|
18
|
+
|
|
19
|
+
/** Options for generating an api-docs tree from Swagger or OpenAPI. */
|
|
20
|
+
export interface ZopiaGenerateOptions {
|
|
21
|
+
/** Directory in which generated files are written. @default 'api_docs' */
|
|
22
|
+
outDir?: string;
|
|
23
|
+
/** Endpoint directory layout. @default 'directory' */
|
|
24
|
+
mode?: ApiDocsMode;
|
|
25
|
+
/** Emit one file per declared schema component. @default false */
|
|
26
|
+
insertComponents?: boolean;
|
|
27
|
+
/** Import emitted components from endpoint files. Requires `insertComponents`. @default false */
|
|
28
|
+
useComponentAsReference?: boolean;
|
|
29
|
+
/** Write the reverse-conversion manifest. @default true */
|
|
30
|
+
manifest?: boolean;
|
|
31
|
+
/** Write merge-safe per-endpoint `custom` companion modules and export them. @default false */
|
|
32
|
+
custom?: boolean;
|
|
33
|
+
/** Split generation into per-bucket sub-trees: `multi-tag` (per primary tag) or `multi-server` (per effective first server). @default undefined */
|
|
34
|
+
preset?: ZopiaGeneratePreset;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One file written by Engine ③, relative to its output directory. */
|
|
38
|
+
export interface ZopiaGeneratedFile {
|
|
39
|
+
/** Portable path relative to `outDir`. */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Generated artifact category. */
|
|
42
|
+
kind: 'endpoint' | 'component' | 'manifest';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Result returned by the Engine ③ public API. */
|
|
46
|
+
export interface ZopiaGenerateResult {
|
|
47
|
+
/** Every file written, sorted by portable relative path. */
|
|
48
|
+
files: ZopiaGeneratedFile[];
|
|
49
|
+
/** Structured, non-fatal conversion diagnostics. */
|
|
50
|
+
warnings: ZopiaWarning[];
|
|
51
|
+
/** Manifest path relative to `outDir`, absent when `manifest` is false. */
|
|
52
|
+
manifestPath?: string;
|
|
53
|
+
/** Generated preset sub-trees, present only when a preset split actually happened. */
|
|
54
|
+
trees?: ZopiaPresetTree[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ValidatedOptions {
|
|
58
|
+
outDir: string;
|
|
59
|
+
mode: ApiDocsMode;
|
|
60
|
+
insertComponents: boolean;
|
|
61
|
+
useComponentAsReference: boolean;
|
|
62
|
+
manifest: boolean;
|
|
63
|
+
custom: boolean;
|
|
64
|
+
preset?: ZopiaGeneratePreset;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const escapePointer = (value: string | number): string => String(value).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
68
|
+
const childPointer = (at: string, value: string | number): string => `${at}/${escapePointer(value)}`;
|
|
69
|
+
|
|
70
|
+
function validateOptions(options: ZopiaGenerateOptions | undefined): ValidatedOptions {
|
|
71
|
+
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
|
|
72
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'generate options must be an object', { hint: 'pass an options object or omit it' });
|
|
73
|
+
}
|
|
74
|
+
const value = options ?? {};
|
|
75
|
+
const known = new Set(['outDir', 'mode', 'insertComponents', 'useComponentAsReference', 'manifest', 'custom', 'preset']);
|
|
76
|
+
const unknown = Object.keys(value).find((key) => !known.has(key));
|
|
77
|
+
if (unknown) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unknown generate option: ${unknown}`, { at: unknown, hint: 'remove the unsupported option' });
|
|
78
|
+
if (value.outDir !== undefined && (typeof value.outDir !== 'string' || value.outDir.trim() === '' || value.outDir.includes('\0'))) {
|
|
79
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'outDir must be a non-empty path', { at: 'outDir', hint: 'provide a writable output directory' });
|
|
80
|
+
}
|
|
81
|
+
if (value.mode !== undefined && value.mode !== 'directory' && value.mode !== 'flat') {
|
|
82
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported layout mode: ${String(value.mode)}`, { at: 'mode', hint: "use 'directory' or 'flat'" });
|
|
83
|
+
}
|
|
84
|
+
for (const key of ['insertComponents', 'useComponentAsReference', 'manifest', 'custom'] as const) {
|
|
85
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `${key} must be a boolean`, { at: key });
|
|
86
|
+
}
|
|
87
|
+
if (value.useComponentAsReference === true && value.insertComponents !== true) {
|
|
88
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'useComponentAsReference requires insertComponents', { at: 'useComponentAsReference', hint: 'enable `insertComponents` first' });
|
|
89
|
+
}
|
|
90
|
+
if (value.preset !== undefined && !ZOPIA_GENERATE_PRESETS.includes(value.preset)) {
|
|
91
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported generate preset: ${String(value.preset)}`, { at: 'preset', hint: `use ${ZOPIA_GENERATE_PRESETS.join(' or ')}` });
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
outDir: value.outDir ?? 'api_docs',
|
|
95
|
+
mode: value.mode ?? 'directory',
|
|
96
|
+
insertComponents: value.insertComponents ?? false,
|
|
97
|
+
useComponentAsReference: value.useComponentAsReference ?? false,
|
|
98
|
+
manifest: value.manifest ?? true,
|
|
99
|
+
custom: value.custom ?? false,
|
|
100
|
+
...(value.preset === undefined ? {} : { preset: value.preset }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const YAML_PATH_PATTERN = /\.ya?ml$/i;
|
|
105
|
+
|
|
106
|
+
/** Detect inline YAML text: multi-line strings that are not JSON, or a single-line mapping entry. */
|
|
107
|
+
function isInlineYamlText(text: string): boolean {
|
|
108
|
+
if (text.includes('\n')) return true;
|
|
109
|
+
const firstLine = text.trimStart();
|
|
110
|
+
return /^---(?:\s|$)/.test(firstLine) || /^[^:#{}[\],&*!|>%@`][^:]*:(?:\s|$)/.test(firstLine);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function asSpecDocument(parsed: unknown, at: string | undefined): OpenApiDocument {
|
|
114
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'expected a Swagger/OpenAPI document object', { at: at ?? '#', hint: 'provide a Swagger/OpenAPI document object' });
|
|
115
|
+
return parsed as OpenApiDocument;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseJsonSpec(text: string, source: string | undefined): OpenApiDocument {
|
|
119
|
+
let parsed: unknown;
|
|
120
|
+
try { parsed = JSON.parse(text) as unknown; }
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new ZopiaError('ZOPIA_SPEC_INVALID_JSON', `invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { at: source, hint: 'fix the JSON syntax', cause: error });
|
|
123
|
+
}
|
|
124
|
+
return asSpecDocument(parsed, source);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseYamlSpec(text: string, source: string | undefined): OpenApiDocument {
|
|
128
|
+
try { return asSpecDocument(parseYaml(text), source); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (source !== undefined && error instanceof ZopiaError && error.code === 'ZOPIA_SPEC_INVALID_YAML') {
|
|
131
|
+
const message = error.message.slice(`${error.code}: `.length);
|
|
132
|
+
throw new ZopiaError('ZOPIA_SPEC_INVALID_YAML', message, { at: source, hint: error.hint, cause: error });
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The parsed input document plus the spec file it was read from (when any). */
|
|
139
|
+
export interface ReadOpenApiSourceInputResult {
|
|
140
|
+
/** Parsed Swagger/OpenAPI document. */
|
|
141
|
+
document: OpenApiDocument;
|
|
142
|
+
/** Input spec file path; external `$ref`s resolve against its folder (D-17). */
|
|
143
|
+
sourceFile?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readInput(input: string | Record<string, unknown>): Promise<ReadOpenApiSourceInputResult> {
|
|
147
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) return { document: input };
|
|
148
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
149
|
+
throw new ZopiaError('ZOPIA_SPEC_INVALID', 'input must be a Swagger/OpenAPI object, JSON/YAML text, or a .json/.yaml/.yml file path', { at: '#', hint: 'pass a Swagger/OpenAPI document object, document text, or file path' });
|
|
150
|
+
}
|
|
151
|
+
const trimmed = input.trimStart();
|
|
152
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return { document: parseJsonSpec(input, undefined) };
|
|
153
|
+
if (isInlineYamlText(input)) return { document: parseYamlSpec(input, undefined) };
|
|
154
|
+
const yamlPath = YAML_PATH_PATTERN.test(input);
|
|
155
|
+
let text: string;
|
|
156
|
+
try { text = await readFile(input, 'utf8'); }
|
|
157
|
+
catch (error) {
|
|
158
|
+
const code = yamlPath ? 'ZOPIA_SPEC_INVALID_YAML' : 'ZOPIA_SPEC_INVALID_JSON';
|
|
159
|
+
throw new ZopiaError(code, `unable to read ${yamlPath ? 'YAML' : 'JSON'} input: ${input}`, { at: input, hint: 'check that the spec file exists and is readable', cause: error });
|
|
160
|
+
}
|
|
161
|
+
if (yamlPath) return { document: parseYamlSpec(text, input), sourceFile: input };
|
|
162
|
+
if (/\.json$/i.test(input)) return { document: parseJsonSpec(text, input), sourceFile: input };
|
|
163
|
+
try { return { document: parseJsonSpec(text, input), sourceFile: input }; }
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof ZopiaError && error.code === 'ZOPIA_SPEC_INVALID_JSON') return { document: parseYamlSpec(text, input), sourceFile: input };
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizePublic(document: OpenApiDocument): OpenApiDocument {
|
|
171
|
+
try { return normalizeOpenApiDocument(document).document; }
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error instanceof ZopiaError) throw error;
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
if (message.includes('Unsupported OpenAPI document version')) {
|
|
176
|
+
throw new ZopiaError('ZOPIA_SPEC_UNSUPPORTED_VERSION', message, { at: '#', hint: 'supported: Swagger 2.0, OpenAPI 3.0, and OpenAPI 3.1' });
|
|
177
|
+
}
|
|
178
|
+
if (message.includes('paths must be an object')) throw new ZopiaError('ZOPIA_SPEC_MISSING_PATHS', message, { at: '#/paths' });
|
|
179
|
+
throw new ZopiaError('ZOPIA_SPEC_INVALID', message, { at: '#', hint: 'fix the invalid Swagger/OpenAPI document', cause: error });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateReferences(document: OpenApiDocument): void {
|
|
184
|
+
const stack = new Set<object>();
|
|
185
|
+
const structuralMaps = new Set(['paths', 'schemas', 'definitions', '$defs', 'properties', 'patternProperties', 'dependentSchemas', 'responses', 'content', 'headers', 'links', 'encoding', 'callbacks', 'parameters', 'requestBodies', 'securitySchemes', 'securityDefinitions', 'pathItems']);
|
|
186
|
+
type VisitMode = 'normal' | 'map' | 'example-map' | 'example-object';
|
|
187
|
+
const visit = (value: unknown, at: string, mode: VisitMode = 'normal'): void => {
|
|
188
|
+
if (!value || typeof value !== 'object') return;
|
|
189
|
+
if (stack.has(value as object)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'circular in-memory OpenAPI value', { at, hint: 'use JSON references instead of JavaScript object cycles' });
|
|
190
|
+
stack.add(value as object);
|
|
191
|
+
if (Array.isArray(value)) value.forEach((child, index) => visit(child, childPointer(at, index)));
|
|
192
|
+
else {
|
|
193
|
+
const object = value as Record<string, unknown>;
|
|
194
|
+
if ((mode === 'normal' || mode === 'example-object') && Object.prototype.hasOwnProperty.call(object, '$ref')) {
|
|
195
|
+
const refAt = childPointer(at, '$ref');
|
|
196
|
+
if (typeof object.$ref !== 'string' || object.$ref.length === 0) throw new ZopiaError('ZOPIA_REF_NOT_FOUND', 'reference must be a non-empty string', { at: refAt, hint: 'use a valid local JSON Pointer' });
|
|
197
|
+
if (!object.$ref.startsWith('#')) throw new ZopiaError('ZOPIA_REF_EXTERNAL', `external reference is not supported: ${object.$ref}`, { at: refAt, hint: 'multi-file references land in Phase 2' });
|
|
198
|
+
try { resolveOpenApiLocalRef(document, object.$ref); }
|
|
199
|
+
catch (error) { throw new ZopiaError('ZOPIA_REF_NOT_FOUND', `unresolved local reference: ${object.$ref}`, { at: refAt, hint: 'check the local JSON Pointer', cause: error }); }
|
|
200
|
+
}
|
|
201
|
+
for (const [key, child] of Object.entries(object)) {
|
|
202
|
+
const childAt = childPointer(at, key);
|
|
203
|
+
if (mode === 'map') visit(child, childAt);
|
|
204
|
+
else if (mode === 'example-map') visit(child, childAt, 'example-object');
|
|
205
|
+
else if (mode === 'example-object' && key === 'value') continue;
|
|
206
|
+
else if (['example', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-')) continue;
|
|
207
|
+
else if (key === 'examples') {
|
|
208
|
+
if (document.swagger !== '2.0' && !Array.isArray(child)) visit(child, childAt, 'example-map');
|
|
209
|
+
} else visit(child, childAt, structuralMaps.has(key) ? 'map' : 'normal');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
stack.delete(value as object);
|
|
213
|
+
};
|
|
214
|
+
visit(document, '#');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function primaryContent(content: unknown): [string, Record<string, any>] | undefined {
|
|
218
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return undefined;
|
|
219
|
+
const entries = Object.entries(content as Record<string, Record<string, any>>);
|
|
220
|
+
return entries.find(([type]) => type.toLowerCase() === 'application/json') ?? entries[0];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function warningsForDocument(document: OpenApiDocument): ZopiaWarning[] {
|
|
224
|
+
const collector = new ZopiaWarningCollector();
|
|
225
|
+
const push = (warning: ZopiaWarning): void => { collector.add(warning); };
|
|
226
|
+
const schemaWithoutRefs = (value: unknown): unknown => {
|
|
227
|
+
if (typeof value === 'boolean') return value;
|
|
228
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
229
|
+
const object = value as Record<string, unknown>;
|
|
230
|
+
if (typeof object.$ref === 'string') {
|
|
231
|
+
const siblings = Object.fromEntries(Object.entries(object).filter(([key]) => key !== '$ref'));
|
|
232
|
+
return Object.keys(siblings).length ? schemaWithoutRefs(siblings) : true;
|
|
233
|
+
}
|
|
234
|
+
const literalKeys = new Set(['example', 'examples', 'default', 'enum', 'const']);
|
|
235
|
+
return Object.fromEntries(Object.entries(object).map(([key, child]) => {
|
|
236
|
+
if (literalKeys.has(key) || key.startsWith('x-')) return [key, child];
|
|
237
|
+
if (Array.isArray(child)) return [key, child.map((item) => item && typeof item === 'object' ? schemaWithoutRefs(item) : item)];
|
|
238
|
+
if (child && typeof child === 'object') return [key, schemaWithoutRefs(child)];
|
|
239
|
+
return [key, child];
|
|
240
|
+
}));
|
|
241
|
+
};
|
|
242
|
+
const resolveReusable = (value: any): any => {
|
|
243
|
+
let current = value;
|
|
244
|
+
const seen = new Set<string>();
|
|
245
|
+
while (current && typeof current === 'object' && typeof current.$ref === 'string' && !seen.has(current.$ref)) {
|
|
246
|
+
seen.add(current.$ref);
|
|
247
|
+
const resolved = resolveOpenApiLocalRef(document, current.$ref);
|
|
248
|
+
if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) break;
|
|
249
|
+
current = { ...(resolved as Record<string, unknown>), ...Object.fromEntries(Object.entries(current).filter(([key]) => key !== '$ref')) };
|
|
250
|
+
}
|
|
251
|
+
return current;
|
|
252
|
+
};
|
|
253
|
+
const addSchemaWarnings = (schema: unknown, at: string): void => {
|
|
254
|
+
if (schema === undefined) return;
|
|
255
|
+
const result = jsonSchemaToZod(schemaWithoutRefs(schema) as JsonSchema);
|
|
256
|
+
collector.addRebased(result.warnings, at);
|
|
257
|
+
};
|
|
258
|
+
const addMultiContentWarning = (content: unknown, at: string): void => {
|
|
259
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid content at ${at}: expected an object`);
|
|
260
|
+
for (const [mediaType, media] of Object.entries(content)) if (!mediaType || !media || typeof media !== 'object' || Array.isArray(media)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid media type content at ${at}/${escapePointer(mediaType)}`);
|
|
261
|
+
const mediaTypes = Object.keys(content);
|
|
262
|
+
if (mediaTypes.length > 1) push({ code: 'ZOPIA_WARN_MULTI_CONTENT', at, message: `using ${primaryContent(content)?.[0]} as the generated primary media type; all ${mediaTypes.length} entries remain in the manifest` });
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (Array.isArray(document.servers)) document.servers.forEach((server: any, index: number) => {
|
|
266
|
+
if (server && typeof server === 'object' && server.variables !== undefined) push({ code: 'ZOPIA_WARN_SERVER_VARIABLES', at: `#/servers/${index}/variables`, message: 'server variables are preserved in the manifest but are not represented in generated endpoint code' });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (document.webhooks !== undefined && collectOpenApiWebhookOperations(document).length === 0) push({ code: 'ZOPIA_WARN_WEBHOOKS', at: '#/webhooks', message: 'webhooks declare no operations and are preserved verbatim in the manifest' });
|
|
270
|
+
|
|
271
|
+
if (document.swagger !== '2.0' && document.components !== undefined && (!document.components || typeof document.components !== 'object' || Array.isArray(document.components))) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid OpenAPI components: expected an object');
|
|
272
|
+
const schemas = document.swagger === '2.0' ? document.definitions : document.components?.schemas;
|
|
273
|
+
if (schemas !== undefined && (!schemas || typeof schemas !== 'object' || Array.isArray(schemas))) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid schema components: expected an object');
|
|
274
|
+
if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) for (const [name, schema] of Object.entries(schemas)) {
|
|
275
|
+
addSchemaWarnings(schema, `${document.swagger === '2.0' ? '#/definitions' : '#/components/schemas'}/${escapePointer(name)}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
279
|
+
if (path.startsWith('x-') || !rawItem || typeof rawItem !== 'object') continue;
|
|
280
|
+
let item = rawItem as Record<string, any>;
|
|
281
|
+
const pathRefs = new Set<string>();
|
|
282
|
+
while (typeof item.$ref === 'string' && !pathRefs.has(item.$ref)) {
|
|
283
|
+
pathRefs.add(item.$ref);
|
|
284
|
+
const resolved = resolveOpenApiLocalRef(document, item.$ref);
|
|
285
|
+
if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) break;
|
|
286
|
+
item = { ...(resolved as Record<string, any>), ...Object.fromEntries(Object.entries(item).filter(([key]) => key !== '$ref')) };
|
|
287
|
+
}
|
|
288
|
+
for (const method of ['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace']) {
|
|
289
|
+
const operation = item[method];
|
|
290
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
291
|
+
const operationAt = `#/paths/${escapePointer(path)}/${method}`;
|
|
292
|
+
const parameters = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(operation.parameters) ? operation.parameters : [])];
|
|
293
|
+
parameters.forEach((rawParameter: any, index: number) => {
|
|
294
|
+
const parameter = resolveReusable(rawParameter);
|
|
295
|
+
if (!parameter || typeof parameter !== 'object') return;
|
|
296
|
+
const parameterAt = `${operationAt}/parameters/${index}`;
|
|
297
|
+
if (parameter.schema !== undefined) addSchemaWarnings(parameter.schema, `${parameterAt}/schema`);
|
|
298
|
+
else if (parameter.in === 'body') addSchemaWarnings(parameter.schema, `${parameterAt}/schema`);
|
|
299
|
+
else if (document.swagger === '2.0' && parameter.type !== undefined) {
|
|
300
|
+
const keys = ['type', 'format', 'items', 'default', 'maximum', 'exclusiveMaximum', 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'maxItems', 'minItems', 'uniqueItems', 'enum', 'multipleOf'];
|
|
301
|
+
addSchemaWarnings(Object.fromEntries(Object.entries(parameter).filter(([key]) => keys.includes(key))), parameterAt);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const requestBody = resolveReusable(operation.requestBody);
|
|
305
|
+
if (requestBody?.content) {
|
|
306
|
+
addMultiContentWarning(requestBody.content, `${operationAt}/requestBody/content`);
|
|
307
|
+
for (const [mediaType, media] of Object.entries(requestBody.content as Record<string, any>)) addSchemaWarnings(media?.schema, `${operationAt}/requestBody/content/${escapePointer(mediaType)}/schema`);
|
|
308
|
+
}
|
|
309
|
+
for (const [status, rawResponse] of Object.entries(operation.responses ?? {})) {
|
|
310
|
+
const response = resolveReusable(rawResponse);
|
|
311
|
+
if (!response || typeof response !== 'object') continue;
|
|
312
|
+
const responseAt = `${operationAt}/responses/${escapePointer(status)}`;
|
|
313
|
+
if (response.content) {
|
|
314
|
+
addMultiContentWarning(response.content, `${responseAt}/content`);
|
|
315
|
+
for (const [mediaType, media] of Object.entries(response.content as Record<string, any>)) addSchemaWarnings(media?.schema, `${responseAt}/content/${escapePointer(mediaType)}/schema`);
|
|
316
|
+
} else addSchemaWarnings(response.schema, `${responseAt}/schema`);
|
|
317
|
+
}
|
|
318
|
+
if (document.swagger === '2.0') {
|
|
319
|
+
const consumes = Array.isArray(operation.consumes) ? operation.consumes : document.consumes;
|
|
320
|
+
const produces = Array.isArray(operation.produces) ? operation.produces : document.produces;
|
|
321
|
+
if (Array.isArray(consumes) && consumes.length > 1) push({ code: 'ZOPIA_WARN_MULTI_CONTENT', at: `${operationAt}/consumes`, message: `using ${consumes.find((type: unknown) => typeof type === 'string' && (/[/+]json$/i.test(type) || type === 'application/json')) ?? consumes[0]} as the generated request media type; all entries remain in the manifest` });
|
|
322
|
+
if (Array.isArray(produces) && produces.length > 1) push({ code: 'ZOPIA_WARN_MULTI_CONTENT', at: `${operationAt}/produces`, message: `using ${produces.find((type: unknown) => typeof type === 'string' && (/[/+]json$/i.test(type) || type === 'application/json')) ?? produces[0]} as the generated response media type; all entries remain in the manifest` });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return collector.toArray();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function mapGenerationError(error: unknown): ZopiaError {
|
|
331
|
+
if (error instanceof ZopiaError && error.code !== 'ZOPIA_SCHEMA_INVALID' && error.code !== 'ZOPIA_MANIFEST_INVALID') return error;
|
|
332
|
+
const message = error instanceof ZopiaError ? error.message.slice(`${error.code}: `.length) : error instanceof Error ? error.message : String(error);
|
|
333
|
+
if (message.includes('path-item $ref') || message.includes('path item $ref')) return new ZopiaError('ZOPIA_SPEC_PATH_REF', message, { hint: 'path-item references must be valid local references', cause: error });
|
|
334
|
+
return new ZopiaError('ZOPIA_SPEC_INVALID', message, { hint: 'fix the invalid Swagger/OpenAPI document', cause: error });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Read a Swagger/OpenAPI document from an in-memory object, JSON/YAML text, or a
|
|
339
|
+
* `.json`/`.yaml`/`.yml` path — the same acceptance rules as {@link openApiToApiDocs}.
|
|
340
|
+
*
|
|
341
|
+
* @param input Swagger/OpenAPI object, JSON/YAML text, or readable JSON/YAML file path.
|
|
342
|
+
* @returns Parsed document plus the spec file path when the input named one.
|
|
343
|
+
* @throws {@link ZopiaError} `ZOPIA_SPEC_INVALID*` when the input cannot be read or parsed.
|
|
344
|
+
*/
|
|
345
|
+
export function readOpenApiSourceInput(input: string | Record<string, unknown>): Promise<ReadOpenApiSourceInputResult> {
|
|
346
|
+
return readInput(input);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validate every local `$ref` inside a normalized document against the rule that
|
|
351
|
+
* references must resolve — the same scan engine ③ runs before planning.
|
|
352
|
+
*
|
|
353
|
+
* @param document Normalized Swagger/OpenAPI document to scan.
|
|
354
|
+
* @returns Nothing.
|
|
355
|
+
* @throws {@link ZopiaError} `ZOPIA_REF_NOT_FOUND`/`ZOPIA_REF_EXTERNAL`/`ZOPIA_SPEC_INVALID` located at the offending reference.
|
|
356
|
+
*/
|
|
357
|
+
export function validateOpenApiReferences(document: OpenApiDocument): void {
|
|
358
|
+
validateReferences(document);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Generate a reversible api-docs tree from Swagger 2.0 or OpenAPI 3.0/3.1.
|
|
363
|
+
*
|
|
364
|
+
* Accepts an in-memory document object, JSON text, YAML text, or a readable
|
|
365
|
+
* `.json`/`.yaml`/`.yml` file path (D-16). See R-641 for primary-media
|
|
366
|
+
* selection and R-408 for structured warning behavior.
|
|
367
|
+
*
|
|
368
|
+
* @param input Swagger/OpenAPI object, JSON/YAML text, or readable JSON/YAML file path.
|
|
369
|
+
* @param options Output directory, layout, component, reference, and manifest controls.
|
|
370
|
+
* @returns Sorted written-file metadata, structured warnings, and the optional manifest path.
|
|
371
|
+
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` for invalid options and `ZOPIA_SPEC_*` or `ZOPIA_REF_*` for invalid input.
|
|
372
|
+
* @example
|
|
373
|
+
* ```ts
|
|
374
|
+
* import { openApiToApiDocs } from 'zopia';
|
|
375
|
+
*
|
|
376
|
+
* const result = await openApiToApiDocs({
|
|
377
|
+
* openapi: '3.1.0',
|
|
378
|
+
* info: { title: 'Example', version: '1.0.0' },
|
|
379
|
+
* paths: {},
|
|
380
|
+
* }, { outDir: 'api_docs', mode: 'flat' });
|
|
381
|
+
* console.log(result.files);
|
|
382
|
+
* ```
|
|
383
|
+
* @see [docs/06-conversions.md → Engine ③](../../docs/06-conversions.md)
|
|
384
|
+
*/
|
|
385
|
+
export async function openApiToApiDocs(input: string | Record<string, unknown>, options?: ZopiaGenerateOptions): Promise<ZopiaGenerateResult> {
|
|
386
|
+
const config = validateOptions(options);
|
|
387
|
+
const inputDocument = await readInput(input);
|
|
388
|
+
const bundled = inputDocument.sourceFile ? await bundleExternalOpenApiRefs(inputDocument.document, inputDocument.sourceFile) : inputDocument.document;
|
|
389
|
+
const document = normalizePublic(bundled);
|
|
390
|
+
validateReferences(document);
|
|
391
|
+
|
|
392
|
+
if (config.preset !== undefined) {
|
|
393
|
+
const buckets = planPresetBuckets(document, config.preset);
|
|
394
|
+
if (buckets !== undefined) {
|
|
395
|
+
const presetWarnings: ZopiaWarning[] = [];
|
|
396
|
+
for (const bucket of buckets) presetWarnings.push(...bucket.warnings);
|
|
397
|
+
const trees: ZopiaPresetTree[] = [];
|
|
398
|
+
const presetFiles: ZopiaGeneratedFile[] = [];
|
|
399
|
+
for (const bucket of buckets) {
|
|
400
|
+
const generatedTree = await openApiToApiDocs(bucket.document, { ...options, preset: undefined, outDir: join(config.outDir, bucket.directory) });
|
|
401
|
+
trees.push({ name: bucket.name, directory: bucket.directory, ...(generatedTree.manifestPath === undefined ? {} : { manifestPath: `${bucket.directory}/${generatedTree.manifestPath}` }) });
|
|
402
|
+
for (const file of generatedTree.files) presetFiles.push({ ...file, path: `${bucket.directory}/${file.path}` });
|
|
403
|
+
// Descriptions stay verbatim; locations gain the bucket directory prefix so downstream tooling can navigate directly.
|
|
404
|
+
for (const warning of generatedTree.warnings) presetWarnings.push(warning.at === undefined ? warning : { ...warning, at: `${bucket.directory}/${warning.at}` });
|
|
405
|
+
}
|
|
406
|
+
presetFiles.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
407
|
+
const collector = new ZopiaWarningCollector(); collector.addAll(presetWarnings);
|
|
408
|
+
return { files: presetFiles, warnings: collector.toArray(), trees };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let hash: string;
|
|
413
|
+
try { hash = hashOpenApiDocument(document); }
|
|
414
|
+
catch (error) { throw mapGenerationError(error); }
|
|
415
|
+
|
|
416
|
+
let warnings: ZopiaWarning[];
|
|
417
|
+
try {
|
|
418
|
+
warnings = warningsForDocument(document);
|
|
419
|
+
const operations = buildOpenApiOperationIR(document);
|
|
420
|
+
for (const operation of operations) extractOperationContracts(operation);
|
|
421
|
+
} catch (error) { throw mapGenerationError(error); }
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const staleness = await inspectZopiaManifestStaleness(config.outDir, {
|
|
425
|
+
sourceSha256: hash,
|
|
426
|
+
mode: config.mode,
|
|
427
|
+
insertComponents: config.insertComponents,
|
|
428
|
+
useComponentAsReference: config.useComponentAsReference,
|
|
429
|
+
manifest: config.manifest,
|
|
430
|
+
custom: config.custom,
|
|
431
|
+
});
|
|
432
|
+
if (staleness.status === 'stale') warnings.push({
|
|
433
|
+
code: 'ZOPIA_WARN_STALE_TREE',
|
|
434
|
+
at: ZOPIA_MANIFEST_FILE,
|
|
435
|
+
message: formatManifestStaleness(staleness.reasons),
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
throw new ZopiaError('ZOPIA_FS_WRITE_FAILED', 'unable to inspect the existing output manifest', { at: config.outDir, cause: error });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let generated;
|
|
442
|
+
try {
|
|
443
|
+
generated = await generateApiDocsFiles(document, {
|
|
444
|
+
outputDir: config.outDir,
|
|
445
|
+
mode: config.mode,
|
|
446
|
+
insertComponents: config.insertComponents,
|
|
447
|
+
useComponentAsReference: config.useComponentAsReference,
|
|
448
|
+
manifest: config.manifest,
|
|
449
|
+
custom: config.custom,
|
|
450
|
+
});
|
|
451
|
+
} catch (error: any) {
|
|
452
|
+
if (['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EEXIST', 'EISDIR', 'ENOTDIR'].includes(String(error?.code))) throw new ZopiaError('ZOPIA_FS_WRITE_FAILED', `unable to write api-docs tree: ${error.message}`, { at: config.outDir, cause: error });
|
|
453
|
+
throw mapGenerationError(error);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const componentSchemas = document.swagger === '2.0' ? document.definitions ?? {} : document.components?.schemas ?? {};
|
|
457
|
+
const componentFiles = new Set(config.insertComponents
|
|
458
|
+
? ['components/index.ts', ...Object.keys(componentSchemas).map((name) => `components/${name}/index.ts`)]
|
|
459
|
+
: []);
|
|
460
|
+
const files: ZopiaGeneratedFile[] = generated.map(({ file }): ZopiaGeneratedFile => ({
|
|
461
|
+
path: file,
|
|
462
|
+
kind: file === ZOPIA_MANIFEST_FILE ? 'manifest' : componentFiles.has(file) ? 'component' : 'endpoint',
|
|
463
|
+
})).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
464
|
+
const warningCollector = new ZopiaWarningCollector(); warningCollector.addAll(warnings);
|
|
465
|
+
return { files, warnings: warningCollector.toArray(), ...(config.manifest ? { manifestPath: ZOPIA_MANIFEST_FILE } : {}) };
|
|
466
|
+
}
|