zopia 0.3.0 โ 0.4.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 +40 -0
- package/README.md +48 -16
- package/docs/07-api-docs.md +77 -2
- package/docs/09-configuration.md +2 -2
- package/docs/10-usage.md +26 -3
- package/package.json +10 -2
- package/src/conversions/api-docs-generate.ts +6 -12
- package/src/conversions/api-docs-names.ts +54 -0
- package/src/conversions/manifest-writer.ts +1 -1
- package/src/conversions/openapi-to-api-docs.ts +5 -10
- package/src/runtime/create-api-docs.ts +321 -0
- package/src/runtime.ts +10 -0
- package/docs/01-overview.md +0 -94
- package/docs/02-targets.md +0 -55
- package/docs/03-roadmap.md +0 -205
- package/docs/04-architecture.md +0 -345
- package/docs/05-concepts.md +0 -239
- package/docs/06-conversions.md +0 -493
- package/docs/08-components.md +0 -223
- package/docs/11-testing.md +0 -267
- package/docs/12-standards.md +0 -242
- package/docs/README.md +0 -42
- package/docs/publish-workflow.yml.example +0 -48
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ณ Runtime consumption of a generated api-docs tree (S-94).
|
|
3
|
+
*
|
|
4
|
+
* Opt-in helpers, shipped behind the `zopia/runtime` subpath export, that turn
|
|
5
|
+
* a generated tree directory into the objects an application consumes:
|
|
6
|
+
* {@link createApiDocs} loads every endpoint module named by the tree's
|
|
7
|
+
* manifest(s) into one nested object keyed by the exact URL path segments with
|
|
8
|
+
* the lowercase method as the leaf key, and {@link flattenApiDocs} deep-walks
|
|
9
|
+
* that tree into a flat record keyed by each config's `operationId` (derived
|
|
10
|
+
* with the generator's own naming rules). Generation output is untouched โ
|
|
11
|
+
* these helpers only read and import.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IMakeApiConfigEntry } from 'km-api';
|
|
15
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
16
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import { asZopiaError, ZopiaError } from '../errors';
|
|
19
|
+
import { endpointExportName, uniqueEndpointName } from '../conversions/api-docs-names';
|
|
20
|
+
import { deriveOperationId, OPENAPI_METHODS, type OpenApiMethod } from '../conversions/openapi-to-api-docs';
|
|
21
|
+
import { ZOPIA_MANIFEST_FILE } from '../conversions/manifest-writer';
|
|
22
|
+
|
|
23
|
+
/** One generated km-api endpoint configuration โ the `export default` value every endpoint module ships (R-732). */
|
|
24
|
+
export type ApiDocsEndpointConfig = IMakeApiConfigEntry;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Nested runtime view of one generated api-docs tree.
|
|
28
|
+
*
|
|
29
|
+
* Branch keys are the URL path segments **verbatim and in order** (including
|
|
30
|
+
* literal `{param}` segments), the leaf key is the lowercase HTTP method, and
|
|
31
|
+
* the leaf value is the endpoint module's default export. Because which keys
|
|
32
|
+
* exist depends on the source spec, every node is typed permissively as both a
|
|
33
|
+
* deeper branch and an {@link ApiDocsEndpointConfig}, so chained access such as
|
|
34
|
+
* `apiDocs.users['{userId}'].get.method` typechecks and reads the runtime
|
|
35
|
+
* values the tree actually holds.
|
|
36
|
+
*/
|
|
37
|
+
export interface ApiDocsTree {
|
|
38
|
+
/** Deeper path-segment branch or the method-leaf endpoint config; presence is decided by the source spec at runtime. */
|
|
39
|
+
[segment: string]: ApiDocsTree & ApiDocsEndpointConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Internal mutable branch node; null-prototype objects keep `{param}` and `__proto__` segments plain own keys. */
|
|
43
|
+
type ApiDocsBranch = Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
/** One merged, deduplicated manifest endpoint record ready for tree insertion. */
|
|
46
|
+
interface MergedApiEntry {
|
|
47
|
+
/** Absolute directory of the manifest this entry came from (its file paths resolve against it). */
|
|
48
|
+
root: string;
|
|
49
|
+
/** Portable endpoint-module path relative to `root`. */
|
|
50
|
+
file: string;
|
|
51
|
+
/** Original OpenAPI path template โ the authority for the nested key chain. */
|
|
52
|
+
path: string;
|
|
53
|
+
/** Lowercase HTTP method โ the leaf key. */
|
|
54
|
+
method: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One discovered manifest with the directory its relative file paths resolve against. */
|
|
58
|
+
interface DiscoveredManifest {
|
|
59
|
+
/** Absolute directory containing this manifest (the tree root or a preset bucket root). */
|
|
60
|
+
root: string;
|
|
61
|
+
/** Validated `apis[]` endpoint records of this manifest, in manifest order. */
|
|
62
|
+
apis: Array<{ file: string; path: string; method: string }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
|
+
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
|
|
67
|
+
const isMissingFileError = (error: unknown): boolean => isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
68
|
+
|
|
69
|
+
/** Split one OpenAPI path template into its literal segment keys (empty segments dropped, so `/` has none). */
|
|
70
|
+
function pathSegments(path: string): string[] {
|
|
71
|
+
return path.split('/').filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Guard one manifest file reference: relative, POSIX-separated, and unable to escape the tree root. */
|
|
75
|
+
function isSafeTreeFile(file: string): boolean {
|
|
76
|
+
if (!file || isAbsolute(file) || file.includes('\\') || /^[A-Za-z]:/.test(file)) return false;
|
|
77
|
+
return file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Validate one parsed manifest and return its `apis[]` records (reader-tolerant shape, hard on unsafe paths). */
|
|
81
|
+
function manifestApiEntries(manifest: unknown, manifestPath: string): Array<{ file: string; path: string; method: string }> {
|
|
82
|
+
if (!isRecord(manifest) || !Array.isArray(manifest.apis)) {
|
|
83
|
+
throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `invalid zopia manifest (expected an object with an apis array): ${manifestPath}`, { at: manifestPath, hint: 'regenerate the tree to rebuild a valid manifest' });
|
|
84
|
+
}
|
|
85
|
+
const entries: Array<{ file: string; path: string; method: string }> = [];
|
|
86
|
+
for (const [index, api] of manifest.apis.entries()) {
|
|
87
|
+
if (!isRecord(api)
|
|
88
|
+
|| typeof api.file !== 'string' || !isSafeTreeFile(api.file)
|
|
89
|
+
|| typeof api.path !== 'string' || !api.path.startsWith('/')
|
|
90
|
+
|| typeof api.method !== 'string' || !(OPENAPI_METHODS as readonly string[]).includes(api.method)) {
|
|
91
|
+
throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `invalid zopia manifest API entry ${index}: ${manifestPath}`, { at: `${manifestPath}#apis/${index}`, hint: 'regenerate the tree to rebuild a valid manifest' });
|
|
92
|
+
}
|
|
93
|
+
entries.push({ file: api.file, path: api.path, method: api.method });
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read and parse one manifest file; a missing file resolves to `undefined`, garbled JSON fails typed. */
|
|
99
|
+
async function readManifestFile(file: string): Promise<unknown> {
|
|
100
|
+
let text: string;
|
|
101
|
+
try { text = await readFile(file, 'utf8'); }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (isMissingFileError(error)) return undefined;
|
|
104
|
+
throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to read zopia manifest', { at: file, hint: 'check that the manifest is readable JSON' });
|
|
105
|
+
}
|
|
106
|
+
try { return JSON.parse(text) as unknown; }
|
|
107
|
+
catch (error) { throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `invalid zopia manifest JSON: ${file}`, { at: file, hint: 'regenerate the tree to rebuild a valid manifest', cause: error }); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Discover every manifest of one output root: the root `.zopia-manifest.json`
|
|
112
|
+
* plus the one-level-deep preset bucket roots (`multi-tag` / `multi-server`
|
|
113
|
+
* splits write one manifest per bucket directory). Real directories only โ
|
|
114
|
+
* symlinked children never masquerade as bucket roots.
|
|
115
|
+
*/
|
|
116
|
+
async function discoverApiDocsManifests(root: string): Promise<DiscoveredManifest[]> {
|
|
117
|
+
const discovered: DiscoveredManifest[] = [];
|
|
118
|
+
const rootManifestPath = join(root, ZOPIA_MANIFEST_FILE);
|
|
119
|
+
const rootManifest = await readManifestFile(rootManifestPath);
|
|
120
|
+
if (rootManifest !== undefined) discovered.push({ root, apis: manifestApiEntries(rootManifest, rootManifestPath) });
|
|
121
|
+
let directories: string[] = [];
|
|
122
|
+
try {
|
|
123
|
+
directories = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(compareText);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// A missing root simply has no buckets; anything else is an unreadable tree.
|
|
126
|
+
if (!isMissingFileError(error)) throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to scan the api-docs directory', { at: root, hint: 'check that the directory is readable' });
|
|
127
|
+
}
|
|
128
|
+
for (const directory of directories) {
|
|
129
|
+
const bucketRoot = join(root, directory);
|
|
130
|
+
const bucketManifestPath = join(bucketRoot, ZOPIA_MANIFEST_FILE);
|
|
131
|
+
const bucketManifest = await readManifestFile(bucketManifestPath);
|
|
132
|
+
if (bucketManifest !== undefined) discovered.push({ root: bucketRoot, apis: manifestApiEntries(bucketManifest, bucketManifestPath) });
|
|
133
|
+
}
|
|
134
|
+
return discovered;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merge discovered manifests into one deterministically sorted entry list.
|
|
139
|
+
* Duplicates are dropped by `${path}#${method}` identity (the root manifest
|
|
140
|
+
* wins over bucket manifests, buckets in sorted directory order); the sort is
|
|
141
|
+
* path segments first, then the canonical method order
|
|
142
|
+
* (`get, post, put, delete, head, options, patch, trace`).
|
|
143
|
+
*/
|
|
144
|
+
function mergeApiEntries(manifests: readonly DiscoveredManifest[]): MergedApiEntry[] {
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const merged: MergedApiEntry[] = [];
|
|
147
|
+
for (const { root, apis } of manifests) {
|
|
148
|
+
for (const api of apis) {
|
|
149
|
+
const identity = `${api.path}#${api.method}`;
|
|
150
|
+
if (seen.has(identity)) continue;
|
|
151
|
+
seen.add(identity);
|
|
152
|
+
merged.push({ root, file: api.file, path: api.path, method: api.method });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return merged.sort((left, right) => {
|
|
156
|
+
const leftSegments = pathSegments(left.path);
|
|
157
|
+
const rightSegments = pathSegments(right.path);
|
|
158
|
+
const depth = Math.min(leftSegments.length, rightSegments.length);
|
|
159
|
+
for (let index = 0; index < depth; index += 1) {
|
|
160
|
+
const order = compareText(leftSegments[index], rightSegments[index]);
|
|
161
|
+
if (order !== 0) return order;
|
|
162
|
+
}
|
|
163
|
+
const lengthOrder = leftSegments.length - rightSegments.length;
|
|
164
|
+
if (lengthOrder !== 0) return lengthOrder;
|
|
165
|
+
const methods = OPENAPI_METHODS as readonly string[];
|
|
166
|
+
return methods.indexOf(left.method) - methods.indexOf(right.method);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Import one generated endpoint module through its file URL โ `pathToFileURL` keeps absolute (Windows-style) paths safe. */
|
|
171
|
+
async function importEndpointModule(root: string, file: string): Promise<Record<string, unknown>> {
|
|
172
|
+
try {
|
|
173
|
+
return await import(pathToFileURL(join(root, file)).href) as Record<string, unknown>;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `unable to import generated endpoint module: ${file}`, { at: file, hint: 'fix or regenerate the affected generated module', cause: error });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Create one null-prototype branch node so every path segment becomes a plain own enumerable key. */
|
|
180
|
+
function createBranch(): ApiDocsBranch {
|
|
181
|
+
return Object.create(null) as ApiDocsBranch;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether one tree node is a branch this resolver created (null prototype) rather than an endpoint config leaf. */
|
|
185
|
+
function isBranch(value: unknown): value is ApiDocsBranch {
|
|
186
|
+
return isRecord(value) && Object.getPrototypeOf(value) === null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Typed failure for paths whose nested key chains cannot coexist in one tree (below a method leaf, or trailing-slash twins). */
|
|
190
|
+
function treeConflictError(file: string, detail: string): ZopiaError {
|
|
191
|
+
return new ZopiaError('ZOPIA_SPEC_INVALID', `the nested api-docs tree cannot represent this path: ${detail}`, { at: file, hint: 'rename the colliding path, or consume its endpoint module through a direct import / flattenApiDocs' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Insert one endpoint config at its segment chain, failing typed when another path already owns a conflicting key. */
|
|
195
|
+
function insertEndpoint(root: ApiDocsBranch, segments: readonly string[], method: string, config: unknown, file: string): void {
|
|
196
|
+
let node = root;
|
|
197
|
+
for (const segment of segments) {
|
|
198
|
+
const existing = node[segment];
|
|
199
|
+
if (existing === undefined) {
|
|
200
|
+
const branch = createBranch();
|
|
201
|
+
node[segment] = branch;
|
|
202
|
+
node = branch;
|
|
203
|
+
} else if (isBranch(existing)) {
|
|
204
|
+
node = existing;
|
|
205
|
+
} else {
|
|
206
|
+
throw treeConflictError(file, `segment '${segment}' is already the method leaf of another path`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (node[method] !== undefined) {
|
|
210
|
+
throw treeConflictError(file, `method leaf '${method}' is already occupied at this tree node by another path with the same segments (paths differing only by a trailing slash collapse to one another)`);
|
|
211
|
+
}
|
|
212
|
+
node[method] = config;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether one tree value is a km-api endpoint config leaf (string `method` + `pathShape`, object `request` + `response`). */
|
|
216
|
+
function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Record<string, unknown> {
|
|
217
|
+
return isRecord(value) && typeof value.method === 'string' && typeof value.pathShape === 'string' && isRecord(value.request) && isRecord(value.response);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Load a generated api-docs tree into one nested endpoint object.
|
|
222
|
+
*
|
|
223
|
+
* The resolver is layout- and path-preserving: it discovers the root
|
|
224
|
+
* `.zopia-manifest.json` plus every one-level-deep preset bucket manifest
|
|
225
|
+
* (`multi-tag` / `multi-server`), merges them (deduplicating by
|
|
226
|
+
* `${path}#${method}`), and accepts any directory that was ever a zopia output
|
|
227
|
+
* root โ tree roots and preset bucket roots alike. Endpoint modules are loaded
|
|
228
|
+
* with dynamic `import()` through `pathToFileURL`, and every leaf is the
|
|
229
|
+
* module's **default export** (its named export stays available on the module
|
|
230
|
+
* itself). Keys are inserted in the deterministic manifest order, so the same
|
|
231
|
+
* tree always enumerates its keys in the same order. The tree is only read and
|
|
232
|
+
* imported โ nothing on disk is written.
|
|
233
|
+
*
|
|
234
|
+
* @param docsDir Generated api-docs output directory (tree root or preset bucket root; absolute or relative).
|
|
235
|
+
* @returns Nested tree keyed by exact URL path segments with lowercase-method leaves holding the endpoint configs.
|
|
236
|
+
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `docsDir` is not a usable path.
|
|
237
|
+
* @throws {ZopiaError} `ZOPIA_DOCS_MISSING_MANIFEST` when no root or bucket manifest exists under `docsDir`.
|
|
238
|
+
* @throws {ZopiaError} `ZOPIA_MANIFEST_INVALID` when a discovered manifest is garbled JSON or has unusable `apis[]` records.
|
|
239
|
+
* @throws {ZopiaError} `ZOPIA_DOCS_IMPORT_FAILED` when an endpoint module cannot be imported or has no default export.
|
|
240
|
+
* @throws {ZopiaError} `ZOPIA_SPEC_INVALID` when two paths cannot coexist in one nested tree (a path continuing below another path's method leaf, or trailing-slash twins).
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { createApiDocs } from './src/runtime';
|
|
244
|
+
*
|
|
245
|
+
* const apiDocs = await createApiDocs('api_docs');
|
|
246
|
+
* const endpoint = apiDocs.users['{userId}'].get;
|
|
247
|
+
* console.log(endpoint.method, endpoint.pathShape);
|
|
248
|
+
* ```
|
|
249
|
+
* @see [docs/07-api-docs.md โ Runtime tree consumption](../../docs/07-api-docs.md)
|
|
250
|
+
*/
|
|
251
|
+
export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
|
|
252
|
+
if (typeof docsDir !== 'string' || docsDir.trim() === '' || docsDir.includes('\0')) {
|
|
253
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'docsDir must be a non-empty directory path', { at: 'docsDir', hint: 'provide the generated api-docs output directory (tree root or preset bucket root)' });
|
|
254
|
+
}
|
|
255
|
+
const root = resolve(docsDir);
|
|
256
|
+
const manifests = await discoverApiDocsManifests(root);
|
|
257
|
+
if (manifests.length === 0) {
|
|
258
|
+
throw new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `no zopia manifest found under ${docsDir}`, { at: join(docsDir, ZOPIA_MANIFEST_FILE), hint: 'generate api docs first (manifests stay enabled by default), or pass a tree or preset-bucket root' });
|
|
259
|
+
}
|
|
260
|
+
const entries = mergeApiEntries(manifests);
|
|
261
|
+
const tree = createBranch();
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const endpointModule = await importEndpointModule(entry.root, entry.file);
|
|
264
|
+
const config = endpointModule.default;
|
|
265
|
+
if (!isRecord(config)) {
|
|
266
|
+
throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `generated endpoint module has no default export: ${entry.file}`, { at: entry.file, hint: "regenerate the tree, or restore the module's default endpoint-config export" });
|
|
267
|
+
}
|
|
268
|
+
insertEndpoint(tree, pathSegments(entry.path), entry.method, config, entry.file);
|
|
269
|
+
}
|
|
270
|
+
return tree as ApiDocsTree;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Flatten one runtime api-docs tree into a record keyed by endpoint name.
|
|
275
|
+
*
|
|
276
|
+
* Deep-walks the tree produced by {@link createApiDocs} in its deterministic
|
|
277
|
+
* leaf order and registers every endpoint config under its `operationId`. When
|
|
278
|
+
* a leaf has no usable `operationId`, the key is derived by the **same rules
|
|
279
|
+
* the generator uses for its export identifiers** (method + PascalCase path
|
|
280
|
+
* segments, camelize, reserved-word guard, leading-numeric guard), and
|
|
281
|
+
* collisions receive the generator's `2`, `3`, โฆ uniqueness suffix โ a later
|
|
282
|
+
* duplicate never clobbers an already-registered name. The returned record has
|
|
283
|
+
* a null prototype, so even an `operationId` spelled `__proto__` stays a plain
|
|
284
|
+
* own key.
|
|
285
|
+
*
|
|
286
|
+
* @param apiDocs Nested tree returned by {@link createApiDocs}.
|
|
287
|
+
* @returns Flat record of every endpoint config keyed by its derived endpoint name, in tree leaf order.
|
|
288
|
+
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `apiDocs` is not the nested tree object (a non-object input or a non-tree value inside it).
|
|
289
|
+
* @throws {ZopiaError} `ZOPIA_SPEC_INVALID` when a leaf without a usable `operationId` cannot be named (its `pathShape` is not an OpenAPI `/`-rooted template or its `method` is not one of the eight standard methods).
|
|
290
|
+
* @example
|
|
291
|
+
* ```ts
|
|
292
|
+
* import { createApiDocs, flattenApiDocs } from './src/runtime';
|
|
293
|
+
*
|
|
294
|
+
* const endpoints = flattenApiDocs(await createApiDocs('api_docs'));
|
|
295
|
+
* console.log(endpoints.getUser.pathShape);
|
|
296
|
+
* ```
|
|
297
|
+
* @see [docs/07-api-docs.md โ Runtime tree consumption](../../docs/07-api-docs.md)
|
|
298
|
+
*/
|
|
299
|
+
export function flattenApiDocs(apiDocs: ApiDocsTree): Record<string, ApiDocsEndpointConfig> {
|
|
300
|
+
if (!isRecord(apiDocs)) {
|
|
301
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'flattenApiDocs expects the tree object returned by createApiDocs', { at: 'apiDocs', hint: 'pass the createApiDocs result (a nested object of endpoint configs)' });
|
|
302
|
+
}
|
|
303
|
+
const flat: Record<string, ApiDocsEndpointConfig> = createBranch() as Record<string, ApiDocsEndpointConfig>;
|
|
304
|
+
const used = new Set<string>();
|
|
305
|
+
const register = (config: ApiDocsEndpointConfig & Record<string, unknown>): void => {
|
|
306
|
+
const operationId = typeof config.operationId === 'string' && config.operationId.trim() !== '' ? config.operationId : undefined;
|
|
307
|
+
const base = endpointExportName(operationId ?? deriveOperationId(config.pathShape, config.method.toLowerCase() as OpenApiMethod));
|
|
308
|
+
const key = uniqueEndpointName(base, used);
|
|
309
|
+
used.add(key);
|
|
310
|
+
flat[key] = config;
|
|
311
|
+
};
|
|
312
|
+
const walk = (node: Record<string, unknown>): void => {
|
|
313
|
+
for (const [key, value] of Object.entries(node)) {
|
|
314
|
+
if (isEndpointConfig(value)) register(value);
|
|
315
|
+
else if (isRecord(value)) walk(value);
|
|
316
|
+
else throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unexpected value in the api-docs tree at key '${key}': ${typeof value}`, { at: key, hint: 'pass the createApiDocs result (every value is a branch or an endpoint config)' });
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
walk(apiDocs);
|
|
320
|
+
return flat;
|
|
321
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ณ Opt-in runtime tree consumption (S-94).
|
|
3
|
+
*
|
|
4
|
+
* Thin subpath entry (`zopia/runtime`) re-exporting the helpers that turn a
|
|
5
|
+
* generated api-docs directory into the objects an application consumes. The
|
|
6
|
+
* package root stays free of filesystem-importing APIs โ importing this
|
|
7
|
+
* subpath is the explicit opt-in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { createApiDocs, flattenApiDocs, type ApiDocsEndpointConfig, type ApiDocsTree } from './runtime/create-api-docs';
|
package/docs/01-overview.md
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# ๐งญ Overview
|
|
2
|
-
|
|
3
|
-
zopia is a **type-safe OpenAPI โ Zod toolkit** for generating, validating, and
|
|
4
|
-
transforming API schemas. It converts between the four formats an API team
|
|
5
|
-
lives in โ **Swagger 2.0**, **OpenAPI 3.x**, **JSON Schema**, and **Zod v4** โ
|
|
6
|
-
and turns any of them into a tree of **type-safe `km-api` endpoint files** that
|
|
7
|
-
drop straight into a TypeScript codebase.
|
|
8
|
-
|
|
9
|
-
## ๐ฉ๏ธ The problem
|
|
10
|
-
|
|
11
|
-
API teams keep the same knowledge in many places, and every copy drifts:
|
|
12
|
-
|
|
13
|
-
| ๐ Pain | ๐ Consequence |
|
|
14
|
-
| --- | --- |
|
|
15
|
-
| The spec (`swagger.json`) describes the API | โฆbut the validation code is written by hand |
|
|
16
|
-
| Zod schemas validate at runtime | โฆbut they are re-typed from the spec, field by field |
|
|
17
|
-
| Components (`$ref`) keep the DRY spec | โฆbut the generated code duplicates the same shapes |
|
|
18
|
-
| Swagger 2.0 and OpenAPI 3.x differ subtly | โฆand both must be supported to read legacy specs |
|
|
19
|
-
|
|
20
|
-
**Every copy is a source of bugs. Every re-typing is a waste of time.**
|
|
21
|
-
|
|
22
|
-
## ๐ ๏ธ The solution
|
|
23
|
-
|
|
24
|
-
zopia makes the spec and the code **two views of one fact**:
|
|
25
|
-
|
|
26
|
-
```mermaid
|
|
27
|
-
flowchart LR
|
|
28
|
-
A["๐ swagger.json<br/>(v2 / v3)"] -->|"โข openapi โ api docs"| B["๐ api_docs/**<br/>(.ts ยท km-api ยท zod v4)"]
|
|
29
|
-
B -->|"โฃ api docs โ openapi"| A
|
|
30
|
-
C["โ๏ธ Zod v4 schemas"] -->|"โ zod โ JSON Schema"| D["๐ JSON Schema"]
|
|
31
|
-
D -->|"โก JSON Schema โ zod"| C
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
- **โข** reads a spec, resolves every `$ref`, and renders one **`index.ts` per
|
|
35
|
-
endpoint** โ each built with `makeApiConfig()` from **km-api** (0.4.x), with
|
|
36
|
-
request/response/params/query/headers/cookies validated by **Zod v4** schemas.
|
|
37
|
-
- **โฃ** reads that tree back and regenerates a complete OpenAPI document โ so
|
|
38
|
-
developer edits to the generated code become the new spec.
|
|
39
|
-
- **โ / โก** are the two primitive schema converters the other engines are built on.
|
|
40
|
-
|
|
41
|
-
## ๐ฏ The promise
|
|
42
|
-
|
|
43
|
-
> **Fast and valid developing.** A developer gets **valid, documented,
|
|
44
|
-
> type-safe** endpoint code **in seconds**, from a spec they already trust โ
|
|
45
|
-
> and can always get the spec back.
|
|
46
|
-
|
|
47
|
-
## ๐งฐ The stack (fixed, by standard)
|
|
48
|
-
|
|
49
|
-
| ๐งฉ Piece | Version | Role in zopia |
|
|
50
|
-
| --- | --- | --- |
|
|
51
|
-
| โ๏ธ [Zod](https://zod.dev/) | **v4** (`^4`) | Runtime validation + static types; built-in `z.toJSONSchema()` |
|
|
52
|
-
| ๐งฑ [km-api](https://www.npmjs.com/package/km-api) | **0.4.x** (`^0.4` from npm) | The "make function" โ `makeApiConfig()` builds every generated endpoint |
|
|
53
|
-
| ๐ฃ [Bun](https://bun.sh/) | `โฅ 1.1` | Primary runtime & toolchain (runs the package, tests, and generated code) |
|
|
54
|
-
| ๐งช [Vitest](https://vitest.dev/) | latest stable | Test runner for the full scenario matrix |
|
|
55
|
-
| ๐ท TypeScript | `5.9+`, `strict` | Language of the package and of every generated file |
|
|
56
|
-
|
|
57
|
-
## ๐ฅ Who is this for?
|
|
58
|
-
|
|
59
|
-
- ๐งโ๐ป **TypeScript backend teams** that maintain a Swagger/OpenAPI spec and
|
|
60
|
-
want the validation layer generated, not hand-written
|
|
61
|
-
- ๐ **Legacy teams** still on Swagger 2.0 who need a path to OpenAPI 3.x
|
|
62
|
-
- ๐ค **Full-stack teams** that want one artifact (`api_docs/**`) shared between
|
|
63
|
-
server validation and client code
|
|
64
|
-
|
|
65
|
-
## ๐ซ Non-goals (Phase 1)
|
|
66
|
-
|
|
67
|
-
Being explicit about what zopia **does not do** keeps the scope honest:
|
|
68
|
-
|
|
69
|
-
- ๐ It is **not a runtime** โ no HTTP server, no client, no hosting of specs.
|
|
70
|
-
(km-api already provides client adapters; zopia generates the definitions.)
|
|
71
|
-
- โ๏ธ It is **not a spec editor** โ it never edits your original spec file.
|
|
72
|
-
- ๐งฎ It does **not execute user code** except through the documented,
|
|
73
|
-
trusted-input contract of engine โฃ (see [Standards โ Safety](12-standards.md#-safety)).
|
|
74
|
-
- ๐ **YAML input** โ v0.1.0 accepted JSON only; v0.2.x lifts that limit
|
|
75
|
-
(D-16): JSON/YAML objects, JSON/YAML text, and `spec.json` / `spec.yaml` /
|
|
76
|
-
`spec.yml` all enter the same normalized model through zopia's owned
|
|
77
|
-
deterministic YAML parser.
|
|
78
|
-
|
|
79
|
-
## ๐ Core principles
|
|
80
|
-
|
|
81
|
-
| # | Principle | Meaning |
|
|
82
|
-
| --- | --- | --- |
|
|
83
|
-
| P-1 | ๐ฏ **Deterministic** | Same input + same options โ **byte-identical** output. No timestamps, no random order, no environment leakage. |
|
|
84
|
-
| P-2 | ๐ **Lossless by design** | Every conversion is reversible; what cannot be represented in the target format is recorded (manifest + warnings), never silently dropped. |
|
|
85
|
-
| P-3 | ๐งผ **Pure core** | In-memory transforms stay pure; documented file reads, generated-tree writes/imports, and CLI process access live at explicit adapter boundaries. |
|
|
86
|
-
| P-4 | ๐ก๏ธ **Safe** | Fail loudly with typed, actionable errors; never write outside the configured output directory; never hide lossy conversions. |
|
|
87
|
-
| P-5 | ๐ **Documented** | Every public symbol has JSDoc; every rule is numbered; every decision is recorded. |
|
|
88
|
-
| P-6 | ๐ฆ **Dependency-light** | Zero bundled runtime dependencies; `zod` and `km-api` remain explicit peers (`zod` powers engines โ /โก and generated schemas; km-api powers generated endpoints and their reverse imports). |
|
|
89
|
-
|
|
90
|
-
## ๐ Next
|
|
91
|
-
|
|
92
|
-
- ๐ฏ The completed public targets โ [Targets](02-targets.md)
|
|
93
|
-
- ๐บ๏ธ Release phases and deferred breadth โ [Roadmap](03-roadmap.md)
|
|
94
|
-
- ๐งฉ Words you will keep seeing โ [Concepts](05-concepts.md)
|
package/docs/02-targets.md
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# ๐ฏ Targets
|
|
2
|
-
|
|
3
|
-
These are the **explicit, testable targets** of zopia. Every Phase 1 target maps
|
|
4
|
-
to its governing document and automated release-gate coverage โ see
|
|
5
|
-
[Testing โ Scenario matrix](11-testing.md).
|
|
6
|
-
|
|
7
|
-
> โ
**Complete** = implemented, documented, and covered by the release gate.
|
|
8
|
-
> Deferred work is listed explicitly under [Roadmap โ Phase 2](03-roadmap.md#-phase-2--breadth-v02x-).
|
|
9
|
-
|
|
10
|
-
## ๐ Conversion targets
|
|
11
|
-
|
|
12
|
-
| # | ๐ฏ Target | Spec | Status |
|
|
13
|
-
| --- | --- | --- | --- |
|
|
14
|
-
| T-1 | **Convert Zod โ JSON Schema** | [Conversions โ Engine โ ](06-conversions.md) | โ
|
|
|
15
|
-
| T-2 | **Convert JSON Schema โ Zod** | [Conversions โ Engine โก](06-conversions.md) | โ
|
|
|
16
|
-
| T-3 | **Convert OpenAPI โ api docs** โ Swagger 2.0 *and* OpenAPI 3.0/3.1 | [Conversions โ Engine โข](06-conversions.md) | โ
|
|
|
17
|
-
| T-4 | **Convert api docs โ OpenAPI** (the reverse direction) | [Conversions โ Engine โฃ](06-conversions.md) | โ
|
|
|
18
|
-
|
|
19
|
-
## ๐ API docs targets
|
|
20
|
-
|
|
21
|
-
| # | ๐ฏ Target | Spec | Status |
|
|
22
|
-
| --- | --- | --- | --- |
|
|
23
|
-
| T-5 | **Layout mode `directory`** โ `api_docs/` + path segments as nested directories + a method-named directory at the last level + `index` file | [API docs format โ directory mode](07-api-docs.md) | โ
|
|
|
24
|
-
| T-6 | **Layout mode `flat`** โ `api_docs/` + one directory per API + method directory + `index` file | [API docs format โ flat mode](07-api-docs.md#-mode--flat) | โ
|
|
|
25
|
-
| T-7 | **`index.ts` files are TypeScript** and fill **all practical content** of the endpoint (method, path, operationId, summary, description, tags, auth, content types, request, response, examples) using **`makeApiConfig()` from km-api** (the package's make function) | [API docs format โ `index.ts` contract](07-api-docs.md#-the-indexts-contract) | โ
|
|
|
26
|
-
| T-8 | **Option `insertComponents: boolean`** โ when `true`, components are written into `api_docs` as their own schema files; **default `false`** | [Components](08-components.md) ยท [Configuration](09-configuration.md) | โ
|
|
|
27
|
-
| T-9 | **Option `useComponentAsReference: boolean`** โ endpoints import emitted components recursively; requires `insertComponents: true`; **default `false`** | [Components โ option matrix](08-components.md) | โ
|
|
|
28
|
-
|
|
29
|
-
## ๐ Reverse-conversion targets
|
|
30
|
-
|
|
31
|
-
| # | ๐ฏ Target | Spec | Status |
|
|
32
|
-
| --- | --- | --- | --- |
|
|
33
|
-
| T-10 | **Reversible output** โ the generated tree contains everything needed to regenerate the spec (paths, methods, schemas, metadata), guaranteed by the manifest | [API docs format โ manifest](07-api-docs.md) | โ
|
|
|
34
|
-
| T-11 | **Round-trip stability** โ `openapi โ api docs โ openapi` and `zod โ JSON Schema โ zod` converge: re-running the pipeline on its own output is a no-op (idempotent) | [Testing โ round-trip tests](11-testing.md#-round-trip-property-tests) | โ
|
|
|
35
|
-
|
|
36
|
-
## ๐๏ธ Engineering targets
|
|
37
|
-
|
|
38
|
-
| # | ๐ฏ Target | Spec | Status |
|
|
39
|
-
| --- | --- | --- | --- |
|
|
40
|
-
| T-12 | **JSDoc everywhere** โ every exported function, type, constant, and class is documented (template + rules fixed) | [Standards โ JSDoc standard](12-standards.md) | โ
|
|
|
41
|
-
| T-13 | **Tests in all scenarios** โ Vitest suite covering the full scenario matrix (spec versions, ref graphs, modes, option combinations, zod features, edge cases), run with Bun | [Testing](11-testing.md) | โ
|
|
|
42
|
-
| T-14 | **Bun runs the project** โ install, typecheck, test, coverage, CLI, and generated-TypeScript imports all work through one pinned-Bun gate | [Standards โ Bun gate](12-standards.md#-bun-gate) | โ
|
|
|
43
|
-
| T-15 | **Standard documentation** โ `docs/` directory with targets, roadmap, architecture, conventions; README links into it; beautiful, icon-based markdown | this document set ยท [Standards โ Docs convention](12-standards.md#-docs-convention) | โ
|
|
|
44
|
-
| T-16 | **Quality bar** โ pure, safe, clean code with descriptions; deterministic output; typed errors; no silent lossy conversions | [Standards](12-standards.md) | โ
|
|
|
45
|
-
| T-17 | **Changelog after commits** โ `CHANGELOG.md` updated by every user-facing commit under `Unreleased` | [Standards โ Changelog convention](12-standards.md#-changelog-convention) | โ
|
|
|
46
|
-
|
|
47
|
-
## ๐บ๏ธ Out of scope for the first phase
|
|
48
|
-
|
|
49
|
-
> ๐ The reverse-conversion *capability* (T-4/T-10/T-11) **is** in the first
|
|
50
|
-
> phase โ it is part of the four targets above. What is deferred:
|
|
51
|
-
|
|
52
|
-
- ~~๐ YAML spec input~~ โ shipped in v0.2.x (D-16; `.yaml`/`.yml` files and inline YAML text reach the same normalized model as JSON)
|
|
53
|
-
- ๐ External (multi-file) `$ref`s โ [Roadmap Phase 2](03-roadmap.md)
|
|
54
|
-
- ๐งฉ Reusable **parameters / responses** as emitted components (Phase 1 emits `components.schemas` only) โ [Roadmap Phase 2](03-roadmap.md)
|
|
55
|
-
- ~~๐ฅ๏ธ `zopia validate` (spec linting)~~ โ shipped in v0.3.x (S-89; [Roadmap Phase 3](03-roadmap.md)); incremental regeneration remains deferred
|