@arc-e-tect/api-only-publisher 0.7.0 → 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.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
 
@@ -270,11 +274,52 @@ What it reports::
270
274
  [horizontal]
271
275
  `created`:: The file was not there, and is now.
272
276
  `identical`:: The file was there, and matches what `init` would write, ignoring line endings and final newlines.
277
+ `updated`:: `apionly.yaml` was there, and lacked a kind's configuration; it does now.
278
+ The message names every slot added -- see <<init-additive,Adding a kind to an existing library §>>.
273
279
  `differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
274
280
  It is left alone.
275
281
  `overwrote`:: The file differed, and `--force` replaced it.
276
282
  +
277
- At a terminal, `--force` first lists the files that differ and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
283
+ At a terminal, `--force` first lists the files that still differ -- after any additive update apionly.yaml already got -- and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
284
+
285
+ [#init-additive]
286
+ === Adding a kind to an existing library
287
+
288
+ Running `init` again, with a kind the library did not have, is how a library grows: the fragments the new kind needs, and the configuration that makes the build see them.
289
+
290
+ *Files* stay create-only, as everywhere else in `init`: a fragment not written before is written now; one already there, however it now reads, is left alone -- even where the new kind changes what the scaffold would write for it, such as `specs/openapi/paths/example/ExamplesV1.yaml` once an AsyncAPI event comes to share its schema.
291
+
292
+ *Configuration* is additive: whichever of `sources.<kind>`, `defaults.<kind>`, the kind's pin in `toolchain`, and `targets.<target>.<kind>` `apionly.yaml` lacks is added to it, in the position a library scaffolded with both kinds from the start would have it in.
293
+ Every value, key and comment already in the file survives untouched, and a slot that is already there, however it reads, is never touched either -- not even to add something missing from *inside* it: a `defaults.openapi` with no `lint` is a choice, and `init` leaves it that way.
294
+
295
+ [source,console]
296
+ ----
297
+ $ api-only-publisher init . --yes --openapi --target orders
298
+ created apionly.yaml
299
+ created specs/openapi/...
300
+
301
+ $ api-only-publisher init . --yes --openapi --asyncapi --target orders
302
+ created specs/asyncapi/...
303
+ updated apionly.yaml (added sources.asyncapi, defaults.asyncapi, toolchain.asyncapi, targets.orders.asyncapi)
304
+ differs specs/openapi/paths/example/ExamplesV1.yaml (left alone; --force overwrites)
305
+ ----
306
+
307
+ Nothing is asked before completing `apionly.yaml`: unlike `--force`, an additive update never discards anything, so there is nothing to confirm.
308
+ Run `init` again with the same kinds, and it reports nothing left to add.
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
+
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.
322
+ Use it to discard a customisation on purpose; use a plain `init` to add a kind without touching anything else.
278
323
 
279
324
  [#what-build-does]
280
325
  == What `build` does
@@ -380,31 +425,176 @@ Where that matters, promote the fragment to a contract of its own, published and
380
425
  [#aggregates]
381
426
  == Aggregates
382
427
 
383
- 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.
384
430
 
385
- AsyncAPI cannot be written that way.
431
+ AsyncAPI never had the choice.
386
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.
387
433
  A hand-written async portfolio would have to copy every operation verbatim, and that copy would start rotting the moment a member changed.
388
434
 
389
- So it is generated instead.
390
- 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 §>>:
391
437
 
392
438
  [source,yaml]
393
439
  ----
394
- portfolio:
395
- publish: false
396
- asyncapi:
397
- bundle: bundles/portfolio_asyncapi_structure.yaml
398
- aggregate:
399
- - orders
400
- - payments
401
- info:
402
- title: Everything, together
403
- 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
404
455
  ----
405
456
 
406
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.
407
- 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.
408
598
 
409
599
  [#channels]
410
600
  == Channels
@@ -519,13 +709,21 @@ targets:
519
709
  # Relative to specs/asyncapi/; built into build/dist/orders/asyncapi.yaml.
520
710
  bundle: bundles/orders_asyncapi_structure.yaml
521
711
  portfolio:
522
- publish: false # built and linted, never published
523
712
  openapi:
524
- 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 §
525
720
  ----
526
721
 
527
722
  `targets` is the single source of truth for what gets built.
528
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
+
529
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>/`.
530
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.
531
729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.7.0",
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",