@arc-e-tect/api-only-publisher 0.3.0 → 0.4.1
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 +75 -5
- package/package.json +1 -1
- package/src/config.js +24 -2
- package/src/fragment-paths.js +107 -0
- package/src/index.js +1 -0
- package/src/pipeline.js +62 -4
package/README.adoc
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
:toclevels: 2
|
|
5
5
|
:icons: font
|
|
6
6
|
:source-highlighter: rouge
|
|
7
|
+
// The component versions this document describes; change them here only.
|
|
8
|
+
:api-only-publisher-version: 0.4.0
|
|
7
9
|
|
|
8
10
|
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]
|
|
9
11
|
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]
|
|
@@ -75,7 +77,7 @@ npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
|
|
|
75
77
|
----
|
|
76
78
|
|
|
77
79
|
.package.json
|
|
78
|
-
[source,json]
|
|
80
|
+
[source,json,subs="attributes+"]
|
|
79
81
|
----
|
|
80
82
|
{
|
|
81
83
|
"private": true,
|
|
@@ -83,7 +85,7 @@ npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
|
|
|
83
85
|
"apionly": "api-only-publisher"
|
|
84
86
|
},
|
|
85
87
|
"devDependencies": {
|
|
86
|
-
"@arc-e-tect/api-only-publisher": "
|
|
88
|
+
"@arc-e-tect/api-only-publisher": "{api-only-publisher-version}"
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
----
|
|
@@ -109,10 +111,10 @@ Run it through the script, as `npm run apionly -- <command>`.
|
|
|
109
111
|
|
|
110
112
|
The fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
|
|
111
113
|
|
|
112
|
-
[source,console]
|
|
114
|
+
[source,console,subs="attributes+"]
|
|
113
115
|
----
|
|
114
|
-
npx @arc-e-tect/api-only-publisher
|
|
115
|
-
npx @arc-e-tect/api-only-publisher
|
|
116
|
+
npx @arc-e-tect/api-only-publisher@{api-only-publisher-version} init my-api-library
|
|
117
|
+
npx @arc-e-tect/api-only-publisher@{api-only-publisher-version} build -C my-api-library
|
|
116
118
|
----
|
|
117
119
|
|
|
118
120
|
* Nothing is added to the repository.
|
|
@@ -190,6 +192,7 @@ A `{{token}}` is replaced with the contents of `<token>.md`, found by searching
|
|
|
190
192
|
Indentation is preserved, so multi-line Markdown stays valid inside an indented YAML scalar.
|
|
191
193
|
|
|
192
194
|
. **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every `$ref` into one flat, self-contained document under `<build.dist>/<target>/`.
|
|
195
|
+
An OpenAPI document's components each carry the path of the fragment they came from, as <<fragment-paths,`x-fragment-path` §>> describes.
|
|
193
196
|
|
|
194
197
|
. **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
|
|
195
198
|
A `publish: false` target keeps the version its source declares.
|
|
@@ -376,6 +379,7 @@ defaults:
|
|
|
376
379
|
openapi:
|
|
377
380
|
lint: .redocly.yaml
|
|
378
381
|
outputName: openapi.yaml
|
|
382
|
+
fragmentPaths: true # x-fragment-path on every component; the default
|
|
379
383
|
asyncapi:
|
|
380
384
|
outputName: asyncapi.yaml
|
|
381
385
|
placeholders:
|
|
@@ -463,6 +467,69 @@ It is deliberately not a re-serialisation: re-emitting a document reformats ever
|
|
|
463
467
|
Splicing keeps every other byte exactly as the bundler wrote it.
|
|
464
468
|
A document with no `info` block, or no version inside it, is an error.
|
|
465
469
|
|
|
470
|
+
[#fragment-paths]
|
|
471
|
+
=== Every component says which fragment it came from
|
|
472
|
+
|
|
473
|
+
Bundling flattens the library's directories into one `components` namespace, and the bundler names each component after its file.
|
|
474
|
+
Two fragments with the same file name in different directories become `UserV1` and `UserV1-2`, and nothing in the document says which is which.
|
|
475
|
+
|
|
476
|
+
So every component the bundler builds from a fragment carries that fragment's path, relative to `sources.root`, as its first key:
|
|
477
|
+
|
|
478
|
+
[source,yaml]
|
|
479
|
+
----
|
|
480
|
+
components:
|
|
481
|
+
schemas:
|
|
482
|
+
UsernameV1:
|
|
483
|
+
x-fragment-path: openapi/components/common/schemas/UsernameV1.yaml
|
|
484
|
+
type: string
|
|
485
|
+
----
|
|
486
|
+
|
|
487
|
+
The path always uses forward slashes, and it names a file in the library.
|
|
488
|
+
Other OpenAPI tooling ignores the key, as it ignores any `x-` extension.
|
|
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
|
+
|
|
491
|
+
It is on by default, for OpenAPI documents only.
|
|
492
|
+
`defaults.openapi.fragmentPaths: false` turns it off.
|
|
493
|
+
|
|
494
|
+
How it is done::
|
|
495
|
+
The bundler is not told anything; it is shown stamped fragments, in copies of the staged tree under `<build.staging>/fragment-paths/<target>/`.
|
|
496
|
+
The first bundle is built with every fragment stamped, to learn which fragments become components.
|
|
497
|
+
The second, the one that is published, is built with only those stamped.
|
|
498
|
+
So a document differs from one built with `fragmentPaths: false` by exactly one line per component, and in no other byte.
|
|
499
|
+
The cost is a second bundler run per OpenAPI target.
|
|
500
|
+
|
|
501
|
+
What stops the build::
|
|
502
|
+
A fragment that writes `x-fragment-path` itself: the Publisher sets that key and nothing else may.
|
|
503
|
+
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.
|
|
504
|
+
|
|
505
|
+
A component written directly in a bundle root, rather than `$ref`'d from a fragment, has no fragment and carries no path.
|
|
506
|
+
|
|
507
|
+
[#fragment-path-versions]
|
|
508
|
+
==== What a change to `x-fragment-path` means for a contract's version
|
|
509
|
+
|
|
510
|
+
Semantic versioning describes the contract, so the version follows from what a change does to the contract's promises:
|
|
511
|
+
|
|
512
|
+
[cols="2,1,3", options="header"]
|
|
513
|
+
|===
|
|
514
|
+
|Change |Version |Why
|
|
515
|
+
|
|
516
|
+
|The key appears for the first time
|
|
517
|
+
|MINOR
|
|
518
|
+
|A new feature of the published document.
|
|
519
|
+
|
|
520
|
+
|A fragment moves to another directory, changing the value
|
|
521
|
+
|PATCH
|
|
522
|
+
|The contract promises the same; only its provenance metadata differs.
|
|
523
|
+
|
|
524
|
+
|The key is removed, by `fragmentPaths: false` after it was published
|
|
525
|
+
|MAJOR
|
|
526
|
+
|Consumers relying on it lose provenance and collision disambiguation, and may stop generating code at all.
|
|
527
|
+
|===
|
|
528
|
+
|
|
529
|
+
Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments.
|
|
530
|
+
`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 §>>).
|
|
532
|
+
|
|
466
533
|
[#distribution]
|
|
467
534
|
=== `distribution` is transitional
|
|
468
535
|
|
|
@@ -492,6 +559,9 @@ In order of what usually needs changing:
|
|
|
492
559
|
|The lint rules
|
|
493
560
|
|`defaults.openapi.lint`
|
|
494
561
|
|
|
562
|
+
|Whether components carry `x-fragment-path`
|
|
563
|
+
|`defaults.openapi.fragmentPaths`, defaulting to `true`
|
|
564
|
+
|
|
495
565
|
|Where lint reports go
|
|
496
566
|
|`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
|
|
497
567
|
|
package/package.json
CHANGED
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
|
-
|
|
157
|
-
|
|
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
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
|
-
|
|
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 };
|