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.
- package/CHANGELOG.md +76 -0
- package/README.md +53 -16
- package/docs/07-api-docs.md +94 -2
- package/docs/09-configuration.md +2 -2
- package/docs/10-usage.md +32 -3
- package/package.json +10 -2
- package/src/conversions/api-docs-generate.ts +20 -13
- package/src/conversions/api-docs-layout.ts +41 -0
- package/src/conversions/api-docs-names.ts +54 -0
- package/src/conversions/api-docs-tree-types.ts +113 -0
- package/src/conversions/manifest-staleness.ts +4 -2
- package/src/conversions/manifest-writer.ts +1 -1
- package/src/conversions/openapi-to-api-docs-public.ts +3 -2
- package/src/conversions/openapi-to-api-docs.ts +5 -10
- package/src/runtime/create-api-docs.ts +329 -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,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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
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)
|