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
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,83 @@ 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
|
+
|
|
46
|
+
## [0.4.0] - 2026-09-29
|
|
47
|
+
|
|
48
|
+
### ๐ Changed
|
|
49
|
+
- ๐ฆ **Slimmer npm package โ practical docs only.** The published archive now
|
|
50
|
+
ships just the user-facing guides (`docs/07-api-docs.md`,
|
|
51
|
+
`docs/09-configuration.md`, `docs/10-usage.md`) next to `README.md` /
|
|
52
|
+
`CHANGELOG.md`; the development documentation (overview, targets, roadmap,
|
|
53
|
+
architecture, concepts, conversions, components, testing, standards, the
|
|
54
|
+
docs map, and the publish-workflow example) stays in the GitHub repository
|
|
55
|
+
and is linked from the README โ npm users installing the package get
|
|
56
|
+
usage/installation docs, not project management artifacts. The tarball
|
|
57
|
+
shrinks **239 KB โ 182 KB packed (915 KB โ 750 KB unpacked, 50 โ 39
|
|
58
|
+
files)**; `npm` force-includes `README*` from any directory, so the docs map
|
|
59
|
+
is explicitly negated (`!docs/README.md`) in `files`. `package:check` and
|
|
60
|
+
the release contract now enforce the slim archive: a missing practical doc
|
|
61
|
+
fails, and any development doc leaking into the pack fails. The release
|
|
62
|
+
standard (R-192) and the docs map describe the split.
|
|
63
|
+
|
|
10
64
|
### โจ Added
|
|
65
|
+
- ๐ณ **Runtime tree consumption โ `zopia/runtime` (S-94).** New opt-in subpath
|
|
66
|
+
export (`import { createApiDocs, flattenApiDocs } from 'zopia/runtime'`) that
|
|
67
|
+
turns a generated api-docs directory into the objects an application
|
|
68
|
+
consumes, with **zero changes to generation output**. `createApiDocs(dir)` โ
|
|
69
|
+
the entire consumer DX โ discovers the root `.zopia-manifest.json` plus every
|
|
70
|
+
one-level-deep preset bucket manifest (`multi-tag`/`multi-server`), merges
|
|
71
|
+
them (deduplicating by `${path}#${method}`, root manifest first), sorts
|
|
72
|
+
deterministically (path segments, then the canonical method order), and
|
|
73
|
+
returns one nested object keyed by the exact URL path segments with the
|
|
74
|
+
lowercase method as leaf key holding the endpoint module's `export default`
|
|
75
|
+
(loaded via `pathToFileURL`, Windows-safe). `flattenApiDocs(tree)` is the
|
|
76
|
+
flat freebie: a `Record<string, config>` keyed by `operationId`, deriving
|
|
77
|
+
missing names and collision suffixes through the **same shared rules the
|
|
78
|
+
generator uses for its export identifiers** (extracted to
|
|
79
|
+
`src/conversions/api-docs-names.ts`: camelize, reserved-word guard,
|
|
80
|
+
leading-numeric guard, `2`/`3`โฆ suffixing โ `await` โ `awaitEndpoint`).
|
|
81
|
+
Missing/garbled manifests, unsafe manifest paths, import failures, and
|
|
82
|
+
modules without default exports fail typed (`ZOPIA_DOCS_MISSING_MANIFEST`,
|
|
83
|
+
`ZOPIA_MANIFEST_INVALID`, `ZOPIA_DOCS_IMPORT_FAILED`); paths that cannot
|
|
84
|
+
share one nested tree (below a method leaf, trailing-slash twins) fail typed
|
|
85
|
+
`ZOPIA_SPEC_INVALID`. Documented in
|
|
86
|
+
[docs/07-api-docs.md โ Runtime tree consumption](docs/07-api-docs.md).
|
|
11
87
|
- ๐ฆ **npm publish pipeline** โ `.github/workflows/publish.yml` publishes on GitHub Release creation (or manually) with the pinned Bun toolchain, verifies the release tag matches `package.json`'s version, and runs `npm publish --provenance --access public`. Its only credential is the repository-secret `NODE_AUTH_TOKEN` (npm `NPM_TOKEN`) โ never handled in chat or commits; npm trusted-publishing (OIDC) is supported by the declared `id-token` permission.
|
|
12
88
|
|
|
13
89
|
### ๐ Fixed
|
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
|
|
|
@@ -91,25 +91,62 @@ await openApiToApiDocs('swagger.json', {
|
|
|
91
91
|
const { openapi } = await apiDocsToOpenApi('api_docs', { version: '3.1' });
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
### ๐ณ Consuming the tree at runtime
|
|
95
|
+
|
|
96
|
+
When wiring endpoints dynamically beats importing generated files one by one,
|
|
97
|
+
import the opt-in runtime subpath and point it at any directory zopia ever
|
|
98
|
+
generated into (`directory`, `flat`, and `multi-tag` / `multi-server` splits
|
|
99
|
+
all resolve through their manifests):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
103
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
104
|
+
|
|
105
|
+
// Nested: URL path segments โ lowercase method โ the makeApiConfig object
|
|
106
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
|
|
107
|
+
const getUser = apiDocs.users['{userId}'].get; // default export of that index.ts
|
|
108
|
+
|
|
109
|
+
// Flat freebie: keyed by operationId, same leaf objects, deterministic order
|
|
110
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
|
|
111
|
+
endpoints.getUser === getUser; // โ true
|
|
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
|
+
|
|
118
|
+
Generation output is untouched โ the resolver only reads manifests and imports
|
|
119
|
+
modules. See [docs/07-api-docs.md โ Runtime tree consumption](docs/07-api-docs.md#-runtime-tree-consumption--createapidocs).
|
|
120
|
+
|
|
94
121
|
## ๐ Documentation
|
|
95
122
|
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
The **practical guides ship inside the npm package** โ usage, configuration,
|
|
124
|
+
and the generated output format. The development documentation (project
|
|
125
|
+
targets, roadmap, architecture, testing strategy, engineering standards)
|
|
126
|
+
stays in the
|
|
127
|
+
[GitHub repository](https://github.com/komeilm76/zopia/tree/main/docs).
|
|
128
|
+
|
|
129
|
+
**๐ฆ Packed with the npm package** (relative links, in `docs/`):
|
|
98
130
|
|
|
99
131
|
| ๐ Document | Contents |
|
|
100
132
|
| --- | --- |
|
|
101
|
-
| ๐งญ [Overview](docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
|
|
102
|
-
| ๐ฏ [Targets](docs/02-targets.md) | The explicit, testable targets of this project |
|
|
103
|
-
| ๐บ๏ธ [Roadmap](docs/03-roadmap.md) | Phases, milestones, definition of done |
|
|
104
|
-
| ๐๏ธ [Architecture](docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
|
|
105
|
-
| ๐งฉ [Concepts](docs/05-concepts.md) | Glossary โ Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
|
|
106
|
-
| ๐ [Conversions](docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
|
|
107
|
-
| ๐ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest |
|
|
108
|
-
| ๐งฑ [Components](docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
|
|
109
|
-
| โ๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
|
|
110
133
|
| ๐ [Usage](docs/10-usage.md) | Installation, programmatic API, CLI, end-to-end example |
|
|
111
|
-
|
|
|
112
|
-
|
|
|
134
|
+
| โ๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
|
|
135
|
+
| ๐ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest, runtime consumption |
|
|
136
|
+
|
|
137
|
+
**๐ GitHub-only** (development docs, not packed):
|
|
138
|
+
|
|
139
|
+
| ๐ Document | Contents |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| ๐งญ [Overview](https://github.com/komeilm76/zopia/blob/main/docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
|
|
142
|
+
| ๐ฏ [Targets](https://github.com/komeilm76/zopia/blob/main/docs/02-targets.md) | The explicit, testable targets of this project |
|
|
143
|
+
| ๐บ๏ธ [Roadmap](https://github.com/komeilm76/zopia/blob/main/docs/03-roadmap.md) | Phases, milestones, definition of done |
|
|
144
|
+
| ๐๏ธ [Architecture](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
|
|
145
|
+
| ๐งฉ [Concepts](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md) | Glossary โ Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
|
|
146
|
+
| ๐ [Conversions](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
|
|
147
|
+
| ๐งฑ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
|
|
148
|
+
| ๐งช [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md) | Vitest strategy, scenario matrix, fixtures, coverage gates |
|
|
149
|
+
| ๐ [Standards](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) | Code, JSDoc, commits, changelog, releases, key decisions |
|
|
113
150
|
|
|
114
151
|
## ๐๏ธ Project structure
|
|
115
152
|
|
|
@@ -139,7 +176,7 @@ zopia/
|
|
|
139
176
|
|
|
140
177
|
## ๐งช Development
|
|
141
178
|
|
|
142
|
-
zopia is a **Bun-first** project (see [docs/12-standards.md](docs/12-standards.md)):
|
|
179
|
+
zopia is a **Bun-first** project (see [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md)):
|
|
143
180
|
|
|
144
181
|
```bash
|
|
145
182
|
bun install --frozen-lockfile # ๐ฆ reproducible install from bun.lock
|
|
@@ -157,7 +194,7 @@ bun run release:check # ๐ข complete pinned-Bun prepublish gate
|
|
|
157
194
|
|
|
158
195
|
## ๐ค Contributing
|
|
159
196
|
|
|
160
|
-
Read [docs/12-standards.md](docs/12-standards.md) first โ it defines code style,
|
|
197
|
+
Read [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) first โ it defines code style,
|
|
161
198
|
JSDoc rules, the commit convention, and the changelog rule. Every feature ships
|
|
162
199
|
**with** its documentation in the same commit.
|
|
163
200
|
|
package/docs/07-api-docs.md
CHANGED
|
@@ -106,6 +106,98 @@ templates, duplicate parameters, unsupported methods, and unsafe roots. Engine
|
|
|
106
106
|
does not emit a facade module in v0.1.0: direct imports are the generated
|
|
107
107
|
file-level API, and the manifest remains authoritative (D-06).
|
|
108
108
|
|
|
109
|
+
## ๐ณ Runtime tree consumption โ `createApiDocs`
|
|
110
|
+
|
|
111
|
+
> ๐ฏ **S-94** โ *an opt-in runtime API, shipped in the `zopia` package itself,
|
|
112
|
+
> converts a generated tree directory into the nested / flat objects an
|
|
113
|
+
> application consumes. No generation output changes: the feature only reads
|
|
114
|
+
> and imports.*
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
118
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
119
|
+
|
|
120
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs'); // โ the entire consumer DX
|
|
121
|
+
const endpoint = apiDocs.applicant['{applicantId}'].exame['{examId}'].get;
|
|
122
|
+
// โ the endpoint module's makeApiConfig object (its default export), EXACTLY typed
|
|
123
|
+
|
|
124
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // the flat freebie
|
|
125
|
+
endpoints.getExam; // same leaf object
|
|
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
|
+
|
|
143
|
+
**Manifest discovery & merge (B-conditions).** The resolver reads the root
|
|
144
|
+
`.zopia-manifest.json` **plus** every one-level-deep preset bucket manifest
|
|
145
|
+
(`multi-tag` / `multi-server` splits write one manifest per bucket directory),
|
|
146
|
+
merging all of them and deduplicating by `${path}#${method}` โ the root
|
|
147
|
+
manifest wins duplicates, buckets follow in sorted directory order. Any
|
|
148
|
+
directory that was ever a zopia output root is accepted, including a single
|
|
149
|
+
preset bucket root. Entries are then sorted deterministically: URL path
|
|
150
|
+
segments first, then the canonical method order
|
|
151
|
+
(`get, post, put, delete, head, options, patch, trace`).
|
|
152
|
+
|
|
153
|
+
**Nesting.** Branch keys are the URL path segments **exactly and in order**
|
|
154
|
+
(including literal `{param}` segments), the leaf key is the lowercase method,
|
|
155
|
+
and the leaf value is the endpoint module's **default export**, loaded with
|
|
156
|
+
dynamic `import()` through `pathToFileURL` (Windows-safe). Because which keys
|
|
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
|
|
160
|
+
(`apiDocs.get`). Paths that cannot coexist in one nested tree โ a path
|
|
161
|
+
continuing below another path's method leaf, or two paths differing only by a
|
|
162
|
+
trailing slash โ fail with a typed `ZOPIA_SPEC_INVALID` instead of silently
|
|
163
|
+
dropping an endpoint. Key insertion follows the deterministic sort, so the
|
|
164
|
+
same tree always enumerates keys in the same order (P-1).
|
|
165
|
+
|
|
166
|
+
**Errors** reuse the manifest conventions: a missing manifest โ
|
|
167
|
+
`ZOPIA_DOCS_MISSING_MANIFEST`; garbled JSON or unusable `apis[]` records โ
|
|
168
|
+
`ZOPIA_MANIFEST_INVALID`; a module that cannot be imported or has no default
|
|
169
|
+
export โ `ZOPIA_DOCS_IMPORT_FAILED` naming the file. Reading is pure โ nothing
|
|
170
|
+
on disk is written.
|
|
171
|
+
|
|
172
|
+
**The flatten freebie.** `flattenApiDocs(apiDocs)` deep-walks the tree in its
|
|
173
|
+
deterministic leaf order and returns a flat `Record<string, config>` keyed by
|
|
174
|
+
each config's `operationId`. When a leaf has no usable `operationId`, or two
|
|
175
|
+
keys collide, the name is derived by the **same rules the generator uses for
|
|
176
|
+
its `export const` identifiers** (R-732: camelize, reserved-word guard,
|
|
177
|
+
leading-numeric guard, `2`/`3`โฆ uniqueness suffix โ `await` โ `awaitEndpoint`,
|
|
178
|
+
`get-a` + `getA` โ `getA` + `getA2`), so the flat keys always match the
|
|
179
|
+
modules' named exports. The shared rules live in
|
|
180
|
+
`src/conversions/api-docs-names.ts` and both call-sites (generation and
|
|
181
|
+
runtime) use them.
|
|
182
|
+
|
|
183
|
+
**A small km-api hand-off** โ every leaf *is* a `makeApiConfig()` object, so
|
|
184
|
+
the km-api surface is available directly:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { createApiDocs } from 'zopia/runtime';
|
|
188
|
+
|
|
189
|
+
const apiDocs = await createApiDocs('api_docs');
|
|
190
|
+
const getUser = apiDocs.users['{userId}'].get;
|
|
191
|
+
|
|
192
|
+
getUser.method; // "GET"
|
|
193
|
+
getUser.pathShape; // "/users/{userId}"
|
|
194
|
+
getUser.request.params.parse({ userId: '22ccbc6a-โฆ' }); // โ
Zod-validated path params
|
|
195
|
+
getUser.response[200].parse({ id: 'u1', email: 'a@b.c' }); // โ
Zod-validated response
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
> ๐ก Generated webhook modules are not URL-path endpoints and stay outside the
|
|
199
|
+
> nested tree โ import them directly or reverse-convert the tree (engine โฃ).
|
|
200
|
+
|
|
109
201
|
## ๐งฌ Operation contract extraction
|
|
110
202
|
|
|
111
203
|
Before rendering an endpoint, zopia normalizes each operation into an
|
|
@@ -177,7 +269,7 @@ With the default options (`insertComponents: false`), every `index.ts` imports
|
|
|
177
269
|
**only** `zod` and `km-api` (R-502): each component use is inlined into its
|
|
178
270
|
request/parameter/response expression (R-403). Cross-file imports appear
|
|
179
271
|
**only** when `useComponentAsReference` is `true` โ see
|
|
180
|
-
[Components](08-components.md).
|
|
272
|
+
[Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md).
|
|
181
273
|
|
|
182
274
|
## ๐ฆ The manifest โ `.zopia-manifest.json`
|
|
183
275
|
|
|
@@ -333,5 +425,5 @@ Security requirements remain manifest-owned because km-api stores only `auth: 'Y
|
|
|
333
425
|
|
|
334
426
|
## ๐ Next
|
|
335
427
|
|
|
336
|
-
- ๐งฑ What changes when components are emitted โ [Components](08-components.md)
|
|
428
|
+
- ๐งฑ What changes when components are emitted โ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
|
|
337
429
|
- โ๏ธ Every option that shapes this output โ [Configuration](09-configuration.md)
|
package/docs/09-configuration.md
CHANGED
|
@@ -107,7 +107,7 @@ interface ZopiaReverseOptions {
|
|
|
107
107
|
|
|
108
108
|
## ๐ `ZodToJsonSchemaOptions` โ engine โ
|
|
109
109
|
|
|
110
|
-
See [Conversions โ Engine โ ](06-conversions.md)
|
|
110
|
+
See [Conversions โ Engine โ ](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md)
|
|
111
111
|
(`target`, `$schema`, `io`).
|
|
112
112
|
|
|
113
113
|
## ๐ `JsonSchemaToZodOptions` โ engine โก
|
|
@@ -164,4 +164,4 @@ zopia diff <old-spec> <new-spec> (no options yet; `--config path` accep
|
|
|
164
164
|
## ๐ Next
|
|
165
165
|
|
|
166
166
|
- ๐ How options are used end-to-end โ [Usage](10-usage.md)
|
|
167
|
-
- ๐ What each option changes in the tree โ [API docs format](07-api-docs.md) ยท [Components](08-components.md)
|
|
167
|
+
- ๐ What each option changes in the tree โ [API docs format](07-api-docs.md) ยท [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
|
package/docs/10-usage.md
CHANGED
|
@@ -178,8 +178,37 @@ stderr, hint included) ยท `2` internal error (should never happen โ report it)
|
|
|
178
178
|
| R-101 | ๐ **Import, don't re-type** โ your app imports the generated `index.ts` files; their Zod schemas *are* the validation | โ |
|
|
179
179
|
| R-102 | ๐ **Spec or generation options changed?** re-run `zopia generate` โ output is idempotent (P-1); manifest staleness warns on source/config/incomplete-tree drift and safely prunes only obsolete manifest-owned files (`ZOPIA_WARN_STALE_TREE`) | [07 โ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
|
|
180
180
|
| R-103 | โ๏ธ **Hand edits** โ Phase 1 overwrites them on regeneration (see the file banner); the merge-safe custom layer arrives in Phase 3 | [07 โ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
|
|
181
|
-
| R-104 | ๐งช **km-api helpers** โ `makeFullPath`, `makeParams`, `convertResponseType`, โฆ are available on every generated config for free | [Concepts โ km-api](05-concepts.md#-km-api) |
|
|
181
|
+
| R-104 | ๐งช **km-api helpers** โ `makeFullPath`, `makeParams`, `convertResponseType`, โฆ are available on every generated config for free | [Concepts โ km-api](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md#-km-api) |
|
|
182
182
|
| R-105 | ๐ซ **No zopia import in app code** โ generated files depend only on `zod` + `km-api` (R-502) | โ |
|
|
183
|
+
| R-106 | ๐ณ **Runtime tree loading** โ when wiring endpoints dynamically beats importing files one by one, `createApiDocs()` from the opt-in `zopia/runtime` subpath turns the whole directory into one nested object (plus the `flattenApiDocs` flat record); generation output is untouched | [07 โ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs) |
|
|
184
|
+
|
|
185
|
+
### ๐ณ Consuming the tree at runtime
|
|
186
|
+
|
|
187
|
+
Import the dedicated subpath (the package root stays free of
|
|
188
|
+
filesystem-importing APIs) and point it at any directory zopia ever generated
|
|
189
|
+
into โ `directory`, `flat`, and split `multi-tag` / `multi-server` trees all
|
|
190
|
+
resolve through their manifests:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
|
|
194
|
+
import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
|
|
195
|
+
|
|
196
|
+
// Nested: exact URL path segments, lowercase method leaf, default-export config
|
|
197
|
+
const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
|
|
198
|
+
const getUser = apiDocs.users['{userId}'].get; // the makeApiConfig object
|
|
199
|
+
|
|
200
|
+
// Flat: keyed by operationId (derived with the generator's own naming rules)
|
|
201
|
+
const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
|
|
202
|
+
endpoints.getUser === getUser; // โ true
|
|
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
|
+
|
|
210
|
+
Failures are typed `ZopiaError`s with `at`/`hint` โ see
|
|
211
|
+
[API docs โ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs).
|
|
183
212
|
|
|
184
213
|
## ๐งฏ Error handling
|
|
185
214
|
|
|
@@ -200,9 +229,9 @@ try {
|
|
|
200
229
|
```
|
|
201
230
|
|
|
202
231
|
The full error-code table lives in
|
|
203
|
-
[Architecture โ Error model](04-architecture.md#-error-model).
|
|
232
|
+
[Architecture โ Error model](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md#-error-model).
|
|
204
233
|
|
|
205
234
|
## ๐ Next
|
|
206
235
|
|
|
207
236
|
- โ๏ธ Every option โ [Configuration](09-configuration.md)
|
|
208
|
-
- ๐งช How all of this is tested โ [Testing](11-testing.md)
|
|
237
|
+
- ๐งช How all of this is tested โ [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zopia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-docs",
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"bin",
|
|
29
29
|
"src",
|
|
30
|
-
"docs",
|
|
30
|
+
"docs/07-api-docs.md",
|
|
31
|
+
"docs/09-configuration.md",
|
|
32
|
+
"docs/10-usage.md",
|
|
33
|
+
"!docs/README.md",
|
|
31
34
|
"CHANGELOG.md",
|
|
32
35
|
"LICENSE",
|
|
33
36
|
"README.md"
|
|
@@ -72,6 +75,11 @@
|
|
|
72
75
|
"types": "./src/index.ts",
|
|
73
76
|
"import": "./src/index.ts",
|
|
74
77
|
"default": "./src/index.ts"
|
|
78
|
+
},
|
|
79
|
+
"./runtime": {
|
|
80
|
+
"types": "./src/runtime.ts",
|
|
81
|
+
"import": "./src/runtime.ts",
|
|
82
|
+
"default": "./src/runtime.ts"
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
85
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { asZopiaError, ZopiaError } from '../errors';
|
|
4
|
+
import { endpointExportName } from './api-docs-names';
|
|
4
5
|
import { buildOpenApiOperationIR } from './openapi-ir';
|
|
5
6
|
import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOperationContracts, reusableDeclarations } from './openapi-contracts';
|
|
6
7
|
import { jsonSchemaToZod } from './json-schema-to-zod';
|
|
7
8
|
import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './api-docs-plan';
|
|
8
9
|
import { isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
|
|
10
|
+
import { renderApiDocsTreeTypes, ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
|
|
9
11
|
import type { OpenApiDocument } from './openapi';
|
|
10
12
|
import { createZopiaManifest, hashOpenApiDocument, writeZopiaManifest, ZOPIA_MANIFEST_FILE } from './manifest-writer';
|
|
11
13
|
import { inspectZopiaManifestStaleness, removeObsoleteManifestFiles } from './manifest-staleness';
|
|
@@ -61,7 +63,7 @@ function collectComponentRefs(value: unknown, names = new Set<string>(), mapEntr
|
|
|
61
63
|
return names;
|
|
62
64
|
}
|
|
63
65
|
function schemaCode(schema: unknown, name: string): string {
|
|
64
|
-
const safeName =
|
|
66
|
+
const safeName = endpointExportName(name);
|
|
65
67
|
const converted = jsonSchemaToZod(schema === undefined ? true : schema as any, { rootName: safeName });
|
|
66
68
|
const source = converted.code.trimEnd();
|
|
67
69
|
const direct = source.match(new RegExp(`^const ${safeName.replace(/[$]/g, '\\$&')} = ([\\s\\S]*);$`));
|
|
@@ -222,23 +224,16 @@ function resolveObject(value: unknown, source: OpenApiDocument): any {
|
|
|
222
224
|
}
|
|
223
225
|
return current;
|
|
224
226
|
}
|
|
225
|
-
function exportName(operationId: string): string {
|
|
226
|
-
const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
|
|
227
|
-
let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
|
|
228
|
-
if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
|
|
229
|
-
if (['arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'].includes(name)) name = `${name}Endpoint`;
|
|
230
|
-
return name;
|
|
231
|
-
}
|
|
232
227
|
function componentExportName(componentName: string): string {
|
|
233
|
-
const name =
|
|
228
|
+
const name = endpointExportName(componentName);
|
|
234
229
|
return name.endsWith('Schema') ? name : `${name}Schema`;
|
|
235
230
|
}
|
|
236
231
|
function componentParameterExportName(componentName: string): string {
|
|
237
|
-
const name =
|
|
232
|
+
const name = endpointExportName(componentName);
|
|
238
233
|
return name.endsWith('Parameter') ? name : `${name}Parameter`;
|
|
239
234
|
}
|
|
240
235
|
function componentResponseExportName(componentName: string): string {
|
|
241
|
-
const name =
|
|
236
|
+
const name = endpointExportName(componentName);
|
|
242
237
|
return name.endsWith('Response') ? name : `${name}Response`;
|
|
243
238
|
}
|
|
244
239
|
function componentReaches(source: OpenApiDocument, from: string, target: string, seen = new Set<string>()): boolean {
|
|
@@ -352,7 +347,7 @@ function renderEndpoint(operation: any, source: OpenApiDocument, mode: ApiDocsMo
|
|
|
352
347
|
const examplesValue = { ...(Object.keys(requestExamples).length ? { request: requestExamples } : {}), ...(Object.keys(responseExamples).length ? { response: responseExamples } : {}) };
|
|
353
348
|
const examples = Object.keys(examplesValue).length ? `examples: JSON.parse(${JSON.stringify(stableDataJson(examplesValue))}),` : '';
|
|
354
349
|
const opId = operation.operationId;
|
|
355
|
-
const exportId =
|
|
350
|
+
const exportId = endpointExportName(opId);
|
|
356
351
|
const tags = ir.tags;
|
|
357
352
|
const auth = ir.security !== undefined && ir.security.length > 0 && ir.security.every((requirement) => Object.keys(requirement as Record<string, unknown>).length > 0) ? 'YES' : 'NO';
|
|
358
353
|
const sourceName = JSON.stringify(`${source.info.title} v${source.info.version}`).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
@@ -458,7 +453,11 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
|
|
|
458
453
|
reservedFiles.push(`${directory}/index.ts`, ...names.map((name) => `${directory}/${name}/index.ts`));
|
|
459
454
|
}
|
|
460
455
|
}
|
|
461
|
-
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
|
+
}
|
|
462
461
|
const plans = avoidReservedFileCollisions(planApiDocsFiles(source, mode), reservedFiles);
|
|
463
462
|
const webhookPlans = avoidReservedFileCollisions(planWebhookDocsFiles(source, mode), [...reservedFiles, ...plans.map((plan) => plan.file)]);
|
|
464
463
|
// OpenAPI requires operationId to be unique document-wide; $ref aliases can make the
|
|
@@ -561,6 +560,14 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
|
|
|
561
560
|
if (manifest) {
|
|
562
561
|
const manifestPath = await writeZopiaManifest(root, manifest);
|
|
563
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' });
|
|
564
571
|
}
|
|
565
572
|
await removeObsoleteManifestFiles(root, previous.ownedFiles, generated.map(({ file }) => file));
|
|
566
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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ Shared endpoint identifier rules (R-732).
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth for deriving safe export identifiers from
|
|
5
|
+
* operation IDs โ and for the numeric uniqueness suffix applied when two
|
|
6
|
+
* derived identifiers collide. Generation (Engine โข's `export const` names
|
|
7
|
+
* and the collectors' deterministic operation IDs) and the runtime tree
|
|
8
|
+
* resolver (`flattenApiDocs` record keys) call these helpers so both surfaces
|
|
9
|
+
* keep producing identical names by construction.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** ECMAScript keywords and literals never emitted as generated identifiers. */
|
|
13
|
+
const RESERVED_IDENTIFIER_NAMES: ReadonlySet<string> = new Set([
|
|
14
|
+
'arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive the endpoint export identifier from one operation ID.
|
|
19
|
+
*
|
|
20
|
+
* Applies the fixed naming contract of R-732: split on non-identifier
|
|
21
|
+
* characters, camel-case the parts (camelize), fall back to `endpoint` when
|
|
22
|
+
* nothing remains, prefix `endpoint` when the result starts with a digit or
|
|
23
|
+
* other non-identifier character, and append `Endpoint` when the result is a
|
|
24
|
+
* reserved ECMAScript keyword or literal (e.g. `await` โ `awaitEndpoint`).
|
|
25
|
+
*
|
|
26
|
+
* @param operationId Explicit or deterministically derived operation identifier.
|
|
27
|
+
* @returns Safe camel-case TypeScript identifier for the module export.
|
|
28
|
+
*/
|
|
29
|
+
export function endpointExportName(operationId: string): string {
|
|
30
|
+
const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
|
|
31
|
+
let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
|
|
32
|
+
if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
|
|
33
|
+
if (RESERVED_IDENTIFIER_NAMES.has(name)) name = `${name}Endpoint`;
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return the first free variant of a derived identifier, suffixing `2`, `3`, โฆ
|
|
39
|
+
*
|
|
40
|
+
* This is the exact uniqueness rule the operation collectors apply when a
|
|
41
|
+
* derived operation ID collides (`getA` โ `getA2` โ `getA3`): the first
|
|
42
|
+
* candidate is the base itself, and every subsequent candidate appends the
|
|
43
|
+
* next integer directly to the base.
|
|
44
|
+
*
|
|
45
|
+
* @param base Identifier before any uniqueness suffix.
|
|
46
|
+
* @param used Already-claimed identifiers this derivation must not clobber.
|
|
47
|
+
* @returns `base` itself when free, otherwise the first suffixed variant.
|
|
48
|
+
*/
|
|
49
|
+
export function uniqueEndpointName(base: string, used: ReadonlySet<string>): string {
|
|
50
|
+
let name = base;
|
|
51
|
+
let suffix = 1;
|
|
52
|
+
while (used.has(name)) name = `${base}${++suffix}`;
|
|
53
|
+
return name;
|
|
54
|
+
}
|