@arc-e-tect/api-only-publisher 0.0.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/LICENSE +21 -0
- package/README.adoc +498 -0
- package/package.json +56 -0
- package/src/aggregate.js +118 -0
- package/src/changed.js +100 -0
- package/src/channels.js +284 -0
- package/src/cli.js +257 -0
- package/src/closure.js +128 -0
- package/src/config.js +157 -0
- package/src/index.js +22 -0
- package/src/init.js +183 -0
- package/src/pack.js +125 -0
- package/src/pipeline.js +193 -0
- package/src/placeholders.js +117 -0
- package/src/split.js +176 -0
- package/src/version-policy.js +67 -0
- package/src/version.js +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Iwan Eising
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.adoc
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
= API-Only Publisher
|
|
2
|
+
:toc: left
|
|
3
|
+
:toc-title: Contents
|
|
4
|
+
:toclevels: 2
|
|
5
|
+
:icons: font
|
|
6
|
+
:source-highlighter: rouge
|
|
7
|
+
|
|
8
|
+
image:https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml/badge.svg[Vulnerability Scan,link=https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml]
|
|
9
|
+
image:https://img.shields.io/npm/v/@arc-e-tect/api-only-publisher[npm,link=https://www.npmjs.com/package/@arc-e-tect/api-only-publisher]
|
|
10
|
+
image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDoneRight-API?filter=api-only-publisher-v*[GitHub release,link=https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/releases?q=api-only-publisher]
|
|
11
|
+
image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
|
|
12
|
+
image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
|
|
13
|
+
|
|
14
|
+
== What it is
|
|
15
|
+
|
|
16
|
+
`api-only-publisher` turns a library of small, reusable API description
|
|
17
|
+
fragments into one complete, self-contained document per target, and distributes
|
|
18
|
+
each to whoever needs it.
|
|
19
|
+
|
|
20
|
+
It is the producer half of a pair. The consumer half, `API-Only Subscriber`,
|
|
21
|
+
fetches a published document into an implementation project and verifies that it
|
|
22
|
+
has not drifted. The two are coupled only by the published artifact and its
|
|
23
|
+
manifest -- neither knows anything else about the other.
|
|
24
|
+
|
|
25
|
+
A *target* is whatever gets its own contract document: a microservice, a modular
|
|
26
|
+
monolith, a backend-for-frontend, or a documentation aggregate spanning all of
|
|
27
|
+
them. It need not be a deployable, which is why this is not called `services`.
|
|
28
|
+
|
|
29
|
+
== Why it is not a shell script
|
|
30
|
+
|
|
31
|
+
It was one, and the shell showed through in three places.
|
|
32
|
+
|
|
33
|
+
The target list was a plain Bash array with a comment explaining that it avoided
|
|
34
|
+
associative arrays for the Bash 3.2 that macOS still ships. Both scripts carried
|
|
35
|
+
a write-to-a-temp-file-and-move dance to work around the difference between BSD
|
|
36
|
+
and GNU `sed -i`. And neither could parse YAML, which is why setting a version
|
|
37
|
+
was a regular expression that silently did nothing when it failed to match.
|
|
38
|
+
|
|
39
|
+
Node was never an optional dependency either: `@redocly/cli` and `@asyncapi/cli`
|
|
40
|
+
are Node tools, so a Node runtime was already required to run the pipeline at all.
|
|
41
|
+
|
|
42
|
+
== Requirements
|
|
43
|
+
|
|
44
|
+
[cols="1,1",options="header"]
|
|
45
|
+
|===
|
|
46
|
+
| Requirement | Version
|
|
47
|
+
| Node | 22 +
|
|
48
|
+
| npm | 10 +
|
|
49
|
+
|===
|
|
50
|
+
|
|
51
|
+
Nothing else. The bundlers are fetched by `npx` at the versions `apionly.yaml`
|
|
52
|
+
pins, so they need no installation, and the only runtime dependency is `yaml`.
|
|
53
|
+
|
|
54
|
+
== Installation
|
|
55
|
+
|
|
56
|
+
As a command, without installing anything:
|
|
57
|
+
|
|
58
|
+
[source,console]
|
|
59
|
+
----
|
|
60
|
+
npx @arc-e-tect/api-only-publisher build
|
|
61
|
+
----
|
|
62
|
+
|
|
63
|
+
As a dependency of a specification library:
|
|
64
|
+
|
|
65
|
+
[source,console]
|
|
66
|
+
----
|
|
67
|
+
npm install --save-dev @arc-e-tect/api-only-publisher
|
|
68
|
+
----
|
|
69
|
+
|
|
70
|
+
Pin it exactly rather than with a range. This tool's output is expected to be
|
|
71
|
+
reproducible — a specification library's golden fixtures assert it byte for byte
|
|
72
|
+
— and a floating version quietly breaks that guarantee.
|
|
73
|
+
|
|
74
|
+
== Commands
|
|
75
|
+
|
|
76
|
+
[cols="1,3", options="header"]
|
|
77
|
+
|===
|
|
78
|
+
|Command |Purpose
|
|
79
|
+
|
|
80
|
+
|`init [dir]`
|
|
81
|
+
|Scaffold `apionly.yaml` and a reference directory layout that builds as it
|
|
82
|
+
stands. This is how the *conventions* become reusable, as opposed to the code.
|
|
83
|
+
|
|
84
|
+
|`build`
|
|
85
|
+
|Stage, substitute placeholders, bundle, stamp the version, lint, and distribute.
|
|
86
|
+
|
|
87
|
+
|`lint`
|
|
88
|
+
|Lint what is already built, without rebuilding, for fast local feedback.
|
|
89
|
+
|
|
90
|
+
|`targets`
|
|
91
|
+
|List the declared targets, what each one builds, and whether it is distributed.
|
|
92
|
+
|
|
93
|
+
|`closure`
|
|
94
|
+
|Show each target's dependency closure: how many files it reaches, and the hash
|
|
95
|
+
of their content.
|
|
96
|
+
|
|
97
|
+
|`changed --since <ref>`
|
|
98
|
+
|Report which targets' closures actually changed since a git ref. This is what
|
|
99
|
+
drives a release: a target whose closure is untouched is not released, however
|
|
100
|
+
much else in the repository moved.
|
|
101
|
+
|
|
102
|
+
|`pack --version <v>`
|
|
103
|
+
|Archive each built target with a `manifest.json`.
|
|
104
|
+
|
|
105
|
+
|`publish --version <v>`
|
|
106
|
+
|Pack once, then ship those same bytes to every configured channel.
|
|
107
|
+
|
|
108
|
+
|`split --out <dir>`
|
|
109
|
+
|Make the library safe to break into separate repositories.
|
|
110
|
+
|===
|
|
111
|
+
|
|
112
|
+
[source,console]
|
|
113
|
+
----
|
|
114
|
+
api-only-publisher init my-api-library
|
|
115
|
+
api-only-publisher build -C my-api-library --version 2.3.1
|
|
116
|
+
api-only-publisher build --target user-account --openapi
|
|
117
|
+
api-only-publisher targets
|
|
118
|
+
----
|
|
119
|
+
|
|
120
|
+
`--target` may be repeated. `-C <dir>` runs as if started in `<dir>`; without it
|
|
121
|
+
the configuration is found by walking up from the current directory.
|
|
122
|
+
|
|
123
|
+
== What `build` actually does
|
|
124
|
+
|
|
125
|
+
. **Stage.** The whole source root is copied to `build/staging/<type>/`. Nothing
|
|
126
|
+
after this point touches the hand-authored tree.
|
|
127
|
+
+
|
|
128
|
+
The *whole* root, not just one specification type's subtree, because the trees
|
|
129
|
+
`$ref` each other -- event schemas reuse the OpenAPI common schemas, so a
|
|
130
|
+
username means the same thing over Kafka as over HTTP. A partial copy breaks
|
|
131
|
+
those references. Each type gets *its own* staging root, so building one never
|
|
132
|
+
invalidates the other's staged tree.
|
|
133
|
+
|
|
134
|
+
. **Substitute placeholders**, in place, across the staged copy. A `{{token}}`
|
|
135
|
+
is replaced with the contents of `<token>.md`, found by searching the staged
|
|
136
|
+
root. Indentation is preserved, so multi-line Markdown stays valid inside an
|
|
137
|
+
indented YAML scalar.
|
|
138
|
+
|
|
139
|
+
. **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every
|
|
140
|
+
`$ref` into one flat, self-contained document under `dist/<target>/`.
|
|
141
|
+
|
|
142
|
+
. **Stamp the version**, if `--version` was given, on the *finished* document.
|
|
143
|
+
|
|
144
|
+
. **Lint** the finished document, so a broken fragment surfaces against the
|
|
145
|
+
target it actually affects rather than at deploy time.
|
|
146
|
+
|
|
147
|
+
. **Distribute**, unless the target is `publish: false`.
|
|
148
|
+
|
|
149
|
+
== Independent versioning, and the dependency closure
|
|
150
|
+
|
|
151
|
+
Giving each target its own version has one non-obvious consequence, and the
|
|
152
|
+
`closure` and `changed` commands are what pay for it.
|
|
153
|
+
|
|
154
|
+
A change under `components/common/` affects every target that reaches it. A
|
|
155
|
+
change under one service's own context affects only that one. Under
|
|
156
|
+
repository-wide versioning the distinction is invisible, because everything bumps
|
|
157
|
+
together; under per-target versioning the release job has to know the difference,
|
|
158
|
+
or one service's edit releases every other service and the version numbers stop
|
|
159
|
+
meaning anything.
|
|
160
|
+
|
|
161
|
+
A target's *closure* is every file reachable from its bundle root by following
|
|
162
|
+
`$ref`s transitively. `changed --since <ref>` resolves each closure and reports
|
|
163
|
+
which targets a commit actually touched:
|
|
164
|
+
|
|
165
|
+
[source,console]
|
|
166
|
+
----
|
|
167
|
+
api-only-publisher changed --since origin/main
|
|
168
|
+
----
|
|
169
|
+
|
|
170
|
+
Two things make this correct rather than approximate. The closure is computed
|
|
171
|
+
over the *staged, substituted* tree, so a change to a Markdown snippet -- which
|
|
172
|
+
genuinely changes the published document -- counts. And a change to the *shape*
|
|
173
|
+
of a closure always means editing a file already inside it, so a bundle gaining
|
|
174
|
+
or losing a `$ref` is caught without diffing membership separately.
|
|
175
|
+
|
|
176
|
+
The closure hash is recorded in each release's manifest as `closureSha256`, which
|
|
177
|
+
is what lets a later commit compare against what was actually published.
|
|
178
|
+
|
|
179
|
+
== Splitting the library
|
|
180
|
+
|
|
181
|
+
A fragment library is one `$ref` graph, and that graph does not respect the
|
|
182
|
+
directory boundaries a repository split would follow. Here, the AsyncAPI event
|
|
183
|
+
schemas reuse `openapi/components/common/schemas/UsernameV1.yaml` -- a username
|
|
184
|
+
means the same thing over Kafka as over HTTP. Move the two trees into separate
|
|
185
|
+
repositories as they stand and every one of those references dangles.
|
|
186
|
+
|
|
187
|
+
[source,console]
|
|
188
|
+
----
|
|
189
|
+
api-only-publisher split --out build/split --by kind
|
|
190
|
+
----
|
|
191
|
+
|
|
192
|
+
Each part is written with its own subtree *plus* a copy of every foreign file it
|
|
193
|
+
reaches, so that afterwards each side is self-contained and builds on its own.
|
|
194
|
+
The copies keep their original path relative to the shared source root, which is
|
|
195
|
+
what makes this safe: every relative `$ref` keeps resolving exactly as it did, so
|
|
196
|
+
nothing is rewritten and no reference can break because a rewrite got the depth
|
|
197
|
+
wrong.
|
|
198
|
+
|
|
199
|
+
`--by kind` splits along the specification types. `--by target` gives every
|
|
200
|
+
target its own self-contained tree, which is what you want before splitting into
|
|
201
|
+
per-service repositories.
|
|
202
|
+
|
|
203
|
+
Any part that received copies gets an `IMPORTED.adoc` listing them and saying
|
|
204
|
+
plainly what it costs: those copies can now drift, and nothing will notice. A
|
|
205
|
+
shared schema that two repositories both define is two schemas that happen to
|
|
206
|
+
agree today. Where that matters, promote the fragment to a contract of its own,
|
|
207
|
+
published and consumed like any other, rather than copied.
|
|
208
|
+
|
|
209
|
+
== Aggregates
|
|
210
|
+
|
|
211
|
+
A whole-landscape OpenAPI view is just another bundle root: every path body
|
|
212
|
+
already lives in a `$ref`'d fragment, so a portfolio costs one hand-written
|
|
213
|
+
table of contents and duplicates no contract text.
|
|
214
|
+
|
|
215
|
+
AsyncAPI cannot be written that way. Its operations use document-root pointers
|
|
216
|
+
(`channel: {$ref: '#/channels/auditV1'}`), and `#` resolves against whichever file
|
|
217
|
+
contains it, so moving an operation into a fragment breaks it. A hand-written
|
|
218
|
+
async portfolio would have to copy every operation verbatim, and that copy would
|
|
219
|
+
start rotting the moment a member changed.
|
|
220
|
+
|
|
221
|
+
So it is generated instead. Declare the members and the aggregate's own identity:
|
|
222
|
+
|
|
223
|
+
[source,yaml]
|
|
224
|
+
----
|
|
225
|
+
portfolio:
|
|
226
|
+
publish: false
|
|
227
|
+
asyncapi:
|
|
228
|
+
bundle: portfolio_asyncapi_structure.yaml
|
|
229
|
+
aggregate:
|
|
230
|
+
- user-account
|
|
231
|
+
info:
|
|
232
|
+
title: Everything, together
|
|
233
|
+
version: 0.0.0
|
|
234
|
+
----
|
|
235
|
+
|
|
236
|
+
The bundle root is synthesised into the staging tree immediately before bundling,
|
|
237
|
+
so nothing is duplicated in the source and the view cannot fall behind its
|
|
238
|
+
members. Two members contributing the same channel or operation key is an error
|
|
239
|
+
rather than a silent overwrite.
|
|
240
|
+
|
|
241
|
+
== Channels
|
|
242
|
+
|
|
243
|
+
[cols="1,3", options="header"]
|
|
244
|
+
|===
|
|
245
|
+
|Channel |What it is for
|
|
246
|
+
|
|
247
|
+
|`file`
|
|
248
|
+
|A local directory. Not a distribution mechanism -- it exists so the pipeline is
|
|
249
|
+
testable end to end with no infrastructure, and as a local-iteration escape
|
|
250
|
+
hatch afterwards.
|
|
251
|
+
|
|
252
|
+
|`maven`
|
|
253
|
+
|A path publishes into a repository layout on disk; an `http(s)` URL deploys to a
|
|
254
|
+
real remote. Native for Gradle consumers: version resolution, caching and
|
|
255
|
+
conflict handling come free, which is what lets the Subscriber carry no HTTP
|
|
256
|
+
client of its own.
|
|
257
|
+
|
|
258
|
+
|`npm`
|
|
259
|
+
|Real semver, integrity hashes for free, private scopes available, and the
|
|
260
|
+
toolchain here is already Node.
|
|
261
|
+
|
|
262
|
+
|`github-release`
|
|
263
|
+
|The language-neutral floor: immutable per tag, works for private repositories
|
|
264
|
+
with a token, readable by a consumer with no JVM and no Node. No
|
|
265
|
+
dependency-resolution semantics and no update notification, which is why it is
|
|
266
|
+
the fallback rather than the default.
|
|
267
|
+
|===
|
|
268
|
+
|
|
269
|
+
Credentials are read from the environment, never from configuration.
|
|
270
|
+
`channels.maven.tokenEnv` names the variable; a configuration file gets
|
|
271
|
+
committed, and a credential in a committed file is a credential that has leaked.
|
|
272
|
+
|
|
273
|
+
=== Why npm and Maven rather than release assets alone
|
|
274
|
+
|
|
275
|
+
Publishing is passive. The producer releases `2.1.0` and nothing happens in an
|
|
276
|
+
implementation repository until somebody looks.
|
|
277
|
+
|
|
278
|
+
Renovate and Dependabot understand npm and Maven natively, so a contract bump
|
|
279
|
+
arrives downstream as a pull request without any bespoke machinery. That is the
|
|
280
|
+
practical argument for preferring them, and the reason to design the coordinates
|
|
281
|
+
so that standard update bots can read them.
|
|
282
|
+
|
|
283
|
+
== Pre-releases
|
|
284
|
+
|
|
285
|
+
API-Only design means implementation starts against a contract that is not
|
|
286
|
+
finished. If the only way to obtain a bundle were a final release, teams would
|
|
287
|
+
work around the tool by cloning the specification repository -- which is exactly
|
|
288
|
+
the broad read access that publishing artifacts exists to avoid, with none of the
|
|
289
|
+
guarantees.
|
|
290
|
+
|
|
291
|
+
So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT`
|
|
292
|
+
spelling, which is not semver-legal but is what Maven consumers expect.
|
|
293
|
+
|
|
294
|
+
The hard rule is the other half of it. A pre-release must never quietly satisfy a
|
|
295
|
+
production build:
|
|
296
|
+
|
|
297
|
+
* npm publishes it under the `next` dist-tag, never `latest`, so `npm install`
|
|
298
|
+
cannot pick one up by accident.
|
|
299
|
+
* A GitHub release is marked as a pre-release, so "latest release" never resolves
|
|
300
|
+
to an unfinished contract.
|
|
301
|
+
* The API-Only Subscriber refuses a pre-release version outright unless the
|
|
302
|
+
subscription sets `allowPrerelease = true` -- a visible, reviewable line in a
|
|
303
|
+
build file rather than a default.
|
|
304
|
+
|
|
305
|
+
== Configuration
|
|
306
|
+
|
|
307
|
+
Everything project-specific lives in `apionly.yaml`, not in the code. Adapting
|
|
308
|
+
the tool to another project means editing configuration.
|
|
309
|
+
|
|
310
|
+
[source,yaml]
|
|
311
|
+
----
|
|
312
|
+
schemaVersion: 1
|
|
313
|
+
|
|
314
|
+
sources:
|
|
315
|
+
root: specs # staged wholesale; the types share fragments
|
|
316
|
+
openapi: openapi
|
|
317
|
+
asyncapi: asyncapi
|
|
318
|
+
|
|
319
|
+
defaults:
|
|
320
|
+
openapi:
|
|
321
|
+
lint: .redocly.yaml
|
|
322
|
+
outputName: openapi.yaml
|
|
323
|
+
asyncapi:
|
|
324
|
+
outputName: asyncapi.yaml
|
|
325
|
+
placeholders:
|
|
326
|
+
strict: true # an unresolved {{token}} fails the build
|
|
327
|
+
|
|
328
|
+
build:
|
|
329
|
+
staging: build/staging
|
|
330
|
+
dist: dist
|
|
331
|
+
|
|
332
|
+
toolchain:
|
|
333
|
+
redocly: "@redocly/cli@2.52.0"
|
|
334
|
+
asyncapi: "@asyncapi/cli@6.0.2"
|
|
335
|
+
|
|
336
|
+
targets:
|
|
337
|
+
user-account:
|
|
338
|
+
openapi:
|
|
339
|
+
bundle: bundles/user-account_openapi_structure.yaml
|
|
340
|
+
asyncapi:
|
|
341
|
+
bundle: user-account_asyncapi_structure.yaml
|
|
342
|
+
portfolio:
|
|
343
|
+
publish: false # built and linted, never distributed
|
|
344
|
+
openapi:
|
|
345
|
+
bundle: bundles/portfolio_openapi_structure.yaml
|
|
346
|
+
----
|
|
347
|
+
|
|
348
|
+
`targets` is the single source of truth for what gets built. It replaced the
|
|
349
|
+
per-script target arrays and the `apis:` map in `redocly.yaml`, which said
|
|
350
|
+
overlapping things in two places under two different names.
|
|
351
|
+
|
|
352
|
+
=== Pinning the bundlers
|
|
353
|
+
|
|
354
|
+
`toolchain` pins the bundler versions. Unpinned, the documents this tool produces
|
|
355
|
+
could change because an upstream release happened, with nothing in the library
|
|
356
|
+
having changed -- which makes any claim that a refactoring preserved output
|
|
357
|
+
unfalsifiable. An upgrade should be a deliberate commit that moves the pin and
|
|
358
|
+
the expected output together.
|
|
359
|
+
|
|
360
|
+
== Behaviour worth knowing about
|
|
361
|
+
|
|
362
|
+
=== Unresolved placeholders fail the build
|
|
363
|
+
|
|
364
|
+
The tool this absorbed emitted the literal string `*MISSING CONTENT*` into the
|
|
365
|
+
document and carried on with a warning, so a broken contract could ship from a
|
|
366
|
+
green build. An unresolved `{{token}}` is now an error. Set
|
|
367
|
+
`defaults.placeholders.strict: false` to get the old leniency, in which case the
|
|
368
|
+
token is left visible rather than replaced with a marker.
|
|
369
|
+
|
|
370
|
+
=== Placeholder snippets are found from the source root
|
|
371
|
+
|
|
372
|
+
The absorbed tool searched downward from the *input file's own directory*, never
|
|
373
|
+
from the directory it was told to use, so a snippet one level up was invisible
|
|
374
|
+
and the workaround was to move the Markdown files. The search now starts at the
|
|
375
|
+
staged source root.
|
|
376
|
+
|
|
377
|
+
=== Version stamping edits one scalar
|
|
378
|
+
|
|
379
|
+
`info.version` is located structurally and that one scalar is spliced. It is
|
|
380
|
+
deliberately not a re-serialisation: re-emitting a document reformats everything
|
|
381
|
+
around the edit, because the AsyncAPI CLI wraps long descriptions at a width no
|
|
382
|
+
YAML emitter reproduces. Splicing keeps every other byte exactly as the bundler
|
|
383
|
+
wrote it. A document with no `info` block, or no version inside it, is an error.
|
|
384
|
+
|
|
385
|
+
=== `distribution` is transitional
|
|
386
|
+
|
|
387
|
+
A producer has no business knowing the directory layout of the projects that
|
|
388
|
+
consume it. The `distribution` block exists so that a specification library can
|
|
389
|
+
adopt this tool without every consumer changing at the same time. The API-Only
|
|
390
|
+
Subscriber replaces it: each consuming project declares where its own copy lands,
|
|
391
|
+
and the block goes away.
|
|
392
|
+
|
|
393
|
+
== Adapting it to another project
|
|
394
|
+
|
|
395
|
+
The tool has no dependency on any particular product, framework or directory
|
|
396
|
+
layout. In order of what usually needs changing:
|
|
397
|
+
|
|
398
|
+
[cols="2,3", options="header"]
|
|
399
|
+
|===
|
|
400
|
+
|What |Where
|
|
401
|
+
|
|
402
|
+
|Which documents get built
|
|
403
|
+
|`targets`
|
|
404
|
+
|
|
405
|
+
|Where the fragments live
|
|
406
|
+
|`sources`
|
|
407
|
+
|
|
408
|
+
|What each document is called
|
|
409
|
+
|`defaults.<type>.outputName`
|
|
410
|
+
|
|
411
|
+
|The lint rules
|
|
412
|
+
|`defaults.openapi.lint`
|
|
413
|
+
|
|
414
|
+
|Where built documents go
|
|
415
|
+
|`build.dist`, and `distribution` while it still exists
|
|
416
|
+
|
|
417
|
+
|Where artifacts are published
|
|
418
|
+
|`channels`
|
|
419
|
+
|===
|
|
420
|
+
|
|
421
|
+
Beyond that: the `common/` versus `<product>/<target>/` split is one convention
|
|
422
|
+
for organising a multi-target fragment library, not a requirement. A single-target
|
|
423
|
+
project can keep every fragment in one flat directory, or skip fragments entirely
|
|
424
|
+
and hand-author one complete document. The tool only needs one bundle root file
|
|
425
|
+
per target; what that file `$ref`s, or whether it `$ref`s anything at all, is
|
|
426
|
+
entirely up to the library.
|
|
427
|
+
|
|
428
|
+
Placeholders are optional too. A library with no `{{token}}` anywhere simply has
|
|
429
|
+
nothing substituted.
|
|
430
|
+
|
|
431
|
+
== Breaking changes
|
|
432
|
+
|
|
433
|
+
The tool does not police them. `changed` tells a release job which targets a
|
|
434
|
+
commit altered, and every manifest records the closure hash of what was
|
|
435
|
+
published, so the hooks a diff gate would need already exist -- but the gate
|
|
436
|
+
itself is deliberately not built.
|
|
437
|
+
|
|
438
|
+
A specification library should write down its own policy. The one this repository
|
|
439
|
+
uses is at `usable-suspects/docs/breaking-changes.adoc`, and is a reasonable
|
|
440
|
+
starting point to copy.
|
|
441
|
+
|
|
442
|
+
== Using it as a library
|
|
443
|
+
|
|
444
|
+
Everything the CLI does is available programmatically, so a release job that
|
|
445
|
+
wants to decide something for itself does not have to parse console output:
|
|
446
|
+
|
|
447
|
+
[source,javascript]
|
|
448
|
+
----
|
|
449
|
+
const { loadFrom, prepare, forTargets, changedSince } =
|
|
450
|
+
require('@arc-e-tect/api-only-publisher');
|
|
451
|
+
|
|
452
|
+
const config = loadFrom('.');
|
|
453
|
+
prepare(config);
|
|
454
|
+
|
|
455
|
+
for (const result of changedSince(config, 'origin/main')) {
|
|
456
|
+
if (result.changed) {
|
|
457
|
+
console.log(`${result.target} changed: ${result.files.join(', ')}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
----
|
|
461
|
+
|
|
462
|
+
== Exit codes
|
|
463
|
+
|
|
464
|
+
[cols="1,3",options="header"]
|
|
465
|
+
|===
|
|
466
|
+
| Code | Meaning
|
|
467
|
+
| `0` | Success.
|
|
468
|
+
| `1` | A handled failure: bad configuration, an unresolved placeholder, a
|
|
469
|
+
document that cannot be stamped, a dangling `$ref`, a channel that
|
|
470
|
+
refused the artifact. The message says which.
|
|
471
|
+
|===
|
|
472
|
+
|
|
473
|
+
Anything else is a bug; please report it.
|
|
474
|
+
|
|
475
|
+
== Tests
|
|
476
|
+
|
|
477
|
+
[source,console]
|
|
478
|
+
----
|
|
479
|
+
npm test
|
|
480
|
+
----
|
|
481
|
+
|
|
482
|
+
Covers placeholder substitution (including each of the three defects fixed on
|
|
483
|
+
the way in), version stamping, configuration parsing, and the scaffold.
|
|
484
|
+
|
|
485
|
+
The end-to-end guarantee lives elsewhere, in the specification library's own
|
|
486
|
+
golden fixtures: they assert that the documents this tool produces are unchanged,
|
|
487
|
+
byte for byte, from the ones the pipeline produced before it existed.
|
|
488
|
+
|
|
489
|
+
== Relationship to the API-Only Subscriber
|
|
490
|
+
|
|
491
|
+
This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber]
|
|
492
|
+
fetches and verifies. They share one seam — an archive plus a `manifest.json` —
|
|
493
|
+
and nothing else, which is why they can be versioned independently while living
|
|
494
|
+
in one repository.
|
|
495
|
+
|
|
496
|
+
== License
|
|
497
|
+
|
|
498
|
+
MIT. See link:LICENSE[LICENSE].
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arc-e-tect/api-only-publisher",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Arc-E-Tect",
|
|
7
|
+
"homepage": "https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/tree/main/api-only-publisher",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API.git",
|
|
11
|
+
"directory": "api-only-publisher"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"openapi",
|
|
18
|
+
"asyncapi",
|
|
19
|
+
"api-first",
|
|
20
|
+
"api-only",
|
|
21
|
+
"contract",
|
|
22
|
+
"specification",
|
|
23
|
+
"publish"
|
|
24
|
+
],
|
|
25
|
+
"type": "commonjs",
|
|
26
|
+
"main": "src/index.js",
|
|
27
|
+
"bin": {
|
|
28
|
+
"api-only-publisher": "src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.adoc"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-functions=90 --test-coverage-branches=80 test/*.test.js",
|
|
39
|
+
"test:quick": "node --test test/*.test.js"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"yaml": "2.9.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
46
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
47
|
+
"@semantic-release/git": "^11.0.1",
|
|
48
|
+
"@semantic-release/github": "^12.0.9",
|
|
49
|
+
"@semantic-release/npm": "^13.1.5",
|
|
50
|
+
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
51
|
+
"semantic-release": "^25.0.9"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": "^22.14.0 || ^24.10.0 || >=26.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/aggregate.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Generated aggregate bundles.
|
|
4
|
+
//
|
|
5
|
+
// A portfolio view of the OpenAPI surface is just another bundle root: every
|
|
6
|
+
// path body already lives in a $ref'd fragment, so a whole-landscape table of
|
|
7
|
+
// contents costs one hand-written file that duplicates no contract text.
|
|
8
|
+
//
|
|
9
|
+
// AsyncAPI cannot be written that way. Its operations use document-root pointers
|
|
10
|
+
// -- `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
|
|
11
|
+
// file contains it, so moving an operation into a fragment breaks it. A
|
|
12
|
+
// hand-written async portfolio would therefore have to copy every operation
|
|
13
|
+
// verbatim, and that copy would start rotting the moment a member changed.
|
|
14
|
+
//
|
|
15
|
+
// So it is generated instead: the aggregate's bundle root is synthesised into the
|
|
16
|
+
// staged tree from its members, immediately before bundling. Nothing is
|
|
17
|
+
// duplicated in the source, and the view cannot fall behind its members.
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const YAML = require("yaml");
|
|
22
|
+
|
|
23
|
+
class AggregateError extends Error {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge one section of a member's document into the aggregate.
|
|
27
|
+
*
|
|
28
|
+
* `agree` marks sections where members are expected to say the same thing.
|
|
29
|
+
* Several services publishing to one broker all declare that broker, and that is
|
|
30
|
+
* the ordinary case rather than a conflict -- so an identical definition merges
|
|
31
|
+
* silently and only a genuine disagreement is an error.
|
|
32
|
+
*
|
|
33
|
+
* Everywhere else a repeated key is an error even when the definitions match,
|
|
34
|
+
* because it makes ownership ambiguous: two members both defining a channel means
|
|
35
|
+
* one of them would silently not appear in the aggregate.
|
|
36
|
+
*/
|
|
37
|
+
function mergeSection(into, from, section, member, seen, { agree = false } = {}) {
|
|
38
|
+
if (!from[section]) return;
|
|
39
|
+
for (const [key, value] of Object.entries(from[section])) {
|
|
40
|
+
const previous = seen[section] && seen[section][key];
|
|
41
|
+
if (previous) {
|
|
42
|
+
const identical = JSON.stringify(into[section][key]) === JSON.stringify(value);
|
|
43
|
+
if (!agree || !identical) {
|
|
44
|
+
throw new AggregateError(
|
|
45
|
+
`aggregate: '${member}' ${agree && !identical ? "disagrees about" : "redefines"} ` +
|
|
46
|
+
`${section}.${key}, already contributed by '${previous}'. ` +
|
|
47
|
+
(agree
|
|
48
|
+
? "Members may share a server, but not define it differently."
|
|
49
|
+
: "Rename it, or leave it out of the aggregate.")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
into[section] = into[section] || {};
|
|
55
|
+
into[section][key] = value;
|
|
56
|
+
seen[section] = seen[section] || {};
|
|
57
|
+
seen[section][key] = member;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Synthesise an aggregate AsyncAPI bundle root into the staged tree.
|
|
63
|
+
*
|
|
64
|
+
* @returns {string} the path of the generated bundle root
|
|
65
|
+
*/
|
|
66
|
+
function generateAsyncApi(config, target, { log = () => {} } = {}) {
|
|
67
|
+
const spec = config.targets[target].asyncapi;
|
|
68
|
+
const members = spec.aggregate;
|
|
69
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
70
|
+
throw new AggregateError(`target '${target}': asyncapi.aggregate must list at least one target`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const merged = { asyncapi: null, info: null, servers: {}, channels: {}, operations: {} };
|
|
74
|
+
const seen = {};
|
|
75
|
+
|
|
76
|
+
for (const member of members) {
|
|
77
|
+
if (!config.targets[member] || !config.targets[member].asyncapi) {
|
|
78
|
+
throw new AggregateError(
|
|
79
|
+
`target '${target}': aggregate member '${member}' declares no asyncapi bundle`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const file = path.join(config.stagingDir("asyncapi"), config.targets[member].asyncapi.bundle);
|
|
83
|
+
if (!fs.existsSync(file)) {
|
|
84
|
+
throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
|
|
85
|
+
}
|
|
86
|
+
const doc = YAML.parse(fs.readFileSync(file, "utf8"));
|
|
87
|
+
|
|
88
|
+
merged.asyncapi = merged.asyncapi || doc.asyncapi;
|
|
89
|
+
mergeSection(merged, doc, "servers", member, seen, { agree: true });
|
|
90
|
+
mergeSection(merged, doc, "channels", member, seen);
|
|
91
|
+
mergeSection(merged, doc, "operations", member, seen);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The aggregate's own identity, not any member's.
|
|
95
|
+
merged.info = spec.info || {
|
|
96
|
+
title: `${target} (aggregate)`,
|
|
97
|
+
version: "0.0.0",
|
|
98
|
+
description: `Every event contract published across ${members.join(", ")}.`,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const out = path.join(config.stagingDir("asyncapi"), spec.bundle);
|
|
102
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
103
|
+
fs.writeFileSync(
|
|
104
|
+
out,
|
|
105
|
+
`# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
|
|
106
|
+
`# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n` +
|
|
107
|
+
YAML.stringify(merged)
|
|
108
|
+
);
|
|
109
|
+
log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isAggregate(config, target, kind) {
|
|
114
|
+
const spec = config.targets[target][kind];
|
|
115
|
+
return Boolean(spec && spec.aggregate);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { generateAsyncApi, isAggregate, AggregateError };
|