@arc-e-tect/api-only-publisher 0.7.1 → 0.8.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
@@ -6,7 +6,7 @@
6
6
  :source-highlighter: rouge
7
7
  // The released version of this component: its snippets use it. The release
8
8
  // workflow updates it; do not change it by hand.
9
- :api-only-publisher-version: 0.7.1
9
+ :api-only-publisher-version: 0.8.0
10
10
 
11
11
  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]
12
12
  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]
@@ -142,6 +142,10 @@ However the Publisher itself is started, it fetches them with `npx`, at the vers
142
142
  |Scaffold `apionly.yaml` and a reference directory layout that builds as it stands: OpenAPI, AsyncAPI or both.
143
143
  At a terminal it asks for each value first, as <<init,Scaffolding a library §>> describes.
144
144
 
145
+ |`config [section] [--yes] [value flags]`
146
+ |Reconfigure a section of `apionly.yaml` that already exists -- `portfolio`, today.
147
+ At a terminal it asks the same shape of question `init` would, offering what is already configured as each default, as <<init-additive,Adding a kind to an existing library §>> and <<aggregates,Aggregates §>> describe.
148
+
145
149
  |`build [--target <t>]... [--pre-release <ids>] [--openapi\|--asyncapi]`
146
150
  |Stage, substitute placeholders, bundle, stamp each published target's version, and lint.
147
151
 
@@ -303,6 +307,17 @@ $ api-only-publisher init . --yes --openapi --asyncapi --target orders
303
307
  Nothing is asked before completing `apionly.yaml`: unlike `--force`, an additive update never discards anything, so there is nothing to confirm.
304
308
  Run `init` again with the same kinds, and it reports nothing left to add.
305
309
 
310
+ The one slot that is asked about, when it applies, is `portfolio` -- see <<aggregates,Aggregates §>> for what it configures, and why growing past one target is what makes it apply.
311
+
312
+ [source,console]
313
+ ----
314
+ $ api-only-publisher init . --yes --openapi --target payments
315
+ Path prefix strategy for a portfolio: target-prefix or none [target-prefix]:
316
+ Where a portfolio's generated bundle root lands, as a directory beside bundles/ [portfolios]:
317
+ created specs/openapi/...
318
+ updated apionly.yaml (added targets.payments.openapi, portfolio)
319
+ ----
320
+
306
321
  `--force` is the other tool for an apionly.yaml that differs, and it does the opposite of the additive update: it reinitialises the file wholesale, exactly as a first run with these values would write it -- any target or key it holds that these values would not produce is gone, additive or not.
307
322
  Use it to discard a customisation on purpose; use a plain `init` to add a kind without touching anything else.
308
323
 
