@arc-e-tect/api-only-publisher 0.7.0 → 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 +217 -19
- 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 +135 -0
- package/src/init.js +50 -8
- package/src/pipeline.js +26 -12
- package/src/split.js +1 -1
package/src/aggregate.js
CHANGED
|
@@ -2,19 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
// Generated aggregate bundles.
|
|
4
4
|
//
|
|
5
|
-
// A portfolio view
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// A portfolio view used to be a hand-written OpenAPI bundle root: every path body
|
|
6
|
+
// already lives in a $ref'd fragment, so a whole-landscape table of contents cost
|
|
7
|
+
// one file that duplicated no contract text. It can be written that way, but it
|
|
8
|
+
// does not follow that it should be: nothing stopped it silently falling behind
|
|
9
|
+
// the members it was meant to list, the moment a service grew an endpoint and
|
|
10
|
+
// nobody remembered the table of contents was a second place to add it.
|
|
8
11
|
//
|
|
9
|
-
// AsyncAPI
|
|
10
|
-
//
|
|
12
|
+
// AsyncAPI never had the choice. Its operations use document-root pointers --
|
|
13
|
+
// `channel: {$ref: '#/channels/auditV1'}` -- and `#` resolves against whatever
|
|
11
14
|
// file contains it, so moving an operation into a fragment breaks it. A
|
|
12
15
|
// hand-written async portfolio would therefore have to copy every operation
|
|
13
16
|
// verbatim, and that copy would start rotting the moment a member changed.
|
|
14
17
|
//
|
|
15
|
-
// So
|
|
18
|
+
// So both are generated: the aggregate's bundle root is synthesised into the
|
|
16
19
|
// staged tree from its members, immediately before bundling. Nothing is
|
|
17
20
|
// duplicated in the source, and the view cannot fall behind its members.
|
|
21
|
+
//
|
|
22
|
+
// Generation happens in two passes, because a member's bundle root is read
|
|
23
|
+
// *before* it is bundled -- staging only copies and substitutes placeholders --
|
|
24
|
+
// and at that point almost everything in it is still a $ref pointer, not the
|
|
25
|
+
// content behind it:
|
|
26
|
+
//
|
|
27
|
+
// Pass 1, here, merges what is visible unresolved: paths and security schemes
|
|
28
|
+
// are keyed by a map key that is already there in the bundle root, and a
|
|
29
|
+
// member's own root-level `security` is written inline, never $ref'd. OpenAPI
|
|
30
|
+
// paths are prefixed with the member's target at this pass, because that is a
|
|
31
|
+
// plain string rewrite of the key -- it needs nothing resolved.
|
|
32
|
+
//
|
|
33
|
+
// Pass 2, in pipeline.js, runs on the aggregate's own bundled output, once
|
|
34
|
+
// every $ref -- the members' and its own -- has been resolved. Only then are an
|
|
35
|
+
// operation's id, and whether it already sets its own `security`, visible at
|
|
36
|
+
// all, so that is where operation ids are prefixed, root security is pushed
|
|
37
|
+
// down onto the operations it reaches, and repeated tags are reconciled by the
|
|
38
|
+
// name inside them, which a $ref pointer to a tag fragment never shows.
|
|
18
39
|
|
|
19
40
|
const fs = require("fs");
|
|
20
41
|
const path = require("path");
|
|
@@ -22,40 +43,128 @@ const YAML = require("yaml");
|
|
|
22
43
|
|
|
23
44
|
class AggregateError extends Error {}
|
|
24
45
|
|
|
46
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
47
|
+
|
|
25
48
|
/**
|
|
26
49
|
* Merge one section of a member's document into the aggregate.
|
|
27
50
|
*
|
|
51
|
+
* Three modes, for three different expectations of what a repeated key means:
|
|
52
|
+
*
|
|
53
|
+
* `unique` (the default) treats a repeated key as ambiguous ownership, whatever
|
|
54
|
+
* it says -- ordinary for a channel, an operation or a path, where one of two
|
|
55
|
+
* identical definitions silently not appearing in the aggregate is exactly the
|
|
56
|
+
* kind of mistake this is here to catch.
|
|
57
|
+
*
|
|
28
58
|
* `agree` marks sections where members are expected to say the same thing.
|
|
29
59
|
* Several services publishing to one broker all declare that broker, and that is
|
|
30
60
|
* the ordinary case rather than a conflict -- so an identical definition merges
|
|
31
61
|
* silently and only a genuine disagreement is an error.
|
|
32
62
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
63
|
+
* `reconcile` is for a section where members are expected to sometimes *disagree*
|
|
64
|
+
* without either being wrong -- a shared tag two members describe slightly
|
|
65
|
+
* differently is an inconsistency to fix, not a defect to fail the build over.
|
|
66
|
+
* An identical repeat merges silently, like `agree`; a differing one merges too,
|
|
67
|
+
* with the first contributing member's value kept and the disagreement reported,
|
|
68
|
+
* rather than failing the build.
|
|
69
|
+
*
|
|
70
|
+
* @returns {Array<{section: string, key: string, winner: string, loser: string}>}
|
|
71
|
+
* one entry per reconciled disagreement, always empty outside `reconcile` mode
|
|
36
72
|
*/
|
|
37
|
-
function mergeSection(into, from, section, member, seen, {
|
|
38
|
-
|
|
73
|
+
function mergeSection(into, from, section, member, seen, { mode = "unique", label = section } = {}) {
|
|
74
|
+
const reconciled = [];
|
|
75
|
+
if (!from[section]) return reconciled;
|
|
39
76
|
for (const [key, value] of Object.entries(from[section])) {
|
|
40
77
|
const previous = seen[section] && seen[section][key];
|
|
41
78
|
if (previous) {
|
|
42
79
|
const identical = JSON.stringify(into[section][key]) === JSON.stringify(value);
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
80
|
+
if (identical) {
|
|
81
|
+
if (mode === "unique") {
|
|
82
|
+
throw new AggregateError(
|
|
83
|
+
`aggregate: '${member}' redefines ${label}.${key}, already contributed by '${previous}'. ` +
|
|
84
|
+
"Rename it, or leave it out of the aggregate."
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (mode === "reconcile") {
|
|
90
|
+
reconciled.push({ section, key, winner: previous, loser: member });
|
|
91
|
+
continue;
|
|
51
92
|
}
|
|
52
|
-
|
|
93
|
+
throw new AggregateError(
|
|
94
|
+
`aggregate: '${member}' ${mode === "agree" ? "disagrees about" : "redefines"} ` +
|
|
95
|
+
`${label}.${key}, already contributed by '${previous}'. ` +
|
|
96
|
+
(mode === "agree"
|
|
97
|
+
? "Members may share a server, but not define it differently."
|
|
98
|
+
: "Rename it, or leave it out of the aggregate.")
|
|
99
|
+
);
|
|
53
100
|
}
|
|
54
101
|
into[section] = into[section] || {};
|
|
55
102
|
into[section][key] = value;
|
|
56
103
|
seen[section] = seen[section] || {};
|
|
57
104
|
seen[section][key] = member;
|
|
58
105
|
}
|
|
106
|
+
return reconciled;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Merges every sub-map of `components` present in `from`, `mode` for each. */
|
|
110
|
+
function mergeComponents(into, from, member, seen, mode) {
|
|
111
|
+
if (!from.components) return;
|
|
112
|
+
into.components = into.components || {};
|
|
113
|
+
seen.components = seen.components || {};
|
|
114
|
+
for (const sub of Object.keys(from.components)) {
|
|
115
|
+
into.components[sub] = into.components[sub] || {};
|
|
116
|
+
seen.components[sub] = seen.components[sub] || {};
|
|
117
|
+
mergeSection(into.components, from.components, sub, member, seen.components, { mode, label: `components.${sub}` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requireMember(config, target, kind, member) {
|
|
122
|
+
if (!config.targets[member] || !config.targets[member][kind]) {
|
|
123
|
+
throw new AggregateError(`target '${target}': aggregate member '${member}' declares no ${kind} bundle`);
|
|
124
|
+
}
|
|
125
|
+
if (config.targets[member][kind].aggregate !== undefined) {
|
|
126
|
+
throw new AggregateError(
|
|
127
|
+
`target '${target}': aggregate member '${member}' is itself an aggregate; nesting is not supported`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function memberDocument(config, target, kind, member) {
|
|
133
|
+
requireMember(config, target, kind, member);
|
|
134
|
+
const file = path.join(config.stagingDir(kind), config.targets[member][kind].bundle);
|
|
135
|
+
if (!fs.existsSync(file)) {
|
|
136
|
+
throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
|
|
137
|
+
}
|
|
138
|
+
return YAML.parse(fs.readFileSync(file, "utf8"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function membersOf(config, target, kind) {
|
|
142
|
+
const members = config.targets[target][kind].aggregate;
|
|
143
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
144
|
+
throw new AggregateError(`target '${target}': ${kind}.aggregate must list at least one target`);
|
|
145
|
+
}
|
|
146
|
+
return members;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The aggregate's own identity: its own info, or a generated stand-in naming its members. */
|
|
150
|
+
function aggregateInfo(spec, target, members, forKind) {
|
|
151
|
+
return spec.info || {
|
|
152
|
+
title: `${target} (aggregate)`,
|
|
153
|
+
version: "0.0.0",
|
|
154
|
+
description: forKind === "asyncapi"
|
|
155
|
+
? `Every event contract published across ${members.join(", ")}.`
|
|
156
|
+
: `Every API published across ${members.join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeGenerated(out, members, content) {
|
|
161
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
out,
|
|
164
|
+
`# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
|
|
165
|
+
"# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n" +
|
|
166
|
+
YAML.stringify(content)
|
|
167
|
+
);
|
|
59
168
|
}
|
|
60
169
|
|
|
61
170
|
/**
|
|
@@ -65,54 +174,169 @@ function mergeSection(into, from, section, member, seen, { agree = false } = {})
|
|
|
65
174
|
*/
|
|
66
175
|
function generateAsyncApi(config, target, { log = () => {} } = {}) {
|
|
67
176
|
const spec = config.targets[target].asyncapi;
|
|
68
|
-
const members =
|
|
69
|
-
if (!Array.isArray(members) || members.length === 0) {
|
|
70
|
-
throw new AggregateError(`target '${target}': asyncapi.aggregate must list at least one target`);
|
|
71
|
-
}
|
|
177
|
+
const members = membersOf(config, target, "asyncapi");
|
|
72
178
|
|
|
73
179
|
const merged = { asyncapi: null, info: null, servers: {}, channels: {}, operations: {} };
|
|
74
180
|
const seen = {};
|
|
75
181
|
|
|
76
182
|
for (const member of members) {
|
|
77
|
-
|
|
78
|
-
throw new AggregateError(
|
|
79
|
-
`target '${target}': aggregate member '${member}' declares no asyncapi bundle`
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
const file = path.join(config.stagingDir("asyncapi"), config.targets[member].asyncapi.bundle);
|
|
83
|
-
if (!fs.existsSync(file)) {
|
|
84
|
-
throw new AggregateError(`aggregate member '${member}': bundle root not found at ${file}`);
|
|
85
|
-
}
|
|
86
|
-
const doc = YAML.parse(fs.readFileSync(file, "utf8"));
|
|
183
|
+
const doc = memberDocument(config, target, "asyncapi", member);
|
|
87
184
|
|
|
88
185
|
merged.asyncapi = merged.asyncapi || doc.asyncapi;
|
|
89
|
-
mergeSection(merged, doc, "servers", member, seen, {
|
|
186
|
+
mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
|
|
90
187
|
mergeSection(merged, doc, "channels", member, seen);
|
|
91
188
|
mergeSection(merged, doc, "operations", member, seen);
|
|
92
189
|
}
|
|
93
190
|
|
|
94
|
-
|
|
95
|
-
merged.info = spec.info || {
|
|
96
|
-
title: `${target} (aggregate)`,
|
|
97
|
-
version: "0.0.0",
|
|
98
|
-
description: `Every event contract published across ${members.join(", ")}.`,
|
|
99
|
-
};
|
|
191
|
+
merged.info = aggregateInfo(spec, target, members, "asyncapi");
|
|
100
192
|
|
|
101
|
-
const out =
|
|
102
|
-
|
|
103
|
-
fs.writeFileSync(
|
|
104
|
-
out,
|
|
105
|
-
`# GENERATED by api-only-publisher from: ${members.join(", ")}\n` +
|
|
106
|
-
`# Do not edit, and do not commit: it is rebuilt into the staging tree on every build.\n` +
|
|
107
|
-
YAML.stringify(merged)
|
|
108
|
-
);
|
|
193
|
+
const out = config.aggregatePath(target, "asyncapi");
|
|
194
|
+
writeGenerated(out, members, merged);
|
|
109
195
|
log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
|
|
110
196
|
return out;
|
|
111
197
|
}
|
|
112
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Synthesise an aggregate OpenAPI bundle root into the staged tree.
|
|
201
|
+
*
|
|
202
|
+
* Only what a member's bundle root holds unresolved: paths (prefixed, so keys are
|
|
203
|
+
* unique by construction -- see openapiPushDown for what happens to the
|
|
204
|
+
* operations behind them once they are resolved), servers, root-level security,
|
|
205
|
+
* and every `components.*` sub-map. Tags are carried over as whatever the member
|
|
206
|
+
* declared -- usually a list of $ref pointers -- and reconciled by name in
|
|
207
|
+
* openapiPushDown, once bundling has resolved them into `{name, ...}` objects a
|
|
208
|
+
* $ref pointer never shows.
|
|
209
|
+
*
|
|
210
|
+
* @returns {string} the path of the generated bundle root
|
|
211
|
+
*/
|
|
212
|
+
function generateOpenApi(config, target, { log = () => {} } = {}) {
|
|
213
|
+
const spec = config.targets[target].openapi;
|
|
214
|
+
const members = membersOf(config, target, "openapi");
|
|
215
|
+
const prefixPaths = config.portfolioPathStrategy() === "target-prefix";
|
|
216
|
+
|
|
217
|
+
const merged = { openapi: null, info: null, servers: {}, tags: [], paths: {}, components: {} };
|
|
218
|
+
const seen = {};
|
|
219
|
+
// Recorded here, in pass 1, because only here is it still known which member a
|
|
220
|
+
// path came from: once paths are merged, one document can no longer tell.
|
|
221
|
+
const owners = {};
|
|
222
|
+
|
|
223
|
+
for (const member of members) {
|
|
224
|
+
const doc = memberDocument(config, target, "openapi", member);
|
|
225
|
+
|
|
226
|
+
merged.openapi = merged.openapi || doc.openapi;
|
|
227
|
+
mergeSection(merged, doc, "servers", member, seen, { mode: "agree" });
|
|
228
|
+
mergeComponents(merged, doc, member, seen, "agree");
|
|
229
|
+
if (Array.isArray(doc.tags)) merged.tags.push(...doc.tags);
|
|
230
|
+
|
|
231
|
+
const prefixed = {};
|
|
232
|
+
for (const [rawPath, item] of Object.entries(doc.paths || {})) {
|
|
233
|
+
prefixed[prefixPaths ? `/${member}${rawPath}` : rawPath] = item;
|
|
234
|
+
}
|
|
235
|
+
mergeSection(merged, { paths: prefixed }, "paths", member, seen);
|
|
236
|
+
for (const finalPath of Object.keys(prefixed)) {
|
|
237
|
+
owners[finalPath] = { member, security: doc.security };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (Object.keys(merged.components).length === 0) delete merged.components;
|
|
241
|
+
if (merged.tags.length === 0) delete merged.tags;
|
|
242
|
+
|
|
243
|
+
merged.info = aggregateInfo(spec, target, members, "openapi");
|
|
244
|
+
|
|
245
|
+
const out = config.aggregatePath(target, "openapi");
|
|
246
|
+
writeGenerated(out, members, merged);
|
|
247
|
+
log(`-- Generated aggregate ${path.basename(out)} from ${members.join(", ")}`);
|
|
248
|
+
return { out, owners };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const SECURITY_NOTE =
|
|
252
|
+
"Each member's own root-level `security` has been moved onto the operations it contributes " +
|
|
253
|
+
"-- see each operation's own `security`, and `components.securitySchemes` for what each scheme requires.";
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Pass 2: what generateOpenApi could not do until its own output was bundled.
|
|
257
|
+
* Run on `text` -- the aggregate's *bundled* document -- after every $ref, the
|
|
258
|
+
* members' and its own, has been resolved.
|
|
259
|
+
*
|
|
260
|
+
* - Every operation id is prefixed with its path's member, the same way the path
|
|
261
|
+
* itself already was, so two members sharing an operation id -- coincidence,
|
|
262
|
+
* not intent -- cannot collide in the portfolio.
|
|
263
|
+
* - Every member's root-level `security` -- explicit `[]` included -- becomes
|
|
264
|
+
* every operation it contributes to's own `security`, unless the operation
|
|
265
|
+
* already set one; the portfolio itself keeps no root `security`. See the
|
|
266
|
+
* Security section of the README for why: union and intersection are both
|
|
267
|
+
* unsound merges of a security requirement, and requiring every member to
|
|
268
|
+
* agree would make a landscape where they legitimately differ unbuildable.
|
|
269
|
+
* Where anything was pushed down, `info.description` and `x-security-note`
|
|
270
|
+
* both say so -- a comment would not survive bundling, so neither carries this.
|
|
271
|
+
* - Two members contributing the same tag name are one tag; an identical body
|
|
272
|
+
* merges silently, a differing one is reported and the first contributor's
|
|
273
|
+
* body wins, deterministically.
|
|
274
|
+
*
|
|
275
|
+
* Edits the document in place -- `YAML.parseDocument`, never a parse and
|
|
276
|
+
* re-stringify -- so everything this pass does not touch keeps the bundler's own
|
|
277
|
+
* formatting exactly, the same reason version.js splices rather than re-emits.
|
|
278
|
+
*
|
|
279
|
+
* @returns {{text: string, reconciled: Array<{section: string, key: string}>}}
|
|
280
|
+
*/
|
|
281
|
+
function openapiPushDown(text, owners, { operationIdStrategy = "target-prefix" } = {}) {
|
|
282
|
+
const doc = YAML.parseDocument(text);
|
|
283
|
+
const plain = doc.toJS();
|
|
284
|
+
let pushedDown = false;
|
|
285
|
+
|
|
286
|
+
for (const [pathKey, item] of Object.entries(plain.paths || {})) {
|
|
287
|
+
const owner = owners[pathKey];
|
|
288
|
+
if (!owner || !item || typeof item !== "object") continue;
|
|
289
|
+
for (const httpMethod of HTTP_METHODS) {
|
|
290
|
+
const op = item[httpMethod];
|
|
291
|
+
if (!op || typeof op !== "object") continue;
|
|
292
|
+
if (op.operationId && operationIdStrategy === "target-prefix") {
|
|
293
|
+
doc.setIn(["paths", pathKey, httpMethod, "operationId"], `${pascalCase(owner.member)}${op.operationId}`);
|
|
294
|
+
}
|
|
295
|
+
if (op.security === undefined && owner.security !== undefined) {
|
|
296
|
+
doc.setIn(["paths", pathKey, httpMethod, "security"], owner.security);
|
|
297
|
+
pushedDown = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (pushedDown) {
|
|
303
|
+
const description = doc.getIn(["info", "description"]);
|
|
304
|
+
doc.setIn(["info", "description"], description ? `${description}\n\n${SECURITY_NOTE}` : SECURITY_NOTE);
|
|
305
|
+
doc.setIn(["x-security-note"], SECURITY_NOTE);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const reconciled = [];
|
|
309
|
+
if (Array.isArray(plain.tags)) {
|
|
310
|
+
const byName = new Map();
|
|
311
|
+
const order = [];
|
|
312
|
+
for (const tag of plain.tags) {
|
|
313
|
+
const existing = byName.get(tag.name);
|
|
314
|
+
if (existing === undefined) {
|
|
315
|
+
byName.set(tag.name, tag);
|
|
316
|
+
order.push(tag.name);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (JSON.stringify(existing) !== JSON.stringify(tag)) {
|
|
320
|
+
reconciled.push({ section: "tags", key: tag.name });
|
|
321
|
+
}
|
|
322
|
+
// The first contributor's body wins either way -- identical or not.
|
|
323
|
+
}
|
|
324
|
+
doc.setIn(["tags"], order.map((name) => byName.get(name)));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { text: doc.toString(), reconciled };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A target name, in PascalCase: user-account -> UserAccount. */
|
|
331
|
+
function pascalCase(target) {
|
|
332
|
+
return target.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
333
|
+
}
|
|
334
|
+
|
|
113
335
|
function isAggregate(config, target, kind) {
|
|
114
336
|
const spec = config.targets[target][kind];
|
|
115
337
|
return Boolean(spec && spec.aggregate);
|
|
116
338
|
}
|
|
117
339
|
|
|
118
|
-
module.exports = {
|
|
340
|
+
module.exports = {
|
|
341
|
+
generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate, mergeSection, pascalCase, AggregateError,
|
|
342
|
+
};
|
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)).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`
|