@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 +38 -4
- package/package.json +1 -1
- package/src/config.js +3 -3
- package/src/fragment-paths.js +40 -10
- package/src/pipeline.js +46 -1
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
|
|
492
|
-
`defaults.openapi.fragmentPaths: false`
|
|
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
|
-
|
|
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
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
|
|
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.
|
|
172
|
+
`defaults.${kind}.fragmentPaths must be true or false, not ${JSON.stringify(configured)}`);
|
|
173
173
|
}
|
|
174
174
|
return configured;
|
|
175
175
|
},
|
package/src/fragment-paths.js
CHANGED
|
@@ -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
|
|
82
|
-
*
|
|
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
|
|
87
|
-
const
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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"
|