@arc-e-tect/api-only-publisher 0.4.2 → 0.5.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
@@ -488,8 +488,8 @@ The path always uses forward slashes, and it names a file in the library.
488
488
  Other OpenAPI tooling ignores the key, as it ignores any `x-` extension.
489
489
  It is how the API-Only TranscriberJ ties generated code to the fragment it came from, instead of to a component name the bundler picked.
490
490
 
491
- It is on by default, for OpenAPI documents only.
492
- `defaults.openapi.fragmentPaths: false` turns it off.
491
+ It is on by default, for both specification kinds.
492
+ `defaults.openapi.fragmentPaths: false` and `defaults.asyncapi.fragmentPaths: false` turn it off, each for its own kind.
493
493
 
494
494
  How it is done::
495
495
  The bundler is not told anything; it is shown stamped fragments, in copies of the staged tree under `<build.staging>/fragment-paths/<target>/`.
@@ -504,6 +504,38 @@ A component's fragment that is also inlined somewhere else in the same document,
504
504
 
505
505
  A component written directly in a bundle root, rather than `$ref`'d from a fragment, has no fragment and carries no path.
506
506
 
507
+ [#fragment-paths-asyncapi]
508
+ ==== AsyncAPI, where the bundler inlines rather than hoists
509
+
510
+ An AsyncAPI bundle has no `components` section at all.
511
+ The bundler inlines a message into its channel, the message's payload into the message, and any schema that payload references into the payload.
512
+ So there is nothing to hoist and nothing to select: every fragment is stamped, and the stamp arrives wherever the bundler put that fragment.
513
+
514
+ [source,yaml]
515
+ ----
516
+ channels:
517
+ auditV1:
518
+ x-fragment-path: asyncapi/channels/iff/useraccount/AuditV1.yaml
519
+ address: iff.useraccount.audit.v1
520
+ messages:
521
+ registrationInitiatedMessage:
522
+ x-fragment-path: asyncapi/messages/iff/useraccount/RegistrationInitiatedMessageV1.yaml
523
+ payload:
524
+ x-fragment-path: asyncapi/components/iff/useraccount/schemas/RegistrationInitiatedEventV1.yaml
525
+ properties:
526
+ username:
527
+ x-fragment-path: openapi/components/common/schemas/UsernameV1.yaml
528
+ ----
529
+
530
+ The last of those is the point of a shared library: a schema the OpenAPI tree owns keeps its own path when an event payload references it, so a consumer can tell that the `username` in an event and the `username` in a response are one fragment, and generate one class for both.
531
+
532
+ The bundle root is the document rather than a fragment of one, so it is never stamped.
533
+ One bundler run is enough, because nothing has to be discovered first.
534
+
535
+ What stops the build::
536
+ A fragment that writes `x-fragment-path` itself, as on the OpenAPI side.
537
+ A stamp in the built document that names no file in the library, which would mean the bundler moved a stamp away from what explains it.
538
+
507
539
  [#fragment-path-versions]
508
540
  ==== What a change to `x-fragment-path` means for a contract's version
509
541
 
@@ -526,9 +558,11 @@ Semantic versioning describes the contract, so the version follows from what a c
526
558
  |Consumers relying on it lose provenance and collision disambiguation, and may stop generating code at all.
527
559
  |===
528
560
 
529
- Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments.
561
+ The policy is the same for both kinds: an AsyncAPI document gains the key exactly as an OpenAPI one does, and the version follows the table above.
562
+
563
+ Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments, and every AsyncAPI document that has fragments at all.
530
564
  `changed` does not see that, because a target's closure hash covers the fragments, not the Publisher.
531
- Bump each such target's MINOR version in the upgrade's commit, or set `fragmentPaths: false` until you do: publishing a changed document under a version already published is refused by every Subscriber that locked it (see <<versions,Versions §>>).
565
+ Bump each such target's MINOR version in the upgrade's commit, or set `fragmentPaths: false` for the kind until you do: publishing a changed document under a version already published is refused by every Subscriber that locked it (see <<versions,Versions §>>).
532
566
 
533
567
  [#distribution]
534
568
  === `distribution` is transitional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.4.2",
3
+ "version": "0.5.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/config.js CHANGED
@@ -164,12 +164,12 @@ function load(configPath) {
164
164
  // Whether built documents of this kind carry x-fragment-path on each
165
165
  // component. On unless turned off; OpenAPI only.
166
166
  fragmentPaths(kind) {
167
- if (kind !== "openapi") return false;
168
- const configured = (this.defaults.openapi || {}).fragmentPaths;
167
+ if (kind !== "openapi" && kind !== "asyncapi") return false;
168
+ const configured = (this.defaults[kind] || {}).fragmentPaths;
169
169
  if (configured === undefined) return true;
170
170
  if (typeof configured !== "boolean") {
171
171
  throw new ConfigError(
172
- `defaults.openapi.fragmentPaths must be true or false, not ${JSON.stringify(configured)}`);
172
+ `defaults.${kind}.fragmentPaths must be true or false, not ${JSON.stringify(configured)}`);
173
173
  }
174
174
  return configured;
175
175
  },
@@ -30,9 +30,11 @@ class FragmentPathError extends Error {}
30
30
  * alone; nothing that becomes a component looks like that.
31
31
  *
32
32
  * @param {Set<string>|null} only the root-relative paths to stamp; all when null
33
+ * @param {Set<string>} except the root-relative paths to leave alone, such as a
34
+ * bundle root, which is the document itself rather than a fragment of one
33
35
  * @returns {string[]} the root-relative paths stamped, sorted
34
36
  */
35
- function stampFiles(root, { only = null } = {}) {
37
+ function stampFiles(root, { only = null, except = new Set() } = {}) {
36
38
  const stamped = [];
37
39
  const walk = (dir) => {
38
40
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
@@ -44,6 +46,7 @@ function stampFiles(root, { only = null } = {}) {
44
46
  if (!/\.ya?ml$/.test(entry.name)) continue;
45
47
  const rel = path.relative(root, full).split(path.sep).join("/");
46
48
  if (only && !only.has(rel)) continue;
49
+ if (except.has(rel)) continue;
47
50
 
48
51
  const doc = YAML.parseDocument(fs.readFileSync(full, "utf8"));
49
52
  if (!YAML.isMap(doc.contents)) continue;
@@ -78,22 +81,26 @@ function componentPaths(document) {
78
81
  }
79
82
 
80
83
  /**
81
- * Every stamp in a parsed document that is not directly on a component, with the
82
- * JSON pointer of the object carrying it.
84
+ * Every stamp in a parsed document, with the JSON pointer of the object carrying
85
+ * it, in document order.
86
+ *
87
+ * <p>An OpenAPI bundle hoists fragments into components, so a stamp anywhere else
88
+ * is a fragment inlined where a component was expected. An AsyncAPI bundle inlines
89
+ * everything, so every stamp sits where its fragment was used: on a channel, a
90
+ * message, a payload, or a schema a payload references.
83
91
  *
84
92
  * @returns {Array<{at: string, path: string}>}
85
93
  */
86
- function strayPaths(document) {
87
- const stray = [];
94
+ function fragmentStamps(document, { where = () => true } = {}) {
95
+ const stamps = [];
88
96
  const visit = (node, pointer) => {
89
97
  if (Array.isArray(node)) {
90
98
  node.forEach((item, i) => visit(item, `${pointer}/${i}`));
91
99
  return;
92
100
  }
93
101
  if (!node || typeof node !== "object") return;
94
- const onComponent = /^\/components\/[^/]+\/[^/]+$/.test(pointer);
95
- if (Object.hasOwn(node, KEY) && !onComponent) {
96
- stray.push({ at: pointer || "/", path: node[KEY] });
102
+ if (Object.hasOwn(node, KEY) && where(pointer)) {
103
+ stamps.push({ at: pointer || "/", path: node[KEY] });
97
104
  }
98
105
  for (const [key, value] of Object.entries(node)) {
99
106
  if (key === KEY) continue;
@@ -101,7 +108,30 @@ function strayPaths(document) {
101
108
  }
102
109
  };
103
110
  visit(document, "");
104
- return stray;
111
+ return stamps;
112
+ }
113
+
114
+ /**
115
+ * Every stamp in a parsed document that is not directly on a component, with the
116
+ * JSON pointer of the object carrying it.
117
+ *
118
+ * @returns {Array<{at: string, path: string}>}
119
+ */
120
+ function strayPaths(document) {
121
+ return fragmentStamps(document, { where: (pointer) => !/^\/components\/[^/]+\/[^/]+$/.test(pointer) });
122
+ }
123
+
124
+ /**
125
+ * The stamps of a parsed document that do not name a file under `root`.
126
+ *
127
+ * <p>A stamp is written by this tool from a file it walked, so an unresolved one
128
+ * means the bundler moved a stamp somewhere its fragment no longer explains, and
129
+ * the document should not be published.
130
+ *
131
+ * @returns {Array<{at: string, path: string}>}
132
+ */
133
+ function unresolvedStamps(document, root) {
134
+ return fragmentStamps(document).filter((stamp) => !fs.existsSync(path.join(root, stamp.path)));
105
135
  }
106
136
 
107
- module.exports = { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY };
137
+ module.exports = { stampFiles, componentPaths, strayPaths, fragmentStamps, unresolvedStamps, FragmentPathError, KEY };
package/src/pipeline.js CHANGED
@@ -10,7 +10,7 @@ const YAML = require("yaml");
10
10
  const { substituteFile } = require("./placeholders");
11
11
  const { stampFile } = require("./version");
12
12
  const { generateAsyncApi, isAggregate } = require("./aggregate");
13
- const { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY } = require("./fragment-paths");
13
+ const { stampFiles, componentPaths, strayPaths, fragmentStamps, unresolvedStamps, FragmentPathError, KEY } = require("./fragment-paths");
14
14
 
15
15
  class BuildError extends Error {}
16
16
 
@@ -112,6 +112,7 @@ function bundle(config, target, kind, outFile, log, { stagingRoot } = {}) {
112
112
  * rather than publish it.
113
113
  */
114
114
  function bundleWithFragmentPaths(config, target, kind, outFile, log) {
115
+ if (kind === "asyncapi") return bundleInlinedWithFragmentPaths(config, target, kind, outFile, log);
115
116
  const scratch = config.fragmentPathStaging(target);
116
117
  fs.rmSync(scratch, { recursive: true, force: true });
117
118
 
@@ -150,6 +151,50 @@ function bundleWithFragmentPaths(config, target, kind, outFile, log) {
150
151
  log(`-- Stamped ${KEY} on ${[...components.values()].filter(Boolean).length} component(s)`);
151
152
  }
152
153
 
154
+ /**
155
+ * Bundle a target whose bundler inlines rather than hoists, with x-fragment-path on
156
+ * every fragment it inlines.
157
+ *
158
+ * An AsyncAPI bundle has no components section: a message is inlined into its
159
+ * channel, its payload into the message, and a schema the payload references into
160
+ * the payload -- including one from the OpenAPI tree, which is how a shared fragment
161
+ * travels. So there is nothing to discover and nothing to select: every fragment is
162
+ * stamped, and the stamp arrives wherever the bundler put that fragment.
163
+ *
164
+ * The bundle root is the document, not a fragment of one, so it is left alone.
165
+ */
166
+ function bundleInlinedWithFragmentPaths(config, target, kind, outFile, log) {
167
+ const scratch = config.fragmentPathStaging(target);
168
+ fs.rmSync(scratch, { recursive: true, force: true });
169
+ const root = path.join(scratch, "stamp");
170
+ fs.cpSync(config.stagingRoot(kind), root, { recursive: true });
171
+
172
+ const bundleRoot = path.relative(root, config.bundlePath(target, kind, root)).split(path.sep).join("/");
173
+ try {
174
+ stampFiles(root, { except: new Set([bundleRoot]) });
175
+ } catch (error) {
176
+ if (error instanceof FragmentPathError) throw new BuildError(`target '${target}': ${error.message}`);
177
+ throw error;
178
+ }
179
+
180
+ log(`-- Bundling ${path.basename(config.bundlePath(target, kind))} with ${KEY}`);
181
+ const file = path.join(scratch, "stamp.yaml");
182
+ bundle(config, target, kind, file, () => {}, { stagingRoot: root });
183
+ const document = YAML.parse(fs.readFileSync(file, "utf8"));
184
+
185
+ const stamps = fragmentStamps(document);
186
+ const missing = unresolvedStamps(document, root);
187
+ if (missing.length > 0) {
188
+ throw new BuildError(
189
+ `target '${target}': ${KEY} names a fragment that is not in the library: ` +
190
+ missing.map((stamp) => `${stamp.at} (${stamp.path})`).join(", "));
191
+ }
192
+
193
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
194
+ fs.copyFileSync(file, outFile);
195
+ log(`-- Stamped ${KEY} on ${stamps.length} inlined fragment(s)`);
196
+ }
197
+
153
198
  function lint(config, kind, file, log, { report = false, reportFile = null } = {}) {
154
199
  const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
155
200
  const args = kind === "openapi"