@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 +186 -18
- package/package.json +1 -1
- package/src/aggregate.js +274 -50
- package/src/cli.js +101 -6
- package/src/closure.js +1 -1
- package/src/config-command.js +100 -0
- package/src/config.js +210 -7
- package/src/init-config.js +27 -7
- package/src/init.js +10 -4
- package/src/pipeline.js +26 -12
- package/src/split.js +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
420
|
-
Declare the members
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/aggregate.js
CHANGED
|
@@ -2,19 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
// Generated aggregate bundles.
|
|
4
4
|
//
|
|
5
|
-
// A portfolio view
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
10
|
-
//
|
|
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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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, {
|
|
38
|
-
|
|
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 (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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 =
|
|
102
|
-
|
|
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 = {
|
|
340
|
+
module.exports = {
|
|
341
|
+
generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate, mergeSection, pascalCase, AggregateError,
|
|
342
|
+
};
|