@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 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
+ }
@@ -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 };