@arc-e-tect/api-only-publisher 0.7.1 → 0.9.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.9.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
@@ -527,6 +687,9 @@ reports:
527
687
  lint: build/reports/lint
528
688
  lint:
529
689
  unreferenced: error # a fragment no target reaches: error, warn or off
690
+ examples:
691
+ asyncapi: warn # a message with no example: error, warn (the default) or off
692
+ openapi: warn # a request body or response with no example: error, warn (the default) or off
530
693
 
531
694
  toolchain:
532
695
  redocly: "@redocly/cli@2.52.0"
@@ -549,13 +712,21 @@ targets:
549
712
  # Relative to specs/asyncapi/; built into build/dist/orders/asyncapi.yaml.
550
713
  bundle: bundles/orders_asyncapi_structure.yaml
551
714
  portfolio:
552
- publish: false # built and linted, never published
553
715
  openapi:
554
- bundle: bundles/portfolio_openapi_structure.yaml
716
+ # Generated, not hand-written: never a bundle, never published -- see
717
+ # Aggregates §, below.
718
+ aggregate:
719
+ - orders
720
+
721
+ portfolio: # absent is fine; an aggregate builds on documented defaults
722
+ location: portfolios # see Aggregates §
555
723
  ----
556
724
 
557
725
  `targets` is the single source of truth for what gets built.
558
726
 
727
+ 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.
728
+ `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.
729
+
559
730
  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
731
  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
732
 
@@ -613,6 +784,32 @@ Any other YAML file under the source root counts.
613
784
 
614
785
  `lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
615
786
 
787
+ [#examples-check]
788
+ === An operation with no example is reported apart from lint
789
+
790
+ `build` also checks, after linting, whether each operation's example is one the rest of the API-Only toolchain could use -- and reports that separately from lint's own findings, since "your contract is wrong" and "your contract limits what you can do with it downstream" are different claims.
791
+ Neither specification requires an example, and this is not a specification-validity rule: it is never run as a Redocly plugin or a Spectral ruleset.
792
+
793
+ For AsyncAPI, it is whether a message's own `examples` array is non-empty -- what Microcks reads to build its async mocks and its conformance tests:
794
+
795
+ [source,text]
796
+ ----
797
+ AsyncAPI operation '<operationId>': message '<messageKey>' (<fragment, or where in the bundle>) carries no example, so this bundle cannot be used for Microcks-based conformance testing.
798
+ ----
799
+
800
+ For OpenAPI, it is whether a request body's or response's media type declares `example`/`examples`, or the schema it references carries its own top-level `examples`.
801
+ Nothing downstream currently reads an OpenAPI example, so the message never claims the bundle cannot be used -- only that the generated documentation is weaker without one:
802
+
803
+ [source,text]
804
+ ----
805
+ OpenAPI operation '<operationId>': <request body, or response <status>> (<fragment, or where in the bundle>) carries no example, which makes the generated documentation harder to read.
806
+ ----
807
+
808
+ A body or response with no `content` at all, such as a `204`, has nothing to exemplify and is never reported.
809
+
810
+ `lint.examples.asyncapi` and `lint.examples.openapi` each default to `warn`; `error` fails the build, `off` does not look.
811
+ A library that has adopted the Microcks-based conformance emitter will usually want `lint.examples.asyncapi: error`: publishing a bundle its own toolchain cannot use is a real defect there, not merely a gap (link:../docs/reference/authoring-a-specification-library.adoc#give-every-operation-an-example[Give every operation an example →]).
812
+
616
813
  [#version-stamping]
617
814
  === Version stamping edits one scalar
618
815
 
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.9.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",