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.
Files changed (47) hide show
  1. package/CHANGELOG.md +354 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/bin/zopia.js +20 -0
  5. package/docs/01-overview.md +94 -0
  6. package/docs/02-targets.md +55 -0
  7. package/docs/03-roadmap.md +205 -0
  8. package/docs/04-architecture.md +345 -0
  9. package/docs/05-concepts.md +239 -0
  10. package/docs/06-conversions.md +493 -0
  11. package/docs/07-api-docs.md +337 -0
  12. package/docs/08-components.md +223 -0
  13. package/docs/09-configuration.md +167 -0
  14. package/docs/10-usage.md +208 -0
  15. package/docs/11-testing.md +267 -0
  16. package/docs/12-standards.md +242 -0
  17. package/docs/README.md +42 -0
  18. package/docs/publish-workflow.yml.example +48 -0
  19. package/package.json +77 -0
  20. package/src/api-docs-navigation.ts +353 -0
  21. package/src/cli-command.ts +537 -0
  22. package/src/cli.ts +4 -0
  23. package/src/config.ts +190 -0
  24. package/src/conversions/api-docs-facade.ts +42 -0
  25. package/src/conversions/api-docs-generate.ts +567 -0
  26. package/src/conversions/api-docs-layout.ts +39 -0
  27. package/src/conversions/api-docs-plan.ts +130 -0
  28. package/src/conversions/api-docs-presets.ts +246 -0
  29. package/src/conversions/json-schema-to-zod.ts +931 -0
  30. package/src/conversions/manifest-staleness.ts +211 -0
  31. package/src/conversions/manifest-to-openapi.ts +1861 -0
  32. package/src/conversions/manifest-writer.ts +778 -0
  33. package/src/conversions/openapi-contracts.ts +333 -0
  34. package/src/conversions/openapi-external-ref.ts +233 -0
  35. package/src/conversions/openapi-ir.ts +74 -0
  36. package/src/conversions/openapi-ref.ts +38 -0
  37. package/src/conversions/openapi-to-api-docs-public.ts +466 -0
  38. package/src/conversions/openapi-to-api-docs.ts +203 -0
  39. package/src/conversions/openapi.ts +80 -0
  40. package/src/conversions/reverse-security.ts +68 -0
  41. package/src/conversions/yaml.ts +876 -0
  42. package/src/conversions/zod-to-json-schema.ts +536 -0
  43. package/src/diff.ts +353 -0
  44. package/src/errors.ts +114 -0
  45. package/src/index.ts +80 -0
  46. package/src/validation.ts +299 -0
  47. 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)