zopia 0.3.0 โ†’ 0.5.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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ๐Ÿง  Generated exact tree types (S-95).
3
+ *
4
+ * Renders the `.zopia-tree.d.ts` declaration emitted beside every retained
5
+ * manifest: a types-only artifact describing the **exact** nested shape the
6
+ * runtime resolver (`createApiDocs`) builds and the **exact** flat record
7
+ * `flattenApiDocs` returns, so consumers get precise IntelliSense โ€” literal
8
+ * path-segment keys, method leaves typed as the generated module's own
9
+ * `makeApiConfig()` export, and typo-proof access (unknown keys are compile
10
+ * errors). Nothing runtime is emitted; the file is pure declaration.
11
+ *
12
+ * Ordering and naming are shared with the runtime: entries sort through
13
+ * `compareApiDocsEntries` and flat keys derive through `endpointExportName` /
14
+ * `uniqueEndpointName` โ€” the same rules as the generator's `export const`
15
+ * names โ€” so the declared types match the runtime objects by construction.
16
+ */
17
+
18
+ import { apiDocsPathSegments, compareApiDocsEntries } from './api-docs-layout';
19
+ import { endpointExportName, uniqueEndpointName } from './api-docs-names';
20
+
21
+ /** File name of the generated exact-tree declaration, written beside the manifest. */
22
+ export const ZOPIA_TREE_TYPES_FILE = '.zopia-tree.d.ts';
23
+
24
+ /** One endpoint record the declaration is rendered from. */
25
+ export interface ApiDocsTreeTypePlan {
26
+ /** Portable endpoint-module path relative to the tree root (POSIX, with `.ts`). */
27
+ file: string;
28
+ /** OpenAPI path template โ€” the authority for the nested key chain. */
29
+ path: string;
30
+ /** Lowercase HTTP method โ€” the leaf key. */
31
+ method: string;
32
+ /** Declared or deterministically derived operation identifier. */
33
+ operationId: string;
34
+ }
35
+
36
+ /** Internal tree node while assembling the declaration: a branch, a method leaf, or both. */
37
+ interface TypeTreeNode {
38
+ /** Child branches/leaves by key; insertion follows the deterministic entry order. */
39
+ children?: Map<string, TypeTreeNode>;
40
+ /** Module type reference when this node is (also) a method leaf. */
41
+ leaf?: { module: string; exportName: string };
42
+ }
43
+
44
+ /** Import-free module type reference: `typeof import('<module>').<exportName>`. */
45
+ const typeReference = (module: string, exportName: string): string => `typeof import('${module}').${exportName}`;
46
+
47
+ /** Module specifier of one endpoint plan: the portable file path without the `.ts` extension. */
48
+ const moduleSpecifier = (file: string): string => `./${file.replace(/\.ts$/, '')}`;
49
+
50
+ /**
51
+ * Render the `.zopia-tree.d.ts` declaration for one tree root.
52
+ *
53
+ * @param apis Endpoint records (path operations only โ€” webhooks are not URL-path endpoints).
54
+ * @returns The complete declaration file text: an `ApiDocsTree` type keyed by exact URL path segments with lowercase-method leaves, and an `ApiDocsFlat` type keyed by the derived endpoint names.
55
+ */
56
+ export function renderApiDocsTreeTypes(apis: readonly ApiDocsTreeTypePlan[]): string {
57
+ const sorted = [...apis].sort(compareApiDocsEntries);
58
+ const root: TypeTreeNode = {};
59
+ for (const api of sorted) {
60
+ let node: TypeTreeNode = root;
61
+ for (const segment of apiDocsPathSegments(api.path)) {
62
+ let child = node.children?.get(segment);
63
+ if (!child) {
64
+ child = {};
65
+ (node.children ??= new Map<string, TypeTreeNode>()).set(segment, child);
66
+ }
67
+ node = child;
68
+ }
69
+ (node.children ??= new Map<string, TypeTreeNode>()).set(api.method, { leaf: { module: moduleSpecifier(api.file), exportName: endpointExportName(api.operationId) } });
70
+ }
71
+
72
+ const renderNode = (node: TypeTreeNode, depth: number): string => {
73
+ if (!node.children || node.children.size === 0) return 'Record<string, never>';
74
+ const indent = ' '.repeat(depth);
75
+ const lines: string[] = [];
76
+ for (const [key, child] of node.children) {
77
+ const childType = child.leaf
78
+ ? child.children && child.children.size > 0
79
+ // A path continuing below another path's method leaf: the key is both the
80
+ // endpoint config and a deeper branch โ€” the permissive intersection shape.
81
+ ? `(${typeReference(child.leaf.module, child.leaf.exportName)} & ${renderNode(child, 1)})`
82
+ : typeReference(child.leaf.module, child.leaf.exportName)
83
+ : renderNode(child, depth + 1);
84
+ lines.push(`${indent} readonly ${JSON.stringify(key)}: ${childType};`);
85
+ }
86
+ return `{\n${lines.join('\n')}\n${indent}}`;
87
+ };
88
+
89
+ const flatLines: string[] = [];
90
+ const used = new Set<string>();
91
+ for (const api of sorted) {
92
+ const key = uniqueEndpointName(endpointExportName(api.operationId), used);
93
+ used.add(key);
94
+ flatLines.push(` readonly ${JSON.stringify(key)}: ${typeReference(moduleSpecifier(api.file), endpointExportName(api.operationId))};`);
95
+ }
96
+
97
+ return [
98
+ '/**',
99
+ ' * Generated by zopia โ€” do not edit by hand.',
100
+ ' *',
101
+ ' * Exact types of this api-docs tree as loaded at runtime by the',
102
+ ' * `createApiDocs()` / `flattenApiDocs()` helpers of the zopia `zopia/runtime`',
103
+ ' * subpath (types-only: this file imports nothing beyond the tree itself):',
104
+ ' *',
105
+ " * const apiDocs = await createApiDocs<ApiDocsTree>('./<this-tree>'); // nested: exact segments โ†’ method",
106
+ ' * const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // flat: exact endpoint names',
107
+ ' */',
108
+ `export type ApiDocsTree = ${renderNode(root, 0)};`,
109
+ '',
110
+ `export type ApiDocsFlat = ${flatLines.length ? `{\n${flatLines.join('\n')}\n}` : 'Record<string, never>'};`,
111
+ '',
112
+ ].join('\n').replace(/[ \t]+$/gm, '');
113
+ }
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile, realpath, rmdir, rm, stat } from 'node:fs/promises';
2
2
  import { asZopiaError } from '../errors';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
