zopia 0.4.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 +36 -0
- package/README.md +8 -3
- package/docs/07-api-docs.md +23 -6
- package/docs/10-usage.md +8 -2
- package/package.json +1 -1
- package/src/conversions/api-docs-generate.ts +14 -1
- package/src/conversions/api-docs-layout.ts +41 -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/runtime/create-api-docs.ts +38 -30
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### โจ Added
|
|
13
|
+
- ๐ง **Exact IntelliSense for runtime tree consumption (S-95).** Every tree
|
|
14
|
+
generated with a manifest now also carries a types-only
|
|
15
|
+
**`.zopia-tree.d.ts`** declaration beside the manifest, and
|
|
16
|
+
`createApiDocs` / `flattenApiDocs` accept it as a type argument โ turning the
|
|
17
|
+
permissive runtime typing into **exact** typing:
|
|
18
|
+
```ts
|
|
19
|
+
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
20
|
+
import type { ApiDocsTree, ApiDocsFlat } from './api_docs/.zopia-tree';
|
|
21
|
+
|
|
22
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('./api_docs');
|
|
23
|
+
apiDocs.users['{userId}'].get.pathShape; // literal "/users/{userId}", autocompleted
|
|
24
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
|
|
25
|
+
endpoints.getUser; // exact key, same leaf object
|
|
26
|
+
```
|
|
27
|
+
Segment and method keys autocomplete exactly (unknown keys are **compile
|
|
28
|
+
errors**, not `any`), every leaf is typed as the generated module's own
|
|
29
|
+
`makeApiConfig()` export (literal `method`/`pathShape`, exact Zod request /
|
|
30
|
+
response shapes), and the flat record's keys derive through the same shared
|
|
31
|
+
naming rules as the generator's export identifiers (camelize,
|
|
32
|
+
reserved-word guard, `2`/`3`โฆ collision suffixes) โ parity with the runtime
|
|
33
|
+
keys is pinned by tests. The declaration is emitted in both layouts and in
|
|
34
|
+
every preset bucket root, follows the manifest lifecycle (pruned when
|
|
35
|
+
manifests are disabled, refreshed on regeneration, and a deleted declaration
|
|
36
|
+
reports `ZOPIA_WARN_STALE_TREE`), and is types-only: it imports nothing
|
|
37
|
+
beyond the tree itself (R-502 holds). `zopia generate` results report the
|
|
38
|
+
file with a new `kind: 'types'`. Conflicting paths that cannot share one
|
|
39
|
+
nested tree (below a method leaf, trailing-slash twins) render the
|
|
40
|
+
permissive intersection shape โ matching the runtime's typed
|
|
41
|
+
`ZOPIA_SPEC_INVALID` failure. The shared deterministic tree ordering
|
|
42
|
+
(path segments, then canonical method order) moved to
|
|
43
|
+
`src/conversions/api-docs-layout.ts` so the runtime resolver and the
|
|
44
|
+
declaration emitter enumerate identically.
|
|
45
|
+
|
|
10
46
|
## [0.4.0] - 2026-09-29
|
|
11
47
|
|
|
12
48
|
### ๐ Changed
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://bun.sh/)
|
|
14
14
|
[](https://vitest.dev/)
|
|
15
15
|
|
|
16
|
-
โ
**Status โ Phase 3 complete ยท v0.
|
|
16
|
+
โ
**Status โ Phase 3 complete ยท v0.5.0 released**
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
@@ -100,16 +100,21 @@ all resolve through their manifests):
|
|
|
100
100
|
|
|
101
101
|
```ts
|
|
102
102
|
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
103
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
103
104
|
|
|
104
105
|
// Nested: URL path segments โ lowercase method โ the makeApiConfig object
|
|
105
|
-
const apiDocs = await createApiDocs('api_docs');
|
|
106
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
|
|
106
107
|
const getUser = apiDocs.users['{userId}'].get; // default export of that index.ts
|
|
107
108
|
|
|
108
109
|
// Flat freebie: keyed by operationId, same leaf objects, deterministic order
|
|
109
|
-
const endpoints = flattenApiDocs(apiDocs);
|
|
110
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
|
|
110
111
|
endpoints.getUser === getUser; // โ true
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
Every generated tree also ships a types-only `.zopia-tree.d.ts` โ pass its
|
|
115
|
+
types as shown and IntelliSense becomes **exact**: keys autocomplete, and
|
|
116
|
+
misspelled segments, methods, or endpoint names are compile errors.
|
|
117
|
+
|
|
113
118
|
Generation output is untouched โ the resolver only reads manifests and imports
|
|
114
119
|
modules. See [docs/07-api-docs.md โ Runtime tree consumption](docs/07-api-docs.md#-runtime-tree-consumption--createapidocs).
|
|
115
120
|
|
package/docs/07-api-docs.md
CHANGED
|
@@ -115,15 +115,31 @@ file-level API, and the manifest remains authoritative (D-06).
|
|
|
115
115
|
|
|
116
116
|
```ts
|
|
117
117
|
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
118
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
118
119
|
|
|
119
|
-
const apiDocs = await createApiDocs('api_docs'); // โ the entire consumer DX
|
|
120
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs'); // โ the entire consumer DX
|
|
120
121
|
const endpoint = apiDocs.applicant['{applicantId}'].exame['{examId}'].get;
|
|
121
|
-
// โ the endpoint module's makeApiConfig object (its default export)
|
|
122
|
+
// โ the endpoint module's makeApiConfig object (its default export), EXACTLY typed
|
|
122
123
|
|
|
123
|
-
const endpoints = flattenApiDocs(apiDocs); // the flat freebie
|
|
124
|
-
endpoints.getExam;
|
|
124
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // the flat freebie
|
|
125
|
+
endpoints.getExam; // same leaf object
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
**Exact IntelliSense (S-95).** Called without a type argument, the helpers
|
|
129
|
+
return permissively typed results (every node is a branch โช config). Passing
|
|
130
|
+
the generated **`.zopia-tree.d.ts`** types โ written beside every retained
|
|
131
|
+
manifest, in both layouts and every preset bucket root โ makes the result
|
|
132
|
+
**exact**: segment and method keys autocomplete, unknown keys are compile
|
|
133
|
+
errors (not `any`), every leaf carries the generated module's own
|
|
134
|
+
`makeApiConfig()` type (literal `method`/`pathShape`, exact Zod request and
|
|
135
|
+
response shapes), and the flat record's keys are the derived endpoint names
|
|
136
|
+
(same shared rules as the generator's export identifiers, including collision
|
|
137
|
+
suffixes). The declaration is types-only โ it imports nothing beyond the tree
|
|
138
|
+
itself (R-502 holds) โ and follows the manifest lifecycle: refreshed on
|
|
139
|
+
regeneration, pruned when manifests are disabled, and a deleted declaration
|
|
140
|
+
reports `ZOPIA_WARN_STALE_TREE`. `zopia generate` results list it with
|
|
141
|
+
`kind: 'types'`.
|
|
142
|
+
|
|
127
143
|
**Manifest discovery & merge (B-conditions).** The resolver reads the root
|
|
128
144
|
`.zopia-manifest.json` **plus** every one-level-deep preset bucket manifest
|
|
129
145
|
(`multi-tag` / `multi-server` splits write one manifest per bucket directory),
|
|
@@ -138,8 +154,9 @@ segments first, then the canonical method order
|
|
|
138
154
|
(including literal `{param}` segments), the leaf key is the lowercase method,
|
|
139
155
|
and the leaf value is the endpoint module's **default export**, loaded with
|
|
140
156
|
dynamic `import()` through `pathToFileURL` (Windows-safe). Because which keys
|
|
141
|
-
exist depends on the source spec, the `ApiDocsTree` type
|
|
142
|
-
|
|
157
|
+
exist depends on the source spec, the default `ApiDocsTree` return type is
|
|
158
|
+
permissive (branch โช config) โ pass the generated `.zopia-tree.d.ts` type for
|
|
159
|
+
exact keys (see below). A tree `/` path nests its methods at the root
|
|
143
160
|
(`apiDocs.get`). Paths that cannot coexist in one nested tree โ a path
|
|
144
161
|
continuing below another path's method leaf, or two paths differing only by a
|
|
145
162
|
trailing slash โ fail with a typed `ZOPIA_SPEC_INVALID` instead of silently
|
package/docs/10-usage.md
CHANGED
|
@@ -191,16 +191,22 @@ resolve through their manifests:
|
|
|
191
191
|
|
|
192
192
|
```ts
|
|
193
193
|
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
194
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
194
195
|
|
|
195
196
|
// Nested: exact URL path segments, lowercase method leaf, default-export config
|
|
196
|
-
const apiDocs = await createApiDocs('api_docs');
|
|
197
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
|
|
197
198
|
const getUser = apiDocs.users['{userId}'].get; // the makeApiConfig object
|
|
198
199
|
|
|
199
200
|
// Flat: keyed by operationId (derived with the generator's own naming rules)
|
|
200
|
-
const endpoints = flattenApiDocs(apiDocs);
|
|
201
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
|
|
201
202
|
endpoints.getUser === getUser; // โ true
|
|
202
203
|
```
|
|
203
204
|
|
|
205
|
+
The type import is optional โ without it the results stay permissively typed โ
|
|
206
|
+
but with it every key is exact: `apiDocs.users.` autocompletes `{userId}`,
|
|
207
|
+
`endpoints.` autocompletes the endpoint names, and misspelled keys are compile
|
|
208
|
+
errors.
|
|
209
|
+
|
|
204
210
|
Failures are typed `ZopiaError`s with `at`/`hint` โ see
|
|
205
211
|
[API docs โ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs).
|
|
206
212
|
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOpe
|
|
|
7
7
|
import { jsonSchemaToZod } from './json-schema-to-zod';
|
|
8
8
|
import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './api-docs-plan';
|
|
9
9
|
import { isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
|
|
10
|
+
import { renderApiDocsTreeTypes, ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
|
|
10
11
|
import type { OpenApiDocument } from './openapi';
|
|
11
12
|
import { createZopiaManifest, hashOpenApiDocument, writeZopiaManifest, ZOPIA_MANIFEST_FILE } from './manifest-writer';
|
|
12
13
|
import { inspectZopiaManifestStaleness, removeObsoleteManifestFiles } from './manifest-staleness';
|
|
@@ -452,7 +453,11 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
|
|
|
452
453
|
reservedFiles.push(`${directory}/index.ts`, ...names.map((name) => `${directory}/${name}/index.ts`));
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
|
-
if (retainManifest)
|
|
456
|
+
if (retainManifest) {
|
|
457
|
+
reservedFiles.push(ZOPIA_MANIFEST_FILE);
|
|
458
|
+
// The exact-tree declaration is emitted beside the manifest and shares its lifecycle.
|
|
459
|
+
reservedFiles.push(ZOPIA_TREE_TYPES_FILE);
|
|
460
|
+
}
|
|
456
461
|
const plans = avoidReservedFileCollisions(planApiDocsFiles(source, mode), reservedFiles);
|
|
457
462
|
const webhookPlans = avoidReservedFileCollisions(planWebhookDocsFiles(source, mode), [...reservedFiles, ...plans.map((plan) => plan.file)]);
|
|
458
463
|
// OpenAPI requires operationId to be unique document-wide; $ref aliases can make the
|
|
@@ -555,6 +560,14 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
|
|
|
555
560
|
if (manifest) {
|
|
556
561
|
const manifestPath = await writeZopiaManifest(root, manifest);
|
|
557
562
|
generated.push({ file: ZOPIA_MANIFEST_FILE, absolutePath: manifestPath, operationId: 'manifest' });
|
|
563
|
+
// Exact IntelliSense: the declaration mirrors the runtime tree/flat shapes (S-95).
|
|
564
|
+
const treeTypesPath = await writeGeneratedFile(root, ZOPIA_TREE_TYPES_FILE, renderApiDocsTreeTypes(plans.map((plan) => ({
|
|
565
|
+
file: plan.file,
|
|
566
|
+
path: plan.path,
|
|
567
|
+
method: plan.method,
|
|
568
|
+
operationId: plan.operationId,
|
|
569
|
+
}))), previouslyOwned);
|
|
570
|
+
generated.push({ file: ZOPIA_TREE_TYPES_FILE, absolutePath: treeTypesPath, operationId: 'tree-types' });
|
|
558
571
|
}
|
|
559
572
|
await removeObsoleteManifestFiles(root, previous.ownedFiles, generated.map(({ file }) => file));
|
|
560
573
|
return generated;
|
|
@@ -4,6 +4,47 @@ import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
|
|
|
4
4
|
/** Filesystem layout used for generated endpoint modules. */
|
|
5
5
|
export type ApiDocsMode = 'directory' | 'flat';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Split one OpenAPI path template into its literal segment keys.
|
|
9
|
+
*
|
|
10
|
+
* Empty segments are dropped, so the root path `/` has no segments and its
|
|
11
|
+
* methods nest directly at the tree root. This is the single segmentation rule
|
|
12
|
+
* shared by the runtime tree resolver and the generated `.zopia-tree.d.ts`.
|
|
13
|
+
*
|
|
14
|
+
* @param path OpenAPI path template beginning with `/`.
|
|
15
|
+
* @returns Path segments in order, including literal `{param}` segments.
|
|
16
|
+
*/
|
|
17
|
+
export function apiDocsPathSegments(path: string): string[] {
|
|
18
|
+
return path.split('/').filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compare two endpoint entries by the canonical deterministic tree order:
|
|
23
|
+
* URL path segments lexically, shorter segment chains first, then the
|
|
24
|
+
* canonical method order (`get, post, put, delete, head, options, patch, trace`).
|
|
25
|
+
*
|
|
26
|
+
* The runtime tree resolver inserts keys in this order and the generated
|
|
27
|
+
* `.zopia-tree.d.ts` declares them in this order, so both surfaces enumerate
|
|
28
|
+
* identically (R-732/S-94).
|
|
29
|
+
*
|
|
30
|
+
* @param left Endpoint entry with an OpenAPI `path` and lowercase `method`.
|
|
31
|
+
* @param right Endpoint entry with an OpenAPI `path` and lowercase `method`.
|
|
32
|
+
* @returns Negative when `left` sorts first, positive when `right` does, zero when equal.
|
|
33
|
+
*/
|
|
34
|
+
export function compareApiDocsEntries(left: { path: string; method: string }, right: { path: string; method: string }): number {
|
|
35
|
+
const leftSegments = apiDocsPathSegments(left.path);
|
|
36
|
+
const rightSegments = apiDocsPathSegments(right.path);
|
|
37
|
+
const depth = Math.min(leftSegments.length, rightSegments.length);
|
|
38
|
+
for (let index = 0; index < depth; index += 1) {
|
|
39
|
+
const order = leftSegments[index] < rightSegments[index] ? -1 : leftSegments[index] > rightSegments[index] ? 1 : 0;
|
|
40
|
+
if (order !== 0) return order;
|
|
41
|
+
}
|
|
42
|
+
const lengthOrder = leftSegments.length - rightSegments.length;
|
|
43
|
+
if (lengthOrder !== 0) return lengthOrder;
|
|
44
|
+
const methods = OPENAPI_METHODS as readonly string[];
|
|
45
|
+
return methods.indexOf(left.method) - methods.indexOf(right.method);
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
/**
|
|
8
49
|
* Return whether one generated-tree path segment is portable across supported filesystems.
|
|
9
50
|
*
|
|
@@ -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 } : {}) };
|
|
@@ -17,6 +17,7 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
18
|
import { asZopiaError, ZopiaError } from '../errors';
|
|
19
19
|
import { endpointExportName, uniqueEndpointName } from '../conversions/api-docs-names';
|
|
20
|
+
import { apiDocsPathSegments, compareApiDocsEntries } from '../conversions/api-docs-layout';
|
|
20
21
|
import { deriveOperationId, OPENAPI_METHODS, type OpenApiMethod } from '../conversions/openapi-to-api-docs';
|
|
21
22
|
import { ZOPIA_MANIFEST_FILE } from '../conversions/manifest-writer';
|
|
22
23
|
|
|
@@ -65,11 +66,7 @@ interface DiscoveredManifest {
|
|
|
65
66
|
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
67
|
const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
|
|
67
68
|
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
|
-
}
|
|
69
|
+
const isSupportedApiDocsMethod = (method: string): boolean => (OPENAPI_METHODS as readonly string[]).includes(method);
|
|
73
70
|
|
|
74
71
|
/** Guard one manifest file reference: relative, POSIX-separated, and unable to escape the tree root. */
|
|
75
72
|
function isSafeTreeFile(file: string): boolean {
|
|
@@ -87,7 +84,7 @@ function manifestApiEntries(manifest: unknown, manifestPath: string): Array<{ fi
|
|
|
87
84
|
if (!isRecord(api)
|
|
88
85
|
|| typeof api.file !== 'string' || !isSafeTreeFile(api.file)
|
|
89
86
|
|| typeof api.path !== 'string' || !api.path.startsWith('/')
|
|
90
|
-
|| typeof api.method !== 'string' || !(
|
|
87
|
+
|| typeof api.method !== 'string' || !isSupportedApiDocsMethod(api.method)) {
|
|
91
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' });
|
|
92
89
|
}
|
|
93
90
|
entries.push({ file: api.file, path: api.path, method: api.method });
|
|
@@ -152,19 +149,7 @@ function mergeApiEntries(manifests: readonly DiscoveredManifest[]): MergedApiEnt
|
|
|
152
149
|
merged.push({ root, file: api.file, path: api.path, method: api.method });
|
|
153
150
|
}
|
|
154
151
|
}
|
|
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
|
-
});
|
|
152
|
+
return merged.sort((left, right) => compareApiDocsEntries(left, right));
|
|
168
153
|
}
|
|
169
154
|
|
|
170
155
|
/** Import one generated endpoint module through its file URL โ `pathToFileURL` keeps absolute (Windows-style) paths safe. */
|
|
@@ -231,6 +216,17 @@ function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Reco
|
|
|
231
216
|
* tree always enumerates its keys in the same order. The tree is only read and
|
|
232
217
|
* imported โ nothing on disk is written.
|
|
233
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
|
+
*
|
|
234
230
|
* @param docsDir Generated api-docs output directory (tree root or preset bucket root; absolute or relative).
|
|
235
231
|
* @returns Nested tree keyed by exact URL path segments with lowercase-method leaves holding the endpoint configs.
|
|
236
232
|
* @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `docsDir` is not a usable path.
|
|
@@ -242,13 +238,14 @@ function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Reco
|
|
|
242
238
|
* ```ts
|
|
243
239
|
* import { createApiDocs } from './src/runtime';
|
|
244
240
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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);
|
|
248
245
|
* ```
|
|
249
246
|
* @see [docs/07-api-docs.md โ Runtime tree consumption](../../docs/07-api-docs.md)
|
|
250
247
|
*/
|
|
251
|
-
export async function createApiDocs(docsDir: string): Promise<
|
|
248
|
+
export async function createApiDocs<TTree extends object = ApiDocsTree>(docsDir: string): Promise<TTree> {
|
|
252
249
|
if (typeof docsDir !== 'string' || docsDir.trim() === '' || docsDir.includes('\0')) {
|
|
253
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)' });
|
|
254
251
|
}
|
|
@@ -265,9 +262,9 @@ export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
|
|
|
265
262
|
if (!isRecord(config)) {
|
|
266
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" });
|
|
267
264
|
}
|
|
268
|
-
insertEndpoint(tree,
|
|
265
|
+
insertEndpoint(tree, apiDocsPathSegments(entry.path), entry.method, config, entry.file);
|
|
269
266
|
}
|
|
270
|
-
return tree as
|
|
267
|
+
return tree as unknown as TTree;
|
|
271
268
|
}
|
|
272
269
|
|
|
273
270
|
/**
|
|
@@ -283,20 +280,31 @@ export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
|
|
|
283
280
|
* a null prototype, so even an `operationId` spelled `__proto__` stays a plain
|
|
284
281
|
* own key.
|
|
285
282
|
*
|
|
286
|
-
*
|
|
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).
|
|
287
293
|
* @returns Flat record of every endpoint config keyed by its derived endpoint name, in tree leaf order.
|
|
288
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).
|
|
289
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).
|
|
290
296
|
* @example
|
|
291
297
|
* ```ts
|
|
292
|
-
* import { createApiDocs, flattenApiDocs } from './src/runtime';
|
|
298
|
+
* import { createApiDocs, flattenApiDocs, type ApiDocsEndpointConfig } from './src/runtime';
|
|
293
299
|
*
|
|
294
|
-
*
|
|
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'));
|
|
295
303
|
* console.log(endpoints.getUser.pathShape);
|
|
296
304
|
* ```
|
|
297
305
|
* @see [docs/07-api-docs.md โ Runtime tree consumption](../../docs/07-api-docs.md)
|
|
298
306
|
*/
|
|
299
|
-
export function flattenApiDocs
|
|
307
|
+
export function flattenApiDocs<TFlat extends Record<string, ApiDocsEndpointConfig> = Record<string, ApiDocsEndpointConfig>>(apiDocs: object): TFlat {
|
|
300
308
|
if (!isRecord(apiDocs)) {
|
|
301
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)' });
|
|
302
310
|
}
|
|
@@ -317,5 +325,5 @@ export function flattenApiDocs(apiDocs: ApiDocsTree): Record<string, ApiDocsEndp
|
|
|
317
325
|
}
|
|
318
326
|
};
|
|
319
327
|
walk(apiDocs);
|
|
320
|
-
return flat;
|
|
328
|
+
return flat as unknown as TFlat;
|
|
321
329
|
}
|