@arc-e-tect/api-only-publisher 0.7.1 → 0.8.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 +186 -18
- package/package.json +1 -1
- package/src/aggregate.js +274 -50
- package/src/cli.js +101 -6
- package/src/closure.js +1 -1
- package/src/config-command.js +100 -0
- package/src/config.js +210 -7
- package/src/init-config.js +27 -7
- package/src/init.js +10 -4
- package/src/pipeline.js +26 -12
- package/src/split.js +1 -1
package/src/cli.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
+
const fs = require("fs");
|
|
4
5
|
const path = require("path");
|
|
6
|
+
const YAML = require("yaml");
|
|
5
7
|
|
|
6
8
|
const { loadFrom, ConfigError } = require("./config");
|
|
7
9
|
const { build, prepare, BuildError } = require("./pipeline");
|
|
8
10
|
const { init, scaffold, plan } = require("./init");
|
|
9
11
|
const { resolveValues } = require("./init-questions");
|
|
12
|
+
const { resolvePortfolioValues, writePortfolioSection } = require("./config-command");
|
|
10
13
|
const { PlaceholderError } = require("./placeholders");
|
|
11
14
|
const { VersionError } = require("./version");
|
|
12
15
|
const { forTargets, ClosureError } = require("./closure");
|
|
@@ -26,6 +29,8 @@ Usage:
|
|
|
26
29
|
[--contact-name <text>] [--contact-url <url>]
|
|
27
30
|
[--license <spdx-id>] [--license-url <url>]
|
|
28
31
|
[--server-url <url>] [--broker-host <host[:port]>]
|
|
32
|
+
api-only-publisher config [section] [--yes] [--portfolio-paths <target-prefix|none>]
|
|
33
|
+
[--portfolio-location <dir>]
|
|
29
34
|
api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
|
|
30
35
|
api-only-publisher lint [--target <name>]...
|
|
31
36
|
api-only-publisher targets
|
|
@@ -44,14 +49,16 @@ Options:
|
|
|
44
49
|
--asyncapi Only build AsyncAPI documents.
|
|
45
50
|
--force init only: overwrite files that differ from the scaffold;
|
|
46
51
|
at a terminal, after listing them and asking once.
|
|
47
|
-
-y, --yes init only: ask nothing, even at a terminal; take the
|
|
48
|
-
|
|
52
|
+
-y, --yes init/config only: ask nothing, even at a terminal; take the
|
|
53
|
+
current or default value for anything no flag gives.
|
|
49
54
|
--openapi, --asyncapi
|
|
50
55
|
init: the kinds of document the library holds; both flags for both.
|
|
51
56
|
--target <name> init: the target's name, in lowercase kebab-case.
|
|
52
57
|
--title, --contract-version, --contact-name, --contact-url, --license,
|
|
53
58
|
--license-url, --server-url, --broker-host
|
|
54
59
|
init only: the value of that question; a flag always wins.
|
|
60
|
+
--portfolio-paths <target-prefix|none>, --portfolio-location <dir>
|
|
61
|
+
config only: the value of that question; a flag always wins.
|
|
55
62
|
--since <ref> changed only: the git ref to compare the working tree against.
|
|
56
63
|
--out <dir> pack/publish/split: where to write.
|
|
57
64
|
--channel <name> publish only: repeat for several. Default: every configured channel.
|
|
@@ -69,13 +76,14 @@ function parseArgs(argv) {
|
|
|
69
76
|
const options = {
|
|
70
77
|
targets: [], kinds: null, preRelease: null, quiet: false, force: false,
|
|
71
78
|
dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
|
|
72
|
-
yes: false, init: {},
|
|
79
|
+
yes: false, init: {}, config: {},
|
|
73
80
|
};
|
|
74
81
|
const INIT_VALUES = {
|
|
75
82
|
"--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
|
|
76
83
|
"--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
|
|
77
84
|
"--server-url": "serverUrl", "--broker-host": "brokerHost",
|
|
78
85
|
};
|
|
86
|
+
const CONFIG_VALUES = { "--portfolio-paths": "paths", "--portfolio-location": "location" };
|
|
79
87
|
const positional = [];
|
|
80
88
|
|
|
81
89
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -109,6 +117,10 @@ function parseArgs(argv) {
|
|
|
109
117
|
options.init[INIT_VALUES[arg]] = next();
|
|
110
118
|
break;
|
|
111
119
|
}
|
|
120
|
+
if (CONFIG_VALUES[arg]) {
|
|
121
|
+
options.config[CONFIG_VALUES[arg]] = next();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
112
124
|
if (arg.startsWith("-")) throw new ConfigError(`unrecognized option '${arg}'`);
|
|
113
125
|
positional.push(arg);
|
|
114
126
|
}
|
|
@@ -136,6 +148,28 @@ function initKinds(kinds) {
|
|
|
136
148
|
return kinds ? ["openapi", "asyncapi"].filter((kind) => kinds.includes(kind)) : undefined;
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Whether init should ask about the portfolio section this run: "ask", once the
|
|
153
|
+
* library is about to have more than one target -- there is nothing to aggregate
|
|
154
|
+
* with just one -- and it does not have a portfolio section yet; "present" the
|
|
155
|
+
* same way, but the section is already there, which init never changes, and
|
|
156
|
+
* reports rather than silently doing nothing about; "not-yet" while there is
|
|
157
|
+
* still only one target, which is not worth mentioning at all.
|
|
158
|
+
*
|
|
159
|
+
* @returns {"ask"|"present"|"not-yet"}
|
|
160
|
+
*/
|
|
161
|
+
function portfolioStatus(dir, target) {
|
|
162
|
+
const file = path.join(dir, "apionly.yaml");
|
|
163
|
+
if (!fs.existsSync(file)) return "not-yet";
|
|
164
|
+
const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"));
|
|
165
|
+
if (doc.errors.length > 0) return "not-yet";
|
|
166
|
+
const targets = doc.get("targets", true);
|
|
167
|
+
const names = YAML.isMap(targets) ? new Set(targets.items.map((pair) => String(pair.key))) : new Set();
|
|
168
|
+
names.add(target);
|
|
169
|
+
if (names.size <= 1) return "not-yet";
|
|
170
|
+
return doc.hasIn(["portfolio"]) ? "present" : "ask";
|
|
171
|
+
}
|
|
172
|
+
|
|
139
173
|
/**
|
|
140
174
|
* Scaffolds a library. At a terminal, and without --yes, it asks for every value no
|
|
141
175
|
* flag gives; anywhere else it takes the defaults, as it always has.
|
|
@@ -163,19 +197,36 @@ async function runInit(options, positional, io, log) {
|
|
|
163
197
|
ask: terminal && ((question) => terminal.question(question)),
|
|
164
198
|
tell: (message) => output.write(`${message}\n`),
|
|
165
199
|
});
|
|
200
|
+
|
|
201
|
+
// Asked, or defaulted, the same way a kind's own questions are -- but only
|
|
202
|
+
// once there is something to aggregate. A section already there is never
|
|
203
|
+
// asked about or changed, and reported as present rather than passed over
|
|
204
|
+
// in silence.
|
|
205
|
+
const portfolioStatusThisRun = portfolioStatus(dir, values.target);
|
|
206
|
+
let portfolio = null;
|
|
207
|
+
if (portfolioStatusThisRun === "ask") {
|
|
208
|
+
portfolio = await resolvePortfolioValues({
|
|
209
|
+
given: options.config,
|
|
210
|
+
current: { paths: "target-prefix", location: "portfolios" },
|
|
211
|
+
ask: terminal && ((question) => terminal.question(question)),
|
|
212
|
+
tell: (message) => output.write(`${message}\n`),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
166
216
|
log(`Scaffolding a specification library in ${dir}`);
|
|
167
217
|
let force = options.force;
|
|
168
218
|
if (terminal && force) {
|
|
169
219
|
// Asked, --force means "after showing me": it overwrites what differs only
|
|
170
220
|
// once the list has been seen and agreed to.
|
|
171
|
-
const differing = plan(dir, scaffold(values), values).filter((entry) => entry.status === "differs");
|
|
221
|
+
const differing = plan(dir, scaffold(values), values, portfolio).filter((entry) => entry.status === "differs");
|
|
172
222
|
if (differing.length > 0) {
|
|
173
223
|
output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
|
|
174
224
|
const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
|
|
175
225
|
force = /^y(es)?$/i.test(answer.trim());
|
|
176
226
|
}
|
|
177
227
|
}
|
|
178
|
-
init(dir, { values, force, log });
|
|
228
|
+
init(dir, { values, force, log, portfolio });
|
|
229
|
+
if (portfolioStatusThisRun === "present") log(" present portfolio (already configured; init never changes it)");
|
|
179
230
|
log(`\nNext: api-only-publisher build -C ${dir}`);
|
|
180
231
|
} finally {
|
|
181
232
|
if (terminal) terminal.close();
|
|
@@ -183,6 +234,50 @@ async function runInit(options, positional, io, log) {
|
|
|
183
234
|
return 0;
|
|
184
235
|
}
|
|
185
236
|
|
|
237
|
+
const CONFIGURABLE_SECTIONS = ["portfolio"];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reconfigures a section of apionly.yaml. Unlike init, this always asks -- at a
|
|
241
|
+
* terminal, each question shows what is already configured, or the documented
|
|
242
|
+
* default when there is nothing yet, and Enter keeps it -- and always rewrites
|
|
243
|
+
* the keys it asked about, whatever else the section or the file holds.
|
|
244
|
+
*/
|
|
245
|
+
async function runConfig(options, positional, io, log) {
|
|
246
|
+
const section = positional[1];
|
|
247
|
+
if (section !== undefined && !CONFIGURABLE_SECTIONS.includes(section)) {
|
|
248
|
+
throw new ConfigError(
|
|
249
|
+
`'${section}' is not a configurable section; there is: ${CONFIGURABLE_SECTIONS.join(", ")}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const config = loadFrom(options.dir);
|
|
253
|
+
const input = io.input || process.stdin;
|
|
254
|
+
const output = io.output || process.stdout;
|
|
255
|
+
const interactive = io.interactive !== undefined ? io.interactive : Boolean(input.isTTY && output.isTTY);
|
|
256
|
+
const terminal = interactive && !options.yes
|
|
257
|
+
? require("node:readline/promises").createInterface({ input, output })
|
|
258
|
+
: null;
|
|
259
|
+
try {
|
|
260
|
+
for (const name of section ? [section] : CONFIGURABLE_SECTIONS) {
|
|
261
|
+
// The only configurable section today; a second one gets its own current
|
|
262
|
+
// values, its own given-flags and its own writer, called the same way.
|
|
263
|
+
const current = { paths: config.portfolioPathStrategy(), location: config.portfolioLocation() };
|
|
264
|
+
const given = { ...options.config };
|
|
265
|
+
for (const key of Object.keys(given)) if (given[key] === undefined) delete given[key];
|
|
266
|
+
|
|
267
|
+
const values = await resolvePortfolioValues({
|
|
268
|
+
given, current,
|
|
269
|
+
ask: terminal && ((question) => terminal.question(question)),
|
|
270
|
+
tell: (message) => output.write(`${message}\n`),
|
|
271
|
+
});
|
|
272
|
+
fs.writeFileSync(config.path, writePortfolioSection(fs.readFileSync(config.path, "utf8"), values));
|
|
273
|
+
log(`Configured ${name}: paths=${values.paths}, location=${values.location}`);
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
if (terminal) terminal.close();
|
|
277
|
+
}
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
186
281
|
async function main(argv, io = {}) {
|
|
187
282
|
const { options, positional } = parseArgs(argv);
|
|
188
283
|
const command = positional[0];
|
|
@@ -195,6 +290,7 @@ async function main(argv, io = {}) {
|
|
|
195
290
|
const log = options.quiet ? () => {} : (message) => console.log(message);
|
|
196
291
|
|
|
197
292
|
if (command === "init") return runInit(options, positional, io, log);
|
|
293
|
+
if (command === "config") return runConfig(options, positional, io, log);
|
|
198
294
|
|
|
199
295
|
const config = loadFrom(options.dir);
|
|
200
296
|
const targets = options.targets.length > 0 ? options.targets : null;
|
|
@@ -236,7 +332,6 @@ async function main(argv, io = {}) {
|
|
|
236
332
|
// Lint without rebuilding, for fast local feedback on what is already
|
|
237
333
|
// in dist/. Every selected document is linted even when one fails, so
|
|
238
334
|
// one run reports every failure rather than only the first.
|
|
239
|
-
const fs = require("fs");
|
|
240
335
|
const { lint } = require("./pipeline");
|
|
241
336
|
const failures = [];
|
|
242
337
|
let linted = 0;
|
package/src/closure.js
CHANGED
|
@@ -104,7 +104,7 @@ function forTargets(config, kinds = ["openapi", "asyncapi"], only = null) {
|
|
|
104
104
|
for (const kind of kinds) {
|
|
105
105
|
for (const target of config.targetsFor(kind)) {
|
|
106
106
|
if (only && !only.includes(target)) continue;
|
|
107
|
-
const entry = config.
|
|
107
|
+
const entry = config.bundleRootPath(target, kind);
|
|
108
108
|
if (!fs.existsSync(entry)) {
|
|
109
109
|
throw new ClosureError(
|
|
110
110
|
`target '${target}': ${kind} bundle root not found at ${entry}; stage the tree first`
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The `config` command's one section so far: `portfolio`.
|
|
4
|
+
//
|
|
5
|
+
// `init` only ever adds this section when it is missing (init-config.js);
|
|
6
|
+
// reconfiguring what is already there is this file's job instead, and it works
|
|
7
|
+
// the opposite way on purpose -- it always asks, offering what is already
|
|
8
|
+
// configured as each default, and always rewrites the keys it asked about.
|
|
9
|
+
//
|
|
10
|
+
// A second configurable section extends this the same way a second kind
|
|
11
|
+
// extended init-config.js's slots: its own question list, its own current()
|
|
12
|
+
// reader and its own writer, called from the same `config` command. Nothing here
|
|
13
|
+
// is written as a registry ahead of that need -- there is exactly one section
|
|
14
|
+
// today, and one small module reads more easily than an abstraction for a
|
|
15
|
+
// second case that does not exist yet.
|
|
16
|
+
|
|
17
|
+
const YAML = require("yaml");
|
|
18
|
+
const { ConfigError } = require("./config");
|
|
19
|
+
|
|
20
|
+
const PATH_STRATEGIES = ["target-prefix", "none"];
|
|
21
|
+
|
|
22
|
+
/** The portfolio section's questions, in the order they are asked. */
|
|
23
|
+
const QUESTIONS = [
|
|
24
|
+
{
|
|
25
|
+
key: "paths", flag: "--portfolio-paths",
|
|
26
|
+
text: "Path prefix strategy for a portfolio: target-prefix or none",
|
|
27
|
+
check: (v) => (PATH_STRATEGIES.includes(v) ? null : `use ${PATH_STRATEGIES.join(" or ")}`),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "location", flag: "--portfolio-location",
|
|
31
|
+
text: "Where a portfolio's generated bundle root lands, as a directory beside bundles/",
|
|
32
|
+
check: (v) => (v.trim() ? null : "it may not be empty"),
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the portfolio section's values: a flag always wins. At a terminal,
|
|
38
|
+
* each question shows `current`'s value for it in brackets -- the section's own,
|
|
39
|
+
* if it has one, or the documented default when it does not -- and Enter keeps
|
|
40
|
+
* it; without a terminal, that same value is taken as given, exactly as if it
|
|
41
|
+
* had been typed.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} options
|
|
44
|
+
* @param {object} [options.given] values given as flags, by key; never asked
|
|
45
|
+
* @param {object} options.current the value each question defaults to
|
|
46
|
+
* @param {function(string): Promise<string>} [options.ask] shows a question, resolves to the answer
|
|
47
|
+
* @param {function(string): void} [options.tell] shows why an answer was refused
|
|
48
|
+
* @returns {Promise<object>} the resolved values, one per question
|
|
49
|
+
* @throws {ConfigError} when a flag's value is invalid
|
|
50
|
+
*/
|
|
51
|
+
async function resolvePortfolioValues({ given = {}, current, ask = null, tell = () => {} } = {}) {
|
|
52
|
+
const values = {};
|
|
53
|
+
for (const question of QUESTIONS) {
|
|
54
|
+
const flagged = given[question.key];
|
|
55
|
+
if (flagged !== undefined) {
|
|
56
|
+
const problem = question.check(flagged);
|
|
57
|
+
if (problem) throw new ConfigError(`${question.flag} ${problem}`);
|
|
58
|
+
values[question.key] = flagged;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const fallback = current[question.key];
|
|
62
|
+
if (!ask) {
|
|
63
|
+
values[question.key] = fallback;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const prompt = `${question.text} [${fallback}]: `;
|
|
67
|
+
for (;;) {
|
|
68
|
+
const text = (await ask(prompt)).trim();
|
|
69
|
+
if (!text) {
|
|
70
|
+
values[question.key] = fallback;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const problem = question.check(text);
|
|
74
|
+
if (!problem) {
|
|
75
|
+
values[question.key] = text;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
tell(problem);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return values;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Writes `values` into apionly.yaml's portfolio section, wholesale for the keys
|
|
86
|
+
* asked about and untouched everywhere else: a hand-set portfolio.openapi.tags,
|
|
87
|
+
* for one, is not something `config portfolio` asked about, so it survives.
|
|
88
|
+
* YAML.parseDocument, never a parse and restringify -- the file's comments, and
|
|
89
|
+
* every value this did not ask about, are not this command's to change.
|
|
90
|
+
*
|
|
91
|
+
* @returns {string} the edited text
|
|
92
|
+
*/
|
|
93
|
+
function writePortfolioSection(text, values) {
|
|
94
|
+
const doc = YAML.parseDocument(text);
|
|
95
|
+
doc.setIn(["portfolio", "openapi", "paths"], values.paths);
|
|
96
|
+
doc.setIn(["portfolio", "location"], values.location);
|
|
97
|
+
return doc.toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { resolvePortfolioValues, writePortfolioSection, QUESTIONS };
|
package/src/config.js
CHANGED
|
@@ -12,8 +12,52 @@ const YAML = require("yaml");
|
|
|
12
12
|
const CONFIG_NAME = "apionly.yaml";
|
|
13
13
|
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
14
14
|
|
|
15
|
+
// The keys allowed at the levels of apionly.yaml that have a fixed shape. `channels`
|
|
16
|
+
// and `toolchain` are deliberately absent: a channel's settings depend on its type,
|
|
17
|
+
// and a tool's name is whatever the author calls it, so neither has a closed
|
|
18
|
+
// vocabulary to check against. Everywhere else, a key nothing reads is far more
|
|
19
|
+
// often a typo or a bad indent than a value nobody has used yet -- see checkKeys.
|
|
20
|
+
const ROOT_KEYS = [
|
|
21
|
+
"schemaVersion", "sources", "defaults", "build", "reports", "lint",
|
|
22
|
+
"distribution", "channels", "targets", "toolchain", "portfolio",
|
|
23
|
+
];
|
|
24
|
+
const SOURCES_KEYS = ["root", "openapi", "asyncapi"];
|
|
25
|
+
const DEFAULTS_KEYS = ["openapi", "asyncapi", "placeholders"];
|
|
26
|
+
const DEFAULTS_KIND_KEYS = { openapi: ["lint", "outputName", "fragmentPaths"], asyncapi: ["lint", "outputName", "fragmentPaths"] };
|
|
27
|
+
const PLACEHOLDERS_KEYS = ["strict"];
|
|
28
|
+
const BUILD_KEYS = ["staging", "dist"];
|
|
29
|
+
const REPORTS_KEYS = ["lint"];
|
|
30
|
+
const LINT_KEYS = ["unreferenced"];
|
|
31
|
+
const DISTRIBUTION_KEYS = ["root", "layout"];
|
|
32
|
+
const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
|
|
33
|
+
const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
|
|
34
|
+
const PORTFOLIO_KEYS = ["openapi", "security", "location"];
|
|
35
|
+
const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
|
|
36
|
+
|
|
37
|
+
// What every portfolio setting is when apionly.yaml says nothing: safe for the
|
|
38
|
+
// ordinary case (a gateway routing /<target>/** to each service), and named rather
|
|
39
|
+
// than boolean, so a third strategy can be added later without breaking the schema.
|
|
40
|
+
// Because a portfolio is regenerated on every build, none of this needs a migration
|
|
41
|
+
// when it changes -- the next build simply produces a different portfolio.
|
|
42
|
+
const PORTFOLIO_DEFAULTS = Object.freeze({
|
|
43
|
+
openapi: Object.freeze({ paths: "target-prefix", operationIds: "target-prefix", tags: "reconcile" }),
|
|
44
|
+
security: "push-down",
|
|
45
|
+
location: "portfolios",
|
|
46
|
+
});
|
|
47
|
+
const PATH_STRATEGIES = ["target-prefix", "none"];
|
|
48
|
+
|
|
15
49
|
class ConfigError extends Error {}
|
|
16
50
|
|
|
51
|
+
/** Refuses a key `obj` has that is not in `allowed`, naming the key and where it is. */
|
|
52
|
+
function checkKeys(obj, allowed, where) {
|
|
53
|
+
if (!obj || typeof obj !== "object") return;
|
|
54
|
+
for (const key of Object.keys(obj)) {
|
|
55
|
+
if (!allowed.includes(key)) {
|
|
56
|
+
throw new ConfigError(`unknown key '${key}' in ${where}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
// Walk up from `startDir` looking for apionly.yaml, so the CLI can be run from
|
|
18
62
|
// anywhere inside the specification repository.
|
|
19
63
|
function locate(startDir) {
|
|
@@ -57,10 +101,38 @@ function load(configPath) {
|
|
|
57
101
|
);
|
|
58
102
|
}
|
|
59
103
|
|
|
104
|
+
checkKeys(parsed, ROOT_KEYS, CONFIG_NAME);
|
|
105
|
+
|
|
60
106
|
const root = path.dirname(configPath);
|
|
61
107
|
const sources = parsed.sources || {};
|
|
108
|
+
checkKeys(sources, SOURCES_KEYS, "sources");
|
|
62
109
|
requireString(sources.root, "sources.root");
|
|
63
110
|
|
|
111
|
+
const defaults = parsed.defaults || {};
|
|
112
|
+
checkKeys(defaults, DEFAULTS_KEYS, "defaults");
|
|
113
|
+
for (const kind of ["openapi", "asyncapi"]) {
|
|
114
|
+
if (defaults[kind]) checkKeys(defaults[kind], DEFAULTS_KIND_KEYS[kind], `defaults.${kind}`);
|
|
115
|
+
}
|
|
116
|
+
if (defaults.placeholders) checkKeys(defaults.placeholders, PLACEHOLDERS_KEYS, "defaults.placeholders");
|
|
117
|
+
|
|
118
|
+
const build = parsed.build || {};
|
|
119
|
+
checkKeys(build, BUILD_KEYS, "build");
|
|
120
|
+
const reports = parsed.reports || {};
|
|
121
|
+
checkKeys(reports, REPORTS_KEYS, "reports");
|
|
122
|
+
const lint = parsed.lint || {};
|
|
123
|
+
checkKeys(lint, LINT_KEYS, "lint");
|
|
124
|
+
if (parsed.distribution) checkKeys(parsed.distribution, DISTRIBUTION_KEYS, "distribution");
|
|
125
|
+
|
|
126
|
+
const portfolio = parsed.portfolio || {};
|
|
127
|
+
checkKeys(portfolio, PORTFOLIO_KEYS, "portfolio");
|
|
128
|
+
if (portfolio.openapi) checkKeys(portfolio.openapi, PORTFOLIO_OPENAPI_KEYS, "portfolio.openapi");
|
|
129
|
+
const pathStrategy = portfolio.openapi && portfolio.openapi.paths;
|
|
130
|
+
if (pathStrategy !== undefined && !PATH_STRATEGIES.includes(pathStrategy)) {
|
|
131
|
+
throw new ConfigError(
|
|
132
|
+
`portfolio.openapi.paths must be ${PATH_STRATEGIES.join(" or ")}, not ${JSON.stringify(pathStrategy)}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
64
136
|
const targets = parsed.targets;
|
|
65
137
|
if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
|
|
66
138
|
throw new ConfigError(`${configPath} declares no targets`);
|
|
@@ -72,14 +144,84 @@ function load(configPath) {
|
|
|
72
144
|
if (!target || typeof target !== "object") {
|
|
73
145
|
throw new ConfigError(`target '${name}' is not a mapping`);
|
|
74
146
|
}
|
|
147
|
+
checkKeys(target, TARGET_KEYS, `targets.${name}`);
|
|
75
148
|
const kinds = ["openapi", "asyncapi"].filter((k) => target[k]);
|
|
76
149
|
if (kinds.length === 0) {
|
|
77
150
|
throw new ConfigError(
|
|
78
151
|
`target '${name}' declares neither an openapi nor an asyncapi bundle`
|
|
79
152
|
);
|
|
80
153
|
}
|
|
154
|
+
|
|
155
|
+
// A portfolio is a catalogue, never a contract of its own: declaring an
|
|
156
|
+
// aggregate for one kind means the target IS a portfolio, so it must
|
|
157
|
+
// aggregate every kind it declares, and it is never published.
|
|
158
|
+
const aggregateKinds = kinds.filter((k) => target[k].aggregate !== undefined);
|
|
159
|
+
if (aggregateKinds.length > 0 && aggregateKinds.length !== kinds.length) {
|
|
160
|
+
const bundled = kinds.find((k) => target[k].aggregate === undefined);
|
|
161
|
+
throw new ConfigError(
|
|
162
|
+
`target '${name}' aggregates ${aggregateKinds[0]} but has a hand-written bundle for ${bundled}; ` +
|
|
163
|
+
"a target may not mix an aggregate with a hand-written bundle across kinds -- a hand-written " +
|
|
164
|
+
"AsyncAPI portfolio is an ordinary bundle, not an aggregate that happens to look like one"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (aggregateKinds.length > 0 && target.publish === true) {
|
|
168
|
+
throw new ConfigError(
|
|
169
|
+
`target '${name}' declares an aggregate; it is never published, whatever publish says. ` +
|
|
170
|
+
"Prefixed operation ids match no member's, transcribed classes would not match a member's " +
|
|
171
|
+
"either, and a portfolio's version answers no useful question, since it moves whenever " +
|
|
172
|
+
"anything anywhere moves. A combined surface that genuinely needs publishing is a bundle " +
|
|
173
|
+
"somebody authors deliberately, with its own owner and version -- author one instead."
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
81
177
|
for (const kind of kinds) {
|
|
82
|
-
|
|
178
|
+
checkKeys(target[kind], TARGET_KIND_KEYS, `targets.${name}.${kind}`);
|
|
179
|
+
if (target[kind].aggregate !== undefined) {
|
|
180
|
+
if (target[kind].bundle !== undefined) {
|
|
181
|
+
throw new ConfigError(
|
|
182
|
+
`target '${name}' declares both aggregate and bundle for ${kind}; an aggregate is ` +
|
|
183
|
+
"generated whole, so it needs no hand-written bundle root"
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const members = target[kind].aggregate;
|
|
187
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
188
|
+
throw new ConfigError(`targets.${name}.${kind}.aggregate must list at least one target`);
|
|
189
|
+
}
|
|
190
|
+
if (members.includes(name)) {
|
|
191
|
+
throw new ConfigError(`target '${name}' aggregates itself`);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
|
|
195
|
+
if (target[kind].info !== undefined) {
|
|
196
|
+
throw new ConfigError(`targets.${name}.${kind}.info only applies to an aggregate`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Every member an aggregate lists is checked by name, and a member that is
|
|
203
|
+
// itself an aggregate is refused outright: it has no hand-written bundle for
|
|
204
|
+
// the generator to read, and reading its *generated* one would mean generating
|
|
205
|
+
// aggregates in dependency order, which nothing here attempts. Because an
|
|
206
|
+
// aggregate can only ever reach a hand-written bundle this way, refusing that
|
|
207
|
+
// one step also catches every transitive self-reference, not only the direct
|
|
208
|
+
// one the loop above already refused.
|
|
209
|
+
for (const [name, target] of Object.entries(targets)) {
|
|
210
|
+
for (const kind of ["openapi", "asyncapi"].filter((k) => target[k] && target[k].aggregate !== undefined)) {
|
|
211
|
+
for (const member of target[kind].aggregate) {
|
|
212
|
+
const memberTarget = targets[member];
|
|
213
|
+
if (!memberTarget || memberTarget[kind] === undefined) {
|
|
214
|
+
throw new ConfigError(
|
|
215
|
+
`target '${name}': aggregate member '${member}' declares no ${kind} bundle`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (memberTarget[kind].aggregate !== undefined) {
|
|
219
|
+
throw new ConfigError(
|
|
220
|
+
`target '${name}' aggregates '${member}', which is itself an aggregate; ` +
|
|
221
|
+
"nesting is not supported"
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
83
225
|
}
|
|
84
226
|
}
|
|
85
227
|
|
|
@@ -88,13 +230,14 @@ function load(configPath) {
|
|
|
88
230
|
root,
|
|
89
231
|
schemaVersion: parsed.schemaVersion,
|
|
90
232
|
sources,
|
|
91
|
-
defaults
|
|
233
|
+
defaults,
|
|
92
234
|
toolchain: parsed.toolchain || {},
|
|
93
|
-
build
|
|
94
|
-
reports
|
|
95
|
-
lint
|
|
235
|
+
build,
|
|
236
|
+
reports,
|
|
237
|
+
lint,
|
|
96
238
|
distribution: parsed.distribution || null,
|
|
97
239
|
channels: parsed.channels || {},
|
|
240
|
+
portfolio,
|
|
98
241
|
targets,
|
|
99
242
|
|
|
100
243
|
// --- derived accessors, so callers never re-derive a path themselves ---
|
|
@@ -148,19 +291,79 @@ function load(configPath) {
|
|
|
148
291
|
targetsFor(kind) {
|
|
149
292
|
return Object.keys(this.targets).filter((t) => this.targets[t][kind]);
|
|
150
293
|
},
|
|
294
|
+
// Whether this target is itself an aggregate, for any kind it declares.
|
|
295
|
+
// load() already refuses a target that aggregates one kind and hand-writes
|
|
296
|
+
// another, so any one kind answers for the whole target.
|
|
297
|
+
isAggregate(target) {
|
|
298
|
+
return ["openapi", "asyncapi"].some((k) => this.targets[target][k] && this.targets[target][k].aggregate !== undefined);
|
|
299
|
+
},
|
|
151
300
|
// A target with `publish: false` is a documentation view rather than a
|
|
152
301
|
// contract any one project implements: built and linted, never shipped.
|
|
302
|
+
// An aggregate is never published either, whether or not it says so --
|
|
303
|
+
// load() already refuses the one case that would contradict this, `publish:
|
|
304
|
+
// true` alongside an aggregate, so nothing here needs to re-check that.
|
|
153
305
|
isPublished(target) {
|
|
154
|
-
return this.targets[target].publish !== false;
|
|
306
|
+
return this.targets[target].publish !== false && !this.isAggregate(target);
|
|
155
307
|
},
|
|
156
308
|
// A target's bundle root in a staged tree: the build's own, unless another
|
|
157
|
-
// staged copy is named.
|
|
309
|
+
// staged copy is named. Only a hand-written bundle has one; an aggregate's
|
|
310
|
+
// generated root is aggregatePath(), below.
|
|
158
311
|
bundlePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
|
|
159
312
|
return path.join(
|
|
160
313
|
stagingRoot,
|
|
161
314
|
requireString(this.sources[kind], `sources.${kind}`),
|
|
162
315
|
this.targets[target][kind].bundle);
|
|
163
316
|
},
|
|
317
|
+
// Where an aggregate's generated bundle root lands in a staged tree: outside
|
|
318
|
+
// bundles/, at the same depth, so every $ref a member contributes -- carried
|
|
319
|
+
// over verbatim -- still resolves. The path is the tool's to decide, not the
|
|
320
|
+
// author's: an aggregate declares its members and its info, never where its
|
|
321
|
+
// generated root lands.
|
|
322
|
+
aggregatePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
|
|
323
|
+
return path.join(
|
|
324
|
+
stagingRoot,
|
|
325
|
+
requireString(this.sources[kind], `sources.${kind}`),
|
|
326
|
+
this.portfolioLocation(),
|
|
327
|
+
`${target}_${kind}_structure.yaml`);
|
|
328
|
+
},
|
|
329
|
+
// Where a target's document root sits in a staged tree, hand-written or
|
|
330
|
+
// generated -- whichever this target is.
|
|
331
|
+
bundleRootPath(target, kind, stagingRoot = this.stagingRoot(kind)) {
|
|
332
|
+
return this.isAggregate(target)
|
|
333
|
+
? this.aggregatePath(target, kind, stagingRoot)
|
|
334
|
+
: this.bundlePath(target, kind, stagingRoot);
|
|
335
|
+
},
|
|
336
|
+
// The path prefix strategy an OpenAPI portfolio merges its members' paths
|
|
337
|
+
// and operation ids with: `target-prefix` (the default) for a gateway
|
|
338
|
+
// routing /<target>/** to each service, `none` where routing is by host and
|
|
339
|
+
// a prefixed path would exist nowhere.
|
|
340
|
+
portfolioPathStrategy() {
|
|
341
|
+
return (this.portfolio.openapi && this.portfolio.openapi.paths) || PORTFOLIO_DEFAULTS.openapi.paths;
|
|
342
|
+
},
|
|
343
|
+
// How a portfolio's operation ids are told apart: currently always prefixed
|
|
344
|
+
// with the target, the same way paths are -- named, like every portfolio
|
|
345
|
+
// setting, so a second strategy can be added later without a breaking change.
|
|
346
|
+
portfolioOperationIdStrategy() {
|
|
347
|
+
return (this.portfolio.openapi && this.portfolio.openapi.operationIds) || PORTFOLIO_DEFAULTS.openapi.operationIds;
|
|
348
|
+
},
|
|
349
|
+
// How two members contributing the same tag name are merged: `reconcile`,
|
|
350
|
+
// today the only mode -- an identical body merges silently, a differing one
|
|
351
|
+
// merges too, reported, with the first contributing member's body winning.
|
|
352
|
+
portfolioTagStrategy() {
|
|
353
|
+
return (this.portfolio.openapi && this.portfolio.openapi.tags) || PORTFOLIO_DEFAULTS.openapi.tags;
|
|
354
|
+
},
|
|
355
|
+
// How a portfolio merges root-level `security`: `push-down`, today the only
|
|
356
|
+
// mode -- each member's root security becomes each of its own contributed
|
|
357
|
+
// operations' security, so the portfolio never has to say something false
|
|
358
|
+
// about any one of them. See the Security section of the README.
|
|
359
|
+
portfolioSecurityStrategy() {
|
|
360
|
+
return this.portfolio.security || PORTFOLIO_DEFAULTS.security;
|
|
361
|
+
},
|
|
362
|
+
// The directory an aggregate's generated bundle root lands in, sibling to
|
|
363
|
+
// bundles/, at the same depth under each kind's own source tree.
|
|
364
|
+
portfolioLocation() {
|
|
365
|
+
return this.portfolio.location || PORTFOLIO_DEFAULTS.location;
|
|
366
|
+
},
|
|
164
367
|
// Whether built documents of this kind carry x-fragment-path: on each
|
|
165
368
|
// component of an OpenAPI document, on each fragment of an AsyncAPI one.
|
|
166
369
|
// On unless turned off, for both kinds.
|
package/src/init-config.js
CHANGED
|
@@ -84,22 +84,33 @@ function place(doc, parentPath, key, value, before) {
|
|
|
84
84
|
* already there survives unchanged; a slot already present, however it reads, is
|
|
85
85
|
* never touched, and neither is anything inside it.
|
|
86
86
|
*
|
|
87
|
+
* `portfolio`, unlike a kind, is never added on its own account: whether a
|
|
88
|
+
* library is about to have more than one target, and so whether the question is
|
|
89
|
+
* even asked, is the caller's decision, made once, before this runs -- give the
|
|
90
|
+
* strategies it resolved here to have the section written with them, or leave
|
|
91
|
+
* this out entirely to leave the section alone, present or not.
|
|
92
|
+
*
|
|
87
93
|
* @param {string} text apionly.yaml, as it is on disk
|
|
88
94
|
* @param {object} v the values scaffold(values) was given, resolved: every value
|
|
89
95
|
* DEFAULTS has, `values`' own where it gives one -- the shape scaffold() itself
|
|
90
96
|
* works from, so a caller that already has that object need not rebuild it
|
|
97
|
+
* @param {{paths: string, location: string}} [portfolio] the portfolio section to
|
|
98
|
+
* add, with every strategy resolved, when it is missing; omitted, the section
|
|
99
|
+
* is never considered at all, whether or not the file already has one
|
|
91
100
|
* @returns {{text: string, added: string[]}} the edited text, and the slots added, in
|
|
92
|
-
* the order scaffold() declares them; `added` is empty, and
|
|
93
|
-
* itself, when nothing was missing.
|
|
101
|
+
* the order scaffold() declares them, portfolio last; `added` is empty, and
|
|
102
|
+
* `text` is `text` itself, when nothing was missing.
|
|
94
103
|
*/
|
|
95
|
-
function addMissingConfig(text, v) {
|
|
104
|
+
function addMissingConfig(text, v, portfolio) {
|
|
96
105
|
const doc = YAML.parseDocument(text);
|
|
97
106
|
const added = [];
|
|
98
107
|
|
|
99
|
-
// Not every file that differs is a library with a kind missing
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
108
|
+
// Not every file that differs is a library with a kind missing. One with a YAML
|
|
109
|
+
// error cannot be edited at all -- parseDocument does not throw on one, it just
|
|
110
|
+
// records it, and a document with errors refuses to be stringified back. One that
|
|
111
|
+
// does not even declare sources.root, whatever it parses to, is not an apionly.yaml
|
|
112
|
+
// this can complete either way; force, or a person, is what either file needs.
|
|
113
|
+
if (doc.errors.length > 0 || !doc.hasIn(["sources", "root"])) return { text, added };
|
|
103
114
|
|
|
104
115
|
for (const kind of ["openapi", "asyncapi"].filter((k) => v.kinds.includes(k))) {
|
|
105
116
|
for (const slot of slotsFor(kind, v)) {
|
|
@@ -109,6 +120,15 @@ function addMissingConfig(text, v) {
|
|
|
109
120
|
}
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
if (portfolio && !doc.hasIn(["portfolio"])) {
|
|
124
|
+
doc.setIn(["portfolio"], {
|
|
125
|
+
openapi: { paths: portfolio.paths, operationIds: "target-prefix", tags: "reconcile" },
|
|
126
|
+
security: "push-down",
|
|
127
|
+
location: portfolio.location,
|
|
128
|
+
});
|
|
129
|
+
added.push("portfolio");
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
return added.length === 0 ? { text, added } : { text: doc.toString(), added };
|
|
113
133
|
}
|
|
114
134
|
|