@arc-e-tect/api-only-publisher 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.adoc CHANGED
@@ -11,64 +11,63 @@ image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDone
11
11
  image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
12
12
  image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
13
13
 
14
- Links say where they go: § jumps to a section of this page, → opens another file in this repository, and ↗ leaves it.
14
+ [NOTE]
15
+ .Where the links in this document go
16
+ ====
17
+ Every link ends with a marker saying where it leads, so you can tell before you follow it:
18
+
19
+ [horizontal]
20
+ `↗`:: An *external* site, outside this repository.
21
+ `→`:: *Another file* in this repository.
22
+ `§`:: *A section of this same file.*
23
+ ====
15
24
 
16
25
  // Remove this notice when 1.0.0 is released.
17
26
  [IMPORTANT]
18
27
  ====
19
28
  Until 1.0.0 is released, any release may change the command line, `apionly.yaml` or the published artifacts incompatibly, without a major version bump.
20
- There are no users to break yet, and the interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
29
+ The interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
21
30
  Pin an exact version, and read the changelog before upgrading.
22
31
  ====
23
32
 
33
+ [#what-it-is]
24
34
  == What it is
25
35
 
26
- `api-only-publisher` turns a library of small, reusable API description
27
- fragments into one complete, self-contained document per target, and distributes
28
- each to whoever needs it.
29
-
30
- It is the producer half of a pair. The consumer half, `API-Only Subscriber`,
31
- fetches a published document into an implementation project and verifies that it
32
- has not drifted. The two are coupled only by the published artifact and its
33
- manifest -- neither knows anything else about the other.
34
-
35
- A *target* is whatever gets its own contract document: a microservice, a modular
36
- monolith, a backend-for-frontend, or a documentation aggregate spanning all of
37
- them. It need not be a deployable, which is why this is not called `services`.
36
+ `api-only-publisher` turns a library of small, reusable API description fragments into one complete, self-contained document per target, and distributes each to whoever needs it.
38
37
 
39
- == Why it is not a shell script
38
+ It is the producer half of a pair.
39
+ The consumer half, the link:../api-only-subscriber/README.adoc[API-Only Subscriber →], fetches a published document into an implementation project and verifies that it has not drifted.
40
+ The two are coupled only by the published artifact and its manifest; neither knows anything else about the other.
40
41
 
41
- It was one, and the shell showed through in three places.
42
+ A *target* is whatever gets its own contract document: a microservice, a modular monolith, a backend-for-frontend, or a documentation aggregate spanning all of them.
43
+ It need not be a deployable, which is why this is not called `services`.
42
44
 
43
- The target list was a plain Bash array with a comment explaining that it avoided
44
- associative arrays for the Bash 3.2 that macOS still ships. Both scripts carried
45
- a write-to-a-temp-file-and-move dance to work around the difference between BSD
46
- and GNU `sed -i`. And neither could parse YAML, which is why setting a version
47
- was a regular expression that silently did nothing when it failed to match.
48
-
49
- Node was never an optional dependency either: `@redocly/cli` and `@asyncapi/cli`
50
- are Node tools, so a Node runtime was already required to run the pipeline at all.
45
+ This document is the reference for the Publisher alone.
46
+ To set it up for your situation, start at link:../docs/README.adoc[the documentation →].
51
47
 
48
+ [#requirements]
52
49
  == Requirements
53
50
 
54
51
  [cols="1,1",options="header"]
55
52
  |===
56
53
  | Requirement | Version
57
- | Node | 22 +
58
- | npm | 10 +
54
+ | Node.js | `^22.14.0`, `^24.10.0` or `>=26.0.0`
55
+ | npm | The one that comes with Node.js
59
56
  |===
60
57
 
61
- Nothing else. The bundlers are fetched by `npx` at the versions `apionly.yaml`
62
- pins, so they need no installation, and the only runtime dependency is `yaml`.
58
+ Nothing else.
59
+ The bundlers, https://redocly.com/docs/cli/[Redocly CLI ↗] and https://www.asyncapi.com/tools/cli[AsyncAPI CLI ↗], are fetched by `npx` at the versions `apionly.yaml` pins, so they need no installation, and the only runtime dependency is `yaml`.
63
60
 
61
+ [#installation]
64
62
  == Installation
65
63
 
66
64
  The Publisher behaves the same however it is started.
67
65
  What differs is where its version is pinned, and what checks that pin.
68
66
 
67
+ [#as-a-development-dependency]
69
68
  === As a development dependency
70
69
 
71
- The better fit for a specification library, and for any build that runs the Publisher more than once.
70
+ The fit for a specification library, and for any build that runs the Publisher more than once.
72
71
 
73
72
  [source,console]
74
73
  ----
@@ -93,7 +92,7 @@ npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
93
92
  ----
94
93
  npm ci
95
94
  npm run apionly -- build
96
- npm run apionly -- publish --target user-account
95
+ npm run apionly -- publish --target orders
97
96
  ----
98
97
 
99
98
  * The version is pinned in one place, `package.json`, and every command and every CI job runs that one.
@@ -102,9 +101,13 @@ npm run apionly -- publish --target user-account
102
101
  * Renovate and Dependabot see the dependency, so an upgrade arrives as a pull request that moves the pin.
103
102
  * The cost is a `package.json` and a `package-lock.json` in the library, and an `npm ci` before the first command.
104
103
 
104
+ Run it through the script, as `npm run apionly -- <command>`.
105
+ `npx api-only-publisher`, without the scope, is not this package: npx would look that name up on the public registry.
106
+
107
+ [#with-npx]
105
108
  === With `npx`, installing nothing
106
109
 
107
- The better fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
110
+ The fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
108
111
 
109
112
  [source,console]
110
113
  ----
@@ -118,99 +121,91 @@ npx @arc-e-tect/api-only-publisher@<version> build -C my-api-library
118
121
  * Nothing records an integrity hash, and nothing notices when a newer version is released.
119
122
 
120
123
  Either way, pin an exact version rather than a range.
121
- This tool's output is expected to be reproducible a specification library's golden fixtures assert it byte for byte — and a floating version quietly breaks that guarantee.
124
+ A published contract should change only when its fragments or its version change, and a floating tool version lets it change without either.
122
125
 
123
126
  The bundlers are a separate matter.
124
127
  However the Publisher itself is started, it fetches them with `npx`, at the versions `toolchain` in `apionly.yaml` pins.
125
128
 
129
+ [#commands]
126
130
  == Commands
127
131
 
128
132
  [cols="1,3", options="header"]
129
133
  |===
130
134
  |Command |Purpose
131
135
 
132
- |`init [dir]`
133
- |Scaffold `apionly.yaml` and a reference directory layout that builds as it
134
- stands. This is how the *conventions* become reusable, as opposed to the code.
136
+ |`init [dir] [--force]`
137
+ |Scaffold `apionly.yaml` and a reference directory layout that builds as it stands.
135
138
 
136
- |`build`
137
- |Stage, substitute placeholders, bundle, stamp each published target's version, lint, and distribute.
139
+ |`build [--target <t>]... [--pre-release <ids>] [--openapi\|--asyncapi]`
140
+ |Stage, substitute placeholders, bundle, stamp each published target's version, and lint.
138
141
 
139
- |`lint`
140
- |Lint what is already built, without rebuilding, for fast local feedback.
142
+ |`lint [--target <t>]...`
143
+ |Lint what is already built, without rebuilding, and write a report per document.
141
144
  Without `--target`, it also fails on a fragment no target reaches, as <<unreferenced,A fragment no target reaches fails the lint §>> describes.
142
145
 
143
146
  |`targets`
144
- |List the declared targets, what each one builds, and whether it is distributed.
147
+ |List the declared targets, what each one builds, and whether it is published.
145
148
 
146
- |`closure`
147
- |Show each target's dependency closure: how many files it reaches, and the hash
148
- of their content.
149
+ |`closure [--target <t>]...`
150
+ |Show each target's dependency closure: how many files it reaches, and the hash of their content.
149
151
 
150
152
  |`changed --since <ref>`
151
- |Report which targets' closures actually changed since a git ref. This is what
152
- drives a release: a target whose closure is untouched is not released, however
153
- much else in the repository moved.
153
+ |Report which targets' closures changed between a git ref and the working tree.
154
+ A target whose closure is untouched needs no new version, however much else in the repository moved.
154
155
 
155
- |`pack`
156
+ |`pack [--pre-release <ids>] [--target <t>]... [--out <dir>]`
156
157
  |Archive each built, published target with a `manifest.json`, at the version its version file declares.
157
158
 
158
- |`publish`
159
- |Pack once, then ship those same bytes to every configured channel.
159
+ |`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--out <dir>]`
160
+ |Pack once, then ship those same bytes to every configured channel, or to each `--channel`.
160
161
 
161
- |`split --out <dir>`
162
+ |`split --out <dir> [--by kind\|target]`
162
163
  |Make the library safe to break into separate repositories.
163
164
  |===
164
165
 
166
+ `--target` and `--channel` may be repeated.
167
+ `-C <dir>` runs as if started in `<dir>`; without it the configuration is found by walking up from the current directory.
168
+ `-q` or `--quiet` reports only errors, which is what a script that reads the output wants.
169
+ `--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
170
+
165
171
  [source,console]
166
172
  ----
167
- api-only-publisher init my-api-library
168
- api-only-publisher build -C my-api-library
169
- api-only-publisher build --target user-account --openapi
170
- api-only-publisher publish --target user-account --pre-release rc.1
171
- api-only-publisher targets
173
+ npm run apionly -- build --target orders --openapi
174
+ npm run apionly -- publish --target orders --pre-release rc.1
175
+ npm run apionly -- changed --since origin/main --quiet
172
176
  ----
173
177
 
174
- `--target` may be repeated. `-C <dir>` runs as if started in `<dir>`; without it
175
- the configuration is found by walking up from the current directory.
176
- `--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
177
-
178
- == What `build` actually does
178
+ [#what-build-does]
179
+ == What `build` does
179
180
 
180
- . **Stage.** The whole source root is copied to `build/staging/<type>/`. Nothing
181
- after this point touches the hand-authored tree.
181
+ . **Stage.** The whole source root is copied to `<build.staging>/<kind>/`.
182
+ Nothing after this point touches the hand-authored tree.
182
183
  +
183
- The *whole* root, not just one specification type's subtree, because the trees
184
- `$ref` each other -- event schemas reuse the OpenAPI common schemas, so a
185
- username means the same thing over Kafka as over HTTP. A partial copy breaks
186
- those references. Each type gets *its own* staging root, so building one never
187
- invalidates the other's staged tree.
184
+ The *whole* root, not just one specification kind's subtree, because the trees `$ref` each other: an event schema can reuse an OpenAPI component schema, so a field means the same thing in an event as in an HTTP response.
185
+ A partial copy breaks those references.
186
+ Each kind gets *its own* staging root, so building one never invalidates the other's staged tree.
188
187
 
189
- . **Substitute placeholders**, in place, across the staged copy. A `{{token}}`
190
- is replaced with the contents of `<token>.md`, found by searching the staged
191
- root. Indentation is preserved, so multi-line Markdown stays valid inside an
192
- indented YAML scalar.
188
+ . **Substitute placeholders**, in place, across the staged copy.
189
+ A `{{token}}` is replaced with the contents of `<token>.md`, found by searching the staged source root.
190
+ Indentation is preserved, so multi-line Markdown stays valid inside an indented YAML scalar.
193
191
 
194
- . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every
195
- `$ref` into one flat, self-contained document under `dist/<target>/`.
192
+ . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every `$ref` into one flat, self-contained document under `<build.dist>/<target>/`.
193
+ An OpenAPI document's components each carry the path of the fragment they came from, as <<fragment-paths,`x-fragment-path` §>> describes.
196
194
 
197
195
  . **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
198
196
  A `publish: false` target keeps the version its source declares.
199
197
 
200
- . **Lint** the finished document, so a broken fragment surfaces against the
201
- target it actually affects rather than at deploy time.
202
-
203
- . **Distribute**, unless the target is `publish: false`.
198
+ . **Lint** the finished document, so a broken fragment surfaces against the target it actually affects.
204
199
 
205
- [[versions]]
200
+ [#versions]
206
201
  == Versions
207
202
 
208
203
  Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
209
204
 
210
- .specs/openapi/bundles/user-account.bundle.properties
205
+ .specs/openapi/bundles/orders.bundle.properties
211
206
  [source,properties]
212
207
  ----
213
- # The version of the user-account contract, for every document it builds.
208
+ # The version of the orders contract, for every document it builds.
214
209
  # Semantic: major for a breaking change, minor for an additive one, patch for
215
210
  # anything else. Change it in the same commit as the fragments it describes.
216
211
  version=2.1.0
@@ -236,79 +231,61 @@ A version names one set of documents.
236
231
  Publishing different documents under a version that was published before, after editing a fragment without changing the version file, is refused by the API-Only Subscriber in every project that locked the earlier ones.
237
232
  Change the version in the same commit as the fragments instead.
238
233
 
234
+ [#closure]
239
235
  == Independent versioning, and the dependency closure
240
236
 
241
- Giving each target its own version has one non-obvious consequence, and the
242
- `closure` and `changed` commands are what pay for it.
237
+ Giving each target its own version has one non-obvious consequence, and the `closure` and `changed` commands are what pay for it.
243
238
 
244
- A change under `components/common/` affects every target that reaches it. A
245
- change under one service's own context affects only that one. Under
246
- repository-wide versioning the distinction is invisible, because everything bumps
247
- together; under per-target versioning the release job has to know the difference,
248
- or one service's edit releases every other service and the version numbers stop
249
- meaning anything.
239
+ A change under `components/common/` affects every target that reaches it.
240
+ A change under one target's own fragments affects only that one.
241
+ Under repository-wide versioning the distinction is invisible, because everything bumps together; under per-target versioning a release has to know the difference, or one target's edit releases every other target and the version numbers stop meaning anything.
250
242
 
251
- A target's *closure* is every file reachable from its bundle root by following
252
- `$ref`s transitively. `changed --since <ref>` resolves each closure and reports
253
- which targets a commit actually touched:
243
+ A target's *closure* is every file reachable from its bundle root by following `$ref`s transitively.
244
+ `changed --since <ref>` resolves each closure and reports which targets a change actually touched:
254
245
 
255
246
  [source,console]
256
247
  ----
257
- api-only-publisher changed --since origin/main
248
+ npm run apionly -- changed --since origin/main
258
249
  ----
259
250
 
260
- Two things make this correct rather than approximate. The closure is computed
261
- over the *staged, substituted* tree, so a change to a Markdown snippet -- which
262
- genuinely changes the published document -- counts. And a change to the *shape*
263
- of a closure always means editing a file already inside it, so a bundle gaining
264
- or losing a `$ref` is caught without diffing membership separately.
251
+ Two things make this correct rather than approximate.
252
+ The closure is computed over the *staged, substituted* tree, so a change to a Markdown snippet, which genuinely changes the published document, counts.
253
+ And a change to the *shape* of a closure always means editing a file already inside it, so a bundle gaining or losing a `$ref` is caught without diffing membership separately.
265
254
 
266
- The closure hash is recorded in each release's manifest as `closureSha256`, which
267
- is what lets a later commit compare against what was actually published.
255
+ The closure hash is recorded in each release's manifest as `closureSha256`, which is what lets a later commit compare against what was actually published.
268
256
 
257
+ [#splitting]
269
258
  == Splitting the library
270
259
 
271
- A fragment library is one `$ref` graph, and that graph does not respect the
272
- directory boundaries a repository split would follow. Here, the AsyncAPI event
273
- schemas reuse `openapi/components/common/schemas/UsernameV1.yaml` -- a username
274
- means the same thing over Kafka as over HTTP. Move the two trees into separate
275
- repositories as they stand and every one of those references dangles.
260
+ A fragment library is one `$ref` graph, and that graph does not respect the directory boundaries a repository split would follow.
261
+ When the AsyncAPI documents reuse `openapi/components/common/schemas/`, moving the two trees into separate repositories as they stand leaves every one of those references dangling.
276
262
 
277
263
  [source,console]
278
264
  ----
279
- api-only-publisher split --out build/split --by kind
265
+ npm run apionly -- split --out build/split --by kind
280
266
  ----
281
267
 
282
- Each part is written with its own subtree *plus* a copy of every foreign file it
283
- reaches, so that afterwards each side is self-contained and builds on its own.
284
- The copies keep their original path relative to the shared source root, which is
285
- what makes this safe: every relative `$ref` keeps resolving exactly as it did, so
286
- nothing is rewritten and no reference can break because a rewrite got the depth
287
- wrong.
268
+ Each part is written with its own subtree *plus* a copy of every foreign file it reaches, so that afterwards each side is self-contained and builds on its own.
269
+ The copies keep their original path relative to the shared source root, which is what makes this safe: every relative `$ref` keeps resolving exactly as it did, so nothing is rewritten and no reference can break because a rewrite got the depth wrong.
288
270
 
289
- `--by kind` splits along the specification types. `--by target` gives every
290
- target its own self-contained tree, which is what you want before splitting into
291
- per-service repositories.
271
+ `--by kind` splits along the specification kinds.
272
+ `--by target` gives every target its own self-contained tree, which is what you want before splitting into per-target repositories.
292
273
 
293
- Any part that received copies gets an `IMPORTED.adoc` listing them and saying
294
- plainly what it costs: those copies can now drift, and nothing will notice. A
295
- shared schema that two repositories both define is two schemas that happen to
296
- agree today. Where that matters, promote the fragment to a contract of its own,
297
- published and consumed like any other, rather than copied.
274
+ Any part that received copies gets an `IMPORTED.adoc` listing them and saying plainly what it costs: those copies can now drift, and nothing will notice.
275
+ A shared schema that two repositories both define is two schemas that happen to agree today.
276
+ Where that matters, promote the fragment to a contract of its own, published and consumed like any other, rather than copied.
298
277
 
278
+ [#aggregates]
299
279
  == Aggregates
300
280
 
301
- A whole-landscape OpenAPI view is just another bundle root: every path body
302
- already lives in a `$ref`'d fragment, so a portfolio costs one hand-written
303
- table of contents and duplicates no contract text.
281
+ A whole-landscape OpenAPI view is just another bundle root: every path body already lives in a `$ref`'d fragment, so a portfolio costs one hand-written table of contents and duplicates no contract text.
304
282
 
305
- AsyncAPI cannot be written that way. Its operations use document-root pointers
306
- (`channel: {$ref: '#/channels/auditV1'}`), and `#` resolves against whichever file
307
- contains it, so moving an operation into a fragment breaks it. A hand-written
308
- async portfolio would have to copy every operation verbatim, and that copy would
309
- start rotting the moment a member changed.
283
+ AsyncAPI cannot be written that way.
284
+ Its operations use document-root pointers (`channel: {$ref: '#/channels/orderPlacedV1'}`), and `#` resolves against whichever file contains it, so moving an operation into a fragment breaks it.
285
+ A hand-written async portfolio would have to copy every operation verbatim, and that copy would start rotting the moment a member changed.
310
286
 
311
- So it is generated instead. Declare the members and the aggregate's own identity:
287
+ So it is generated instead.
288
+ Declare the members and the aggregate's own identity:
312
289
 
313
290
  [source,yaml]
314
291
  ----
@@ -317,17 +294,17 @@ portfolio:
317
294
  asyncapi:
318
295
  bundle: portfolio_asyncapi_structure.yaml
319
296
  aggregate:
320
- - user-account
297
+ - orders
298
+ - payments
321
299
  info:
322
300
  title: Everything, together
323
301
  version: 0.0.0
324
302
  ----
325
303
 
326
- The bundle root is synthesised into the staging tree immediately before bundling,
327
- so nothing is duplicated in the source and the view cannot fall behind its
328
- members. Two members contributing the same channel or operation key is an error
329
- rather than a silent overwrite.
304
+ The bundle root is synthesised into the staging tree immediately before bundling, so nothing is duplicated in the source and the view cannot fall behind its members.
305
+ Two members contributing the same channel or operation key is an error rather than a silent overwrite.
330
306
 
307
+ [#channels]
331
308
  == Channels
332
309
 
333
310
  [cols="1,3", options="header"]
@@ -336,74 +313,63 @@ rather than a silent overwrite.
336
313
 
337
314
  |`file`
338
315
  |A local directory, laid out as `<directory>/<target>/<version>/`.
339
- Not a distribution mechanism between repositories: it exists so the pipeline is testable end to end with no infrastructure, so a library can share one build with the projects that implement it, and as a local-iteration escape hatch.
316
+ Not a distribution mechanism between repositories: it makes the pipeline testable end to end with no infrastructure, lets a library share one build with the projects that implement it, and is a local-iteration escape hatch.
340
317
  Publishing a version again replaces that version and keeps the others; with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
341
318
 
342
319
  |`maven`
343
- |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a
344
- real remote. Native for Gradle consumers: version resolution, caching and
345
- conflict handling come free, which is what lets the Subscriber carry no HTTP
346
- client of its own.
320
+ |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a remote repository with an HTTP `PUT` per file and a bearer token.
321
+ Native for Gradle consumers: version resolution, caching and conflict handling come free, which is what lets the Subscriber carry no HTTP client of its own.
347
322
 
348
323
  |`npm`
349
- |Real semver, integrity hashes for free, private scopes available, and the
350
- toolchain here is already Node.
324
+ |Real semver, integrity hashes, private scopes, and a toolchain that is already Node.js.
351
325
 
352
326
  |`github-release`
353
- |The language-neutral floor: immutable per tag, works for private repositories
354
- with a token, readable by a consumer with no JVM and no Node. No
355
- dependency-resolution semantics and no update notification, which is why it is
356
- the fallback rather than the default.
327
+ |The language-neutral floor: immutable per tag, works for private repositories with a token, readable by a consumer with no JVM and no Node.js.
328
+ No dependency-resolution semantics and no update notification, which is why it is the fallback rather than the default.
357
329
  |===
358
330
 
359
331
  Credentials are read from the environment, never from configuration.
360
- `channels.maven.tokenEnv` names the variable; a configuration file gets
361
- committed, and a credential in a committed file is a credential that has leaked.
332
+ `channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
333
+
334
+ link:../docs/reference/how-publisher-and-subscriber-work-together.adoc#channels[How the Publisher and the Subscriber work together →] lists each channel's layout and options.
362
335
 
336
+ [#why-registries]
363
337
  === Why npm and Maven rather than release assets alone
364
338
 
365
- Publishing is passive. The producer releases `2.1.0` and nothing happens in an
366
- implementation repository until somebody looks.
339
+ Publishing is passive.
340
+ The producer releases `2.1.0` and nothing happens in an implementation repository until somebody looks.
367
341
 
368
- Renovate and Dependabot understand npm and Maven natively, so a contract bump
369
- arrives downstream as a pull request without any bespoke machinery. That is the
370
- practical argument for preferring them, and the reason to design the coordinates
371
- so that standard update bots can read them.
342
+ Renovate and Dependabot understand npm and Maven natively, so a contract bump arrives downstream as a pull request without any bespoke machinery.
343
+ That is the practical argument for preferring them, and the reason to design the coordinates so that standard update bots can read them.
372
344
 
345
+ [#pre-releases]
373
346
  == Pre-releases
374
347
 
375
- API-Only design means implementation starts against a contract that is not
376
- finished. If the only way to obtain a bundle were a final release, teams would
377
- work around the tool by cloning the specification repository -- which is exactly
378
- the broad read access that publishing artifacts exists to avoid, with none of the
379
- guarantees.
348
+ API-first design means implementation starts against a contract that is not finished.
349
+ If the only way to obtain a bundle were a final release, teams would work around the tool by cloning the specification repository, which is exactly the broad read access that publishing artifacts exists to avoid, with none of the guarantees.
380
350
 
381
- So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT`
382
- spelling, which is not semver-legal but is what Maven consumers expect.
351
+ So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT` spelling, which is not semver-legal but is what Maven consumers expect.
383
352
  It is cut from the version in the target's version file: `publish --pre-release rc.1` publishes `2.1.0-rc.1` when the file declares `2.1.0`.
384
353
 
385
- The hard rule is the other half of it. A pre-release must never quietly satisfy a
386
- production build:
354
+ The hard rule is the other half of it.
355
+ A pre-release must never quietly satisfy a production build:
387
356
 
388
- * npm publishes it under the `next` dist-tag, never `latest`, so `npm install`
389
- cannot pick one up by accident.
390
- * A GitHub release is marked as a pre-release, so "latest release" never resolves
391
- to an unfinished contract.
392
- * The API-Only Subscriber refuses a pre-release version outright unless the
393
- subscription sets `allowPrerelease = true` -- a visible, reviewable line in a
394
- build file rather than a default.
357
+ * npm publishes it under the `next` dist-tag, never `latest`, so `npm install` cannot pick one up by accident.
358
+ * A GitHub release is marked as a pre-release, so "latest release" never resolves to an unfinished contract.
359
+ * The API-Only Subscriber refuses a pre-release version outright unless the subscription sets `allowPrerelease = true`: a visible, reviewable line in a build file rather than a default.
395
360
 
361
+ [#configuration]
396
362
  == Configuration
397
363
 
398
- Everything project-specific lives in `apionly.yaml`, not in the code. Adapting
399
- the tool to another project means editing configuration.
364
+ Everything project-specific lives in `apionly.yaml`, not in the code.
365
+ Adapting the tool to another project means editing configuration.
400
366
 
401
367
  [source,yaml]
402
368
  ----
403
369
  schemaVersion: 1
404
370
 
405
371
  sources:
406
- root: specs # staged wholesale; the types share fragments
372
+ root: specs # staged wholesale; the kinds share fragments
407
373
  openapi: openapi
408
374
  asyncapi: asyncapi
409
375
 
@@ -411,6 +377,7 @@ defaults:
411
377
  openapi:
412
378
  lint: .redocly.yaml
413
379
  outputName: openapi.yaml
380
+ fragmentPaths: true # x-fragment-path on every component; the default
414
381
  asyncapi:
415
382
  outputName: asyncapi.yaml
416
383
  placeholders:
@@ -418,7 +385,7 @@ defaults:
418
385
 
419
386
  build:
420
387
  staging: build/staging
421
- dist: dist
388
+ dist: build/dist
422
389
  reports:
423
390
  lint: build/reports/lint
424
391
  lint:
@@ -434,83 +401,145 @@ channels:
434
401
  clean: true # keep only the version being published
435
402
 
436
403
  targets:
437
- user-account:
438
- # versionFile: versions/user-account.properties
439
- # default: user-account.bundle.properties beside the first bundle root
404
+ orders:
405
+ # versionFile: versions/orders.properties
406
+ # default: orders.bundle.properties beside the first bundle root
440
407
  openapi:
441
- bundle: bundles/user-account_openapi_structure.yaml
408
+ bundle: bundles/orders.yaml
442
409
  asyncapi:
443
- bundle: user-account_asyncapi_structure.yaml
410
+ bundle: orders_asyncapi.yaml
444
411
  portfolio:
445
- publish: false # built and linted, never distributed
412
+ publish: false # built and linted, never published
446
413
  openapi:
447
- bundle: bundles/portfolio_openapi_structure.yaml
414
+ bundle: bundles/portfolio.yaml
448
415
  ----
449
416
 
450
- `targets` is the single source of truth for what gets built. It replaced the
451
- per-script target arrays and the `apis:` map in `redocly.yaml`, which said
452
- overlapping things in two places under two different names.
417
+ `targets` is the single source of truth for what gets built.
453
418
 
419
+ [#pinning-the-bundlers]
454
420
  === Pinning the bundlers
455
421
 
456
- `toolchain` pins the bundler versions. Unpinned, the documents this tool produces
457
- could change because an upstream release happened, with nothing in the library
458
- having changed -- which makes any claim that a refactoring preserved output
459
- unfalsifiable. An upgrade should be a deliberate commit that moves the pin and
460
- the expected output together.
422
+ `toolchain` pins the bundler versions.
423
+ Unpinned, the documents this tool produces could change because an upstream release happened, with nothing in the library having changed.
424
+ An upgrade should be a deliberate commit that moves the pin, reviewed with whatever it changes in the published documents.
461
425
 
426
+ [#behaviour]
462
427
  == Behaviour worth knowing about
463
428
 
429
+ [#unresolved-placeholders]
464
430
  === Unresolved placeholders fail the build
465
431
 
466
- The tool this absorbed emitted the literal string `*MISSING CONTENT*` into the
467
- document and carried on with a warning, so a broken contract could ship from a
468
- green build. An unresolved `{{token}}` is now an error. Set
469
- `defaults.placeholders.strict: false` to get the old leniency, in which case the
470
- token is left visible rather than replaced with a marker.
432
+ An unresolved `{{token}}` is an error, so a broken contract cannot ship from a green build.
433
+ Set `defaults.placeholders.strict: false` to leave the token visible in the document instead.
471
434
 
435
+ [#placeholder-search]
472
436
  === Placeholder snippets are found from the source root
473
437
 
474
- The absorbed tool searched downward from the *input file's own directory*, never
475
- from the directory it was told to use, so a snippet one level up was invisible
476
- and the workaround was to move the Markdown files. The search now starts at the
477
- staged source root.
438
+ `<token>.md` is searched for from the staged source root downwards, so a snippet can live anywhere under it.
478
439
 
479
- [[unreferenced]]
440
+ [#unreferenced]
480
441
  === A fragment no target reaches fails the lint
481
442
 
482
443
  A linter checks documents, and a fragment reaches a document only through a `$ref`.
483
444
  A fragment that no target references is therefore never linted, however wrong it is, until the day a target starts to use it.
484
445
 
485
- So `lint`, run without `--target`, also lists every YAML file under the source root that no target's closure reaches, in `build/reports/lint/unreferenced.txt`, and fails when there is one.
446
+ So `lint`, run without `--target`, also lists every YAML file under the source root that no target's closure reaches, in `<reports.lint>/unreferenced.txt`, and fails when there is one.
486
447
  Every target counts, `publish: false` ones included, because they are linted too.
487
448
  That reports a fragment that does not comply before anything uses it, and finds a definition left behind by a change that stopped using it.
488
449
 
450
+ Configuration is not a fragment, so it may sit anywhere, `sources.root` included.
451
+ The check skips the well-known configuration files of lint tools, wherever they are: `redocly.yaml`, `.redocly.yaml`, `.redocly.lint-ignore.yaml`, `.spectral.yaml`, `.spectral.yml`, and `apionly.yaml`.
452
+ It also skips the lint configurations `defaults.<kind>.lint` names, whatever they are called.
453
+ Any other YAML file under the source root counts.
454
+
489
455
  `lint.unreferenced` in `apionly.yaml` sets what happens: `error` by default, `warn` to report without failing, or `off` not to look.
490
456
  `lint --target` does not look, because an unreferenced fragment belongs to no target.
491
457
 
492
458
  `lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
493
459
 
460
+ [#version-stamping]
494
461
  === Version stamping edits one scalar
495
462
 
496
- `info.version` is located structurally and that one scalar is spliced. It is
497
- deliberately not a re-serialisation: re-emitting a document reformats everything
498
- around the edit, because the AsyncAPI CLI wraps long descriptions at a width no
499
- YAML emitter reproduces. Splicing keeps every other byte exactly as the bundler
500
- wrote it. A document with no `info` block, or no version inside it, is an error.
463
+ `info.version` is located structurally and that one scalar is spliced.
464
+ It is deliberately not a re-serialisation: re-emitting a document reformats everything around the edit, because the AsyncAPI CLI wraps long descriptions at a width no YAML emitter reproduces.
465
+ Splicing keeps every other byte exactly as the bundler wrote it.
466
+ A document with no `info` block, or no version inside it, is an error.
467
+
468
+ [#fragment-paths]
469
+ === Every component says which fragment it came from
501
470
 
471
+ Bundling flattens the library's directories into one `components` namespace, and the bundler names each component after its file.
472
+ Two fragments with the same file name in different directories become `UserV1` and `UserV1-2`, and nothing in the document says which is which.
473
+
474
+ So every component the bundler builds from a fragment carries that fragment's path, relative to `sources.root`, as its first key:
475
+
476
+ [source,yaml]
477
+ ----
478
+ components:
479
+ schemas:
480
+ UsernameV1:
481
+ x-fragment-path: openapi/components/common/schemas/UsernameV1.yaml
482
+ type: string
483
+ ----
484
+
485
+ The path always uses forward slashes, and it names a file in the library.
486
+ Other OpenAPI tooling ignores the key, as it ignores any `x-` extension.
487
+ It is how the API-Only TranscriberJ ties generated code to the fragment it came from, instead of to a component name the bundler picked.
488
+
489
+ It is on by default, for OpenAPI documents only.
490
+ `defaults.openapi.fragmentPaths: false` turns it off.
491
+
492
+ How it is done::
493
+ The bundler is not told anything; it is shown stamped fragments, in copies of the staged tree under `<build.staging>/fragment-paths/<target>/`.
494
+ The first bundle is built with every fragment stamped, to learn which fragments become components.
495
+ The second, the one that is published, is built with only those stamped.
496
+ So a document differs from one built with `fragmentPaths: false` by exactly one line per component, and in no other byte.
497
+ The cost is a second bundler run per OpenAPI target.
498
+
499
+ What stops the build::
500
+ A fragment that writes `x-fragment-path` itself: the Publisher sets that key and nothing else may.
501
+ A component's fragment that is also inlined somewhere else in the same document, since the inlined copy would carry the key as well; the error names where.
502
+
503
+ A component written directly in a bundle root, rather than `$ref`'d from a fragment, has no fragment and carries no path.
504
+
505
+ [#fragment-path-versions]
506
+ ==== What a change to `x-fragment-path` means for a contract's version
507
+
508
+ Semantic versioning describes the contract, so the version follows from what a change does to the contract's promises:
509
+
510
+ [cols="2,1,3", options="header"]
511
+ |===
512
+ |Change |Version |Why
513
+
514
+ |The key appears for the first time
515
+ |MINOR
516
+ |A new feature of the published document.
517
+
518
+ |A fragment moves to another directory, changing the value
519
+ |PATCH
520
+ |The contract promises the same; only its provenance metadata differs.
521
+
522
+ |The key is removed, by `fragmentPaths: false` after it was published
523
+ |MAJOR
524
+ |Consumers relying on it lose provenance and collision disambiguation, and may stop generating code at all.
525
+ |===
526
+
527
+ Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments.
528
+ `changed` does not see that, because a target's closure hash covers the fragments, not the Publisher.
529
+ Bump each such target's MINOR version in the upgrade's commit, or set `fragmentPaths: false` until you do: publishing a changed document under a version already published is refused by every Subscriber that locked it (see <<versions,Versions §>>).
530
+
531
+ [#distribution]
502
532
  === `distribution` is transitional
503
533
 
504
- A producer has no business knowing the directory layout of the projects that
505
- consume it. The `distribution` block exists so that a specification library can
506
- adopt this tool without every consumer changing at the same time. The API-Only
507
- Subscriber replaces it: each consuming project declares where its own copy lands,
508
- and the block goes away.
534
+ A producer has no business knowing the directory layout of the projects that consume it.
535
+ The `distribution` block copies built documents into such a layout, so that a library can adopt this tool before its consumers adopt the API-Only Subscriber.
536
+ Once they subscribe, each consuming project declares where its own copy lands, and the block goes away.
509
537
 
538
+ [#adapting]
510
539
  == Adapting it to another project
511
540
 
512
- The tool has no dependency on any particular product, framework or directory
513
- layout. In order of what usually needs changing:
541
+ The tool has no dependency on any particular product, framework or directory layout.
542
+ In order of what usually needs changing:
514
543
 
515
544
  [cols="2,3", options="header"]
516
545
  |===
@@ -523,16 +552,19 @@ layout. In order of what usually needs changing:
523
552
  |`sources`
524
553
 
525
554
  |What each document is called
526
- |`defaults.<type>.outputName`
555
+ |`defaults.<kind>.outputName`
527
556
 
528
557
  |The lint rules
529
558
  |`defaults.openapi.lint`
530
559
 
560
+ |Whether components carry `x-fragment-path`
561
+ |`defaults.openapi.fragmentPaths`, defaulting to `true`
562
+
531
563
  |Where lint reports go
532
564
  |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
533
565
 
534
566
  |Where built documents go
535
- |`build.dist`, and `distribution` while it still exists
567
+ |`build.dist`
536
568
 
537
569
  |Where artifacts are published
538
570
  |`channels`
@@ -541,37 +573,30 @@ layout. In order of what usually needs changing:
541
573
  |Its version file, or `targets.<target>.versionFile`
542
574
  |===
543
575
 
544
- Beyond that: the `common/` versus `<product>/<target>/` split is one convention
545
- for organising a multi-target fragment library, not a requirement. A single-target
546
- project can keep every fragment in one flat directory, or skip fragments entirely
547
- and hand-author one complete document. The tool only needs one bundle root file
548
- per target; what that file `$ref`s, or whether it `$ref`s anything at all, is
549
- entirely up to the library.
576
+ Beyond that, a `components/common/` directory beside per-target directories is one convention for organising a multi-target fragment library, not a requirement.
577
+ A single-target project can keep every fragment in one flat directory, or skip fragments entirely and hand-author one complete document.
578
+ The tool only needs one bundle root file per target; what that file `$ref`s, or whether it `$ref`s anything at all, is up to the library.
550
579
 
551
- Placeholders are optional too. A library with no `{{token}}` anywhere simply has
552
- nothing substituted.
580
+ Placeholders are optional too.
581
+ A library with no `{{token}}` anywhere simply has nothing substituted.
553
582
 
583
+ [#breaking-changes]
554
584
  == Breaking changes
555
585
 
556
- The tool does not police them. `changed` tells a release job which targets a
557
- commit altered, and every manifest records the closure hash of what was
558
- published, so the hooks a diff gate would need already exist -- but the gate
559
- itself is deliberately not built.
586
+ The tool does not police them.
587
+ `changed` tells a release which targets a commit altered, and every manifest records the closure hash of what was published, so the hooks a diff gate would need already exist, but the gate itself is deliberately not built.
560
588
 
561
589
  A breaking change is declared where it is made: the major version in the target's version file changes in the same commit as the fragments it breaks.
590
+ A specification library should write down its own policy for what counts as breaking.
562
591
 
563
- A specification library should write down its own policy. The one this repository
564
- uses is at `usable-suspects/docs/breaking-changes.adoc`, and is a reasonable
565
- starting point to copy.
566
-
592
+ [#as-a-library]
567
593
  == Using it as a library
568
594
 
569
- Everything the CLI does is available programmatically, so a release job that
570
- wants to decide something for itself does not have to parse console output:
595
+ Everything the CLI does is available programmatically, so a release job that wants to decide something for itself does not have to parse console output:
571
596
 
572
597
  [source,javascript]
573
598
  ----
574
- const { loadFrom, prepare, forTargets, changedSince } =
599
+ const { loadFrom, prepare, changedSince } =
575
600
  require('@arc-e-tect/api-only-publisher');
576
601
 
577
602
  const config = loadFrom('.');
@@ -584,19 +609,20 @@ for (const result of changedSince(config, 'origin/main')) {
584
609
  }
585
610
  ----
586
611
 
612
+ [#exit-codes]
587
613
  == Exit codes
588
614
 
589
615
  [cols="1,3",options="header"]
590
616
  |===
591
617
  | Code | Meaning
592
618
  | `0` | Success.
593
- | `1` | A handled failure: bad configuration, an unresolved placeholder, a
594
- document that cannot be stamped, a dangling `$ref`, a channel that
595
- refused the artifact. The message says which.
619
+ | `1` | A handled failure: bad configuration, an unresolved placeholder, a document that cannot be stamped, a dangling `$ref`, a lint failure, a channel that refused the artifact.
620
+ The message says which.
596
621
  |===
597
622
 
598
623
  Anything else is a bug; please report it.
599
624
 
625
+ [#tests]
600
626
  == Tests
601
627
 
602
628
  [source,console]
@@ -604,32 +630,22 @@ Anything else is a bug; please report it.
604
630
  npm test
605
631
  ----
606
632
 
607
- Covers placeholder substitution (including each of the three defects fixed on
608
- the way in), version stamping, version files, configuration parsing, every command, and the scaffold.
609
-
610
- The end-to-end guarantee lives elsewhere, in the specification library's own
611
- golden fixtures: they assert that the documents this tool produces are unchanged,
612
- byte for byte, from the ones the pipeline produced before it existed.
633
+ Covers every module and every command: placeholder substitution, version stamping, version files, configuration parsing, closures, packing, channels, splitting, and the scaffold.
634
+ `build.e2e.test.js` runs the real bundlers.
635
+ The run fails below 90% of lines and functions, or 80% of branches.
613
636
 
637
+ [#relationship]
614
638
  == Relationship to the API-Only Subscriber
615
639
 
616
- This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber]
617
- fetches and verifies. They share one seam an archive plus a `manifest.json`
618
- and nothing else, which is why they can be versioned independently while living
619
- in one repository.
620
-
621
- This document is a reference for this half alone. For how the two are put
622
- together in a real project, see link:../docs/README.adoc[the manuals], which are
623
- organised by where the API descriptions live relative to the code that implements
624
- them: link:../docs/in-the-implementation-project.adoc[in the implementation
625
- project], link:../docs/elsewhere-in-the-repository.adoc[elsewhere in the same
626
- repository], link:../docs/in-its-own-repository.adoc[in a repository of their
627
- own]. The first uses only `build` and reads the result directly; the second
628
- publishes to the `file` channel, and the third to a registry. If you are
629
- replacing a script that copies a document into `src/main/resources`, start at
630
- link:../docs/migrating-a-checked-in-document.adoc[Migrating a checked-in
631
- document].
640
+ This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber] fetches and verifies.
641
+ They share one seam, an archive plus a `manifest.json`, and nothing else, which is why they can be versioned independently while living in one repository.
642
+
643
+ * link:../docs/README.adoc[The documentation →] helps you pick the use case that fits, with a tutorial and AI prompts for each.
644
+ * link:../docs/reference/how-publisher-and-subscriber-work-together.adoc[How the Publisher and the Subscriber work together →] maps every setting, command and check across the two tools, and says where a change to either belongs.
645
+ * link:../docs/reference/run-book.adoc[The run-book →] covers operating both, and every failure message.
632
646
 
647
+ [#license]
633
648
  == License
634
649
 
635
- MIT. See link:LICENSE[LICENSE].
650
+ MIT.
651
+ See link:LICENSE[LICENSE →].
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
package/src/config.js CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  // Reading and validating apionly.yaml.
4
4
  //
5
- // The configuration is the single source of truth for what this library builds:
6
- // it replaced the per-script SERVICES arrays, and the `apis:` map that said an
7
- // overlapping thing under a different name.
5
+ // The configuration is the single source of truth for what a library builds:
6
+ // which targets exist, where their fragments live, and where each document goes.
8
7
 
9
8
  const fs = require("fs");
10
9
  const path = require("path");
@@ -154,8 +153,30 @@ function load(configPath) {
154
153
  isPublished(target) {
155
154
  return this.targets[target].publish !== false;
156
155
  },
157
- bundlePath(target, kind) {
158
- return path.join(this.stagingDir(kind), this.targets[target][kind].bundle);
156
+ // A target's bundle root in a staged tree: the build's own, unless another
157
+ // staged copy is named.
158
+ bundlePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
159
+ return path.join(
160
+ stagingRoot,
161
+ requireString(this.sources[kind], `sources.${kind}`),
162
+ this.targets[target][kind].bundle);
163
+ },
164
+ // Whether built documents of this kind carry x-fragment-path on each
165
+ // component. On unless turned off; OpenAPI only.
166
+ fragmentPaths(kind) {
167
+ if (kind !== "openapi") return false;
168
+ const configured = (this.defaults.openapi || {}).fragmentPaths;
169
+ if (configured === undefined) return true;
170
+ if (typeof configured !== "boolean") {
171
+ throw new ConfigError(
172
+ `defaults.openapi.fragmentPaths must be true or false, not ${JSON.stringify(configured)}`);
173
+ }
174
+ return configured;
175
+ },
176
+ // Where one target's stamped copies of the staged tree are built.
177
+ fragmentPathStaging(target) {
178
+ const staging = this.build.staging || "build/staging";
179
+ return path.resolve(this.root, staging, "fragment-paths", target);
159
180
  },
160
181
  // The file a target's version is read from: the target's own `versionFile`,
161
182
  // relative to this configuration, or else <target>.bundle.properties beside
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ // x-fragment-path: which fragment a bundled component came from.
4
+ //
5
+ // Bundling flattens the library's directory tree into one components namespace,
6
+ // renaming on collision (`UserV1`, `UserV1-2`). The extension puts the provenance
7
+ // back, so a consumer -- the TranscriberJ above all -- never has to read meaning
8
+ // into a bundler's key.
9
+ //
10
+ // The bundler is not told anything; it is shown stamped fragments. It copies a
11
+ // fragment's top-level keys into whatever it produces from that fragment, so a
12
+ // stamp at the top of a file arrives on the component built from it. But the same
13
+ // is true of every fragment the bundler inlines -- paths, info, tags -- so the
14
+ // build stamps in two passes: every fragment first, to learn which ones became
15
+ // components, then only those. See pipeline.bundleWithFragmentPaths.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const YAML = require("yaml");
20
+
21
+ const KEY = "x-fragment-path";
22
+
23
+ class FragmentPathError extends Error {}
24
+
25
+ /**
26
+ * Stamp YAML fragments under `root`, in place, with their path from `root`.
27
+ *
28
+ * The path uses forward slashes on every platform. A file whose root is not a
29
+ * mapping -- a list of servers, say -- has nowhere to carry the key and is left
30
+ * alone; nothing that becomes a component looks like that.
31
+ *
32
+ * @param {Set<string>|null} only the root-relative paths to stamp; all when null
33
+ * @returns {string[]} the root-relative paths stamped, sorted
34
+ */
35
+ function stampFiles(root, { only = null } = {}) {
36
+ const stamped = [];
37
+ const walk = (dir) => {
38
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ walk(full);
42
+ continue;
43
+ }
44
+ if (!/\.ya?ml$/.test(entry.name)) continue;
45
+ const rel = path.relative(root, full).split(path.sep).join("/");
46
+ if (only && !only.has(rel)) continue;
47
+
48
+ const doc = YAML.parseDocument(fs.readFileSync(full, "utf8"));
49
+ if (!YAML.isMap(doc.contents)) continue;
50
+ if (doc.contents.has(KEY)) {
51
+ throw new FragmentPathError(`${rel} declares ${KEY}; the Publisher sets that key and a fragment may not`);
52
+ }
53
+ doc.contents.items.unshift(doc.createPair(KEY, rel));
54
+ fs.writeFileSync(full, doc.toString());
55
+ stamped.push(rel);
56
+ }
57
+ };
58
+ walk(root);
59
+ return stamped;
60
+ }
61
+
62
+ /**
63
+ * The stamp on every component of a parsed document, keyed `<type>/<name>`, in
64
+ * document order. A component with no stamp -- one written inline in the bundle
65
+ * root, say -- maps to null.
66
+ *
67
+ * @returns {Map<string, string|null>}
68
+ */
69
+ function componentPaths(document) {
70
+ const result = new Map();
71
+ for (const [type, entries] of Object.entries(document.components || {})) {
72
+ for (const [name, value] of Object.entries(entries || {})) {
73
+ const fragment = value && typeof value === "object" ? value[KEY] : undefined;
74
+ result.set(`${type}/${name}`, fragment === undefined ? null : fragment);
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Every stamp in a parsed document that is not directly on a component, with the
82
+ * JSON pointer of the object carrying it.
83
+ *
84
+ * @returns {Array<{at: string, path: string}>}
85
+ */
86
+ function strayPaths(document) {
87
+ const stray = [];
88
+ const visit = (node, pointer) => {
89
+ if (Array.isArray(node)) {
90
+ node.forEach((item, i) => visit(item, `${pointer}/${i}`));
91
+ return;
92
+ }
93
+ if (!node || typeof node !== "object") return;
94
+ const onComponent = /^\/components\/[^/]+\/[^/]+$/.test(pointer);
95
+ if (Object.hasOwn(node, KEY) && !onComponent) {
96
+ stray.push({ at: pointer || "/", path: node[KEY] });
97
+ }
98
+ for (const [key, value] of Object.entries(node)) {
99
+ if (key === KEY) continue;
100
+ visit(value, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
101
+ }
102
+ };
103
+ visit(document, "");
104
+ return stray;
105
+ }
106
+
107
+ module.exports = { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY };
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ module.exports = {
16
16
  ...require("./closure"),
17
17
  ...require("./unreferenced"),
18
18
  ...require("./aggregate"),
19
+ ...require("./fragment-paths"),
19
20
  ...require("./pipeline"),
20
21
  ...require("./pack"),
21
22
  ...require("./changed"),
package/src/pipeline.js CHANGED
@@ -5,10 +5,12 @@
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const { execFileSync } = require("child_process");
8
+ const YAML = require("yaml");
8
9
 
9
10
  const { substituteFile } = require("./placeholders");
10
11
  const { stampFile } = require("./version");
11
12
  const { generateAsyncApi, isAggregate } = require("./aggregate");
13
+ const { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY } = require("./fragment-paths");
12
14
 
13
15
  class BuildError extends Error {}
14
16
 
@@ -55,12 +57,8 @@ function stage(config, kind, log) {
55
57
  /**
56
58
  * Substitute placeholders across the staged tree, in place.
57
59
  *
58
- * Every YAML file is visited rather than only the bundle roots and info.yaml.
59
- * The tool this replaces read only the one file it was handed, which is why the
60
- * shared info block had to be preprocessed as a separate up-front step and why
61
- * bundles had to $ref a generated merged_info.yaml instead of the file they
62
- * meant. Visiting the staged tree removes that special case: a placeholder works
63
- * wherever it is written.
60
+ * Every YAML file is visited, not only the bundle roots, so a placeholder works
61
+ * wherever it is written, and no file has to be preprocessed separately first.
64
62
  */
65
63
  function substituteTree(config, kind, log) {
66
64
  const stagingRoot = config.stagingRoot(kind);
@@ -86,8 +84,8 @@ function substituteTree(config, kind, log) {
86
84
  log(`-- Substituted ${tokens} placeholder(s) across ${files} file(s)`);
87
85
  }
88
86
 
89
- function bundle(config, target, kind, outFile, log) {
90
- const source = config.bundlePath(target, kind);
87
+ function bundle(config, target, kind, outFile, log, { stagingRoot } = {}) {
88
+ const source = config.bundlePath(target, kind, stagingRoot);
91
89
  if (!fs.existsSync(source)) {
92
90
  throw new BuildError(`target '${target}': bundle root not found at ${source}`);
93
91
  }
@@ -100,6 +98,58 @@ function bundle(config, target, kind, outFile, log) {
100
98
  }
101
99
  }
102
100
 
101
+ /**
102
+ * Bundle a target with x-fragment-path on every component built from a fragment.
103
+ *
104
+ * Two passes over two fresh copies of the staged tree, so the build's own staged
105
+ * tree -- which every other target bundles from -- is never stamped. The first
106
+ * stamps every fragment and learns which of them the bundler made components of.
107
+ * The second stamps only those, so the document differs from an unstamped one by
108
+ * exactly one line per such component.
109
+ *
110
+ * That holds as long as no component's fragment is also inlined somewhere else;
111
+ * if one is, the inlined copy would carry the stamp too, and the build fails
112
+ * rather than publish it.
113
+ */
114
+ function bundleWithFragmentPaths(config, target, kind, outFile, log) {
115
+ const scratch = config.fragmentPathStaging(target);
116
+ fs.rmSync(scratch, { recursive: true, force: true });
117
+
118
+ const pass = (name, only) => {
119
+ const root = path.join(scratch, name);
120
+ fs.cpSync(config.stagingRoot(kind), root, { recursive: true });
121
+ try {
122
+ stampFiles(root, { only });
123
+ } catch (error) {
124
+ if (error instanceof FragmentPathError) throw new BuildError(`target '${target}': ${error.message}`);
125
+ throw error;
126
+ }
127
+ const file = path.join(scratch, `${name}.yaml`);
128
+ bundle(config, target, kind, file, () => {}, { stagingRoot: root });
129
+ return YAML.parse(fs.readFileSync(file, "utf8"));
130
+ };
131
+
132
+ log(`-- Bundling ${path.basename(config.bundlePath(target, kind))} with ${KEY}`);
133
+ const discovered = componentPaths(pass("discover", null));
134
+ const fragments = new Set([...discovered.values()].filter(Boolean));
135
+ const document = pass("stamp", fragments);
136
+
137
+ const components = componentPaths(document);
138
+ if ([...components.keys()].join("\n") !== [...discovered.keys()].join("\n")) {
139
+ throw new BuildError(`target '${target}': stamping ${KEY} changed the bundler's component names`);
140
+ }
141
+ const stray = strayPaths(document);
142
+ if (stray.length > 0) {
143
+ throw new BuildError(
144
+ `target '${target}': a component's fragment is also inlined elsewhere, so ${KEY} would appear ` +
145
+ `off its component: ${stray.map((s) => `${s.at} (${s.path})`).join(", ")}`);
146
+ }
147
+
148
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
149
+ fs.copyFileSync(path.join(scratch, "stamp.yaml"), outFile);
150
+ log(`-- Stamped ${KEY} on ${[...components.values()].filter(Boolean).length} component(s)`);
151
+ }
152
+
103
153
  function lint(config, kind, file, log, { report = false, reportFile = null } = {}) {
104
154
  const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
105
155
  const args = kind === "openapi"
@@ -184,7 +234,11 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
184
234
  generateAsyncApi(config, target, { log });
185
235
  }
186
236
  const outFile = path.join(config.distDir(target), config.outputName(kind));
187
- bundle(config, target, kind, outFile, log);
237
+ if (config.fragmentPaths(kind)) {
238
+ bundleWithFragmentPaths(config, target, kind, outFile, log);
239
+ } else {
240
+ bundle(config, target, kind, outFile, log);
241
+ }
188
242
  const version = versionOf(target);
189
243
  if (version) {
190
244
  log(`-- Stamping version '${version}'`);
@@ -204,4 +258,4 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
204
258
  return results;
205
259
  }
206
260
 
207
- module.exports = { build, prepare, stage, substituteTree, bundle, lint, distribute, BuildError };
261
+ module.exports = { build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, distribute, BuildError };
@@ -2,21 +2,17 @@
2
2
 
3
3
  // Placeholder substitution.
4
4
  //
5
- // Absorbed from sedr_utils/openapi/prep_openapi's preprocess_openapi.js, with
6
- // three defects fixed rather than inherited:
5
+ // A {{token}} in a staged YAML file is replaced by the contents of <token>.md.
6
+ // Three choices here are deliberate:
7
7
  //
8
- // 1. Search scope. The original searched for <placeholder>.md recursively
9
- // downward from the *input file's own directory*, never from the -d
10
- // argument the caller passed. The workaround was to move the Markdown
11
- // files next to whatever referenced them, and the gotcha had to be
12
- // documented. The search root is now a parameter, defaulting to the source
13
- // root the caller actually named.
14
- // 2. Failure mode. A missing Markdown file produced a warning and the literal
15
- // string *MISSING CONTENT* in the output, so a broken document shipped from
16
- // a green build. Unresolved placeholders are now an error by default.
17
- // 3. Token grammar. The pattern \{\{(\w+)\}\} silently excluded '-' and '.'
18
- // from placeholder names, so {{status-codes}} was left in the output rather
19
- // than reported. The grammar now admits them.
8
+ // 1. Search scope. <token>.md is searched for from the root the caller names,
9
+ // the staged source root by default, not from the directory of the file
10
+ // holding the token, so a snippet can live anywhere under that root.
11
+ // 2. Failure mode. An unresolved placeholder is an error by default. Leaving a
12
+ // marker in the document and carrying on would let a broken contract ship
13
+ // from a green build.
14
+ // 3. Token grammar. Names may contain '-' and '.', so {{status-codes}} is a
15
+ // placeholder like any other, never text silently left in the output.
20
16
 
21
17
  const fs = require("fs");
22
18
  const path = require("path");
@@ -103,9 +99,8 @@ function substitute(text, { searchRoot, strict = true, describeAs = "input" } =
103
99
  }
104
100
 
105
101
  /**
106
- * Substitute a file in place. Unlike the tool this replaces, nothing named
107
- * merged_* is left behind: the file is rewritten where it stands, which is safe
108
- * because it is always a staged copy.
102
+ * Substitute a file in place. The file is rewritten where it stands and nothing
103
+ * is written beside it, which is safe because it is always a staged copy.
109
104
  */
110
105
  function substituteFile(file, options) {
111
106
  const before = fs.readFileSync(file, "utf8");
package/src/split.js CHANGED
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // The problem it solves is real and easy to miss. A fragment library is one $ref
6
6
  // graph, and that graph does not respect the directory boundaries a repository
7
- // split would follow. In this library the AsyncAPI event schemas reuse
8
- // `openapi/components/common/schemas/UsernameV1.yaml`, so a username means the
9
- // same thing over Kafka as over HTTP. Move the two trees into separate
10
- // repositories as they stand and every one of those references dangles.
7
+ // split would follow. An AsyncAPI event schema that reuses an OpenAPI common
8
+ // schema -- a username, say -- means the same thing over a message broker as over
9
+ // HTTP, and that reuse is a reference across the two trees. Move the trees into
10
+ // separate repositories as they stand and every such reference dangles.
11
11
  //
12
12
  // `split` resolves each part's dependency closure and materialises it whole:
13
13
  // each part gets its own subtree *plus* a copy of every foreign file it reaches,
@@ -25,6 +25,18 @@ function yamlFiles(dir) {
25
25
 
26
26
  const portable = (file) => file.split(path.sep).join("/");
27
27
 
28
+ // Configuration files of the tools an API library is linted with, and this tool's
29
+ // own. They are YAML, but they describe how to check an API, not the API, so they
30
+ // are never fragments, wherever they sit and whether or not apionly.yaml names them.
31
+ const TOOL_CONFIGS = new Set([
32
+ "apionly.yaml",
33
+ "redocly.yaml",
34
+ ".redocly.yaml",
35
+ ".redocly.lint-ignore.yaml",
36
+ ".spectral.yaml",
37
+ ".spectral.yml",
38
+ ]);
39
+
28
40
  /**
29
41
  * Every YAML file under the source root that no target's closure reaches,
30
42
  * relative to the source root and sorted.
@@ -33,6 +45,9 @@ const portable = (file) => file.split(path.sep).join("/");
33
45
  * linted like any other target, so whatever it reaches is linted too. The staged
34
46
  * tree must already exist, as for forTargets.
35
47
  *
48
+ * Configuration is not a fragment: neither the well-known configuration files of
49
+ * lint tools, nor the lint configuration apionly.yaml names, whatever it is called.
50
+ *
36
51
  * @returns {string[]}
37
52
  */
38
53
  function unreferenced(config) {
@@ -44,9 +59,13 @@ function unreferenced(config) {
44
59
  }
45
60
  }
46
61
  const sourceRoot = config.sourceRoot();
62
+ const own = new Set(["openapi", "asyncapi"]
63
+ .map((kind) => config.lintConfig(kind))
64
+ .filter(Boolean)
65
+ .map((file) => portable(path.relative(sourceRoot, file))));
47
66
  return yamlFiles(sourceRoot)
48
67
  .map((file) => portable(path.relative(sourceRoot, file)))
49
- .filter((file) => !reached.has(file))
68
+ .filter((file) => !reached.has(file) && !own.has(file) && !TOOL_CONFIGS.has(path.posix.basename(file)))
50
69
  .sort();
51
70
  }
52
71
 
package/src/version.js CHANGED
@@ -2,12 +2,10 @@
2
2
 
3
3
  // Setting info.version on a bundled document.
4
4
  //
5
- // This replaces two different sed expressions, which between them differed per
6
- // specification type (OpenAPI quoted the version, AsyncAPI did not), did nothing
7
- // at all when the pattern missed, and could match a `version:` field elsewhere in
8
- // the document.
9
- //
10
- // The field is located structurally and only that one scalar is spliced. It is
5
+ // A pattern-based substitution would have to differ per specification type,
6
+ // would silently do nothing when its pattern missed, and could match a
7
+ // `version:` field elsewhere in the document. So the field is located
8
+ // structurally, and only that one scalar is spliced. It is
11
9
  // deliberately not a re-serialisation: re-emitting the document reformats
12
10
  // everything around the edit, because the AsyncAPI CLI wraps long descriptions at
13
11
  // a width no YAML emitter reproduces. Splicing keeps every other byte exactly as