@arc-e-tect/api-only-publisher 0.3.0 → 0.4.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
@@ -190,6 +190,7 @@ A `{{token}}` is replaced with the contents of `<token>.md`, found by searching
190
190
  Indentation is preserved, so multi-line Markdown stays valid inside an indented YAML scalar.
191
191
 
192
192
  . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every `$ref` into one flat, self-contained document under `<build.dist>/<target>/`.
193
+ An OpenAPI document's components each carry the path of the fragment they came from, as <<fragment-paths,`x-fragment-path` §>> describes.
193
194
 
194
195
  . **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
195
196
  A `publish: false` target keeps the version its source declares.
@@ -376,6 +377,7 @@ defaults:
376
377
  openapi:
377
378
  lint: .redocly.yaml
378
379
  outputName: openapi.yaml
380
+ fragmentPaths: true # x-fragment-path on every component; the default
379
381
  asyncapi:
380
382
  outputName: asyncapi.yaml
381
383
  placeholders:
@@ -463,6 +465,69 @@ It is deliberately not a re-serialisation: re-emitting a document reformats ever
463
465
  Splicing keeps every other byte exactly as the bundler wrote it.
464
466
  A document with no `info` block, or no version inside it, is an error.
465
467
 
468
+ [#fragment-paths]
469
+ === Every component says which fragment it came from
470
+
471
+ Bundling flattens the library's directories into one `components` namespace, and the bundler names each component after its file.
472
+ Two fragments with the same file name in different directories become `UserV1` and `UserV1-2`, and nothing in the document says which is which.
473
+
474
+ So every component the bundler builds from a fragment carries that fragment's path, relative to `sources.root`, as its first key:
475
+
476
+ [source,yaml]
477
+ ----
478
+ components:
479
+ schemas:
480
+ UsernameV1:
481
+ x-fragment-path: openapi/components/common/schemas/UsernameV1.yaml
482
+ type: string
483
+ ----
484
+
485
+ The path always uses forward slashes, and it names a file in the library.
486
+ Other OpenAPI tooling ignores the key, as it ignores any `x-` extension.
487
+ 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.
488
+
489
+ It is on by default, for OpenAPI documents only.
490
+ `defaults.openapi.fragmentPaths: false` turns it off.
491
+
492
+ How it is done::
493
+ The bundler is not told anything; it is shown stamped fragments, in copies of the staged tree under `<build.staging>/fragment-paths/<target>/`.
494
+ The first bundle is built with every fragment stamped, to learn which fragments become components.
495
+ The second, the one that is published, is built with only those stamped.
496
+ So a document differs from one built with `fragmentPaths: false` by exactly one line per component, and in no other byte.
497
+ The cost is a second bundler run per OpenAPI target.
498
+
499
+ What stops the build::
500
+ A fragment that writes `x-fragment-path` itself: the Publisher sets that key and nothing else may.
501
+ A component's fragment that is also inlined somewhere else in the same document, since the inlined copy would carry the key as well; the error names where.
502
+
503
+ A component written directly in a bundle root, rather than `$ref`'d from a fragment, has no fragment and carries no path.
504
+
505
+ [#fragment-path-versions]
506
+ ==== What a change to `x-fragment-path` means for a contract's version
507
+
508
+ Semantic versioning describes the contract, so the version follows from what a change does to the contract's promises:
509
+
510
+ [cols="2,1,3", options="header"]
511
+ |===
512
+ |Change |Version |Why
513
+
514
+ |The key appears for the first time
515
+ |MINOR
516
+ |A new feature of the published document.
517
+
518
+ |A fragment moves to another directory, changing the value
519
+ |PATCH
520
+ |The contract promises the same; only its provenance metadata differs.
521
+
522
+ |The key is removed, by `fragmentPaths: false` after it was published
523
+ |MAJOR
524
+ |Consumers relying on it lose provenance and collision disambiguation, and may stop generating code at all.
525
+ |===
526
+
527
+ Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments.
528
+ `changed` does not see that, because a target's closure hash covers the fragments, not the Publisher.
529
+ 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 §>>).
530
+
466
531
  [#distribution]
467
532
  === `distribution` is transitional
468
533
 
@@ -492,6 +557,9 @@ In order of what usually needs changing:
492
557
  |The lint rules
493
558
  |`defaults.openapi.lint`
494
559
 
560
+ |Whether components carry `x-fragment-path`
561
+ |`defaults.openapi.fragmentPaths`, defaulting to `true`
562
+
495
563
  |Where lint reports go
496
564
  |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
497
565
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
@@ -153,8 +153,30 @@ function load(configPath) {
153
153
  isPublished(target) {
154
154
  return this.targets[target].publish !== false;
155
155
  },
156
- bundlePath(target, kind) {
157
- return path.join(this.stagingDir(kind), this.targets[target][kind].bundle);
156
+ // A target's bundle root in a staged tree: the build's own, unless another
157
+ // staged copy is named.
158
+ bundlePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
159
+ return path.join(
160
+ stagingRoot,
161
+ requireString(this.sources[kind], `sources.${kind}`),
162
+ this.targets[target][kind].bundle);
163
+ },
164
+ // Whether built documents of this kind carry x-fragment-path on each
165
+ // component. On unless turned off; OpenAPI only.
166
+ fragmentPaths(kind) {
167
+ if (kind !== "openapi") return false;
168
+ const configured = (this.defaults.openapi || {}).fragmentPaths;
169
+ if (configured === undefined) return true;
170
+ if (typeof configured !== "boolean") {
171
+ throw new ConfigError(
172
+ `defaults.openapi.fragmentPaths must be true or false, not ${JSON.stringify(configured)}`);
173
+ }
174
+ return configured;
175
+ },
176
+ // Where one target's stamped copies of the staged tree are built.
177
+ fragmentPathStaging(target) {
178
+ const staging = this.build.staging || "build/staging";
179
+ return path.resolve(this.root, staging, "fragment-paths", target);
158
180
  },
159
181
  // The file a target's version is read from: the target's own `versionFile`,
160
182
  // relative to this configuration, or else <target>.bundle.properties beside
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ // x-fragment-path: which fragment a bundled component came from.
4
+ //
5
+ // Bundling flattens the library's directory tree into one components namespace,
6
+ // renaming on collision (`UserV1`, `UserV1-2`). The extension puts the provenance
7
+ // back, so a consumer -- the TranscriberJ above all -- never has to read meaning
8
+ // into a bundler's key.
9
+ //
10
+ // The bundler is not told anything; it is shown stamped fragments. It copies a
11
+ // fragment's top-level keys into whatever it produces from that fragment, so a
12
+ // stamp at the top of a file arrives on the component built from it. But the same
13
+ // is true of every fragment the bundler inlines -- paths, info, tags -- so the
14
+ // build stamps in two passes: every fragment first, to learn which ones became
15
+ // components, then only those. See pipeline.bundleWithFragmentPaths.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const YAML = require("yaml");
20
+
21
+ const KEY = "x-fragment-path";
22
+
23
+ class FragmentPathError extends Error {}
24
+
25
+ /**
26
+ * Stamp YAML fragments under `root`, in place, with their path from `root`.
27
+ *
28
+ * The path uses forward slashes on every platform. A file whose root is not a
29
+ * mapping -- a list of servers, say -- has nowhere to carry the key and is left
30
+ * alone; nothing that becomes a component looks like that.
31
+ *
32
+ * @param {Set<string>|null} only the root-relative paths to stamp; all when null
33
+ * @returns {string[]} the root-relative paths stamped, sorted
34
+ */
35
+ function stampFiles(root, { only = null } = {}) {
36
+ const stamped = [];
37
+ const walk = (dir) => {
38
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ walk(full);
42
+ continue;
43
+ }
44
+ if (!/\.ya?ml$/.test(entry.name)) continue;
45
+ const rel = path.relative(root, full).split(path.sep).join("/");
46
+ if (only && !only.has(rel)) continue;
47
+
48
+ const doc = YAML.parseDocument(fs.readFileSync(full, "utf8"));
49
+ if (!YAML.isMap(doc.contents)) continue;
50
+ if (doc.contents.has(KEY)) {
51
+ throw new FragmentPathError(`${rel} declares ${KEY}; the Publisher sets that key and a fragment may not`);
52
+ }
53
+ doc.contents.items.unshift(doc.createPair(KEY, rel));
54
+ fs.writeFileSync(full, doc.toString());
55
+ stamped.push(rel);
56
+ }
57
+ };
58
+ walk(root);
59
+ return stamped;
60
+ }
61
+
62
+ /**
63
+ * The stamp on every component of a parsed document, keyed `<type>/<name>`, in
64
+ * document order. A component with no stamp -- one written inline in the bundle
65
+ * root, say -- maps to null.
66
+ *
67
+ * @returns {Map<string, string|null>}
68
+ */
69
+ function componentPaths(document) {
70
+ const result = new Map();
71
+ for (const [type, entries] of Object.entries(document.components || {})) {
72
+ for (const [name, value] of Object.entries(entries || {})) {
73
+ const fragment = value && typeof value === "object" ? value[KEY] : undefined;
74
+ result.set(`${type}/${name}`, fragment === undefined ? null : fragment);
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Every stamp in a parsed document that is not directly on a component, with the
82
+ * JSON pointer of the object carrying it.
83
+ *
84
+ * @returns {Array<{at: string, path: string}>}
85
+ */
86
+ function strayPaths(document) {
87
+ const stray = [];
88
+ const visit = (node, pointer) => {
89
+ if (Array.isArray(node)) {
90
+ node.forEach((item, i) => visit(item, `${pointer}/${i}`));
91
+ return;
92
+ }
93
+ 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] });
97
+ }
98
+ for (const [key, value] of Object.entries(node)) {
99
+ if (key === KEY) continue;
100
+ visit(value, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
101
+ }
102
+ };
103
+ visit(document, "");
104
+ return stray;
105
+ }
106
+
107
+ module.exports = { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY };
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ module.exports = {
16
16
  ...require("./closure"),
17
17
  ...require("./unreferenced"),
18
18
  ...require("./aggregate"),
19
+ ...require("./fragment-paths"),
19
20
  ...require("./pipeline"),
20
21
  ...require("./pack"),
21
22
  ...require("./changed"),
package/src/pipeline.js CHANGED
@@ -5,10 +5,12 @@
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const { execFileSync } = require("child_process");
8
+ const YAML = require("yaml");
8
9
 
9
10
  const { substituteFile } = require("./placeholders");
10
11
  const { stampFile } = require("./version");
11
12
  const { generateAsyncApi, isAggregate } = require("./aggregate");
13
+ const { stampFiles, componentPaths, strayPaths, FragmentPathError, KEY } = require("./fragment-paths");
12
14
 
13
15
  class BuildError extends Error {}
14
16
 
@@ -82,8 +84,8 @@ function substituteTree(config, kind, log) {
82
84
  log(`-- Substituted ${tokens} placeholder(s) across ${files} file(s)`);
83
85
  }
84
86
 
85
- function bundle(config, target, kind, outFile, log) {
86
- const source = config.bundlePath(target, kind);
87
+ function bundle(config, target, kind, outFile, log, { stagingRoot } = {}) {
88
+ const source = config.bundlePath(target, kind, stagingRoot);
87
89
  if (!fs.existsSync(source)) {
88
90
  throw new BuildError(`target '${target}': bundle root not found at ${source}`);
89
91
  }
@@ -96,6 +98,58 @@ function bundle(config, target, kind, outFile, log) {
96
98
  }
97
99
  }
98
100
 
101
+ /**
102
+ * Bundle a target with x-fragment-path on every component built from a fragment.
103
+ *
104
+ * Two passes over two fresh copies of the staged tree, so the build's own staged
105
+ * tree -- which every other target bundles from -- is never stamped. The first
106
+ * stamps every fragment and learns which of them the bundler made components of.
107
+ * The second stamps only those, so the document differs from an unstamped one by
108
+ * exactly one line per such component.
109
+ *
110
+ * That holds as long as no component's fragment is also inlined somewhere else;
111
+ * if one is, the inlined copy would carry the stamp too, and the build fails
112
+ * rather than publish it.
113
+ */
114
+ function bundleWithFragmentPaths(config, target, kind, outFile, log) {
115
+ const scratch = config.fragmentPathStaging(target);
116
+ fs.rmSync(scratch, { recursive: true, force: true });
117
+
118
+ const pass = (name, only) => {
119
+ const root = path.join(scratch, name);
120
+ fs.cpSync(config.stagingRoot(kind), root, { recursive: true });
121
+ try {
122
+ stampFiles(root, { only });
123
+ } catch (error) {
124
+ if (error instanceof FragmentPathError) throw new BuildError(`target '${target}': ${error.message}`);
125
+ throw error;
126
+ }
127
+ const file = path.join(scratch, `${name}.yaml`);
128
+ bundle(config, target, kind, file, () => {}, { stagingRoot: root });
129
+ return YAML.parse(fs.readFileSync(file, "utf8"));
130
+ };
131
+
132
+ log(`-- Bundling ${path.basename(config.bundlePath(target, kind))} with ${KEY}`);
133
+ const discovered = componentPaths(pass("discover", null));
134
+ const fragments = new Set([...discovered.values()].filter(Boolean));
135
+ const document = pass("stamp", fragments);
136
+
137
+ const components = componentPaths(document);
138
+ if ([...components.keys()].join("\n") !== [...discovered.keys()].join("\n")) {
139
+ throw new BuildError(`target '${target}': stamping ${KEY} changed the bundler's component names`);
140
+ }
141
+ const stray = strayPaths(document);
142
+ if (stray.length > 0) {
143
+ throw new BuildError(
144
+ `target '${target}': a component's fragment is also inlined elsewhere, so ${KEY} would appear ` +
145
+ `off its component: ${stray.map((s) => `${s.at} (${s.path})`).join(", ")}`);
146
+ }
147
+
148
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
149
+ fs.copyFileSync(path.join(scratch, "stamp.yaml"), outFile);
150
+ log(`-- Stamped ${KEY} on ${[...components.values()].filter(Boolean).length} component(s)`);
151
+ }
152
+
99
153
  function lint(config, kind, file, log, { report = false, reportFile = null } = {}) {
100
154
  const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
101
155
  const args = kind === "openapi"
@@ -180,7 +234,11 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
180
234
  generateAsyncApi(config, target, { log });
181
235
  }
182
236
  const outFile = path.join(config.distDir(target), config.outputName(kind));
183
- bundle(config, target, kind, outFile, log);
237
+ if (config.fragmentPaths(kind)) {
238
+ bundleWithFragmentPaths(config, target, kind, outFile, log);
239
+ } else {
240
+ bundle(config, target, kind, outFile, log);
241
+ }
184
242
  const version = versionOf(target);
185
243
  if (version) {
186
244
  log(`-- Stamping version '${version}'`);
@@ -200,4 +258,4 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
200
258
  return results;
201
259
  }
202
260
 
203
- module.exports = { build, prepare, stage, substituteTree, bundle, lint, distribute, BuildError };
261
+ module.exports = { build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, distribute, BuildError };