@@ -410,31 +425,176 @@ Where that matters, promote the fragment to a contract of its own, published and
410
425
  [#aggregates]
411
426
  == Aggregates
412
427
 
413
- 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.
428
+ A whole-landscape view -- a portfolio -- used to be maintained by hand for OpenAPI: every path body already lives in a `$ref`'d fragment, so it can be one table of contents that duplicates no contract text.
429
+ It can be written that way, but it does not follow that it should be: nothing stops it silently falling behind the members it lists, the moment a service grows an endpoint and nobody remembers the table of contents is a second place to add it.
414
430
 
415
- AsyncAPI cannot be written that way.
431
+ AsyncAPI never had the choice.
416
432
  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.
417
433
  A hand-written async portfolio would have to copy every operation verbatim, and that copy would start rotting the moment a member changed.
418
434
 
419
- So it is generated instead.
420
- Declare the members and the aggregate's own identity:
435
+ So both are generated.
436
+ Declare the members on the target that is the aggregate; nothing here says where its generated root lands, because that is the tool's decision, not the author's -- see <<portfolio-location,Where a generated portfolio lands §>>:
421
437
 
422
438
  [source,yaml]
423
439
  ----
424
- portfolio:
425
- publish: false
426
- asyncapi:
427
- bundle: bundles/portfolio_asyncapi_structure.yaml
428
- aggregate:
429
- - orders
430
- - payments
431
- info:
432
- title: Everything, together
433
- version: 0.0.0
440
+ targets:
441
+ orders:
442
+ openapi:
443
+ bundle: bundles/orders_openapi_structure.yaml
444
+ payments:
445
+ openapi:
446
+ bundle: bundles/payments_openapi_structure.yaml
447
+ portfolio:
448
+ openapi:
449
+ aggregate:
450
+ - orders
451
+ - payments
452
+ info:
453
+ title: Everything, together
454
+ version: 0.0.0
434
455
  ----
435
456
 
436
457
  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.
437
- Two members contributing the same channel or operation key is an error rather than a silent overwrite.
458
+ `info` is the aggregate's own identity; omit it and a generated one names the members instead.
459
+ Where every member agrees, an AsyncAPI aggregate looks the same, with `asyncapi.aggregate` in place of `openapi.aggregate`.
460
+
461
+ [#aggregates-merge]
462
+ === How members merge
463
+
464
+ A repeated key across members is not always the same kind of event, so there are three modes:
465
+
466
+ [cols="1,2,2", options="header"]
467
+ |===
468
+ |Mode |A repeat with an identical value |A repeat that differs
469
+
470
+ |`unique` (the default)
471
+ |Error.
472
+ Ownership would be ambiguous even when both members agree, because one of the two would silently not appear in the aggregate.
473
+ |Error.
474
+
475
+ |`agree`
476
+ |Merges silently.
477
+ Several services publishing to one broker, or defining the same shared security scheme, is the ordinary case, not a conflict.
478
+ |Error, naming both members.
479
+ Members may share a definition; they may not define it differently.
480
+
481
+ |`reconcile`
482
+ |Merges silently.
483
+ |Merges too: reported, and the first contributing member's value wins, deterministically -- an inconsistency to notice and fix, not a defect that fails the build.
484
+ |===
485
+
486
+ What each section of an OpenAPI aggregate uses:
487
+
488
+ [cols="1,2", options="header"]
489
+ |===
490
+ |Section |Mode
491
+
492
+ |`paths`
493
+ |Prefixed with the member's target, so a repeat is unreachable in practice; `unique` regardless, so a member listed twice is still caught by name.
494
+
495
+ |`tags`
496
+ |`reconcile`, keyed by the tag's `name`.
497
+ Two members `$ref`ing the same shared tag fragment -- the ordinary case -- are one tag in the portfolio; two members describing a tag of the same name differently are reconciled, reported, and the first wins.
498
+
499
+ |`servers`
500
+ |`agree`, the same as an AsyncAPI aggregate already used.
501
+
502
+ |`security`
503
+ |Not merged at all -- pushed down. See <<aggregates-security,Security §>>.
504
+
505
+ |`components.securitySchemes`
506
+ |`agree`. A pushed-down operation's `security` names a scheme, so the name has to mean one thing; rename one of them if two members disagree.
507
+
508
+ |`components.*` (every other sub-map)
509
+ |`agree`, per sub-map.
510
+ |===
511
+
512
+ Every operation id is prefixed with its path's member too, in PascalCase: `orders` contributes `OrdersGetOrder`, `payments` contributes `PaymentsGetOrder`, however each member itself spelled its own id.
513
+ Two members sharing an operation id is coincidence rather than intent -- an operation id identifies an operation within its own document -- so this is not configurable.
514
+
515
+ Paths and operation ids are pass-2 work: the merge above happens on what a member's bundle root shows unresolved, before the aggregate's own output is bundled, because that is the only point at which it is still known which member a path came from.
516
+ Prefixing paths and pushing security down happen afterwards, on the aggregate's own bundled document, once every `$ref` -- the members' and its own -- has resolved into something with a name to prefix, reconcile or push onto.
517
+ Neither pass changes a fragment: this only ever merges documents.
518
+
519
+ Prefixing paths assumes path-based routing -- a gateway mapping `/orders/**` to that service.
520
+ Where routing is by host instead, and a prefixed path would exist nowhere, `portfolio.openapi.paths: none` leaves every path exactly as its member wrote it; a repeated path is then a genuine, reachable conflict, refused by name.
521
+
522
+ [#aggregates-security]
523
+ === Security
524
+
525
+ Root-level `security` is an array of Security Requirement Objects -- an OR, where any one entry satisfies the requirement, and AND within one entry, across several schemes.
526
+ `security: []` means explicitly nothing required.
527
+ Operation-level `security` *replaces* the root's value; it does not merge with it.
528
+
529
+ Members legitimately differ: one service requires a bearer token, another's login endpoint cannot.
530
+ Three ways to merge that were rejected, and the one that is used:
531
+
532
+ * *Union the arrays.* Looks natural, and is dangerous: because the array is OR, unioning `[{bearerAuth: []}]` with `[]` says every operation may be called with a bearer token *or with nothing*, weakening every member to its least-authenticated one.
533
+ * *Intersect the arrays.* Wrong for the same family of reasons: it silently strips a requirement a member actually has.
534
+ * *Require every member to agree* (`agree` mode, as `servers` uses). Safe, but different services legitimately having different authentication is normal, not a mistake, so this would make an ordinary landscape unbuildable.
535
+ * *Push each member's root security down onto the operations it contributes* (used). Meaning is preserved exactly, per operation: the portfolio keeps no root `security`, and every operation carries its own -- its member's root, unless the operation already declared one of its own, in which case that one is left alone, since it already replaces the root.
536
+ A member whose root is explicitly `security: []` has `[]` pushed down explicitly too, rather than left absent: absent means "inherit", and inheriting from an absent root only *coincidentally* means the same thing.
537
+
538
+ The portfolio is more verbose than its members for this, which is the fair price of being accurate about each one.
539
+ Because a YAML comment does not survive bundling -- it parses and re-serialises -- the portfolio says so twice: `info.description` gains a line, for a reader, and `x-security-note` carries the same text machine-readably.
540
+
541
+ [#aggregates-never-published]
542
+ === An aggregate is never published
543
+
544
+ A portfolio is a catalogue.
545
+ You do not ship a catalogue; you ship the things in it, each on its own, with its own version and its own owner.
546
+
547
+ * Declaring `aggregate` for a kind means the target is never published for that kind, whatever `publish` says; writing `publish: false` beside it is accepted but no longer necessary.
548
+ * `publish: true` alongside an aggregate is refused, naming why: prefixed operation ids match no member's, so a subscriber locking one builds against a name nothing uses; the TranscriberJ names a class after `operationId`, so transcribing a portfolio makes `OrdersGetOrderOperation` where the member makes `GetOrderOperation`; and a portfolio's version answers no useful question, since it moves whenever anything anywhere moves.
549
+ A combined surface that genuinely needs publishing is a bundle somebody authors deliberately, with its own owner and version, not a by-product of aggregation.
550
+ * A target may not declare both `aggregate` and `bundle` for the same kind: an aggregate is generated whole, so there is no hand-written base to merge into.
551
+ * A target aggregating one kind must aggregate every kind it declares; mixing an aggregate for one kind with a hand-written bundle for another is refused.
552
+ Somebody who wants a hand-written AsyncAPI portfolio writes an ordinary bundle that happens to look like one, in the bundles location, as an ordinary published target -- which is then a bundle, not a portfolio, the honest description of what they made.
553
+ * An aggregate cannot list itself, directly or through another aggregate: nesting is not supported, so listing one aggregate as another's member is refused by name.
554
+
555
+ [#portfolio-location]
556
+ === Where a generated portfolio lands
557
+
558
+ Outside the bundles directory, at the same depth: `specs/openapi/portfolios/` beside `specs/openapi/bundles/`, `specs/asyncapi/portfolios/` beside `specs/asyncapi/bundles/`.
559
+
560
+ That depth matters more than it looks.
561
+ The merged document carries each member's `$ref` strings over verbatim -- `../paths/common/RootV1.yaml` -- and those resolve correctly only from the same directory depth the member's own bundle root sat at.
562
+ Putting the generated root anywhere else would mean rewriting every such reference, which is work with a subtle failure mode and no benefit, so the directory is fixed at that depth instead.
563
+
564
+ Keeping generated portfolios out of `bundles/` also makes them visibly separate from what is hand-authored -- nothing hand-written should ever `$ref` into a portfolio, since it is regenerated, and can change shape, on every build.
565
+
566
+ [#portfolio-config]
567
+ === Configuring a portfolio
568
+
569
+ Every strategy above has a documented default and a name in `apionly.yaml`'s `portfolio` section, which is absent by default -- an aggregate with no section builds on the defaults, and needs none added to work:
570
+
571
+ [source,yaml]
572
+ ----
573
+ portfolio:
574
+ openapi:
575
+ paths: target-prefix # target-prefix (default) | none
576
+ operationIds: target-prefix # the only value today; named for room to grow
577
+ tags: reconcile # the only value today
578
+ security: push-down # the only value today
579
+ location: portfolios # the directory name, beside bundles/, under each kind
580
+ ----
581
+
582
+ Because a portfolio is regenerated on every build, changing a setting here needs no migration: the next build simply produces a different portfolio.
583
+
584
+ `init` writes this section -- asking, at a terminal, about `paths` and `location` -- once a library is about to have more than one target and does not have the section yet, the same way it writes a kind's own configuration; see <<init-additive,Adding a kind to an existing library §>>.
585
+ It never changes a section that already exists, and says so rather than passing over it in silence: ` present portfolio (already configured; init never changes it)`.
586
+
587
+ `config portfolio` is the other side of that: it always asks, offering what is already configured as each default, and always rewrites the keys it asked about, whatever else the section or the file holds:
588
+
589
+ [source,console]
590
+ ----
591
+ $ api-only-publisher config portfolio
592
+ Path prefix strategy for a portfolio: target-prefix or none [target-prefix]: none
593
+ Where a portfolio's generated bundle root lands, as a directory beside bundles/ [portfolios]:
594
+ Configured portfolio: paths=none, location=portfolios
595
+ ----
596
+
597
+ `--portfolio-paths` and `--portfolio-location` answer those questions from the command line, for both `init` and `config`, the same way `init`'s other value flags do.
438
598
 
439
599
  [#channels]
440
600
  == Channels
@@ -549,13 +709,21 @@ targets:
549
709
  # Relative to specs/asyncapi/; built into build/dist/orders/asyncapi.yaml.
550
710
  bundle: bundles/orders_asyncapi_structure.yaml
551
711
  portfolio:
552
- publish: false # built and linted, never published
553
712
  openapi:
554
- bundle: bundles/portfolio_openapi_structure.yaml
713
+ # Generated, not hand-written: never a bundle, never published -- see
714
+ # Aggregates §, below.
715
+ aggregate:
716
+ - orders
717
+
718
+ portfolio: # absent is fine; an aggregate builds on documented defaults
719
+ location: portfolios # see Aggregates §
555
720
  ----
556
721
 
557
722
  `targets` is the single source of truth for what gets built.
558
723
 
724
+ Every key here is checked against what that level of `apionly.yaml` allows -- a `portfolio:` block that lands under `build:` by a bad indent is `unknown key 'portfolio' in build`, not a silently ignored typo.
725
+ `channels` and `toolchain` are the two exceptions: a channel's settings depend on its type and a tool's name is whatever the author calls it, so neither has a closed vocabulary to check against.
726
+
559
727
  A target's `bundle` and a kind's `outputName` are the two ends of one build: the bundle root is what a document is built *from*, in the library, and `outputName` is what it is built *to*, in `<build.dist>/<target>/`.
560
728
  Every target's document of one kind has the same `outputName`; keep the defaults, `openapi.yaml` and `asyncapi.yaml`, because the Subscriber looks for exactly those names.
561
729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.7.1",
3
+ "version": "0.8.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/aggregate.js CHANGED
@@ -2,19 +2,40 @@
2
2
 
3
3
  // Generated aggregate bundles.
4
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.
5
+ // A portfolio view used to be a hand-written OpenAPI bundle root: every path body
6
+ // already lives in a $ref'd fragment, so a whole-landscape table of contents cost
7
+ // one file that duplicated no contract text. It can be written that way, but it
8
+ // does not follow that it should be: nothing stopped it silently falling behind
9
+ // the members it was meant to list, the moment a service grew an endpoint and
10
+ // nobody remembered the table of contents was a second place to add it.
8
11
  //
9
- // AsyncAPI cannot be written that way. Its operations use document-root pointers
10
- // -- `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
12
+ // AsyncAPI never had the choice. Its operations use document-root pointers --
13
+ // `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
11
14
  // file contains it, so moving an operation into a fragment breaks it. A
12
15
  // hand-written async portfolio would therefore have to copy every operation
13
16
  // verbatim, and that copy would start rotting the moment a member changed.
14
17
  //
15
- // So it is generated instead: the aggregate's bundle root is synthesised into the
18
+ // So both are generated: the aggregate's bundle root is synthesised into the
16
19
  // staged tree from its members, immediately before bundling. Nothing is
17
20
  // duplicated in the source, and the view cannot fall behind its members.
21
+ //
22
+ // Generation happens in two passes, because a member's bundle root is read
23
+ // *before* it is bundled -- staging only copies and substitutes placeholders --
24
+ // and at that point almost everything in it is still a $ref pointer, not the
25
+ // content behind it:
26
+ //
27
+ // Pass 1, here, merges what is visible unresolved: paths and security schemes
28
+ // are keyed by a map key that is already there in the bundle root, and a
29
+ // member's own root-level `security` is written inline, never $ref'd. OpenAPI
30
+ // paths are prefixed with the member's target at this pass, because that is a
31
+ // plain string rewrite of the key -- it needs nothing resolved.
32
+ //
33
+ // Pass 2, in pipeline.js, runs on the aggregate's own bundled output, once
34
+ // every $ref -- the members' and its own -- has been resolved. Only then are an
35
+ // operation's id, and whether it already sets its own `security`, visible at
36
+ // all, so that is where operation ids are prefixed, root security is pushed
37
+ // down onto the operations it reaches, and repeated tags are reconciled by the
38
+ // name inside them, which a $ref pointer to a tag fragment never shows.
18
39
 
19
40
  const fs = require("fs");
20
41
  const path = require("path");
@@ -22,40 +43,128 @@ const YAML = require("yaml");
22
43
 
23
44
  class AggregateError extends Error {}
24
45
 
46
+ const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
47
+
25
48
  /**
26
49
  * Merge one section of a member's document into the aggregate.
27
50
  *
51
+ * Three modes, for three different expectations of what a repeated key means:
52
+ *
53
+ * `unique` (the default) treats a repeated key as ambiguous ownership, whatever
54
+ * it says -- ordinary for a channel, an operation or a path, where one of two
55
+ * identical definitions silently not appearing in the aggregate is exactly the
56
+ * kind of mistake this is here to catch.
57
+ *
28
58
  * `agree` marks sections where members are expected to say the same thing.
29
59
  * Several services publishing to one broker all declare that broker, and that is
30
60
  * the ordinary case rather than a conflict -- so an identical definition merges
31
61
  * silently and only a genuine disagreement is an error.
32
62
  *
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.
63
+ * `reconcile` is for a section where members are expected to sometimes *disagree*
64
+ * without either being wrong -- a shared tag two members describe slightly
65
+ * differently is an inconsistency to fix, not a defect to fail the build over.
66
+ * An identical repeat merges silently, like `agree`; a differing one merges too,
67
+ * with the first contributing member's value kept and the disagreement reported,
68
+ * rather than failing the build.
69
+ *
70
+ * @returns {Array<{section: string, key: string, winner: string, loser: string}>}
71
+ * one entry per reconciled disagreement, always empty outside `reconcile` mode
36
72
  */
37
- function mergeSection(into, from, section, member, seen, { agree = false } = {}) {
38
- if (!from[section]) return;
73
+ function mergeSection(into, from, section, member, seen, { mode = "unique", label = section } = {}) {
74
+ const reconciled = [];
75
+ if (!from[section]) return reconciled;
39
76
  for (const [key, value] of Object.entries(from[section])) {
40
77
  const previous = seen[section] && seen[section][key];
41
78
  if (previous) {
42
79
  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
- );
80
+ if (identical) {
81
+ if (mode === "unique") {
82
+ throw new AggregateError(
83
+ `aggregate: '${member}' redefines ${label}.${key}, already contributed by '${previous}'. ` +
84
+ "Rename it, or leave it out of the aggregate."
85
+ );
86
+ }
87
+ continue;
88
+ }
89
+ if (mode === "reconcile") {
90
+ reconciled.push({ section, key, winner: previous, loser: member });
91
+ continue;
51
92
  }
52
- continue;
93
+ throw new AggregateError(
94
+ `aggregate: '${member}' ${mode === "agree" ? "disagrees about" : "redefines"} ` +
95
+ `${label}.${key}, already contributed by '${previous}'. ` +
96
+ (mode === "agree"
97
+ ? "Members may share a server, but not define it differently."
98
+ : "Rename it, or leave it out of the aggregate.")
99
+ );
53
100
  }
54
101
  into[section] = into[section] || {};
55
102
  into[section][key] = value;
56
103
  seen[section] = seen[section] || {};
57
104
  seen[section][key] = member;
58
105
  }
106
+ return reconciled;
107
+ }
108
+
109
+ /** Merges every sub-map of `components` present in `from`, `mode` for each. */
110
+ function mergeComponents(into, from, member, seen, mode) {
111
+ if (!from.components) return;
112
+ into.components = into.components || {};
113
+ seen.components = seen.components || {};
114
+ for (const sub of Object.keys(from.components)) {
115
+ into.components[sub] = into.components[sub] || {};
116
+ seen.components[sub] = seen.components[sub] || {};
117
+ mergeSection(into.components, from.components, sub, member, seen.components, { mode, label: `components.${sub}` });
118
+ }
119
+ }
120
+
121
+ function requireMember(config, target, kind, member) {
122
+ if (!config.targets[member] || !config.targets[member][kind]) {
123
+ throw new AggregateError(`target '${target}': aggregate member '${member}' declares no ${kind} bundle`);
124
+ }
125
+ if (config.targets[member][kind].aggregate !== undefined) {
126
+ throw new AggregateError(
127
+ `target '${target}': aggregate member '${member}' is itself an aggregate; nesting is not supported`
128
+ );
129
+ }
130
+ }
131
+
132
+ function memberDocument(config, target, kind, member) {
133
+ requireMember(config, target, kind, member);
134
+ const file = path.join(config.stagingDir(kind), config.targets[member][kind].bundle);
135
+ if (!fs.existsSync(file)) {
136
+ throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
137
+ }
138
+ return YAML.parse(fs.readFileSync(file, "utf8"));
139
+ }
140
+
141
+ function membersOf(config, target, kind) {
142
+ const members = config.targets[target][kind].aggregate;
143
+ if (!Array.isArray(members) || members.length === 0) {
144
+ throw new AggregateError(`target '${target}': ${kind}.aggregate must list at least one target`);
145
+ }
146
+ return members;
147
+ }
148
+
149
+ /** The aggregate's own identity: its own info, or a generated stand-in naming its members. */
150
+ function aggregateInfo(spec, target, members, forKind) {
151
+ return spec.info || {
152
+ title: `${target} (aggregate)`,
153
+ version: "0.0.0",
154
+ description: forKind === "asyncapi"
155
+ ? `Every event contract published across ${members.join(", ")}.`
156
+ : `Every API published across ${members.join(", ")}.`,
157
+ };
158
+ }
159
+
160
+ function writeGenerated(out, members, content) {
161
+ fs.mkdirSync(path.dirname(out), { recursive: true });
162
+ fs.writeFileSync(
163
+ out,
164
+ `# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
165
+ "# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n" +
166
+ YAML.stringify(content)
167
+ );
59
168
  }
60
169
 
61
170
  /**
@@ -65,54 +174,169 @@ function mergeSection(into, from, section, member, seen, { agree = false } = {})
65
174
  */
66
175
  function generateAsyncApi(config, target, { log = () => {} } = {}) {
67
176
  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
- }
177
+ const members = membersOf(config, target, "asyncapi");
72
178
 
73
179
  const merged = { asyncapi: null, info: null, servers: {}, channels: {}, operations: {} };
74
180
  const seen = {};
75
181
 
76
182
  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"));
183
+ const doc = memberDocument(config, target, "asyncapi", member);
87
184
 
88
185
  merged.asyncapi = merged.asyncapi || doc.asyncapi;
89
- mergeSection(merged, doc, "servers", member, seen, { agree: true });
186
+ mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
90
187
  mergeSection(merged, doc, "channels", member, seen);
91
188
  mergeSection(merged, doc, "operations", member, seen);
92
189
  }
93
190
 
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
- };
191
+ merged.info = aggregateInfo(spec, target, members, "asyncapi");
100
192
 
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
- );
193
+ const out = config.aggregatePath(target, "asyncapi");
194
+ writeGenerated(out, members, merged);
109
195
  log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
110
196
  return out;
111
197
  }
112
198
 
199
+ /**
200
+ * Synthesise an aggregate OpenAPI bundle root into the staged tree.
201
+ *
202
+ * Only what a member's bundle root holds unresolved: paths (prefixed, so keys are
203
+ * unique by construction -- see openapiPushDown for what happens to the
204
+ * operations behind them once they are resolved), servers, root-level security,
205
+ * and every `components.*` sub-map. Tags are carried over as whatever the member
206
+ * declared -- usually a list of $ref pointers -- and reconciled by name in
207
+ * openapiPushDown, once bundling has resolved them into `{name, ...}` objects a
208
+ * $ref pointer never shows.
209
+ *
210
+ * @returns {string} the path of the generated bundle root
211
+ */
212
+ function generateOpenApi(config, target, { log = () => {} } = {}) {
213
+ const spec = config.targets[target].openapi;
214
+ const members = membersOf(config, target, "openapi");
215
+ const prefixPaths = config.portfolioPathStrategy() === "target-prefix";
216
+
217
+ const merged = { openapi: null, info: null, servers: {}, tags: [], paths: {}, components: {} };
218
+ const seen = {};
219
+ // Recorded here, in pass 1, because only here is it still known which member a
220
+ // path came from: once paths are merged, one document can no longer tell.
221
+ const owners = {};
222
+
223
+ for (const member of members) {
224
+ const doc = memberDocument(config, target, "openapi", member);
225
+
226
+ merged.openapi = merged.openapi || doc.openapi;
227
+ mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
228
+ mergeComponents(merged, doc, member, seen, "agree");
229
+ if (Array.isArray(doc.tags)) merged.tags.push(...doc.tags);
230
+
231
+ const prefixed = {};
232
+ for (const [rawPath, item] of Object.entries(doc.paths || {})) {
233
+ prefixed[prefixPaths ? `/${member}${rawPath}` : rawPath] = item;
234
+ }
235
+ mergeSection(merged, { paths: prefixed }, "paths", member, seen);
236
+ for (const finalPath of Object.keys(prefixed)) {
237
+ owners[finalPath] = { member, security: doc.security };
238
+ }
239
+ }
240
+ if (Object.keys(merged.components).length === 0) delete merged.components;
241
+ if (merged.tags.length === 0) delete merged.tags;
242
+
243
+ merged.info = aggregateInfo(spec, target, members, "openapi");
244
+
245
+ const out = config.aggregatePath(target, "openapi");
246
+ writeGenerated(out, members, merged);
247
+ log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
248
+ return { out, owners };
249
+ }
250
+
251
+ const SECURITY_NOTE =
252
+ "Each member's own root-level `security` has been moved onto the operations it contributes " +
253
+ "-- see each operation's own `security`, and `components.securitySchemes` for what each scheme requires.";
254
+
255
+ /**
256
+ * Pass 2: what generateOpenApi could not do until its own output was bundled.
257
+ * Run on `text` -- the aggregate's *bundled* document -- after every $ref, the
258
+ * members' and its own, has been resolved.
259
+ *
260
+ * - Every operation id is prefixed with its path's member, the same way the path
261
+ * itself already was, so two members sharing an operation id -- coincidence,
262
+ * not intent -- cannot collide in the portfolio.
263
+ * - Every member's root-level `security` -- explicit `[]` included -- becomes
264
+ * every operation it contributes to's own `security`, unless the operation
265
+ * already set one; the portfolio itself keeps no root `security`. See the
266
+ * Security section of the README for why: union and intersection are both
267
+ * unsound merges of a security requirement, and requiring every member to
268
+ * agree would make a landscape where they legitimately differ unbuildable.
269
+ * Where anything was pushed down, `info.description` and `x-security-note`
270
+ * both say so -- a comment would not survive bundling, so neither carries this.
271
+ * - Two members contributing the same tag name are one tag; an identical body
272
+ * merges silently, a differing one is reported and the first contributor's
273
+ * body wins, deterministically.
274
+ *
275
+ * Edits the document in place -- `YAML.parseDocument`, never a parse and
276
+ * re-stringify -- so everything this pass does not touch keeps the bundler's own
277
+ * formatting exactly, the same reason version.js splices rather than re-emits.
278
+ *
279
+ * @returns {{text: string, reconciled: Array<{section: string, key: string}>}}
280
+ */
281
+ function openapiPushDown(text, owners, { operationIdStrategy = "target-prefix" } = {}) {
282
+ const doc = YAML.parseDocument(text);
283
+ const plain = doc.toJS();
284
+ let pushedDown = false;
285
+
286
+ for (const [pathKey, item] of Object.entries(plain.paths || {})) {
287
+ const owner = owners[pathKey];
288
+ if (!owner || !item || typeof item !== "object") continue;
289
+ for (const httpMethod of HTTP_METHODS) {
290
+ const op = item[httpMethod];
291
+ if (!op || typeof op !== "object") continue;
292
+ if (op.operationId && operationIdStrategy === "target-prefix") {
293
+ doc.setIn(["paths", pathKey, httpMethod, "operationId"], `${pascalCase(owner.member)}${op.operationId}`);
294
+ }
295
+ if (op.security === undefined && owner.security !== undefined) {
296
+ doc.setIn(["paths", pathKey, httpMethod, "security"], owner.security);
297
+ pushedDown = true;
298
+ }
299
+ }
300
+ }
301
+
302
+ if (pushedDown) {
303
+ const description = doc.getIn(["info", "description"]);
304
+ doc.setIn(["info", "description"], description ? `${description}\n\n${SECURITY_NOTE}` : SECURITY_NOTE);
305
+ doc.setIn(["x-security-note"], SECURITY_NOTE);
306
+ }
307
+
308
+ const reconciled = [];
309
+ if (Array.isArray(plain.tags)) {
310
+ const byName = new Map();
311
+ const order = [];
312
+ for (const tag of plain.tags) {
313
+ const existing = byName.get(tag.name);
314
+ if (existing === undefined) {
315
+ byName.set(tag.name, tag);
316
+ order.push(tag.name);
317
+ continue;
318
+ }
319
+ if (JSON.stringify(existing) !== JSON.stringify(tag)) {
320
+ reconciled.push({ section: "tags", key: tag.name });
321
+ }
322
+ // The first contributor's body wins either way -- identical or not.
323
+ }
324
+ doc.setIn(["tags"], order.map((name) => byName.get(name)));
325
+ }
326
+
327
+ return { text: doc.toString(), reconciled };
328
+ }
329
+
330
+ /** A target name, in PascalCase: user-account -> UserAccount. */
331
+ function pascalCase(target) {
332
+ return target.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
333
+ }
334
+
113
335
  function isAggregate(config, target, kind) {
114
336
  const spec = config.targets[target][kind];
115
337
  return Boolean(spec && spec.aggregate);
116
338
  }
117
339
 
118
- module.exports = { generateAsyncApi, isAggregate, AggregateError };
340
+ module.exports = {
341
+ generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate, mergeSection, pascalCase, AggregateError,
342
+ };