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.
@@ -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';
@@ -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)
@@ -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