4
5
  import type { ApiDocsMode } from './api-docs-layout';
5
6
  import {
6
7
  validateZopiaManifest,
@@ -55,7 +56,8 @@ function compareText(left: string, right: string): number {
55
56
  const isManifestComponent = (value: unknown): value is { kind?: string; file?: unknown } => typeof value === 'object' && value !== null;
56
57
 
57
58
  function collectOwnedFiles(manifest: GeneratedZopiaManifest): string[] {
58
- const files = new Set<string>([ZOPIA_MANIFEST_FILE]);
59
+ // The exact-tree declaration is written and pruned together with the manifest.
60
+ const files = new Set<string>([ZOPIA_MANIFEST_FILE, ZOPIA_TREE_TYPES_FILE]);
59
61
  for (const api of manifest.apis) files.add(api.file);
60
62
  for (const webhook of manifest.webhooks ?? []) files.add(webhook.file);
61
63
  for (const component of manifest.components) if (component.file !== null) files.add(component.file);
@@ -110,7 +112,7 @@ export async function inspectZopiaManifestStaleness(outputDir: string, identity:
110
112
  return {
111
113
  status: 'stale',
112
114
  reasons: ['invalid-manifest', ...(!identity.manifest ? ['manifest-disabled' as const] : [])],
113
- ownedFiles: [ZOPIA_MANIFEST_FILE],
115
+ ownedFiles: [ZOPIA_MANIFEST_FILE, ZOPIA_TREE_TYPES_FILE],
114
116
  };
115
117
  }
116
118
 
@@ -16,7 +16,7 @@ export const ZOPIA_MANIFEST_SCHEMA = 'zopia:manifest@1' as const;
16
16
  export const ZOPIA_MANIFEST_FILE = '.zopia-manifest.json' as const;
17
17
 
18
18
  /** Package version recorded by the current manifest writer. */
19
- export const ZOPIA_VERSION = '0.3.0' as const;
19
+ export const ZOPIA_VERSION = '0.5.0' as const;
20
20
 
21
21
  /** Supported source dialect labels stored in a manifest. */
22
22
  export type ZopiaManifestSourceKind = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
@@ -12,6 +12,7 @@ import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
12
12
  import { parseYaml } from './yaml';
13
13
  import { jsonSchemaToZod, type JsonSchema } from './json-schema-to-zod';
14
14
  import { hashOpenApiDocument, ZOPIA_MANIFEST_FILE } from './manifest-writer';
15
+ import { ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
15
16
  import { formatManifestStaleness, inspectZopiaManifestStaleness } from './manifest-staleness';
16
17
  import { join } from 'node:path';
17
18
  import { planPresetBuckets, ZOPIA_GENERATE_PRESETS, type ZopiaGeneratePreset, type ZopiaPresetTree } from './api-docs-presets';
@@ -39,7 +40,7 @@ export interface ZopiaGeneratedFile {
39
40
  /** Portable path relative to `outDir`. */
40
41
  path: string;
41
42
  /** Generated artifact category. */
42
- kind: 'endpoint' | 'component' | 'manifest';
43
+ kind: 'endpoint' | 'component' | 'manifest' | 'types';
43
44
  }
44
45
 
45
46
  /** Result returned by the Engine โ‘ข public API. */
@@ -459,7 +460,7 @@ export async function openApiToApiDocs(input: string | Record<string, unknown>,
459
460
  : []);
460
461
  const files: ZopiaGeneratedFile[] = generated.map(({ file }): ZopiaGeneratedFile => ({
461
462
  path: file,
462
- kind: file === ZOPIA_MANIFEST_FILE ? 'manifest' : componentFiles.has(file) ? 'component' : 'endpoint',
463
+ kind: file === ZOPIA_MANIFEST_FILE ? 'manifest' : file === ZOPIA_TREE_TYPES_FILE ? 'types' : componentFiles.has(file) ? 'component' : 'endpoint',
463
464
  })).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
464
465
  const warningCollector = new ZopiaWarningCollector(); warningCollector.addAll(warnings);
465
466
  return { files, warnings: warningCollector.toArray(), ...(config.manifest ? { manifestPath: ZOPIA_MANIFEST_FILE } : {}) };
@@ -1,5 +1,6 @@
1
1
  import { ZopiaError } from '../errors';
2
2
  import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
3
+ import { uniqueEndpointName } from './api-docs-names';
3
4
  import { resolveOpenApiLocalRef } from './openapi-ref';
4
5
 
5
6
  /** Canonical km-api/OpenAPI operation method order. */
@@ -107,15 +108,12 @@ export function collectOpenApiOperations(input: OpenApiDocument | string): OpenA
107
108
  const previous = owners.get(operationId);
108
109
  if (previous) {
109
110
  if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
110
- let replacement = previous.operationId; let suffix = 1;
111
- while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
111
+ const replacement = uniqueEndpointName(operationId, ids);
112
112
  ids.delete(previous.operationId); owners.delete(previous.operationId);
113
113
  previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
114
114
  }
115
115
  } else {
116
- const base = deriveOperationId(path, method);
117
- operationId = base; let suffix = 1;
118
- while (ids.has(operationId)) operationId = `${base}${++suffix}`;
116
+ operationId = uniqueEndpointName(deriveOperationId(path, method), ids);
119
117
  }
120
118
  const collected = { path, method, operation, operationId, parameters: mergedParameters };
121
119
  ids.add(operationId); owners.set(operationId, collected); operations.push(collected);
@@ -185,15 +183,12 @@ export function collectOpenApiWebhookOperations(document: OpenApiDocument): Open
185
183
  const previous = owners.get(operationId);
186
184
  if (previous) {
187
185
  if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
188
- let replacement = previous.operationId; let suffix = 1;
189
- while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
186
+ const replacement = uniqueEndpointName(operationId, ids);
190
187
  ids.delete(previous.operationId); owners.delete(previous.operationId);
191
188
  previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
192
189
  }
193
190
  } else {
194
- const base = `${method}${pascalPath(name)}`;
195
- operationId = base; let suffix = 1;
196
- while (ids.has(operationId)) operationId = `${base}${++suffix}`;
191
+ operationId = uniqueEndpointName(`${method}${pascalPath(name)}`, ids);
197
192
  }
198
193
  const collected = { path: name, method, operation, operationId, parameters: mergedParameters };
199
194
  ids.add(operationId); owners.set(operationId, collected); operations.push(collected);
@@ -0,0 +1,329 @@
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 { apiDocsPathSegments, compareApiDocsEntries } from '../conversions/api-docs-layout';
21
+ import { deriveOperationId, OPENAPI_METHODS, type OpenApiMethod } from '../conversions/openapi-to-api-docs';
22
+ import { ZOPIA_MANIFEST_FILE } from '../conversions/manifest-writer';
23
+
24
+ /** One generated km-api endpoint configuration โ€” the `export default` value every endpoint module ships (R-732). */
25
+ export type ApiDocsEndpointConfig = IMakeApiConfigEntry;
26
+
27
+ /**
28
+ * Nested runtime view of one generated api-docs tree.
29
+ *
30
+ * Branch keys are the URL path segments **verbatim and in order** (including
31
+ * literal `{param}` segments), the leaf key is the lowercase HTTP method, and
32
+ * the leaf value is the endpoint module's default export. Because which keys
33
+ * exist depends on the source spec, every node is typed permissively as both a
34
+ * deeper branch and an {@link ApiDocsEndpointConfig}, so chained access such as
35
+ * `apiDocs.users['{userId}'].get.method` typechecks and reads the runtime
36
+ * values the tree actually holds.
37
+ */
38
+ export interface ApiDocsTree {
39
+ /** Deeper path-segment branch or the method-leaf endpoint config; presence is decided by the source spec at runtime. */
40
+ [segment: string]: ApiDocsTree & ApiDocsEndpointConfig;
41
+ }
42
+
43
+ /** Internal mutable branch node; null-prototype objects keep `{param}` and `__proto__` segments plain own keys. */
44
+ type ApiDocsBranch = Record<string, unknown>;
45
+
46
+ /** One merged, deduplicated manifest endpoint record ready for tree insertion. */
47
+ interface MergedApiEntry {
48
+ /** Absolute directory of the manifest this entry came from (its file paths resolve against it). */
49
+ root: string;
50
+ /** Portable endpoint-module path relative to `root`. */
51
+ file: string;
52
+ /** Original OpenAPI path template โ€” the authority for the nested key chain. */
53
+ path: string;
54
+ /** Lowercase HTTP method โ€” the leaf key. */
55
+ method: string;
56
+ }
57
+
58
+ /** One discovered manifest with the directory its relative file paths resolve against. */
59
+ interface DiscoveredManifest {
60
+ /** Absolute directory containing this manifest (the tree root or a preset bucket root). */
61
+ root: string;
62
+ /** Validated `apis[]` endpoint records of this manifest, in manifest order. */
63
+ apis: Array<{ file: string; path: string; method: string }>;
64
+ }
65
+
66
+ const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);
67
+ const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
68
+ const isMissingFileError = (error: unknown): boolean => isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
69
+ const isSupportedApiDocsMethod = (method: string): boolean => (OPENAPI_METHODS as readonly string[]).includes(method);
70
+
71
+ /** Guard one manifest file reference: relative, POSIX-separated, and unable to escape the tree root. */
72
+ function isSafeTreeFile(file: string): boolean {
73
+ if (!file || isAbsolute(file) || file.includes('\\') || /^[A-Za-z]:/.test(file)) return false;
74
+ return file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
75
+ }
76
+
77
+ /** Validate one parsed manifest and return its `apis[]` records (reader-tolerant shape, hard on unsafe paths). */
78
+ function manifestApiEntries(manifest: unknown, manifestPath: string): Array<{ file: string; path: string; method: string }> {
79
+ if (!isRecord(manifest) || !Array.isArray(manifest.apis)) {
80
+ 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' });
81
+ }
82
+ const entries: Array<{ file: string; path: string; method: string }> = [];
83
+ for (const [index, api] of manifest.apis.entries()) {
84
+ if (!isRecord(api)
85
+ || typeof api.file !== 'string' || !isSafeTreeFile(api.file)
86
+ || typeof api.path !== 'string' || !api.path.startsWith('/')
87
+ || typeof api.method !== 'string' || !isSupportedApiDocsMethod(api.method)) {
88
+ 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' });
89
+ }
90
+ entries.push({ file: api.file, path: api.path, method: api.method });
91
+ }
92
+ return entries;
93
+ }
94
+
95
+ /** Read and parse one manifest file; a missing file resolves to `undefined`, garbled JSON fails typed. */
96
+ async function readManifestFile(file: string): Promise<unknown> {
97
+ let text: string;
98
+ try { text = await readFile(file, 'utf8'); }
99
+ catch (error) {
100
+ if (isMissingFileError(error)) return undefined;
101
+ throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to read zopia manifest', { at: file, hint: 'check that the manifest is readable JSON' });
102
+ }
103
+ try { return JSON.parse(text) as unknown; }
104
+ 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 }); }
105
+ }
106
+
107
+ /**
108
+ * Discover every manifest of one output root: the root `.zopia-manifest.json`
109
+ * plus the one-level-deep preset bucket roots (`multi-tag` / `multi-server`
110
+ * splits write one manifest per bucket directory). Real directories only โ€”
111
+ * symlinked children never masquerade as bucket roots.
112
+ */
113
+ async function discoverApiDocsManifests(root: string): Promise<DiscoveredManifest[]> {
114
+ const discovered: DiscoveredManifest[] = [];
115
+ const rootManifestPath = join(root, ZOPIA_MANIFEST_FILE);
116
+ const rootManifest = await readManifestFile(rootManifestPath);
117
+ if (rootManifest !== undefined) discovered.push({ root, apis: manifestApiEntries(rootManifest, rootManifestPath) });
118
+ let directories: string[] = [];
119
+ try {
120
+ directories = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(compareText);
121
+ } catch (error) {
122
+ // A missing root simply has no buckets; anything else is an unreadable tree.
123
+ 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' });
124
+ }
125
+ for (const directory of directories) {
126
+ const bucketRoot = join(root, directory);
127
+ const bucketManifestPath = join(bucketRoot, ZOPIA_MANIFEST_FILE);
128
+ const bucketManifest = await readManifestFile(bucketManifestPath);
129
+ if (bucketManifest !== undefined) discovered.push({ root: bucketRoot, apis: manifestApiEntries(bucketManifest, bucketManifestPath) });
130
+ }
131
+ return discovered;
132
+ }
133
+
134
+ /**
135
+ * Merge discovered manifests into one deterministically sorted entry list.
136
+ * Duplicates are dropped by `${path}#${method}` identity (the root manifest
137
+ * wins over bucket manifests, buckets in sorted directory order); the sort is
138
+ * path segments first, then the canonical method order
139
+ * (`get, post, put, delete, head, options, patch, trace`).
140
+ */
141
+ function mergeApiEntries(manifests: readonly DiscoveredManifest[]): MergedApiEntry[] {
142
+ const seen = new Set<string>();
143
+ const merged: MergedApiEntry[] = [];
144
+ for (const { root, apis } of manifests) {
145
+ for (const api of apis) {
146
+ const identity = `${api.path}#${api.method}`;
147
+ if (seen.has(identity)) continue;
148
+ seen.add(identity);
149
+ merged.push({ root, file: api.file, path: api.path, method: api.method });
150
+ }
151
+ }
152
+ return merged.sort((left, right) => compareApiDocsEntries(left, right));
153
+ }
154
+
155
+ /** Import one generated endpoint module through its file URL โ€” `pathToFileURL` keeps absolute (Windows-style) paths safe. */
156
+ async function importEndpointModule(root: string, file: string): Promise<Record<string, unknown>> {
157
+ try {
158
+ return await import(pathToFileURL(join(root, file)).href) as Record<string, unknown>;
159
+ } catch (error) {
160
+ 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 });
161
+ }
162
+ }
163
+
164
+ /** Create one null-prototype branch node so every path segment becomes a plain own enumerable key. */
165
+ function createBranch(): ApiDocsBranch {
166
+ return Object.create(null) as ApiDocsBranch;
167
+ }
168
+
169
+ /** Whether one tree node is a branch this resolver created (null prototype) rather than an endpoint config leaf. */
170
+ function isBranch(value: unknown): value is ApiDocsBranch {
171
+ return isRecord(value) && Object.getPrototypeOf(value) === null;
172
+ }
173
+
174
+ /** Typed failure for paths whose nested key chains cannot coexist in one tree (below a method leaf, or trailing-slash twins). */
175
+ function treeConflictError(file: string, detail: string): ZopiaError {
176
+ 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' });
177
+ }
178
+
179
+ /** Insert one endpoint config at its segment chain, failing typed when another path already owns a conflicting key. */
180
+ function insertEndpoint(root: ApiDocsBranch, segments: readonly string[], method: string, config: unknown, file: string): void {
181
+ let node = root;
182
+ for (const segment of segments) {
183
+ const existing = node[segment];
184
+ if (existing === undefined) {
185
+ const branch = createBranch();
186
+ node[segment] = branch;
187
+ node = branch;
188
+ } else if (isBranch(existing)) {
189
+ node = existing;
190
+ } else {
191
+ throw treeConflictError(file, `segment '${segment}' is already the method leaf of another path`);
192
+ }
193
+ }
194
+ if (node[method] !== undefined) {
195
+ 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)`);
196
+ }
197
+ node[method] = config;
198
+ }
199
+
200
+ /** Whether one tree value is a km-api endpoint config leaf (string `method` + `pathShape`, object `request` + `response`). */
201
+ function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Record<string, unknown> {
202
+ return isRecord(value) && typeof value.method === 'string' && typeof value.pathShape === 'string' && isRecord(value.request) && isRecord(value.response);
203
+ }
204
+
205
+ /**
206
+ * Load a generated api-docs tree into one nested endpoint object.
207
+ *
208
+ * The resolver is layout- and path-preserving: it discovers the root
209
+ * `.zopia-manifest.json` plus every one-level-deep preset bucket manifest
210
+ * (`multi-tag` / `multi-server`), merges them (deduplicating by
211
+ * `${path}#${method}`), and accepts any directory that was ever a zopia output
212
+ * root โ€” tree roots and preset bucket roots alike. Endpoint modules are loaded
213
+ * with dynamic `import()` through `pathToFileURL`, and every leaf is the
214
+ * module's **default export** (its named export stays available on the module
215
+ * itself). Keys are inserted in the deterministic manifest order, so the same
216
+ * tree always enumerates its keys in the same order. The tree is only read and
217
+ * imported โ€” nothing on disk is written.
218
+ *
219
+ * **Exact IntelliSense (S-95):** every tree generated with a manifest also
220
+ * carries a `.zopia-tree.d.ts` declaration. Pass its `ApiDocsTree` type as the
221
+ * type argument to type the result exactly โ€” literal path-segment keys,
222
+ * method-leaf configs typed as the generated module's own `makeApiConfig()`
223
+ * export, and unknown keys become compile errors:
224
+ *
225
+ * ```ts
226
+ * import type { ApiDocsTree } from './api_docs/.zopia-tree';
227
+ * const apiDocs = await createApiDocs<ApiDocsTree>('./api_docs');
228
+ * ```
229
+ *
230
+ * @param docsDir Generated api-docs output directory (tree root or preset bucket root; absolute or relative).
231
+ * @returns Nested tree keyed by exact URL path segments with lowercase-method leaves holding the endpoint configs.
232
+ * @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `docsDir` is not a usable path.
233
+ * @throws {ZopiaError} `ZOPIA_DOCS_MISSING_MANIFEST` when no root or bucket manifest exists under `docsDir`.
234
+ * @throws {ZopiaError} `ZOPIA_MANIFEST_INVALID` when a discovered manifest is garbled JSON or has unusable `apis[]` records.
235
+ * @throws {ZopiaError} `ZOPIA_DOCS_IMPORT_FAILED` when an endpoint module cannot be imported or has no default export.
236
+ * @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).
237
+ * @example
238
+ * ```ts
239
+ * import { createApiDocs } from './src/runtime';
240
+ *
241
+ * // Exact typing: pass the generated `.zopia-tree.d.ts` tree type.
242
+ * type MyTree = { readonly users: { readonly get: { readonly pathShape: string } } };
243
+ * const apiDocs = await createApiDocs<MyTree>('api_docs');
244
+ * console.log(apiDocs.users.get.pathShape);
245
+ * ```
246
+ * @see [docs/07-api-docs.md โ†’ Runtime tree consumption](../../docs/07-api-docs.md)
247
+ */
248
+ export async function createApiDocs<TTree extends object = ApiDocsTree>(docsDir: string): Promise<TTree> {
249
+ if (typeof docsDir !== 'string' || docsDir.trim() === '' || docsDir.includes('\0')) {
250
+ 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)' });
251
+ }
252
+ const root = resolve(docsDir);
253
+ const manifests = await discoverApiDocsManifests(root);
254
+ if (manifests.length === 0) {
255
+ 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' });
256
+ }
257
+ const entries = mergeApiEntries(manifests);
258
+ const tree = createBranch();
259
+ for (const entry of entries) {
260
+ const endpointModule = await importEndpointModule(entry.root, entry.file);
261
+ const config = endpointModule.default;
262
+ if (!isRecord(config)) {
263
+ 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" });
264
+ }
265
+ insertEndpoint(tree, apiDocsPathSegments(entry.path), entry.method, config, entry.file);
266
+ }
267
+ return tree as unknown as TTree;
268
+ }
269
+
270
+ /**
271
+ * Flatten one runtime api-docs tree into a record keyed by endpoint name.
272
+ *
273
+ * Deep-walks the tree produced by {@link createApiDocs} in its deterministic
274
+ * leaf order and registers every endpoint config under its `operationId`. When
275
+ * a leaf has no usable `operationId`, the key is derived by the **same rules
276
+ * the generator uses for its export identifiers** (method + PascalCase path
277
+ * segments, camelize, reserved-word guard, leading-numeric guard), and
278
+ * collisions receive the generator's `2`, `3`, โ€ฆ uniqueness suffix โ€” a later
279
+ * duplicate never clobbers an already-registered name. The returned record has
280
+ * a null prototype, so even an `operationId` spelled `__proto__` stays a plain
281
+ * own key.
282
+ *
283
+ * **Exact IntelliSense (S-95):** pass the generated `.zopia-tree.d.ts`
284
+ * `ApiDocsFlat` type as the type argument to key the record exactly โ€”
285
+ * `endpoints.getUser` autocompletes and unknown names become compile errors:
286
+ *
287
+ * ```ts
288
+ * import type { ApiDocsFlat } from './api_docs/.zopia-tree';
289
+ * const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
290
+ * ```
291
+ *
292
+ * @param apiDocs Nested tree returned by {@link createApiDocs} (exact generated tree types are accepted).
293
+ * @returns Flat record of every endpoint config keyed by its derived endpoint name, in tree leaf order.
294
+ * @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `apiDocs` is not the nested tree object (a non-object input or a non-tree value inside it).
295
+ * @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).
296
+ * @example
297
+ * ```ts
298
+ * import { createApiDocs, flattenApiDocs, type ApiDocsEndpointConfig } from './src/runtime';
299
+ *
300
+ * // Exact typing: pass the generated `.zopia-tree.d.ts` flat type.
301
+ * type MyFlat = { readonly getUser: ApiDocsEndpointConfig };
302
+ * const endpoints = flattenApiDocs<MyFlat>(await createApiDocs('api_docs'));
303
+ * console.log(endpoints.getUser.pathShape);
304
+ * ```
305
+ * @see [docs/07-api-docs.md โ†’ Runtime tree consumption](../../docs/07-api-docs.md)
306
+ */
307
+ export function flattenApiDocs<TFlat extends Record<string, ApiDocsEndpointConfig> = Record<string, ApiDocsEndpointConfig>>(apiDocs: object): TFlat {
308
+ if (!isRecord(apiDocs)) {
309
+ 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)' });
310
+ }
311
+ const flat: Record<string, ApiDocsEndpointConfig> = createBranch() as Record<string, ApiDocsEndpointConfig>;
312
+ const used = new Set<string>();
313
+ const register = (config: ApiDocsEndpointConfig & Record<string, unknown>): void => {
314
+ const operationId = typeof config.operationId === 'string' && config.operationId.trim() !== '' ? config.operationId : undefined;
315
+ const base = endpointExportName(operationId ?? deriveOperationId(config.pathShape, config.method.toLowerCase() as OpenApiMethod));
316
+ const key = uniqueEndpointName(base, used);
317
+ used.add(key);
318
+ flat[key] = config;
319
+ };
320
+ const walk = (node: Record<string, unknown>): void => {
321
+ for (const [key, value] of Object.entries(node)) {
322
+ if (isEndpointConfig(value)) register(value);
323
+ else if (isRecord(value)) walk(value);
324
+ 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)' });
325
+ }
326
+ };
327
+ walk(apiDocs);
328
+ return flat as unknown as TFlat;
329
+ }
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';
@@ -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)