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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,90 @@ 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>/`.
196
193
 
197
194
  . **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
198
195
  A `publish: false` target keeps the version its source declares.
199
196
 
200
- . **Lint** the finished document, so a broken fragment surfaces against the
201
- target it actually affects rather than at deploy time.
197
+ . **Lint** the finished document, so a broken fragment surfaces against the target it actually affects.
202
198
 
203
- . **Distribute**, unless the target is `publish: false`.
204
-
205
- [[versions]]
199
+ [#versions]
206
200
  == Versions
207
201
 
208
202
  Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
209
203
 
210
- .specs/openapi/bundles/user-account.bundle.properties
204
+ .specs/openapi/bundles/orders.bundle.properties
211
205
  [source,properties]
212
206
  ----
213
- # The version of the user-account contract, for every document it builds.
207
+ # The version of the orders contract, for every document it builds.
214
208
  # Semantic: major for a breaking change, minor for an additive one, patch for
215
209
  # anything else. Change it in the same commit as the fragments it describes.
216
210
  version=2.1.0
@@ -236,79 +230,61 @@ A version names one set of documents.
236
230
  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
231
  Change the version in the same commit as the fragments instead.
238
232
 
233
+ [#closure]
239
234
  == Independent versioning, and the dependency closure
240
235
 
241
- Giving each target its own version has one non-obvious consequence, and the
242
- `closure` and `changed` commands are what pay for it.
236
+ Giving each target its own version has one non-obvious consequence, and the `closure` and `changed` commands are what pay for it.
243
237
 
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.
238
+ A change under `components/common/` affects every target that reaches it.
239
+ A change under one target's own fragments affects only that one.
240
+ 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
241
 
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:
242
+ A target's *closure* is every file reachable from its bundle root by following `$ref`s transitively.
243
+ `changed --since <ref>` resolves each closure and reports which targets a change actually touched:
254
244
 
255
245
  [source,console]
256
246
  ----
257
- api-only-publisher changed --since origin/main
247
+ npm run apionly -- changed --since origin/main
258
248
  ----
259
249
 
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.
250
+ Two things make this correct rather than approximate.
251
+ The closure is computed over the *staged, substituted* tree, so a change to a Markdown snippet, which genuinely changes the published document, counts.
252
+ 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
253
 
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.
254
+ 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
255
 
256
+ [#splitting]
269
257
  == Splitting the library
270
258
 
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.
259
+ A fragment library is one `$ref` graph, and that graph does not respect the directory boundaries a repository split would follow.
260
+ 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
261
 
277
262
  [source,console]
278
263
  ----
279
- api-only-publisher split --out build/split --by kind
264
+ npm run apionly -- split --out build/split --by kind
280
265
  ----
281
266
 
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.
267
+ 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.
268
+ 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
269
 
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.
270
+ `--by kind` splits along the specification kinds.
271
+ `--by target` gives every target its own self-contained tree, which is what you want before splitting into per-target repositories.
292
272
 
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.
273
+ 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.
274
+ A shared schema that two repositories both define is two schemas that happen to agree today.
275
+ Where that matters, promote the fragment to a contract of its own, published and consumed like any other, rather than copied.
298
276
 
277
+ [#aggregates]
299
278
  == Aggregates
300
279
 
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.
280
+ 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
281
 
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.
282
+ AsyncAPI cannot be written that way.
283
+ 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.
284
+ A hand-written async portfolio would have to copy every operation verbatim, and that copy would start rotting the moment a member changed.
310
285
 
311
- So it is generated instead. Declare the members and the aggregate's own identity:
286
+ So it is generated instead.
287
+ Declare the members and the aggregate's own identity:
312
288
 
313
289
  [source,yaml]
314
290
  ----
@@ -317,17 +293,17 @@ portfolio:
317
293
  asyncapi:
318
294
  bundle: portfolio_asyncapi_structure.yaml
319
295
  aggregate:
320
- - user-account
296
+ - orders
297
+ - payments
321
298
  info:
322
299
  title: Everything, together
323
300
  version: 0.0.0
324
301
  ----
325
302
 
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.
303
+ 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.
304
+ Two members contributing the same channel or operation key is an error rather than a silent overwrite.
330
305
 
306
+ [#channels]
331
307
  == Channels
332
308
 
333
309
  [cols="1,3", options="header"]
@@ -336,74 +312,63 @@ rather than a silent overwrite.
336
312
 
337
313
  |`file`
338
314
  |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.
315
+ 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
316
  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
317
 
342
318
  |`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.
