zopia 0.3.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 +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# ๐ API Docs Format
|
|
2
|
+
|
|
3
|
+
The **api docs** are zopia's main artifact: an `api_docs/` directory where
|
|
4
|
+
every endpoint becomes an `index.ts` file built with `makeApiConfig()` from
|
|
5
|
+
**km-api** (the make function), validated by **Zod v4** schemas.
|
|
6
|
+
|
|
7
|
+
Path-level parameters are merged with operation-level parameters; an
|
|
8
|
+
operation-level declaration with the same `name` and `in` overrides the
|
|
9
|
+
path-level declaration.
|
|
10
|
+
|
|
11
|
+
This document is the *output contract* โ layout, naming, file format, and the
|
|
12
|
+
manifest.
|
|
13
|
+
|
|
14
|
+
> ๐ **Rule R-701** โ the generated tree is a *contract*, not a suggestion.
|
|
15
|
+
> Any tool (including engine โฃ) may rely on every invariant stated here.
|
|
16
|
+
|
|
17
|
+
## ๐งช The canonical example
|
|
18
|
+
|
|
19
|
+
All layout examples in this document derive from one spec โ the **Admin API**
|
|
20
|
+
(OpenAPI 3.0.3). It is also the primary golden fixture of the test suite:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
๐ Admin API v1.2.0
|
|
24
|
+
โโโ GET /users/{userId} โ 200 User ยท 404 Problem (path: userId uuid)
|
|
25
|
+
โโโ PATCH /users/{userId} โ 200 User ยท default (body: CreateUser)
|
|
26
|
+
โโโ GET /health โ 204 (public override)
|
|
27
|
+
|
|
28
|
+
๐งฑ components.schemas: User ยท CreateUser
|
|
29
|
+
๐ securitySchemes: oauth (client credentials) โ document default; health opts out
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## ๐ Mode โ `directory` (default)
|
|
33
|
+
|
|
34
|
+
> ๐ฏ **T-5** โ *convert API addresses to nested directories; the last level of
|
|
35
|
+
> the path is a directory named after the method; inside it lives the `index` file.*
|
|
36
|
+
|
|
37
|
+
Each path segment becomes a directory; **then** one more directory named after
|
|
38
|
+
the method (lowercase); **then** `index.ts`:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
api_docs/
|
|
42
|
+
โโโ .zopia-manifest.json
|
|
43
|
+
โโโ health/
|
|
44
|
+
โ โโโ get/
|
|
45
|
+
โ โโโ index.ts # ๐ก GET /health
|
|
46
|
+
โโโ users/
|
|
47
|
+
โโโ {userId}/
|
|
48
|
+
โโโ get/
|
|
49
|
+
โ โโโ index.ts # ๐ก GET /users/{userId}
|
|
50
|
+
โโโ patch/
|
|
51
|
+
โโโ index.ts # ๐ก PATCH /users/{userId}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Invariants**
|
|
55
|
+
|
|
56
|
+
| # | Invariant |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| R-711 | ๐ฃ๏ธ Path segments (including `{param}` segments โ braces preserved, so the path is recoverable from the tree alone) form the directory chain under `api_docs/` |
|
|
59
|
+
| R-712 | ๐งญ The method directory is **always** the child of the path-leaf directory, named with the **lowercase** method โ the eight standard methods: `get`, `post`, `put`, `delete`, `head`, `options`, `patch`, `trace` (km-api โฅ 0.4.1) |
|
|
60
|
+
| R-713 | ๐ The file is **always** named `index.ts` โ `.ts` format, TypeScript (T-7) |
|
|
61
|
+
| R-714 | ๐ A literal segment may equal a method name (e.g. path `/users/get`): within the endpoint area (outside `components/`, whose component dirs also hold an `index.ts`) the tree stays formally unambiguous โ **a directory containing `index.ts` is a method directory; every other directory is a path segment** (method dirs hold exactly that one file, R-713). If another path would place directories beneath a method directory, the conflicting literal segment receives `-2`, `-3`, โฆ regardless of source order. The manifest (D-06) remains the *authority* engine โฃ reads, tree shape only a convenience |
|
|
62
|
+
|
|
63
|
+
## ๐ Mode โ `flat`
|
|
64
|
+
|
|
65
|
+
> ๐ฏ **T-6** โ *one directory per API, then the method directory, then the
|
|
66
|
+
> `index` file.*
|
|
67
|
+
|
|
68
|
+
The full path is flattened into **one** directory name: segments joined by
|
|
69
|
+
`-`, braces preserved:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
api_docs/
|
|
73
|
+
โโโ .zopia-manifest.json
|
|
74
|
+
โโโ health/
|
|
75
|
+
โ โโโ get/
|
|
76
|
+
โ โโโ index.ts # ๐ก GET /health
|
|
77
|
+
โโโ users-{userId}/
|
|
78
|
+
โโโ get/
|
|
79
|
+
โ โโโ index.ts # ๐ก GET /users/{userId}
|
|
80
|
+
โโโ patch/
|
|
81
|
+
โโโ index.ts # ๐ก PATCH /users/{userId}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| # | Invariant |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| R-721 | ๐ท๏ธ Flat name = path segments (minus leading `/`) joined by `-`; `{param}` segments keep their braces |
|
|
87
|
+
| R-722 | โ ๏ธ Two *different* paths may flatten to the same name (e.g. `/admin/users` and `/admin-users`). The second one gets a numeric suffix `-2`, `-3`, โฆ โ the manifest always holds the truth, the name is only an ergonomic label |
|
|
88
|
+
| R-723 | ๐งญ Method directory + `index.ts` โ identical to directory mode (R-712/R-713) |
|
|
89
|
+
|
|
90
|
+
> ๐ก Because flat names can collide *in principle*, **engine โฃ relies on the
|
|
91
|
+
> manifest, never on tree shape**, in both modes (D-06).
|
|
92
|
+
|
|
93
|
+
## ๐งญ Ergonomic facade-path helper
|
|
94
|
+
|
|
95
|
+
`apiDocsFacadeAccess(path, method, root?)` returns a safe TypeScript access path
|
|
96
|
+
for callers that build an optional nested facade around direct endpoint imports:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
apiDocsFacadeAccess('/applicant/{applicantId}/exame/{examId}', 'get');
|
|
100
|
+
// โ apiDocs.applicant["{applicantId}"].exame["{examId}"].get
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Ordinary segments become normalized camel-case properties; parameter segments
|
|
104
|
+
stay in bracket notation. The helper rejects unsafe property names, malformed
|
|
105
|
+
templates, duplicate parameters, unsupported methods, and unsafe roots. Engine โข
|
|
106
|
+
does not emit a facade module in v0.1.0: direct imports are the generated
|
|
107
|
+
file-level API, and the manifest remains authoritative (D-06).
|
|
108
|
+
|
|
109
|
+
## ๐งฌ Operation contract extraction
|
|
110
|
+
|
|
111
|
+
Before rendering an endpoint, zopia normalizes each operation into an
|
|
112
|
+
intermediate representation and preserves request/response media types. The
|
|
113
|
+
first content-bearing media type is emitted verbatim; malformed content, `null`
|
|
114
|
+
schema nodes, malformed schema-component maps, and unresolved request/response
|
|
115
|
+
`$ref` values are errors, never silently replaced or dropped. Endpoint rendering
|
|
116
|
+
either inlines reusable schema references or imports emitted
|
|
117
|
+
components according to the selected component options.
|
|
118
|
+
|
|
119
|
+
## ๐ The `index.ts` contract
|
|
120
|
+
|
|
121
|
+
Every endpoint file follows this generated shape (T-7 โ *all practical content is
|
|
122
|
+
filled with the make function*). This is the checked-in canonical GET endpoint:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
/** Generated by zopia โ do not edit by hand. */
|
|
126
|
+
import { z } from 'zod';
|
|
127
|
+
import { makeApiConfig } from 'km-api';
|
|
128
|
+
|
|
129
|
+
export const getUser = makeApiConfig({
|
|
130
|
+
method: "GET",
|
|
131
|
+
pathShape: "/users/{userId}",
|
|
132
|
+
operationId: "getUser",
|
|
133
|
+
|
|
134
|
+
responseContentType: "application/json" as unknown as import('km-api').IResponseContentType,
|
|
135
|
+
deprecated: 'YES',
|
|
136
|
+
auth: "YES",
|
|
137
|
+
summary: "Get a user",
|
|
138
|
+
description: "Returns one user",
|
|
139
|
+
tags: ["#users"],
|
|
140
|
+
examples: JSON.parse("{\"response\":{\"200\":{\"default\":{\"value\":{\"email\":\"admin@example.test\",\"id\":\"22ccbc6a-436b-4b1c-9e64-7440ce63a90e\",\"role\":\"admin\"}}}}}"),
|
|
141
|
+
request: { body: z.any(), params: z.object({ ["userId"]: z.string().uuid() }), query: z.object({ }), headers: z.object({ ["X-Trace-Id"]: z.string().optional() }), cookies: z.object({ }) },
|
|
142
|
+
response: { 200: z.object({ ["email"]: z.string().email(), ["id"]: z.string().uuid(), ["nickname"]: z.string().nullable().optional(), ["role"]: z.enum(["admin","viewer"]).optional() }).strict().meta({"title":"User","description":"A user account"}), 404: z.object({ }).passthrough() },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export default getUser;
|
|
146
|
+
// Source: "Admin API v1.2.0"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### ๐งพ Field-filling policy (IR โ `makeApiConfig`)
|
|
150
|
+
|
|
151
|
+
| ๐งฌ IR / spec fact | ๐ Where it lands | ๐ Rule |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `method` | `method` (uppercase) | R-731 |
|
|
154
|
+
| `path` | `pathShape` (OpenAPI `{param}` syntax โ D-05) | R-731 |
|
|
155
|
+
| `summary` | `summary` | R-731 |
|
|
156
|
+
| `description` | `description` (Markdown passes through) | R-731 |
|
|
157
|
+
| `tags` | `tags` โ km-api convention: each prefixed with `#` | R-731 |
|
|
158
|
+
| effective `security` requirements | `auth: 'YES'` when authentication is required; absent/empty requirements or an empty `{}` alternative โ `'NO'` (always emitted explicitly). Exact schemes/scopes remain manifest-owned | R-731 |
|
|
159
|
+
| `request.body` | `request.body`; *no body* โ `z.any()` | R-731 |
|
|
160
|
+
| `request.params / query / headers / cookies` | `request.params / query / headers / cookies` โ always `z.object(โฆ)` (km-api requires all five) | R-731 |
|
|
161
|
+
| `requestContentType` | `requestContentType` โ exact MIME string emitted with a type-only `IRequestContentType` boundary assertion because published km-api 0.4.1 enumerates known values while OpenAPI is open; the unchanged runtime string becomes the reverse-trip `content` key | R-731 |
|
|
162
|
+
| `responseContentType` | `responseContentType` (from the first content-bearing response) โ exact MIME string emitted with the corresponding narrow boundary assertion; the unchanged runtime string becomes the reverse-trip `content` key | R-731 |
|
|
163
|
+
| `response.statuses[]` | `response` โ `code: schema`; no-content status (204) โ `z.void()` โ **zopia's own marker**, deliberately not `z.object({})` (the shape km-api's README examples use for 204 โ both typecheck, response values accept any Zod schema): `z.void()` is the unambiguous no-content marker, and engine โฃ detects it *before* engine โ (Zod lists `z.void()` as unrepresentable, R-614) so it emits **no `content` at all**; a real `z.object({})` stays a schema. Valid custom codes (`100`โ`599`) and `default` are emitted as numeric/`default` keys, while OpenAPI-only `1XX`โ`5XX` ranges use quoted keys (km-api โฅ 0.4.1); response `headers` have no km-api home โ `responseOverlay` (R-754) | R-731 |
|
|
164
|
+
| `deprecated: true` | `deprecated: 'YES'` (km-api โฅ 0.4.1); omitted when false | R-731 |
|
|
165
|
+
| `examples` | `examples` โ km-api's `request` / `response` maps of `IExamplesMap` | R-731 |
|
|
166
|
+
| `operationId` | the config's `operationId` field (km-api โฅ 0.4.1) **and** the export identifier (see Naming below) | R-732 |
|
|
167
|
+
|
|
168
|
+
> ๐ **Rule R-732** โ the export identifier is the `operationId` when present
|
|
169
|
+
> (camelCased); otherwise derived deterministically: **method + PascalCase of
|
|
170
|
+
> every path segment** (parameter braces stripped), e.g.
|
|
171
|
+
> `get` + `Admin` + `Users` + `Id` โ `getAdminUsersId`. A file has exactly one
|
|
172
|
+
> endpoint export โ named **and** `default`.
|
|
173
|
+
|
|
174
|
+
### ๐ฆ Self-contained by default
|
|
175
|
+
|
|
176
|
+
With the default options (`insertComponents: false`), every `index.ts` imports
|
|
177
|
+
**only** `zod` and `km-api` (R-502): each component use is inlined into its
|
|
178
|
+
request/parameter/response expression (R-403). Cross-file imports appear
|
|
179
|
+
**only** when `useComponentAsReference` is `true` โ see
|
|
180
|
+
[Components](08-components.md).
|
|
181
|
+
|
|
182
|
+
## ๐ฆ The manifest โ `.zopia-manifest.json`
|
|
183
|
+
|
|
184
|
+
> ๐ฏ **T-10** โ the manifest is what makes every conversion reversible (D-06).
|
|
185
|
+
> It is written by default (the `manifest` option, on unless explicitly
|
|
186
|
+
> disabled โ [Configuration](09-configuration.md)), has no timestamps or
|
|
187
|
+
> environment data (P-1), is always hidden (dotfile), and always versioned
|
|
188
|
+
> (`"$schema": "zopia:manifest@1"`). The dedicated writer validates the complete
|
|
189
|
+
> writer-owned shape before output, recursively orders object keys, preserves
|
|
190
|
+
> semantic array order, and writes through a sibling temporary file before an
|
|
191
|
+
> atomic rename. Identical JSON input and generation options therefore produce
|
|
192
|
+
> byte-identical manifest content with exactly one trailing newline; a failed
|
|
193
|
+
> validation or write never exposes a partial manifest.
|
|
194
|
+
|
|
195
|
+
```jsonc
|
|
196
|
+
{
|
|
197
|
+
"$schema": "zopia:manifest@1",
|
|
198
|
+
"zopiaVersion": "0.1.0",
|
|
199
|
+
"mode": "directory",
|
|
200
|
+
"options": {
|
|
201
|
+
"insertComponents": false,
|
|
202
|
+
"useComponentAsReference": false
|
|
203
|
+
},
|
|
204
|
+
"pathOrder": ["/health", "/users/{userId}"],
|
|
205
|
+
"schemaComponentsPresent": true,
|
|
206
|
+
"source": {
|
|
207
|
+
"kind": "openapi-3.0",
|
|
208
|
+
"openapiVersion": "3.0.3",
|
|
209
|
+
"title": "Admin API",
|
|
210
|
+
"version": "1.2.0",
|
|
211
|
+
"sha256": "de6afdd09c3db1444044438bca0ed29c28a7876d786cb4750f254b898e11ac10"
|
|
212
|
+
},
|
|
213
|
+
"servers": [
|
|
214
|
+
{ "description": "Production", "url": "https://api.example.test/v1" },
|
|
215
|
+
{ "description": "Staging", "url": "https://staging.example.test/v1" }
|
|
216
|
+
],
|
|
217
|
+
"tags": [{ "description": "User administration", "name": "users" }],
|
|
218
|
+
"securitySchemes": {
|
|
219
|
+
"oauth": { "type": "oauth2", "flows": { "clientCredentials": { "tokenUrl": "https://auth.example.test/token", "scopes": { "users:read": "Read users", "users:write": "Write users" } } } }
|
|
220
|
+
},
|
|
221
|
+
"defaultSecurity": [{ "oauth": ["users:read"] }],
|
|
222
|
+
"components": [
|
|
223
|
+
// One entry per declaration; file is null in default mode. Schema shortened.
|
|
224
|
+
{
|
|
225
|
+
"file": null,
|
|
226
|
+
"name": "User",
|
|
227
|
+
"overlay": [],
|
|
228
|
+
"schema": { "type": "object", "title": "User", "required": ["id", "email"], "properties": { "โฆ": "โฆ" } }
|
|
229
|
+
}
|
|
230
|
+
],
|
|
231
|
+
"apis": [
|
|
232
|
+
// Abbreviated getUser entry; the manifest also contains health/updateUser.
|
|
233
|
+
{
|
|
234
|
+
"file": "users/{userId}/get/index.ts",
|
|
235
|
+
"path": "/users/{userId}",
|
|
236
|
+
"method": "get",
|
|
237
|
+
"operationId": "getUser",
|
|
238
|
+
"refs": [
|
|
239
|
+
{ "at": "/parameters/0/$ref", "ref": "#/components/parameters/TraceId" },
|
|
240
|
+
{ "at": "/responses/200/content/application~1json/schema/$ref", "component": "User", "ref": "#/components/schemas/User" },
|
|
241
|
+
{ "at": "/responses/200/content/application~1xml/schema/$ref", "component": "User", "ref": "#/components/schemas/User" },
|
|
242
|
+
{ "at": "/responses/404/$ref", "ref": "#/components/responses/Problem" }
|
|
243
|
+
],
|
|
244
|
+
"overlay": [],
|
|
245
|
+
"responseOverlay": [
|
|
246
|
+
{ "status": "200", "headers": { "ETag": { "schema": { "type": "string" } } } }
|
|
247
|
+
],
|
|
248
|
+
"sourceOperation": { "โฆ": "full detached operation snapshot" }
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
| ๐งพ Key | ๐ What engine โฃ needs it for |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `source` | ๐ท๏ธ rebuild `info`; retain the exact source `openapi` patch string in `source.openapiVersion`; verify the tree matches the spec it claims to come from |
|
|
257
|
+
| `pathOrder` | ๐งญ retain source `paths` key order so reverse regeneration makes the same collision decisions; it also records operation-free and completely empty Path Items that have no endpoint file or overlay |
|
|
258
|
+
| `schemaComponentsPresent` | ๐งฑ distinguish an absent schema-component container from an explicitly empty Swagger `definitions: {}` or OpenAPI `components.schemas: {}` declaration |
|
|
259
|
+
| `servers`, `tags`, `securitySchemes` | ๐๐ท๏ธ๐ document frame that has no home in Zod; presence is retained independently from value, so absent and explicitly empty collections round-trip differently (R-656/R-657) |
|
|
260
|
+
| `pathsOverlay` | ๐ฃ๏ธ path-item metadata (`summary`, `description`, shared parameters, local `$ref`, extensions) and `paths` extensions that endpoint modules cannot own |
|
|
261
|
+
| `components[].schema` | ๐งฑ the **full** component JSON Schema โ restored verbatim into `components.schemas` (R-655/R-751) |
|
|
262
|
+
| `componentsOverlay` | ๐งฐ non-schema OpenAPI component sections such as reusable parameters, responses, headers, examples, links, callbacks, and path items |
|
|
263
|
+
| `swaggerParameters`, `swaggerResponses` | ๐งฐ Swagger 2.0 reusable parameter and response definitions, restored at the document root |
|
|
264
|
+
| `components[].file` | ๐งฑ where to find the emitted component file (`null` โ not emitted โ `insertComponents` was `false`); when set, `manifestFileToOpenApi()` imports its Zod schema and converts it to the source dialect, so developer edits win while cross-component references remain `$ref`s |
|
|
265
|
+
| `components[].overlay` | ๐ฉน schema-local Engine โก restorations for emitted components; applied after runtime Zod serialization, with empty-string `at` addressing the component root |
|
|
266
|
+
| `apis[]` | ๐ก **exact** file โ (path, method, operationId) mapping โ `manifestFileToOpenApi()` imports each file and uses its runtime km-api metadata plus request/response Zod schemas; `pathItemRef: true` marks an unchanged operation inherited only through a path-item `$ref`, preventing reverse conversion from duplicating it beside that ref; the manifest supplies unsupported overlays and exact security facts |
|
|
267
|
+
| `defaultSecurity` | ๐ the spec-level `security` requirement list, verbatim โ applies to every operation unless the operation declares its own `security`; key absent โ the source had no global `security` |
|
|
268
|
+
| `apis[].security` | ๐ the operation's own `security` requirement list โ present only when the operation declares the key (including an explicit `[]` = "no security"); km-api's config can store only the `auth` boolean, so the actual requirement (which schemes, which scopes) lives here (R-653/R-656) |
|
|
269
|
+
| `apis[].refs` | ๐ `$ref` placement: JSON pointer (relative to the operation subtree), exact local `ref`, and schema component name when applicable (R-752/R-659); `$ref`-looking literal data inside examples/defaults/enums/consts/extensions is excluded |
|
|
270
|
+
| `apis[].overlay` | ๐ฉน keyword-level restorations & frozen subtrees โ the non-representable facts, verbatim (R-753/R-635) |
|
|
271
|
+
| `apis[].responseOverlay` | ๐ฆ response facts with no km-api home โ response `headers` and future non-schema fields, restored after code-derived response schemas/content (R-754) |
|
|
272
|
+
| `source.sha256` | ๐ canonical source identity: regeneration compares it with the normalized new input, alongside `mode` and component options, to detect a stale tree without false positives from object-key order |
|
|
273
|
+
|
|
274
|
+
Presence is part of the reversible contract: an absent optional field remains absent rather than becoming `false`, and explicit empty schema containers, empty Path Items, unconstrained boolean schemas, schema-less media objects, and schema-local definition containers survive file-backed reverse conversion exactly.
|
|
275
|
+
|
|
276
|
+
Before importing any code, `manifestFileToOpenApi()` verifies that every `apis[].file` and every non-null `components[].file` still resolves to a regular file inside the manifest directory. Missing or renamed entries fail the whole preflight with `ZOPIA_DOCS_MANIFEST_MISMATCH`; no earlier module is executed.
|
|
277
|
+
|
|
278
|
+
Security requirements remain manifest-owned because km-api stores only `auth: 'YES' | 'NO'`: an operation-level requirement (including `[]`, or the recoverable `sourceOperation.security` in a legacy manifest) wins first, then `defaultSecurity` applies. Runtime `auth: 'YES'` triggers a reverse fallback only when neither source records a requirement. That fallback emits one `ZOPIA_WARN_DEFAULT_SECURITY` warning per affected operation, reuses one deterministic bearer scheme across operations, and never overwrites an existing incompatible `bearerAuth` definition (it selects `bearerAuth2`, `bearerAuth3`, and so on). OpenAPI 3 output receives an HTTP bearer scheme; source-preserving Swagger 2.0 output receives an `Authorization` header `apiKey` approximation because Swagger 2.0 has no HTTP bearer scheme type.
|
|
279
|
+
|
|
280
|
+
> ๐ **Rule R-751** โ the manifest carries a **full** `schema` for every
|
|
281
|
+
> declared component, in every mode. `schemaComponentsPresent` separately
|
|
282
|
+
> retains an explicitly empty declaration. The component snapshot is the
|
|
283
|
+
> verbatim source of `components.schemas` on the reverse trip; when `file` is set, the imported
|
|
284
|
+
> file takes precedence (the code is the truth, D-08), followed only by the
|
|
285
|
+
> component's R-635 overlay for facts Zod cannot serialize.
|
|
286
|
+
>
|
|
287
|
+
> ๐ **Rule R-752** โ `refs` entries address **the source operation subtree**
|
|
288
|
+
> with RFC 6901 pointers relative to `paths.<path>.<method>` (so `/` inside a
|
|
289
|
+
> media type is encoded as `~1`). File-based reverse conversion restores that
|
|
290
|
+
> placement after schema serialization. If runtime code already emits a
|
|
291
|
+
> different component `$ref`, both the source ref and overlays beneath it are
|
|
292
|
+
> skipped so the developer-selected target wins. Older manifests whose media
|
|
293
|
+
> type segments were not escaped remain readable.
|
|
294
|
+
>
|
|
295
|
+
> ๐ **Rule R-753** โ `overlay` entries are `{ at, set?, remove?, node? }`
|
|
296
|
+
> (R-635). `node`-form entries retain a subtree's original form while the
|
|
297
|
+
> generated reference-free subtree still matches its deterministic baseline;
|
|
298
|
+
> an edit skips that restoration. Reference-bearing frozen subtrees remain
|
|
299
|
+
> manifest-owned when no independent local baseline can be built. The generated
|
|
300
|
+
> position carries a `// @zopia:warn ZOPIA_WARN_FROZEN_SUBTREE` comment so
|
|
301
|
+
> developers can see the boundary.
|
|
302
|
+
>
|
|
303
|
+
> ๐ **Rule R-754** โ `apis[].responseOverlay` records the response facts that
|
|
304
|
+
> have no home in km-api (today: response `headers`; any future
|
|
305
|
+
> non-expressible response field joins it). Engine โฃ merges those fields into
|
|
306
|
+
> response statuses that still exist after code conversion; code-derived
|
|
307
|
+
> `content`, Swagger `schema`, examples, and changed/removed statuses remain
|
|
308
|
+
> authoritative. Everything else km-api can express โ incl. `trace` operations, custom status
|
|
309
|
+
> codes, `default` responses, and arbitrary media types (km-api โฅ 0.4.1, R-642)
|
|
310
|
+
> โ lives in the generated code.
|
|
311
|
+
|
|
312
|
+
## ๐ท๏ธ Naming conventions (fixed)
|
|
313
|
+
|
|
314
|
+
| ๐งฉ Thing | ๐ Convention | Example |
|
|
315
|
+
| --- | --- | --- |
|
|
316
|
+
| Endpoint export (with `operationId`) | `operationId` camelCased | `getUser` |
|
|
317
|
+
| Endpoint export (derived) | `method + PascalCase(segments)` โ braces stripped; synthetic collisions receive `2`, `3`, while explicit IDs remain authoritative | `getAdminUsersId` |
|
|
318
|
+
| Directory-mode directories | path segments verbatim (braces preserved); collapsed/root/case/component-file collisions receive `-2`, `-3` on the conflicting segment or leaf, including method-directory prefix conflicts | `admin/users/{id}` |
|
|
319
|
+
| Flat-mode directory | segments joined by `-` (braces preserved); collisions โ `-2`, `-3`; a name equal to the enabled `.zopia-manifest.json` file is also disambiguated | `admin-users-{id}` |
|
|
320
|
+
| Method directories | lowercase method | `get` |
|
|
321
|
+
| Component directories | exact component name (case preserved โ round-trip) | `User`, `CreateUser` |
|
|
322
|
+
| Component export | `<ComponentName>Schema` (a name already ending in `Schema` is kept as-is) | `UserSchema` |
|
|
323
|
+
| The file | always `index.ts` | โ |
|
|
324
|
+
|
|
325
|
+
## ๐ Regeneration & manual edits (Phase 1 policy)
|
|
326
|
+
|
|
327
|
+
| # | Rule |
|
|
328
|
+
| --- | --- |
|
|
329
|
+
| R-741 | โป๏ธ **Idempotent** โ same input + options โ byte-identical tree (P-1); canonical hashing ignores object-key order, so a semantically identical reorder is not stale |
|
|
330
|
+
| R-742 | โ๏ธ **Overwrite & prune ownership** โ generation rewrites files whose rendered bytes differ and skips byte-identical files entirely, so unchanged files keep their mtimes and watch-mode/bundler tooling stays quiet (D-24); after a successful generation it removes obsolete files listed by the previous valid manifest and then removes only empty generated directories. Unlisted/custom files are never pruned |
|
|
331
|
+
| R-743 | โ ๏ธ **Stale tree** โ source-hash drift, layout/component/`custom`-option drift, missing manifest-owned files, disabling manifest output, and invalid existing manifests produce one deterministic `ZOPIA_WARN_STALE_TREE` at `.zopia-manifest.json`. Invalid manifests are replaced/removed but are not trusted to identify old artifacts. Unsafe symlinked path ancestors instead fail with `ZOPIA_FS_OUTSIDE_OUTDIR` before that path is written |
|
|
332
|
+
| R-744 | ๐งฉ **Merge-safe custom companions** (D-24, opt-in) โ with `custom: true` (CLI `--custom`) every endpoint and webhook module appends `export * as custom from './custom';` and zopia scaffolds a sibling `custom.ts` **exactly once**: any existing file or symlink at that path is left untouched, never overwritten and never deleted by staleness pruning, and toggling the option off only drops the export line. Components get no companions |
|
|
333
|
+
|
|
334
|
+
## ๐ Next
|
|
335
|
+
|
|
336
|
+
- ๐งฑ What changes when components are emitted โ [Components](08-components.md)
|
|
337
|
+
- โ๏ธ Every option that shapes this output โ [Configuration](09-configuration.md)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# ๐งฑ Components
|
|
2
|
+
|
|
3
|
+
A spec keeps DRY with **components** โ named, reusable schemas referenced via
|
|
4
|
+
`$ref` (Swagger 2.0: `definitions`; OpenAPI 3.x: `components.schemas`).
|
|
5
|
+
Components can reference *other* components, and even themselves (cycles).
|
|
6
|
+
This document defines how zopia treats them in the generated tree.
|
|
7
|
+
|
|
8
|
+
## ๐ Local references
|
|
9
|
+
|
|
10
|
+
OpenAPI local JSON Pointer references are resolved only when their target exists
|
|
11
|
+
in the same document. The resolver supports `#` for the document root and the
|
|
12
|
+
standard `~1` and `~0` pointer escapes. External references and invalid pointer
|
|
13
|
+
escapes are rejected explicitly rather than silently dropped. Component files
|
|
14
|
+
and reusable endpoint references are part of the full rendering phase.
|
|
15
|
+
|
|
16
|
+
## โ๏ธ The two options
|
|
17
|
+
|
|
18
|
+
> ๐ฏ **T-8 / T-9** โ both options are **booleans, both default `false`**, and
|
|
19
|
+
> `useComponentAsReference` only makes sense when `insertComponents` is `true`.
|
|
20
|
+
> Component files and exact endpoint schema-reference imports are implemented;
|
|
21
|
+
> nested component-reference imports are recursively supported across the complete Engine โก schema surface, including objects, arrays, compositions, conditionals/refinements, nullable schemas, enums, constants, named schema maps, and additional-property schemas. Direct aliases retain distinct lazy identities for reverse conversion, and direct/mutual cyclic imports use lazy schemas.
|
|
22
|
+
|
|
23
|
+
| โ๏ธ `insertComponents` | โ๏ธ `useComponentAsReference` | ๐ What is generated | ๐ What endpoint files do |
|
|
24
|
+
| :---: | :---: | --- | --- |
|
|
25
|
+
| `false` | `false` *(forced)* | nothing extra | inline every referenced schema occurrence (R-403) โ **fully self-contained** |
|
|
26
|
+
| `false` | `true` | ๐ **invalid** โ `ZOPIA_CONFIG_INVALID` ("enable `insertComponents` first") | โ |
|
|
27
|
+
| `true` | `false` | `components/**` written | still **inline** (components exist as standalone files, but endpoints do not import them) |
|
|
28
|
+
| `true` | `true` | `components/**` written | **import** components โ single source of truth, no duplication |
|
|
29
|
+
|
|
30
|
+
> ๐ **Rule R-801** โ `insertComponents: true` emits **every** declared
|
|
31
|
+
> component (even ones no operation references) โ the tree mirrors the spec.
|
|
32
|
+
> Unused components are still useful documentation, and the mirror keeps the
|
|
33
|
+
> reverse conversion complete.
|
|
34
|
+
|
|
35
|
+
## ๐ Layout (when `insertComponents: true`)
|
|
36
|
+
|
|
37
|
+
Components land in `api_docs/components/`, one directory **per component,
|
|
38
|
+
named exactly as declared** (case preserved โ the name is data, D-06):
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
api_docs/
|
|
42
|
+
โโโ .zopia-manifest.json
|
|
43
|
+
โโโ components/
|
|
44
|
+
โ โโโ index.ts # ๐ช barrel โ re-exports every component
|
|
45
|
+
โ โโโ CreateUser/
|
|
46
|
+
โ โ โโโ index.ts # export const CreateUserSchema = โฆ
|
|
47
|
+
โ โโโ User/
|
|
48
|
+
โ โ โโโ index.ts # export const UserSchema = โฆ
|
|
49
|
+
โ โโโ parameters/
|
|
50
|
+
โ โ โโโ index.ts # ๐ช reusable-parameter barrel (only when declared)
|
|
51
|
+
โ โ โโโ TraceId/
|
|
52
|
+
โ โ โโโ index.ts # export const TraceIdParameter = โฆ
|
|
53
|
+
โ โโโ responses/
|
|
54
|
+
โ โโโ index.ts # ๐ช reusable-response barrel (only when declared)
|
|
55
|
+
โ โโโ Problem/
|
|
56
|
+
โ โโโ index.ts # export const ProblemResponse = โฆ
|
|
57
|
+
โโโ health/get/index.ts
|
|
58
|
+
โโโ users/{userId}/
|
|
59
|
+
โโโ get/index.ts
|
|
60
|
+
โโโ patch/index.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### ๐ Component file format
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
/** Generated by zopia โ do not edit by hand. */
|
|
67
|
+
import { z } from 'zod';
|
|
68
|
+
|
|
69
|
+
export const UserSchema = (() => { /**
|
|
70
|
+
* User
|
|
71
|
+
* A user account
|
|
72
|
+
*/
|
|
73
|
+
const UserSchema = z.object({ ["email"]: z.string().email(), ["id"]: z.string().uuid(), ["nickname"]: z.string().nullable().optional(), ["role"]: z.enum(["admin","viewer"]).optional() }).strict().meta({"title":"User","description":"A user account"}); return UserSchema; })();
|
|
74
|
+
|
|
75
|
+
export default UserSchema;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Component expressions are emitted through the same complete Engine โก converter
|
|
79
|
+
used for endpoint schemas, then structural component references are replaced by
|
|
80
|
+
imports. Constraints such as `not`, property-name checks, tuple/array bounds,
|
|
81
|
+
`uniqueItems`, and `$ref` siblings therefore remain live runtime validation;
|
|
82
|
+
root and nested annotations (`deprecated`, access flags, XML/external docs, and
|
|
83
|
+
`x-โฆ`) remain Zod metadata for reverse conversion.
|
|
84
|
+
|
|
85
|
+
### ๐ช The barrel
|
|
86
|
+
|
|
87
|
+
`components/index.ts` re-exports every component in name order:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export { CreateUserSchema } from "./CreateUser/index";
|
|
91
|
+
export { UserSchema } from "./User/index";
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> ๐ **Rule R-802** โ endpoint files import components **through the barrel**
|
|
95
|
+
> (`from '<relative>/components/index'`), never from a deep path. A component
|
|
96
|
+
> named `index.ts` (case-insensitively) is rejected before any write because its
|
|
97
|
+
> required directory would collide with this barrel file. The relative
|
|
98
|
+
> prefix is computed per file and per mode:
|
|
99
|
+
>
|
|
100
|
+
> | mode | file depth | import |
|
|
101
|
+
> | --- | --- | --- |
|
|
102
|
+
> | directory ยท `users/{userId}/get/index.ts` | 3 | `import { UserSchema } from '../../../components/index';` |
|
|
103
|
+
> | flat ยท `users-{userId}/get/index.ts` | 2 | `import { UserSchema } from '../../components/index';` |
|
|
104
|
+
|
|
105
|
+
## ๐ Component โ component references
|
|
106
|
+
|
|
107
|
+
When a component uses another component, the dependency becomes a **relative
|
|
108
|
+
import between component files** โ the same `$ref` graph, now in TypeScript:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// components/Post/index.ts
|
|
112
|
+
import { AuthorSchema } from '../Author/index';
|
|
113
|
+
|
|
114
|
+
export const PostSchema = z.object({
|
|
115
|
+
title: z.string(),
|
|
116
|
+
author: AuthorSchema, // โคต $ref #/components/schemas/Author
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| # | Rule |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| R-811 | ๐งฉ Import graph mirrors structural `$ref` edges exactly (R-402); `$ref`-looking values inside `default`, `example(s)`, `enum`, `const`, and `x-โฆ` data stay literal. Component files import only *component files* (never endpoint files), and valid `$ref` siblings retain their runtime constraints and annotations |
|
|
123
|
+
| R-812 | ๐ **Cycles** (e.g. `Comment.replies โ Comment`) become `z.lazy(() => CommentSchema)` on the *cyclic edge only* โ the file still loads (R-402). Direct aliases also use a lazy wrapper so alias and target remain distinct runtime identities during reverse conversion |
|
|
124
|
+
| R-813 | ๐ฆ Components imported from the barrel by endpoints never create import cycles: components never import endpoints (R-811) |
|
|
125
|
+
|
|
126
|
+
## ๐ Generated endpoint files with `useComponentAsReference: true`
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
/** Generated by zopia โ do not edit by hand. */
|
|
130
|
+
import { z } from 'zod';
|
|
131
|
+
import { makeApiConfig } from 'km-api';
|
|
132
|
+
import { UserSchema } from '../../../components/index';
|
|
133
|
+
import { TraceIdParameter } from '../../../components/parameters/index';
|
|
134
|
+
import { ProblemResponse } from '../../../components/responses/index';
|
|
135
|
+
|
|
136
|
+
export const getUser = makeApiConfig({
|
|
137
|
+
method: "GET",
|
|
138
|
+
pathShape: "/users/{userId}",
|
|
139
|
+
operationId: "getUser",
|
|
140
|
+
|
|
141
|
+
responseContentType: "application/json" as unknown as import('km-api').IResponseContentType,
|
|
142
|
+
deprecated: 'YES',
|
|
143
|
+
auth: "YES",
|
|
144
|
+
summary: "Get a user",
|
|
145
|
+
description: "Returns one user",
|
|
146
|
+
tags: ["#users"],
|
|
147
|
+
examples: JSON.parse("{\"response\":{\"200\":{\"default\":{\"value\":{\"email\":\"admin@example.test\",\"id\":\"22ccbc6a-436b-4b1c-9e64-7440ce63a90e\",\"role\":\"admin\"}}}}}"),
|
|
148
|
+
request: { body: z.any(), params: z.object({ ["userId"]: z.string().uuid() }), query: z.object({ }), headers: z.object({ ["X-Trace-Id"]: TraceIdParameter.optional() }), cookies: z.object({ }) },
|
|
149
|
+
response: { 200: UserSchema, 404: ProblemResponse },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export default getUser;
|
|
153
|
+
// Source: "Admin API v1.2.0"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
With references on, each component export is imported once per endpoint file
|
|
157
|
+
and reused at every matching identity (R-403). Non-component schemas remain
|
|
158
|
+
inline.
|
|
159
|
+
|
|
160
|
+
### โป๏ธ Reusable parameters and responses (v0.2.x)
|
|
161
|
+
|
|
162
|
+
Declared **reusable parameters** (`#/components/parameters/โฆ`, Swagger 2.0:
|
|
163
|
+
`#/parameters/โฆ`) and **reusable responses** (`#/components/responses/โฆ`,
|
|
164
|
+
Swagger 2.0: `#/responses/โฆ`) also become their own component modules when
|
|
165
|
+
component files are emitted โ under `components/parameters/<Name>/index.ts`
|
|
166
|
+
and `components/responses/<Name>/index.ts`, exporting `<Name>Parameter` and
|
|
167
|
+
`<Name>Response`. A parameter module holds the parameter's *schema* (name,
|
|
168
|
+
location, `required`, and descriptions stay operation-level data); a response
|
|
169
|
+
module holds its primary media-type schema. A declaration whose only key is a
|
|
170
|
+
single-segment namespace `$ref` at a use site resolves to the module export โ
|
|
171
|
+
sibling-merged `$ref`s at use sites keep their inline composition as before.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// components/parameters/TraceId/index.ts (reusable parameter)
|
|
175
|
+
/** Generated by zopia โ do not edit by hand. */
|
|
176
|
+
import { z } from 'zod';
|
|
177
|
+
|
|
178
|
+
export const TraceIdParameter = z.string();
|
|
179
|
+
|
|
180
|
+
export default TraceIdParameter;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Each kind gets a barrel (`components/parameters/index.ts`,
|
|
184
|
+
`components/responses/index.ts`) **only when that kind has declarations**, so
|
|
185
|
+
specs without reusables gain no files. Cross-kind export-name collisions are
|
|
186
|
+
impossible (suffixes `Schema`/`Parameter`/`Response`); within a kind, name and
|
|
187
|
+
export collisions fail with `ZOPIA_SPEC_INVALID` exactly like schema modules.
|
|
188
|
+
A reusable response without a schema (no media type in 3.x, no `schema` in
|
|
189
|
+
2.0) has nothing to centralize โ it emits **no module** and renders
|
|
190
|
+
`z.void()` at use sites; its metadata still round-trips verbatim through the
|
|
191
|
+
manifest (`componentsOverlay` / Swagger globals).
|
|
192
|
+
|
|
193
|
+
## ๐ Reverse conversion (engine โฃ)
|
|
194
|
+
|
|
195
|
+
| # | Rule |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| R-821 | ๐งฑ Engine โฃ rebuilds `components.schemas` from the manifest in **every** mode: `file` set (components mode) โ the component file is imported and converted with engine โ (developer edits win); `file: null` (default mode) โ the manifest `schema` is re-emitted verbatim (R-655/R-751) |
|
|
198
|
+
| R-822 | ๐ Endpoint use-sites of imported components become `$ref: "#/components/schemas/<Name>"` again from runtime Zod identity; changing the component used in generated code changes the emitted `$ref`. RFC 6901-escaped names are decoded for imports and re-escaped in output, and direct aliases retain their `$ref` sibling keywords as Zod metadata. |
|
|
199
|
+
| R-823 | ๐ `z.lazy` cycles serialize back to self `$ref`s โ recursion round-trips |
|
|
200
|
+
| R-824 | ๐ธ Component annotations and direct-`$ref` siblings are emitted as Zod metadata when a component file exists, so code remains authoritative; only `file: null` components use the manifest schema snapshot |
|
|
201
|
+
| R-825 | โป๏ธ Reusable parameter/response modules reverse symmetrically (v0.2.x): the manifest component entry carries `kind: "parameter"` (or `"response"`) and the declaration (`#/components/parameters/<Name>`, Swagger 2.0 `#/parameters/<Name>`, responses likewise) is refreshed from the current module schema before assembly โ developer edits win (R-821 semantics per kind). Use sites are restored **verbatim** from the manifest placement records, so every `$ref: "#/components/parameters/<Name>"` (and bare body/formData/response `$ref`s) reappears exactly where it was declared to be used; an import a developer adds at a *new* position simply inlines there |
|
|
202
|
+
|
|
203
|
+
## ๐ซ Phase 1 scope (documented limits)
|
|
204
|
+
|
|
205
|
+
Reusable non-schema objects (`components.parameters`, `components.responses`,
|
|
206
|
+
`components.examples`, and Swagger globals) are resolved at endpoint use sites
|
|
207
|
+
for generated km-api code. The manifest preserves their declarations and ref
|
|
208
|
+
placements, so engine โฃ restores reusable identity. Phase 2 is only needed to
|
|
209
|
+
emit those objects as standalone generated files.
|
|
210
|
+
|
|
211
|
+
For file-path inputs, same-folder external refs are bundled inline before
|
|
212
|
+
this matrix applies (D-17), so an external spec and its inline equivalent land
|
|
213
|
+
in the same rows.
|
|
214
|
+
|
|
215
|
+
| ๐งฉ Thing | Current behaviour | When |
|
|
216
|
+
| --- | --- | --- |
|
|
217
|
+
| external refs in the spec folder (file inputs) | bundled inline before processing (D-17) | v0.2.x |
|
|
218
|
+
| external refs outside the spec folder, or from object/text input | `ZOPIA_REF_EXTERNAL` | โ |
|
|
219
|
+
|
|
220
|
+
## ๐ Next
|
|
221
|
+
|
|
222
|
+
- โ๏ธ Options reference โ [Configuration](09-configuration.md)
|
|
223
|
+
- ๐ How refs are resolved โ [Architecture โ The reference graph](04-architecture.md#-the-reference-graph)
|