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,567 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { asZopiaError, ZopiaError } from '../errors';
|
|
4
|
+
import { buildOpenApiOperationIR } from './openapi-ir';
|
|
5
|
+
import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOperationContracts, reusableDeclarations } from './openapi-contracts';
|
|
6
|
+
import { jsonSchemaToZod } from './json-schema-to-zod';
|
|
7
|
+
import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './api-docs-plan';
|
|
8
|
+
import { isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
|
|
9
|
+
import type { OpenApiDocument } from './openapi';
|
|
10
|
+
import { createZopiaManifest, hashOpenApiDocument, writeZopiaManifest, ZOPIA_MANIFEST_FILE } from './manifest-writer';
|
|
11
|
+
import { inspectZopiaManifestStaleness, removeObsoleteManifestFiles } from './manifest-staleness';
|
|
12
|
+
import { decodeJsonPointerSegment, resolveOpenApiLocalRef } from './openapi-ref';
|
|
13
|
+
|
|
14
|
+
/** A low-level generated file record with both relative and absolute paths. */
|
|
15
|
+
export interface GeneratedApiDocsFile {
|
|
16
|
+
/** Portable path relative to the configured output directory. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** Absolute filesystem path written by the generator. */
|
|
19
|
+
absolutePath: string;
|
|
20
|
+
/** Endpoint operation ID or component artifact name. */
|
|
21
|
+
operationId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Low-level file-generator options used by the Engine ③ public wrapper. */
|
|
25
|
+
export interface GenerateApiDocsOptions {
|
|
26
|
+
/** Required output directory for the low-level writer. */
|
|
27
|
+
outputDir: string;
|
|
28
|
+
/** Endpoint layout mode. @default 'directory' */
|
|
29
|
+
mode?: ApiDocsMode;
|
|
30
|
+
/** Whether component files are emitted. @default false */
|
|
31
|
+
insertComponents?: boolean;
|
|
32
|
+
/** Whether endpoint schemas import emitted components. @default false */
|
|
33
|
+
useComponentAsReference?: boolean;
|
|
34
|
+
/** Whether the reverse-conversion manifest is emitted. @default true */
|
|
35
|
+
manifest?: boolean;
|
|
36
|
+
/** Whether merge-safe per-endpoint custom companion modules are emitted. @default false */
|
|
37
|
+
custom?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function componentReferenceSuffix(ref: unknown): string | undefined {
|
|
41
|
+
if (typeof ref !== 'string') return undefined;
|
|
42
|
+
const prefix = ref.startsWith('#/components/schemas/') ? '#/components/schemas/' : ref.startsWith('#/definitions/') ? '#/definitions/' : undefined;
|
|
43
|
+
return prefix ? ref.slice(prefix.length) : undefined;
|
|
44
|
+
}
|
|
45
|
+
function componentTarget(ref: unknown): string | undefined {
|
|
46
|
+
const suffix = componentReferenceSuffix(ref);
|
|
47
|
+
return suffix !== undefined && !suffix.includes('/') ? decodeJsonPointerSegment(suffix, String(ref)) : undefined;
|
|
48
|
+
}
|
|
49
|
+
function componentExport(ref: unknown): string | undefined {
|
|
50
|
+
const target = componentTarget(ref); return target === undefined ? undefined : componentExportName(target);
|
|
51
|
+
}
|
|
52
|
+
const STRUCTURAL_REF_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions', 'responses', 'content', 'headers', 'links', 'encoding', 'callbacks']);
|
|
53
|
+
function collectComponentRefs(value: unknown, names = new Set<string>(), mapEntries = false): Set<string> {
|
|
54
|
+
if (Array.isArray(value)) value.forEach((item) => collectComponentRefs(item, names));
|
|
55
|
+
else if (value && typeof value === 'object') for (const [key, child] of Object.entries(value)) {
|
|
56
|
+
if (mapEntries) collectComponentRefs(child, names);
|
|
57
|
+
else if (['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-')) continue;
|
|
58
|
+
else if (key === '$ref') { const name = componentExport(child); if (name) names.add(name); }
|
|
59
|
+
else collectComponentRefs(child, names, STRUCTURAL_REF_MAP_KEYS.has(key));
|
|
60
|
+
}
|
|
61
|
+
return names;
|
|
62
|
+
}
|
|
63
|
+
function schemaCode(schema: unknown, name: string): string {
|
|
64
|
+
const safeName = exportName(name);
|
|
65
|
+
const converted = jsonSchemaToZod(schema === undefined ? true : schema as any, { rootName: safeName });
|
|
66
|
+
const source = converted.code.trimEnd();
|
|
67
|
+
const direct = source.match(new RegExp(`^const ${safeName.replace(/[$]/g, '\\$&')} = ([\\s\\S]*);$`));
|
|
68
|
+
return direct ? direct[1] : `(() => { ${source} return ${safeName}; })()`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function schemaCodeWithDocumentRefs(schema: unknown, name: string, source: OpenApiDocument): string {
|
|
72
|
+
const schemas = source.openapi ? source.components?.schemas ?? {} : source.definitions ?? {};
|
|
73
|
+
const schemaObject = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema as Record<string, unknown> : undefined;
|
|
74
|
+
const ownDefinitions = schemaObject?.$defs && typeof schemaObject.$defs === 'object' && !Array.isArray(schemaObject.$defs) ? schemaObject.$defs as Record<string, unknown> : {};
|
|
75
|
+
let namespace = '__zopiaComponents';
|
|
76
|
+
while (Object.prototype.hasOwnProperty.call(ownDefinitions, namespace)) namespace += '_';
|
|
77
|
+
let found = false;
|
|
78
|
+
const normalizeRefs = (value: unknown, mapEntries = false): unknown => {
|
|
79
|
+
if (Array.isArray(value)) return value.map((child) => normalizeRefs(child));
|
|
80
|
+
if (!value || typeof value !== 'object') return value;
|
|
81
|
+
const object = value as Record<string, unknown>;
|
|
82
|
+
if (mapEntries) return Object.fromEntries(Object.entries(object).map(([key, child]) => [key, normalizeRefs(child)]));
|
|
83
|
+
return Object.fromEntries(Object.entries(object).map(([key, child]) => {
|
|
84
|
+
if (key === '$ref') {
|
|
85
|
+
const suffix = componentReferenceSuffix(child);
|
|
86
|
+
if (suffix !== undefined) { found = true; return [key, `#/$defs/${namespace}/${suffix}`]; }
|
|
87
|
+
}
|
|
88
|
+
if (['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-')) return [key, child];
|
|
89
|
+
return [key, normalizeRefs(child, STRUCTURAL_REF_MAP_KEYS.has(key))];
|
|
90
|
+
}));
|
|
91
|
+
};
|
|
92
|
+
const normalized = normalizeRefs(schema);
|
|
93
|
+
if (!found || !normalized || typeof normalized !== 'object' || Array.isArray(normalized)) return schemaCode(normalized, name);
|
|
94
|
+
found = false;
|
|
95
|
+
const normalizedComponents = Object.fromEntries(Object.entries(schemas).map(([componentName, component]) => [componentName, normalizeRefs(component)]));
|
|
96
|
+
const normalizedOwnDefinitions = (normalized as Record<string, unknown>).$defs;
|
|
97
|
+
return schemaCode({
|
|
98
|
+
...(normalized as Record<string, unknown>),
|
|
99
|
+
$defs: {
|
|
100
|
+
...(normalizedOwnDefinitions && typeof normalizedOwnDefinitions === 'object' && !Array.isArray(normalizedOwnDefinitions) ? normalizedOwnDefinitions as Record<string, unknown> : {}),
|
|
101
|
+
[namespace]: normalizedComponents,
|
|
102
|
+
},
|
|
103
|
+
}, name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function schemaCodeWithComponentImports(schema: unknown, name: string, source: OpenApiDocument, rootComponent: string): { code: string; imports: Map<string, string> } {
|
|
107
|
+
const imports = new Map<string, string>();
|
|
108
|
+
const replacements = new Map<string, string>();
|
|
109
|
+
let markerIndex = 0;
|
|
110
|
+
const rewrite = (value: unknown, root = false, mapEntries = false): unknown => {
|
|
111
|
+
if (Array.isArray(value)) return value.map((child) => rewrite(child));
|
|
112
|
+
if (!value || typeof value !== 'object') return value;
|
|
113
|
+
const object = value as Record<string, unknown>;
|
|
114
|
+
if (mapEntries) return Object.fromEntries(Object.entries(object).map(([key, child]) => [key, rewrite(child)]));
|
|
115
|
+
const target = componentTarget(object.$ref);
|
|
116
|
+
if (target) {
|
|
117
|
+
const component = componentExportName(target);
|
|
118
|
+
imports.set(component, target);
|
|
119
|
+
let marker = `__zopia_component_reference_${markerIndex++}__`;
|
|
120
|
+
const serialized = JSON.stringify(value) ?? '';
|
|
121
|
+
while (serialized.includes(JSON.stringify(marker))) marker = `__zopia_component_reference_${markerIndex++}__`;
|
|
122
|
+
replacements.set(marker, root || componentReaches(source, target, rootComponent) ? `z.lazy(() => ${component})` : component);
|
|
123
|
+
const siblings = Object.fromEntries(Object.entries(object).filter(([key]) => key !== '$ref').map(([key, child]) => [key, ['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-') ? child : rewrite(child, false, STRUCTURAL_REF_MAP_KEYS.has(key))]));
|
|
124
|
+
if (Object.keys(siblings).length === 0) return { const: marker };
|
|
125
|
+
const existingAllOf = siblings.allOf; delete siblings.allOf;
|
|
126
|
+
const allOf: unknown[] = [{ const: marker }];
|
|
127
|
+
if (Array.isArray(existingAllOf)) allOf.push(...existingAllOf);
|
|
128
|
+
else if (existingAllOf !== undefined) allOf.push({ allOf: existingAllOf });
|
|
129
|
+
return { ...siblings, allOf };
|
|
130
|
+
}
|
|
131
|
+
const entries = Object.entries(object).map(([key, child]) => [key, ['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-') ? child : rewrite(child, false, STRUCTURAL_REF_MAP_KEYS.has(key))] as const);
|
|
132
|
+
const stringConstraints = ['minLength', 'maxLength', 'pattern', 'format', 'contentEncoding', 'contentMediaType'];
|
|
133
|
+
if (object.type === 'string' && Array.isArray(object.enum) && object.enum.every((item) => typeof item === 'string') && !stringConstraints.some((key) => Object.prototype.hasOwnProperty.call(object, key))) return Object.fromEntries(entries.filter(([key]) => key !== 'type'));
|
|
134
|
+
return Object.fromEntries(entries);
|
|
135
|
+
};
|
|
136
|
+
let code = schemaCodeWithDocumentRefs(rewrite(schema, true), name, source);
|
|
137
|
+
for (const [marker, replacement] of replacements) code = code.split(`z.literal(${JSON.stringify(marker)})`).join(replacement);
|
|
138
|
+
return { code, imports };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function quoteStatus(status: string): string { return /^\d+$/.test(status) ? status : JSON.stringify(status); }
|
|
142
|
+
function stableDataJson(value: unknown): string {
|
|
143
|
+
return JSON.stringify(value, (_key, child) => child && typeof child === 'object' && !Array.isArray(child)
|
|
144
|
+
? Object.fromEntries(Object.entries(child).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
|
|
145
|
+
: child);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isMissingPath = (error: unknown): boolean => Boolean(error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT');
|
|
149
|
+
function outputPathError(file: string): ZopiaError {
|
|
150
|
+
return new ZopiaError('ZOPIA_FS_OUTSIDE_OUTDIR', `generated path is unsafe: ${file}`, { at: file, hint: 'remove symlinks or non-directory ancestors from the output tree' });
|
|
151
|
+
}
|
|
152
|
+
function isInside(root: string, candidate: string): boolean {
|
|
153
|
+
const fromRoot = relative(root, candidate);
|
|
154
|
+
return fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
155
|
+
}
|
|
156
|
+
async function writeGeneratedFile(root: string, file: string, content: string, previouslyOwned: ReadonlySet<string>): Promise<string> {
|
|
157
|
+
const absolutePath = resolve(root, ...file.split('/'));
|
|
158
|
+
if (!isInside(root, absolutePath) || absolutePath === root) throw outputPathError(file);
|
|
159
|
+
const parent = dirname(absolutePath);
|
|
160
|
+
const parentRelative = relative(root, parent);
|
|
161
|
+
let cursor = root;
|
|
162
|
+
for (const segment of parentRelative ? parentRelative.split(sep) : []) {
|
|
163
|
+
cursor = join(cursor, segment);
|
|
164
|
+
try {
|
|
165
|
+
const metadata = await lstat(cursor);
|
|
166
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw outputPathError(file);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (isMissingPath(error)) break;
|
|
169
|
+
throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect generated output path', { at: file, hint: 'check output-directory permissions and symlinks' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try { await mkdir(parent, { recursive: true }); }
|
|
173
|
+
catch (error) { throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to create generated output directory', { at: file, hint: 'check output-directory permissions' }); }
|
|
174
|
+
try {
|
|
175
|
+
const metadata = await lstat(absolutePath);
|
|
176
|
+
if (metadata.isDirectory()) throw outputPathError(file);
|
|
177
|
+
if (metadata.isSymbolicLink()) {
|
|
178
|
+
if (!previouslyOwned.has(file)) throw outputPathError(file);
|
|
179
|
+
await rm(absolutePath, { force: true });
|
|
180
|
+
} else if (metadata.isFile() && await readFile(absolutePath, 'utf8').catch(() => undefined) === content) return absolutePath;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (!isMissingPath(error)) throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect generated output file', { at: file, hint: 'check output-directory permissions and file types' });
|
|
183
|
+
}
|
|
184
|
+
try { await writeFile(absolutePath, content, 'utf8'); }
|
|
185
|
+
catch (error) { throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to write generated output file', { at: file, hint: 'check output-directory permissions and available disk space' }); }
|
|
186
|
+
return absolutePath;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const CUSTOM_SCAFFOLD = '/**\n * Hand-written companion for the generated endpoint module in `./index`.\n * zopia writes this scaffold once and never overwrites it — edits survive regeneration.\n */\nexport {};\n';
|
|
190
|
+
|
|
191
|
+
/** Sibling `custom.ts` path of one planned endpoint module. */
|
|
192
|
+
function customCompanionFile(planFile: string): string {
|
|
193
|
+
if (!planFile.endsWith('/index.ts')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `unsafe custom companion target: ${planFile}`, { at: planFile, hint: 'generated endpoint modules must live in their own directory' });
|
|
194
|
+
return `${planFile.slice(0, -'index.ts'.length)}custom.ts`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Writes a companion `custom.ts` scaffold exactly once; existing files and symlinks at the path are kept untouched. */
|
|
198
|
+
async function writeCustomScaffold(root: string, file: string): Promise<void> {
|
|
199
|
+
const absolutePath = resolve(root, ...file.split('/'));
|
|
200
|
+
if (!isInside(root, absolutePath) || absolutePath === root) throw outputPathError(file);
|
|
201
|
+
try {
|
|
202
|
+
const metadata = await lstat(absolutePath);
|
|
203
|
+
// A directory at the companion path would shadow the `./custom` import of the sibling module.
|
|
204
|
+
if (metadata.isDirectory()) throw outputPathError(file);
|
|
205
|
+
return;
|
|
206
|
+
} catch (error) { if (!isMissingPath(error)) throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect custom companion file', { at: file, hint: 'check output-directory permissions and file types' }); }
|
|
207
|
+
try { await writeFile(absolutePath, CUSTOM_SCAFFOLD, { encoding: 'utf8', flag: 'wx' }); }
|
|
208
|
+
catch (error) {
|
|
209
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return;
|
|
210
|
+
throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to write custom companion file', { at: file, hint: 'check output-directory permissions' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveObject(value: unknown, source: OpenApiDocument): any {
|
|
215
|
+
let current = value; const seen = new Set<string>();
|
|
216
|
+
while (current && typeof current === 'object' && !Array.isArray(current) && '$ref' in current) {
|
|
217
|
+
const ref = (current as any).$ref;
|
|
218
|
+
if (typeof ref !== 'string' || seen.has(ref)) return current;
|
|
219
|
+
seen.add(ref); const target = resolveOpenApiLocalRef(source, ref);
|
|
220
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) return current;
|
|
221
|
+
current = { ...(target as any), ...Object.fromEntries(Object.entries(current as any).filter(([key]) => key !== '$ref')) };
|
|
222
|
+
}
|
|
223
|
+
return current;
|
|
224
|
+
}
|
|
225
|
+
function exportName(operationId: string): string {
|
|
226
|
+
const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
|
|
227
|
+
let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
|
|
228
|
+
if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
|
|
229
|
+
if (['arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'].includes(name)) name = `${name}Endpoint`;
|
|
230
|
+
return name;
|
|
231
|
+
}
|
|
232
|
+
function componentExportName(componentName: string): string {
|
|
233
|
+
const name = exportName(componentName);
|
|
234
|
+
return name.endsWith('Schema') ? name : `${name}Schema`;
|
|
235
|
+
}
|
|
236
|
+
function componentParameterExportName(componentName: string): string {
|
|
237
|
+
const name = exportName(componentName);
|
|
238
|
+
return name.endsWith('Parameter') ? name : `${name}Parameter`;
|
|
239
|
+
}
|
|
240
|
+
function componentResponseExportName(componentName: string): string {
|
|
241
|
+
const name = exportName(componentName);
|
|
242
|
+
return name.endsWith('Response') ? name : `${name}Response`;
|
|
243
|
+
}
|
|
244
|
+
function componentReaches(source: OpenApiDocument, from: string, target: string, seen = new Set<string>()): boolean {
|
|
245
|
+
if (from === target) return true;
|
|
246
|
+
if (seen.has(from)) return false;
|
|
247
|
+
seen.add(from);
|
|
248
|
+
const schemas = source.openapi ? source.components?.schemas ?? {} : source.definitions ?? {};
|
|
249
|
+
const schema = schemas[from];
|
|
250
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
251
|
+
const refs: string[] = [];
|
|
252
|
+
const visit = (value: unknown, mapEntries = false): void => {
|
|
253
|
+
if (!value || typeof value !== 'object') return;
|
|
254
|
+
if (Array.isArray(value)) { value.forEach((child) => visit(child)); return; }
|
|
255
|
+
const object = value as Record<string, unknown>;
|
|
256
|
+
if (mapEntries) { for (const child of Object.values(object)) visit(child); return; }
|
|
257
|
+
const ref = componentTarget(object.$ref);
|
|
258
|
+
if (ref) refs.push(ref);
|
|
259
|
+
for (const [key, child] of Object.entries(object)) if (!['example', 'examples', 'default', 'enum', 'const'].includes(key) && !key.startsWith('x-')) visit(child, STRUCTURAL_REF_MAP_KEYS.has(key));
|
|
260
|
+
};
|
|
261
|
+
visit(schema);
|
|
262
|
+
return refs.some((ref) => componentReaches(source, ref, target, seen));
|
|
263
|
+
}
|
|
264
|
+
function renderComponent(name: string, schema: unknown, source: OpenApiDocument, exportNameFor: (componentName: string) => string = componentExportName, schemaImportPrefix = '../'): string {
|
|
265
|
+
const componentName = exportNameFor(name);
|
|
266
|
+
const { code, imports } = schemaCodeWithComponentImports(schema, componentName, source, name);
|
|
267
|
+
const importLine = [...imports.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).filter(([ref]) => ref !== componentName).map(([ref, target]) => `import { ${ref} } from ${JSON.stringify(`${schemaImportPrefix}${target}/index`)};`).join('\n');
|
|
268
|
+
return `/** Generated by zopia — do not edit by hand. */\nimport { z } from 'zod';\n${importLine}${importLine ? '\n' : ''}\nexport const ${componentName} = ${code};\n\nexport default ${componentName};\n`;
|
|
269
|
+
}
|
|
270
|
+
function renderEndpoint(operation: any, source: OpenApiDocument, mode: ApiDocsMode = 'directory', useComponents = false, runtimePath?: string, emitCustom = false): string {
|
|
271
|
+
const ir = buildOpenApiOperationIR(source).find((candidate) => candidate.operationId === operation.operationId && candidate.path === operation.path && candidate.method.toLowerCase() === operation.method);
|
|
272
|
+
if (!ir) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unable to build operation IR: ${operation.operationId}`);
|
|
273
|
+
const contracts = extractOperationContracts(ir);
|
|
274
|
+
const componentRefs = useComponents ? collectComponentRefs({ operation: operation.operation, parameters: ir.parameters }) : new Set<string>();
|
|
275
|
+
const componentSchema = (schema: unknown, fallback: string) => {
|
|
276
|
+
if (useComponents) {
|
|
277
|
+
const replacements = new Map<string, string>(); let markerIndex = 0;
|
|
278
|
+
const rewrite = (value: unknown, mapEntries = false): unknown => {
|
|
279
|
+
if (Array.isArray(value)) return value.map((child) => rewrite(child));
|
|
280
|
+
if (!value || typeof value !== 'object') return value;
|
|
281
|
+
const object = value as Record<string, unknown>;
|
|
282
|
+
if (mapEntries) return Object.fromEntries(Object.entries(object).map(([key, child]) => [key, rewrite(child)]));
|
|
283
|
+
const component = componentExport(object.$ref);
|
|
284
|
+
if (component) {
|
|
285
|
+
componentRefs.add(component);
|
|
286
|
+
let marker = `__zopia_component_reference_${markerIndex++}__`;
|
|
287
|
+
const serialized = JSON.stringify(value) ?? '';
|
|
288
|
+
while (serialized.includes(JSON.stringify(marker))) marker = `__zopia_component_reference_${markerIndex++}__`;
|
|
289
|
+
replacements.set(marker, component);
|
|
290
|
+
const siblings = Object.fromEntries(Object.entries(object).filter(([key]) => key !== '$ref').map(([key, child]) => [key, ['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-') ? child : rewrite(child, STRUCTURAL_REF_MAP_KEYS.has(key))]));
|
|
291
|
+
if (Object.keys(siblings).length === 0) return { const: marker };
|
|
292
|
+
const existingAllOf = siblings.allOf; delete siblings.allOf;
|
|
293
|
+
const allOf: unknown[] = [{ const: marker }];
|
|
294
|
+
if (Array.isArray(existingAllOf)) allOf.push(...existingAllOf);
|
|
295
|
+
else if (existingAllOf !== undefined) allOf.push({ allOf: existingAllOf });
|
|
296
|
+
return { ...siblings, allOf };
|
|
297
|
+
}
|
|
298
|
+
return Object.fromEntries(Object.entries(object).map(([key, child]) => [key, ['example', 'examples', 'default', 'enum', 'const'].includes(key) || key.startsWith('x-') ? child : rewrite(child, STRUCTURAL_REF_MAP_KEYS.has(key))]));
|
|
299
|
+
};
|
|
300
|
+
let code = schemaCodeWithDocumentRefs(rewrite(schema), fallback, source);
|
|
301
|
+
for (const [marker, component] of replacements) code = code.split(`z.literal(${JSON.stringify(marker)})`).join(component);
|
|
302
|
+
return code;
|
|
303
|
+
}
|
|
304
|
+
return schemaCodeWithDocumentRefs(schema, fallback, source);
|
|
305
|
+
};
|
|
306
|
+
const reusableUses = { parameter: new Set<string>(), response: new Set<string>() };
|
|
307
|
+
const reusableParameterExport = (reusable: string): string => { const name = componentParameterExportName(reusable); reusableUses.parameter.add(name); return name; };
|
|
308
|
+
const params = (location: string) => contracts.parameters.filter((p) => p.in === location).map((p) => `[${JSON.stringify(p.name)}]: ${useComponents && p.reusable ? reusableParameterExport(p.reusable) : componentSchema(p.schema, `param${p.name.replace(/[^A-Za-z0-9]/g, '') || 'Value'}`)}${p.required ? '' : '.optional()'}`).join(', ');
|
|
309
|
+
const rawRequestSchema = contracts.requestBody?.schema;
|
|
310
|
+
const formDataMarkers: Array<readonly [string, string]> = [];
|
|
311
|
+
let requestSchema = rawRequestSchema;
|
|
312
|
+
if (useComponents && contracts.requestBody?.formDataReusable && requestSchema && typeof requestSchema === 'object' && !Array.isArray(requestSchema)) {
|
|
313
|
+
const object = requestSchema as Record<string, unknown>;
|
|
314
|
+
if (object.properties && typeof object.properties === 'object' && !Array.isArray(object.properties)) {
|
|
315
|
+
const serialized = JSON.stringify(requestSchema) ?? '';
|
|
316
|
+
const properties = { ...(object.properties as Record<string, unknown>) };
|
|
317
|
+
for (const [property, reusable] of Object.entries(contracts.requestBody.formDataReusable)) {
|
|
318
|
+
if (!Object.prototype.hasOwnProperty.call(properties, property)) continue;
|
|
319
|
+
let marker = `__zopia_reusable_reference_${formDataMarkers.length}__`;
|
|
320
|
+
while (serialized.includes(JSON.stringify(marker))) marker = `__zopia_reusable_reference_${formDataMarkers.length}_${marker.split('_').length}__`;
|
|
321
|
+
formDataMarkers.push([marker, reusableParameterExport(reusable)]);
|
|
322
|
+
properties[property] = { const: marker };
|
|
323
|
+
}
|
|
324
|
+
requestSchema = { ...object, properties };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let requestBodyCode = contracts.requestBody ? componentSchema(requestSchema, 'requestBody') : 'z.any()';
|
|
328
|
+
if (useComponents && contracts.requestBody?.reusable) requestBodyCode = reusableParameterExport(contracts.requestBody.reusable);
|
|
329
|
+
for (const [marker, exportId] of formDataMarkers) requestBodyCode = requestBodyCode.split(`z.literal(${JSON.stringify(marker)})`).join(exportId);
|
|
330
|
+
const request = `request: { body: ${requestBodyCode}, params: z.object({ ${params('path')} }), query: z.object({ ${params('query')} }), headers: z.object({ ${params('header')} }), cookies: z.object({ ${params('cookie')} }) }`;
|
|
331
|
+
const response = contracts.responses.map((r) => `${quoteStatus(r.status)}: ${useComponents && r.reusable && r.schema !== undefined ? (() => { const name = componentResponseExportName(r.reusable); reusableUses.response.add(name); return name; })() : r.schema === undefined ? 'z.void()' : componentSchema(r.schema, `response${r.status.replace(/[^A-Za-z0-9]/g, '') || 'Default'}`)}`).join(', ');
|
|
332
|
+
const responseContentType = contracts.responses.find((r) => r.contentType)?.contentType;
|
|
333
|
+
// OpenAPI media-type keys are open strings, while km-api 0.4.1's declarations
|
|
334
|
+
// enumerate known values. Keep the exact runtime value across that narrow boundary.
|
|
335
|
+
const kmApiContentType = (value: string, type: 'IRequestContentType' | 'IResponseContentType'): string =>
|
|
336
|
+
`${JSON.stringify(value)} as unknown as import('km-api').${type}`;
|
|
337
|
+
const requestExamples: Record<string, unknown> = Object.create(null);
|
|
338
|
+
const requestBody = resolveObject(operation.operation.requestBody, source);
|
|
339
|
+
const requestMedia = requestBody?.content && contracts.requestBody?.contentType ? requestBody.content[contracts.requestBody.contentType] : undefined;
|
|
340
|
+
if (requestMedia?.example !== undefined) requestExamples.default = { value: requestMedia.example };
|
|
341
|
+
if (requestMedia?.examples && typeof requestMedia.examples === 'object') Object.assign(requestExamples, requestMedia.examples);
|
|
342
|
+
const responseExamples: Record<string, unknown> = {};
|
|
343
|
+
for (const [status, value] of Object.entries(operation.operation.responses ?? {})) {
|
|
344
|
+
const raw = resolveObject(value, source);
|
|
345
|
+
const selectedContentType = contracts.responses.find((response) => response.status === status)?.contentType;
|
|
346
|
+
const media = raw && typeof raw === 'object' && (raw as any).content && selectedContentType ? (raw as any).content[selectedContentType] : undefined;
|
|
347
|
+
if (media?.example !== undefined) responseExamples[status] = { default: { value: media.example } };
|
|
348
|
+
if (media?.examples && typeof media.examples === 'object') responseExamples[status] = media.examples;
|
|
349
|
+
const legacyExamples = raw && typeof raw === 'object' ? (raw as any).examples : undefined;
|
|
350
|
+
if (legacyExamples && typeof legacyExamples === 'object') responseExamples[status] = Object.fromEntries(Object.entries(legacyExamples).map(([contentType, value]) => [contentType, { value }]));
|
|
351
|
+
}
|
|
352
|
+
const examplesValue = { ...(Object.keys(requestExamples).length ? { request: requestExamples } : {}), ...(Object.keys(responseExamples).length ? { response: responseExamples } : {}) };
|
|
353
|
+
const examples = Object.keys(examplesValue).length ? `examples: JSON.parse(${JSON.stringify(stableDataJson(examplesValue))}),` : '';
|
|
354
|
+
const opId = operation.operationId;
|
|
355
|
+
const exportId = exportName(opId);
|
|
356
|
+
const tags = ir.tags;
|
|
357
|
+
const auth = ir.security !== undefined && ir.security.length > 0 && ir.security.every((requirement) => Object.keys(requirement as Record<string, unknown>).length > 0) ? 'YES' : 'NO';
|
|
358
|
+
const sourceName = JSON.stringify(`${source.info.title} v${source.info.version}`).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
359
|
+
const endpointDepth = typeof operation.file === 'string' ? operation.file.split('/').length - 1 : mode === 'flat' ? 2 : operation.path.split('/').filter(Boolean).length + 1;
|
|
360
|
+
const depthPrefix = '../'.repeat(endpointDepth);
|
|
361
|
+
const componentImports: string[] = [];
|
|
362
|
+
if (useComponents && componentRefs.size) componentImports.push(`import { ${[...componentRefs].sort().join(', ')} } from '${depthPrefix}components/index';`);
|
|
363
|
+
if (useComponents && reusableUses.parameter.size) componentImports.push(`import { ${[...reusableUses.parameter].sort().join(', ')} } from '${depthPrefix}components/parameters/index';`);
|
|
364
|
+
if (useComponents && reusableUses.response.size) componentImports.push(`import { ${[...reusableUses.response].sort().join(', ')} } from '${depthPrefix}components/responses/index';`);
|
|
365
|
+
const componentImport = componentImports.length ? `\n${componentImports.join('\n')}` : '';
|
|
366
|
+
return `/** Generated by zopia — do not edit by hand. */\nimport { z } from 'zod';\nimport { makeApiConfig } from 'km-api';${componentImport}\n\nexport const ${exportId} = makeApiConfig({\n method: ${JSON.stringify(operation.method.toUpperCase())},\n pathShape: ${JSON.stringify(runtimePath ?? operation.path)},\n operationId: ${JSON.stringify(opId)},\n ${contracts.requestBody ? `requestContentType: ${kmApiContentType(contracts.requestBody.contentType, 'IRequestContentType')},` : ''}\n ${responseContentType ? `responseContentType: ${kmApiContentType(responseContentType, 'IResponseContentType')},` : ''}\n ${ir.deprecated ? "deprecated: 'YES'," : ''}\n auth: ${JSON.stringify(auth)},\n summary: ${JSON.stringify(operation.operation.summary ?? '')},\n description: ${JSON.stringify(operation.operation.description ?? '')},\n tags: ${JSON.stringify(tags)},\n ${examples}\n ${request},\n response: { ${response} },\n});\n\nexport default ${exportId};\n${emitCustom ? "export * as custom from './custom';\n" : ''}// Source: ${sourceName}\n`.replace(/[ \t]+$/gm, '');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function avoidReservedFileCollisions(plans: readonly ApiDocsFilePlan[], reservedFiles: Iterable<string>): ApiDocsFilePlan[] {
|
|
370
|
+
const reserved = [...reservedFiles].map((file) => file.split('/').map((segment) => segment.toLowerCase()));
|
|
371
|
+
const used = new Set(reserved.map((segments) => segments.join('/')));
|
|
372
|
+
const groups = new Map<string, ApiDocsFilePlan[]>();
|
|
373
|
+
const assigned: Array<{ segments: string[]; methods: string[] }> = [];
|
|
374
|
+
for (const plan of plans) {
|
|
375
|
+
const group = groups.get(plan.path);
|
|
376
|
+
if (group) group.push(plan);
|
|
377
|
+
else groups.set(plan.path, [plan]);
|
|
378
|
+
}
|
|
379
|
+
const startsWith = (value: readonly string[], prefix: readonly string[]): boolean => prefix.length <= value.length && prefix.every((segment, index) => value[index].toLowerCase() === segment.toLowerCase());
|
|
380
|
+
const filesByPath = new Map<string, Map<string, string>>();
|
|
381
|
+
for (const [path, group] of groups) {
|
|
382
|
+
const originalSegments = group[0].file.split('/').slice(0, -2);
|
|
383
|
+
const candidate = [...originalSegments];
|
|
384
|
+
const methods = group.map((plan) => plan.method);
|
|
385
|
+
const suffixes = new Map<number, number>();
|
|
386
|
+
const conflictIndex = (): number | undefined => {
|
|
387
|
+
const candidateFiles = methods.map((method) => `${candidate.join('/')}/${method}/index.ts`.toLowerCase());
|
|
388
|
+
if (candidateFiles.some((file) => used.has(file))) return candidate.length - 1;
|
|
389
|
+
for (const file of reserved) if (startsWith(candidate, file)) return file.length - 1;
|
|
390
|
+
for (const previous of assigned) {
|
|
391
|
+
if (candidate.length === previous.segments.length && startsWith(candidate, previous.segments)) return candidate.length - 1;
|
|
392
|
+
for (const method of previous.methods) if (startsWith(candidate, [...previous.segments, method])) return previous.segments.length;
|
|
393
|
+
for (const method of methods) if (startsWith(previous.segments, [...candidate, method])) return candidate.length - 1;
|
|
394
|
+
}
|
|
395
|
+
return undefined;
|
|
396
|
+
};
|
|
397
|
+
let conflict = conflictIndex();
|
|
398
|
+
while (conflict !== undefined) {
|
|
399
|
+
const suffix = (suffixes.get(conflict) ?? 1) + 1;
|
|
400
|
+
suffixes.set(conflict, suffix);
|
|
401
|
+
candidate[conflict] = `${originalSegments[conflict]}-${suffix}`;
|
|
402
|
+
conflict = conflictIndex();
|
|
403
|
+
}
|
|
404
|
+
const candidateFiles = group.map((plan) => `${candidate.join('/')}/${plan.method}/index.ts`);
|
|
405
|
+
filesByPath.set(path, new Map(group.map((plan, index) => [plan.method, candidateFiles[index]])));
|
|
406
|
+
for (const file of candidateFiles) used.add(file.toLowerCase());
|
|
407
|
+
assigned.push({ segments: candidate, methods });
|
|
408
|
+
}
|
|
409
|
+
return plans.map((plan) => ({ ...plan, file: filesByPath.get(plan.path)!.get(plan.method)! }));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Generate the planned endpoint files on disk. Existing generated files are overwritten.
|
|
414
|
+
*
|
|
415
|
+
* @param input Valid Swagger/OpenAPI object or JSON text.
|
|
416
|
+
* @param options Validated low-level filesystem generation options.
|
|
417
|
+
* @returns Generated endpoint, component, barrel, and manifest file records.
|
|
418
|
+
* @throws {@link ZopiaError} when conversion, validation, or filesystem output fails.
|
|
419
|
+
*/
|
|
420
|
+
export async function generateApiDocsFiles(input: OpenApiDocument | string, options: GenerateApiDocsOptions): Promise<GeneratedApiDocsFile[]> {
|
|
421
|
+
try { return await generateApiDocsFilesInternal(input, options); }
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (error instanceof ZopiaError && (error.code === 'ZOPIA_SCHEMA_INVALID' || error.code === 'ZOPIA_MANIFEST_INVALID')) {
|
|
424
|
+
const message = error.message.slice(`${error.code}: `.length);
|
|
425
|
+
throw new ZopiaError('ZOPIA_SPEC_INVALID', message, { at: error.at ?? '#', cause: error });
|
|
426
|
+
}
|
|
427
|
+
throw asZopiaError(error, 'ZOPIA_SPEC_INVALID', 'unable to generate api-docs files', { at: '#', hint: 'check the source document and generation options' });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function generateApiDocsFilesInternal(input: OpenApiDocument | string, options: GenerateApiDocsOptions): Promise<GeneratedApiDocsFile[]> {
|
|
432
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || typeof options.outputDir !== 'string' || !options.outputDir || options.outputDir.includes('\0')) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'outputDir is required', { at: 'outputDir', hint: 'provide a generated-tree output directory' });
|
|
433
|
+
const unknown = Object.keys(options).find((key) => !['outputDir', 'mode', 'insertComponents', 'useComponentAsReference', 'manifest', 'custom'].includes(key));
|
|
434
|
+
if (unknown) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unknown generation option: ${unknown}`, { at: unknown, hint: 'remove the unsupported option' });
|
|
435
|
+
for (const key of ['insertComponents', 'useComponentAsReference', 'manifest', 'custom'] as const) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `${key} must be a boolean`, { at: key });
|
|
436
|
+
let source: OpenApiDocument;
|
|
437
|
+
try { source = typeof input === 'string' ? JSON.parse(input) as OpenApiDocument : input; }
|
|
438
|
+
catch (error) { throw asZopiaError(error, 'ZOPIA_SPEC_INVALID_JSON', 'invalid OpenAPI JSON text', { at: '#', hint: 'fix the JSON syntax' }); }
|
|
439
|
+
if (options.useComponentAsReference && !options.insertComponents) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'useComponentAsReference requires insertComponents', { at: 'useComponentAsReference', hint: 'enable `insertComponents` first' });
|
|
440
|
+
const mode = options.mode ?? 'directory';
|
|
441
|
+
const insertComponents = options.insertComponents === true;
|
|
442
|
+
const useComponentAsReference = options.useComponentAsReference === true;
|
|
443
|
+
const retainManifest = options.manifest !== false;
|
|
444
|
+
const emitCustom = options.custom === true;
|
|
445
|
+
const schemas = source.openapi ? source.components?.schemas ?? {} : source.definitions ?? {};
|
|
446
|
+
const componentNames = insertComponents ? Object.keys(schemas).sort() : [];
|
|
447
|
+
const reservedFiles = componentNames.map((name) => `components/${name}/index.ts`);
|
|
448
|
+
if (insertComponents) {
|
|
449
|
+
reservedFiles.push('components/index.ts');
|
|
450
|
+
const declaredReusable = reusableDeclarations(source);
|
|
451
|
+
for (const [directory, map, derive] of [['components/parameters', declaredReusable.parameter, deriveReusableParameterSchema], ['components/responses', declaredReusable.response, deriveReusableResponseSchema]] as const) {
|
|
452
|
+
const names = Object.keys(map).sort().filter((name) => {
|
|
453
|
+
const value = map[name];
|
|
454
|
+
if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))) return true; // surfaced later as a typed derivation error
|
|
455
|
+
return derive === deriveReusableParameterSchema || derive(source, name, value) !== undefined;
|
|
456
|
+
});
|
|
457
|
+
if (!names.length) continue;
|
|
458
|
+
reservedFiles.push(`${directory}/index.ts`, ...names.map((name) => `${directory}/${name}/index.ts`));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (retainManifest) reservedFiles.push(ZOPIA_MANIFEST_FILE);
|
|
462
|
+
const plans = avoidReservedFileCollisions(planApiDocsFiles(source, mode), reservedFiles);
|
|
463
|
+
const webhookPlans = avoidReservedFileCollisions(planWebhookDocsFiles(source, mode), [...reservedFiles, ...plans.map((plan) => plan.file)]);
|
|
464
|
+
// OpenAPI requires operationId to be unique document-wide; $ref aliases can make the
|
|
465
|
+
// same id appear in both the path and webhook namespaces even though each collector
|
|
466
|
+
// dedupes only its own list — reject before producing a tree ④ can never reverse.
|
|
467
|
+
assertUniqueOperationIdsAcrossScopes(plans, webhookPlans);
|
|
468
|
+
const previous = await inspectZopiaManifestStaleness(options.outputDir, {
|
|
469
|
+
sourceSha256: hashOpenApiDocument(source),
|
|
470
|
+
mode,
|
|
471
|
+
insertComponents,
|
|
472
|
+
useComponentAsReference,
|
|
473
|
+
manifest: retainManifest,
|
|
474
|
+
custom: emitCustom,
|
|
475
|
+
});
|
|
476
|
+
const manifest = retainManifest ? createZopiaManifest(source, plans, {
|
|
477
|
+
mode,
|
|
478
|
+
insertComponents,
|
|
479
|
+
useComponentAsReference,
|
|
480
|
+
custom: emitCustom,
|
|
481
|
+
}, webhookPlans) : undefined;
|
|
482
|
+
const root = resolve(options.outputDir);
|
|
483
|
+
const previouslyOwned = new Set(previous.ownedFiles);
|
|
484
|
+
const renderedEndpoints = plans.map((plan) => ({ plan, content: renderEndpoint(plan, source, mode, useComponentAsReference, undefined, emitCustom) }));
|
|
485
|
+
const renderedWebhooks = webhookPlans.map((plan) => ({ plan, content: renderEndpoint(plan, source, mode, useComponentAsReference, webhookRuntimePath(plan.path), emitCustom) }));
|
|
486
|
+
const generated: GeneratedApiDocsFile[] = [];
|
|
487
|
+
if (insertComponents) {
|
|
488
|
+
const componentExports = new Map<string, string>();
|
|
489
|
+
const componentDirectories = new Map<string, string>();
|
|
490
|
+
for (const name of componentNames) {
|
|
491
|
+
if (name.toLowerCase() === 'index.ts') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component file name collides with the barrel: ${name}`);
|
|
492
|
+
const existingDirectory = componentDirectories.get(name.toLowerCase());
|
|
493
|
+
if (existingDirectory) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component file name collision: ${existingDirectory} and ${name}`);
|
|
494
|
+
componentDirectories.set(name.toLowerCase(), name);
|
|
495
|
+
const componentExport = componentExportName(name);
|
|
496
|
+
const previous = componentExports.get(componentExport);
|
|
497
|
+
if (previous) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component export name collision: ${previous} and ${name}`);
|
|
498
|
+
componentExports.set(componentExport, name);
|
|
499
|
+
}
|
|
500
|
+
const renderedComponents = componentNames.map((name) => {
|
|
501
|
+
if (name.includes('/') || !isPortableApiDocsSegment(name)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsafe component name: ${name}`);
|
|
502
|
+
return { name, content: renderComponent(name, schemas[name], source) };
|
|
503
|
+
});
|
|
504
|
+
for (const { name, content } of renderedComponents) {
|
|
505
|
+
const file = `components/${name}/index.ts`;
|
|
506
|
+
const absolutePath = await writeGeneratedFile(root, file, content, previouslyOwned);
|
|
507
|
+
generated.push({ file, absolutePath, operationId: name });
|
|
508
|
+
}
|
|
509
|
+
const barrel = componentNames.map((name) => `export { ${componentExportName(name)} } from ${JSON.stringify(`./${name}/index`)};`).join('\n') + (componentNames.length ? '\n' : '');
|
|
510
|
+
const barrelFile = 'components/index.ts';
|
|
511
|
+
const barrelPath = await writeGeneratedFile(root, barrelFile, barrel, previouslyOwned);
|
|
512
|
+
generated.push({ file: barrelFile, absolutePath: barrelPath, operationId: 'components' });
|
|
513
|
+
}
|
|
514
|
+
if (insertComponents) {
|
|
515
|
+
const swagger = source.swagger === '2.0';
|
|
516
|
+
const declarations = reusableDeclarations(source);
|
|
517
|
+
for (const table of [
|
|
518
|
+
{ kind: 'parameter', directory: 'components/parameters', raw: swagger ? source.parameters : source.components?.parameters, containerAt: swagger ? '#/parameters' : '#/components/parameters', declared: declarations.parameter, exportNameFor: componentParameterExportName, derive: deriveReusableParameterSchema },
|
|
519
|
+
{ kind: 'response', directory: 'components/responses', raw: swagger ? source.responses : source.components?.responses, containerAt: swagger ? '#/responses' : '#/components/responses', declared: declarations.response, exportNameFor: componentResponseExportName, derive: deriveReusableResponseSchema },
|
|
520
|
+
] as const) {
|
|
521
|
+
if (table.raw !== undefined && (!table.raw || typeof table.raw !== 'object' || Array.isArray(table.raw))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid reusable ${table.kind} components: expected an object`, { at: table.containerAt });
|
|
522
|
+
const names = Object.keys(table.declared).sort();
|
|
523
|
+
if (!names.length) continue;
|
|
524
|
+
const exportUses = new Map<string, string>();
|
|
525
|
+
const directories = new Map<string, string>();
|
|
526
|
+
for (const name of names) {
|
|
527
|
+
if (name.toLowerCase() === 'index.ts') throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component file name collides with the barrel: ${name}`);
|
|
528
|
+
const previousDirectory = directories.get(name.toLowerCase());
|
|
529
|
+
if (previousDirectory) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component file name collision: ${previousDirectory} and ${name}`);
|
|
530
|
+
directories.set(name.toLowerCase(), name);
|
|
531
|
+
const exportId = table.exportNameFor(name);
|
|
532
|
+
const previousExport = exportUses.get(exportId);
|
|
533
|
+
if (previousExport) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Component export name collision: ${previousExport} and ${name}`);
|
|
534
|
+
exportUses.set(exportId, name);
|
|
535
|
+
}
|
|
536
|
+
const derived = new Map(names.map((name) => [name, table.derive(source, name, table.declared[name])]));
|
|
537
|
+
// Schema-less responses render `z.void()` at use sites — there is no shared schema to centralize (D-18).
|
|
538
|
+
const emitted = names.filter((name) => table.kind === 'parameter' || derived.get(name) !== undefined);
|
|
539
|
+
if (!emitted.length) continue;
|
|
540
|
+
for (const name of emitted) {
|
|
541
|
+
if (name.includes('/') || !isPortableApiDocsSegment(name)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsafe component name: ${name}`);
|
|
542
|
+
const absolutePath = await writeGeneratedFile(root, `${table.directory}/${name}/index.ts`, renderComponent(name, derived.get(name), source, table.exportNameFor, '../../'), previouslyOwned);
|
|
543
|
+
generated.push({ file: `${table.directory}/${name}/index.ts`, absolutePath, operationId: name });
|
|
544
|
+
}
|
|
545
|
+
const barrel = emitted.map((name) => `export { ${table.exportNameFor(name)} } from ${JSON.stringify(`./${name}/index`)};`).join('\n') + '\n';
|
|
546
|
+
const barrelFile = `${table.directory}/index.ts`;
|
|
547
|
+
const barrelPath = await writeGeneratedFile(root, barrelFile, barrel, previouslyOwned);
|
|
548
|
+
generated.push({ file: barrelFile, absolutePath: barrelPath, operationId: table.directory });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (const { plan, content } of renderedEndpoints) {
|
|
552
|
+
const absolutePath = await writeGeneratedFile(root, plan.file, content, previouslyOwned);
|
|
553
|
+
generated.push({ file: plan.file, absolutePath, operationId: plan.operationId });
|
|
554
|
+
if (emitCustom) await writeCustomScaffold(root, customCompanionFile(plan.file));
|
|
555
|
+
}
|
|
556
|
+
for (const { plan, content } of renderedWebhooks) {
|
|
557
|
+
const absolutePath = await writeGeneratedFile(root, plan.file, content, previouslyOwned);
|
|
558
|
+
generated.push({ file: plan.file, absolutePath, operationId: plan.operationId });
|
|
559
|
+
if (emitCustom) await writeCustomScaffold(root, customCompanionFile(plan.file));
|
|
560
|
+
}
|
|
561
|
+
if (manifest) {
|
|
562
|
+
const manifestPath = await writeZopiaManifest(root, manifest);
|
|
563
|
+
generated.push({ file: ZOPIA_MANIFEST_FILE, absolutePath: manifestPath, operationId: 'manifest' });
|
|
564
|
+
}
|
|
565
|
+
await removeObsoleteManifestFiles(root, previous.ownedFiles, generated.map(({ file }) => file));
|
|
566
|
+
return generated;
|
|
567
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ZopiaError } from '../errors';
|
|
2
|
+
import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
|
|
3
|
+
|
|
4
|
+
/** Filesystem layout used for generated endpoint modules. */
|
|
5
|
+
export type ApiDocsMode = 'directory' | 'flat';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return whether one generated-tree path segment is portable across supported filesystems.
|
|
9
|
+
*
|
|
10
|
+
* @param segment Candidate path segment without separators.
|
|
11
|
+
* @returns Whether the segment is safe on POSIX and Windows filesystems.
|
|
12
|
+
*/
|
|
13
|
+
export function isPortableApiDocsSegment(segment: string): boolean {
|
|
14
|
+
if (!segment || segment === '.' || segment === '..' || segment.includes('\\') || /[<>:"|?*\u0000-\u001f]/.test(segment) || /[ .]$/.test(segment)) return false;
|
|
15
|
+
const basename = segment.split('.')[0];
|
|
16
|
+
return !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return a portable relative POSIX path for one generated endpoint file.
|
|
21
|
+
*
|
|
22
|
+
* @param path OpenAPI path template beginning with `/`.
|
|
23
|
+
* @param method Supported lowercase HTTP method.
|
|
24
|
+
* @param mode Directory or flattened endpoint layout.
|
|
25
|
+
* @returns Portable endpoint-module path relative to the output directory.
|
|
26
|
+
* @throws {@link ZopiaError} when the path, method, or mode is invalid or unsafe.
|
|
27
|
+
*/
|
|
28
|
+
export function endpointFilePath(path: string, method: OpenApiMethod, mode: ApiDocsMode = 'directory'): string {
|
|
29
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('?') || path.includes('#')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Invalid API path: ${path}`);
|
|
30
|
+
if (!(OPENAPI_METHODS as readonly string[]).includes(method)) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsupported HTTP method: ${String(method)}`);
|
|
31
|
+
const segments = path.split('/').filter(Boolean);
|
|
32
|
+
if (segments.some((segment) => !isPortableApiDocsSegment(segment))) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsafe API path segment: ${path}`);
|
|
33
|
+
if (mode === 'flat') {
|
|
34
|
+
const base = segments.join('-') || 'root';
|
|
35
|
+
return `${base}/${method}/index.ts`;
|
|
36
|
+
}
|
|
37
|
+
if (mode !== 'directory') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `Unsupported API docs mode: ${mode}`, { at: 'mode', hint: "use 'directory' or 'flat'" });
|
|
38
|
+
return [...(segments.length ? segments : ['root']), method, 'index.ts'].join('/');
|
|
39
|
+
}
|