319
+ |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.
320
+ 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
321
 
348
322
  |`npm`
349
- |Real semver, integrity hashes for free, private scopes available, and the
350
- toolchain here is already Node.
323
+ |Real semver, integrity hashes, private scopes, and a toolchain that is already Node.js.
351
324
 
352
325
  |`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.
326
+ |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.
327
+ No dependency-resolution semantics and no update notification, which is why it is the fallback rather than the default.
357
328
  |===
358
329
 
359
330
  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.
331
+ `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.
332
+
333
+ 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
334
 
335
+ [#why-registries]
363
336
  === Why npm and Maven rather than release assets alone
364
337
 
365
- Publishing is passive. The producer releases `2.1.0` and nothing happens in an
366
- implementation repository until somebody looks.
338
+ Publishing is passive.
339
+ The producer releases `2.1.0` and nothing happens in an implementation repository until somebody looks.
367
340
 
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.
341
+ Renovate and Dependabot understand npm and Maven natively, so a contract bump arrives downstream as a pull request without any bespoke machinery.
342
+ That is the practical argument for preferring them, and the reason to design the coordinates so that standard update bots can read them.
372
343
 
344
+ [#pre-releases]
373
345
  == Pre-releases
374
346
 
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.
347
+ API-first design means implementation starts against a contract that is not finished.
348
+ 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
349
 
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.
350
+ 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
351
  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
352
 
385
- The hard rule is the other half of it. A pre-release must never quietly satisfy a
386
- production build:
353
+ The hard rule is the other half of it.
354
+ A pre-release must never quietly satisfy a production build:
387
355
 
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.
356
+ * npm publishes it under the `next` dist-tag, never `latest`, so `npm install` cannot pick one up by accident.
357
+ * A GitHub release is marked as a pre-release, so "latest release" never resolves to an unfinished contract.
358
+ * 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
359
 
360
+ [#configuration]
396
361
  == Configuration
397
362
 
398
- Everything project-specific lives in `apionly.yaml`, not in the code. Adapting
399
- the tool to another project means editing configuration.
363
+ Everything project-specific lives in `apionly.yaml`, not in the code.
364
+ Adapting the tool to another project means editing configuration.
400
365
 
401
366
  [source,yaml]
402
367
  ----
403
368
  schemaVersion: 1
404
369
 
405
370
  sources:
406
- root: specs # staged wholesale; the types share fragments
371
+ root: specs # staged wholesale; the kinds share fragments
407
372
  openapi: openapi
408
373
  asyncapi: asyncapi
409
374
 
@@ -418,7 +383,7 @@ defaults:
418
383
 
419
384
  build:
420
385
  staging: build/staging
421
- dist: dist
386
+ dist: build/dist
422
387
  reports:
423
388
  lint: build/reports/lint
424
389
  lint:
@@ -434,83 +399,82 @@ channels:
434
399
  clean: true # keep only the version being published
435
400
 
436
401
  targets:
437
- user-account:
438
- # versionFile: versions/user-account.properties
439
- # default: user-account.bundle.properties beside the first bundle root
402
+ orders:
403
+ # versionFile: versions/orders.properties
404
+ # default: orders.bundle.properties beside the first bundle root
440
405
  openapi:
441
- bundle: bundles/user-account_openapi_structure.yaml
406
+ bundle: bundles/orders.yaml
442
407
  asyncapi:
443
- bundle: user-account_asyncapi_structure.yaml
408
+ bundle: orders_asyncapi.yaml
444
409
  portfolio:
445
- publish: false # built and linted, never distributed
410
+ publish: false # built and linted, never published
446
411
  openapi:
447
- bundle: bundles/portfolio_openapi_structure.yaml
412
+ bundle: bundles/portfolio.yaml
448
413
  ----
449
414
 
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.
415
+ `targets` is the single source of truth for what gets built.
453
416
 
417
+ [#pinning-the-bundlers]
454
418
  === Pinning the bundlers
455
419
 
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.
420
+ `toolchain` pins the bundler versions.
421
+ Unpinned, the documents this tool produces could change because an upstream release happened, with nothing in the library having changed.
422
+ An upgrade should be a deliberate commit that moves the pin, reviewed with whatever it changes in the published documents.
461
423
 
424
+ [#behaviour]
462
425
  == Behaviour worth knowing about
463
426
 
427
+ [#unresolved-placeholders]
464
428
  === Unresolved placeholders fail the build
465
429
 
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.
430
+ An unresolved `{{token}}` is an error, so a broken contract cannot ship from a green build.
431
+ Set `defaults.placeholders.strict: false` to leave the token visible in the document instead.
471
432
 
433
+ [#placeholder-search]
472
434
  === Placeholder snippets are found from the source root
473
435
 
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.
436
+ `<token>.md` is searched for from the staged source root downwards, so a snippet can live anywhere under it.
478
437
 
479
- [[unreferenced]]
438
+ [#unreferenced]
480
439
  === A fragment no target reaches fails the lint
481
440
 
482
441
  A linter checks documents, and a fragment reaches a document only through a `$ref`.
483
442
  A fragment that no target references is therefore never linted, however wrong it is, until the day a target starts to use it.
484
443
 
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.
444
+ 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
445
  Every target counts, `publish: false` ones included, because they are linted too.
487
446
  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
447
 
448
+ Configuration is not a fragment, so it may sit anywhere, `sources.root` included.
449
+ 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`.
450
+ It also skips the lint configurations `defaults.<kind>.lint` names, whatever they are called.
451
+ Any other YAML file under the source root counts.
452
+
489
453
  `lint.unreferenced` in `apionly.yaml` sets what happens: `error` by default, `warn` to report without failing, or `off` not to look.
490
454
  `lint --target` does not look, because an unreferenced fragment belongs to no target.
491
455
 
492
456
  `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
457
 
458
+ [#version-stamping]
494
459
  === Version stamping edits one scalar
495
460
 
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.
461
+ `info.version` is located structurally and that one scalar is spliced.
462
+ 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.
463
+ Splicing keeps every other byte exactly as the bundler wrote it.
464
+ A document with no `info` block, or no version inside it, is an error.
501
465
 
466
+ [#distribution]
502
467
  === `distribution` is transitional
503
468
 
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.
469
+ A producer has no business knowing the directory layout of the projects that consume it.
470
+ 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.
471
+ Once they subscribe, each consuming project declares where its own copy lands, and the block goes away.
509
472
 
473
+ [#adapting]
510
474
  == Adapting it to another project
511
475
 
512
- The tool has no dependency on any particular product, framework or directory
513
- layout. In order of what usually needs changing:
476
+ The tool has no dependency on any particular product, framework or directory layout.
477
+ In order of what usually needs changing:
514
478
 
515
479
  [cols="2,3", options="header"]
516
480
  |===
@@ -523,7 +487,7 @@ layout. In order of what usually needs changing:
523
487
  |`sources`
524
488
 
525
489
  |What each document is called
526
- |`defaults.<type>.outputName`
490
+ |`defaults.<kind>.outputName`
527
491
 
528
492
  |The lint rules
529
493
  |`defaults.openapi.lint`
@@ -532,7 +496,7 @@ layout. In order of what usually needs changing:
532
496
  |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
533
497
 
534
498
  |Where built documents go
535
- |`build.dist`, and `distribution` while it still exists
499
+ |`build.dist`
536
500
 
537
501
  |Where artifacts are published
538
502
  |`channels`
@@ -541,37 +505,30 @@ layout. In order of what usually needs changing:
541
505
  |Its version file, or `targets.<target>.versionFile`
542
506
  |===
543
507
 
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.
508
+ Beyond that, a `components/common/` directory beside per-target directories is one convention for organising a multi-target fragment library, not a requirement.
509
+ A single-target project can keep every fragment in one flat directory, or skip fragments entirely and hand-author one complete document.
510
+ 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
511
 
551
- Placeholders are optional too. A library with no `{{token}}` anywhere simply has
552
- nothing substituted.
512
+ Placeholders are optional too.
513
+ A library with no `{{token}}` anywhere simply has nothing substituted.
553
514
 
515
+ [#breaking-changes]
554
516
  == Breaking changes
555
517
 
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.
518
+ The tool does not police them.
519
+ `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
520
 
561
521
  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.
522
+ A specification library should write down its own policy for what counts as breaking.
562
523
 
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
-
524
+ [#as-a-library]
567
525
  == Using it as a library
568
526
 
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:
527
+ 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
528
 
572
529
  [source,javascript]
573
530
  ----
574
- const { loadFrom, prepare, forTargets, changedSince } =
531
+ const { loadFrom, prepare, changedSince } =
575
532
  require('@arc-e-tect/api-only-publisher');
576
533
 
577
534
  const config = loadFrom('.');
@@ -584,19 +541,20 @@ for (const result of changedSince(config, 'origin/main')) {
584
541
  }
585
542
  ----
586
543
 
544
+ [#exit-codes]
587
545
  == Exit codes
588
546
 
589
547
  [cols="1,3",options="header"]
590
548
  |===
591
549
  | Code | Meaning
592
550
  | `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.
551
+ | `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.
552
+ The message says which.
596
553
  |===
597
554
 
598
555
  Anything else is a bug; please report it.
599
556
 
557
+ [#tests]
600
558
  == Tests
601
559
 
602
560
  [source,console]
@@ -604,32 +562,22 @@ Anything else is a bug; please report it.
604
562
  npm test
605
563
  ----
606
564
 
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.
565
+ Covers every module and every command: placeholder substitution, version stamping, version files, configuration parsing, closures, packing, channels, splitting, and the scaffold.
566
+ `build.e2e.test.js` runs the real bundlers.
567
+ The run fails below 90% of lines and functions, or 80% of branches.
613
568
 
569
+ [#relationship]
614
570
  == Relationship to the API-Only Subscriber
615
571
 
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].
572
+ This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber] fetches and verifies.
573
+ 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.
574
+
575
+ * link:../docs/README.adoc[The documentation →] helps you pick the use case that fits, with a tutorial and AI prompts for each.
576
+ * 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.
577
+ * link:../docs/reference/run-book.adoc[The run-book →] covers operating both, and every failure message.
632
578
 
579
+ [#license]
633
580
  == License
634
581
 
635
- MIT. See link:LICENSE[LICENSE].
582
+ MIT.
583
+ 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.3.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");
package/src/pipeline.js CHANGED
@@ -55,12 +55,8 @@ function stage(config, kind, log) {
55
55
  /**
56
56
  * Substitute placeholders across the staged tree, in place.
57
57
  *
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.
58
+ * Every YAML file is visited, not only the bundle roots, so a placeholder works
59
+ * wherever it is written, and no file has to be preprocessed separately first.
64
60
  */
65
61
  function substituteTree(config, kind, log) {
66
62
  const stagingRoot = config.stagingRoot(kind);
@@ -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