@arc-e-tect/api-only-publisher 0.6.2 → 0.7.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 +132 -4
- package/package.json +1 -1
- package/src/cli.js +82 -11
- package/src/init-config.js +115 -0
- package/src/init-questions.js +186 -0
- package/src/init.js +276 -72
package/README.adoc
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
:source-highlighter: rouge
|
|
7
7
|
// The released version of this component: its snippets use it. The release
|
|
8
8
|
// workflow updates it; do not change it by hand.
|
|
9
|
-
:api-only-publisher-version: 0.
|
|
9
|
+
:api-only-publisher-version: 0.7.1
|
|
10
10
|
|
|
11
11
|
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]
|
|
12
12
|
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]
|
|
@@ -42,6 +42,8 @@ It is the producer half of a pair.
|
|
|
42
42
|
The consumer half, the link:../api-only-subscriber/README.adoc[API-Only Subscriber →], fetches a published document into an implementation project and verifies that it has not drifted.
|
|
43
43
|
The two are coupled only by the published artifact and its manifest; neither knows anything else about the other.
|
|
44
44
|
|
|
45
|
+
The link:../api-only-transcriberj/README.adoc[API-Only TranscriberJ →] builds on what the Subscriber fetched: it generates Java classes for contract tests from the published document, named after the fragments the Publisher stamps into it with `x-fragment-path`.
|
|
46
|
+
|
|
45
47
|
A *target* is whatever gets its own contract document: a microservice, a modular monolith, a backend-for-frontend, or a documentation aggregate spanning all of them.
|
|
46
48
|
It need not be a deployable, which is why this is not called `services`.
|
|
47
49
|
|
|
@@ -136,8 +138,9 @@ However the Publisher itself is started, it fetches them with `npx`, at the vers
|
|
|
136
138
|
|===
|
|
137
139
|
|Command |Purpose
|
|
138
140
|
|
|
139
|
-
|`init [dir] [--force]`
|
|
140
|
-
|Scaffold `apionly.yaml` and a reference directory layout that builds as it stands.
|
|
141
|
+
|`init [dir] [--yes] [--force] [value flags]`
|
|
142
|
+
|Scaffold `apionly.yaml` and a reference directory layout that builds as it stands: OpenAPI, AsyncAPI or both.
|
|
143
|
+
At a terminal it asks for each value first, as <<init,Scaffolding a library §>> describes.
|
|
141
144
|
|
|
142
145
|
|`build [--target <t>]... [--pre-release <ids>] [--openapi\|--asyncapi]`
|
|
143
146
|
|Stage, substitute placeholders, bundle, stamp each published target's version, and lint.
|
|
@@ -178,6 +181,131 @@ npm run apionly -- publish --target orders --pre-release rc.1
|
|
|
178
181
|
npm run apionly -- changed --since origin/main --quiet
|
|
179
182
|
----
|
|
180
183
|
|
|
184
|
+
[#init]
|
|
185
|
+
== Scaffolding a library
|
|
186
|
+
|
|
187
|
+
`init` writes a library that builds, lints and publishes as it stands, from a handful of values: which kinds of document it holds, its target's name, the API's title and so on.
|
|
188
|
+
|
|
189
|
+
[source,console,subs="attributes+"]
|
|
190
|
+
----
|
|
191
|
+
npx @arc-e-tect/api-only-publisher@{api-only-publisher-version} init my-api-library
|
|
192
|
+
----
|
|
193
|
+
|
|
194
|
+
At a terminal it asks for each value, showing the default in brackets; Enter takes it.
|
|
195
|
+
An answer that would break the build later is refused, and the question asked again.
|
|
196
|
+
|
|
197
|
+
[source,text]
|
|
198
|
+
----
|
|
199
|
+
Kinds of document: openapi, asyncapi or both [openapi]: both
|
|
200
|
+
Target name [example-service]: Customer_Orders
|
|
201
|
+
'Customer_Orders' is not a target name: use lowercase letters and digits, in words joined by single hyphens, starting with a letter, such as customer-orders
|
|
202
|
+
Target name [example-service]: customer-orders
|
|
203
|
+
API title [Example API]: Customer Orders API
|
|
204
|
+
Initial contract version [0.1.0]:
|
|
205
|
+
...
|
|
206
|
+
----
|
|
207
|
+
|
|
208
|
+
Where nobody is watching -- no terminal, as in CI, a script or an AI assistant -- it asks nothing and takes the defaults, as it always has.
|
|
209
|
+
`--yes` (or `-y`) does the same at a terminal.
|
|
210
|
+
|
|
211
|
+
[cols="2,2,3", options="header"]
|
|
212
|
+
|===
|
|
213
|
+
|Question |Flag |Default, and what is accepted
|
|
214
|
+
|
|
215
|
+
|Kinds of document
|
|
216
|
+
|`--openapi`, `--asyncapi`; both flags for both
|
|
217
|
+
|`openapi`. The library's trees and targets follow from it.
|
|
218
|
+
|
|
219
|
+
|Target name
|
|
220
|
+
|`--target <name>`
|
|
221
|
+
|`example-service`. Lowercase kebab-case: a lowercase letter first, then lowercase letters and digits, in words joined by single hyphens -- `orders`, `customer-orders`, `orders-v2`.
|
|
222
|
+
The name becomes file names, a properties key, a Maven artifactId, a git tag and, through the Subscriber, a Gradle task name, which treats `-`, `_` and `.` alike; one separator keeps two targets from claiming one task.
|
|
223
|
+
|
|
224
|
+
|API title
|
|
225
|
+
|`--title <text>`
|
|
226
|
+
|`Example API`. Any text.
|
|
227
|
+
|
|
228
|
+
|Initial contract version
|
|
229
|
+
|`--contract-version <version>`
|
|
230
|
+
|`0.1.0`. A semantic release version, not a pre-release: it goes in the target's version file.
|
|
231
|
+
|
|
232
|
+
|Contact name
|
|
233
|
+
|`--contact-name <text>`
|
|
234
|
+
|`Example Team`.
|
|
235
|
+
|
|
236
|
+
|Contact URL
|
|
237
|
+
|`--contact-url <url>`
|
|
238
|
+
|`https://example.invalid`. An http or https URL.
|
|
239
|
+
|
|
240
|
+
|Licence
|
|
241
|
+
|`--license <spdx-id>`
|
|
242
|
+
|`Apache-2.0`. An SPDX identifier; the URL of a well-known licence follows from it.
|
|
243
|
+
|
|
244
|
+
|Licence URL
|
|
245
|
+
|`--license-url <url>`
|
|
246
|
+
|Asked only for a licence whose URL the tool does not know, and then required.
|
|
247
|
+
|
|
248
|
+
|Production server URL
|
|
249
|
+
|`--server-url <url>`
|
|
250
|
+
|`https://api.example.invalid`. Asked only for a library with OpenAPI.
|
|
251
|
+
|
|
252
|
+
|Broker host
|
|
253
|
+
|`--broker-host <host[:port]>`
|
|
254
|
+
|`kafka:9092`. Asked only for a library with AsyncAPI.
|
|
255
|
+
|===
|
|
256
|
+
|
|
257
|
+
A flag always wins: its question is not asked, and an invalid value, or a flag for a kind the library does not hold, stops `init` before it writes anything.
|
|
258
|
+
|
|
259
|
+
What each kind of library gets::
|
|
260
|
+
* *OpenAPI*: the OpenAPI tree under `specs/openapi/`, its target, and `.redocly.yaml`, the lint rules Redocly applies.
|
|
261
|
+
`apionly.yaml` holds no `asyncapi` keys.
|
|
262
|
+
* *AsyncAPI*: the AsyncAPI tree under `specs/asyncapi/` -- a channel, a message and its payload -- and its target.
|
|
263
|
+
`apionly.yaml` holds no `openapi` keys, and there is no `.redocly.yaml` or Redocly pin: an AsyncAPI document is bundled and validated with the AsyncAPI CLI alone.
|
|
264
|
+
* *Both*: both trees, one target building both documents, and one schema they share: `specs/openapi/components/common/schemas/ExampleIdV1.yaml`, which the HTTP response and the event payload both refer to.
|
|
265
|
+
That is the library's convention in miniature -- a definition used by both protocols is one fragment -- and why the whole source root is staged together (<<what-build-does,What `build` does §>>).
|
|
266
|
+
|
|
267
|
+
What it reports::
|
|
268
|
+
`init` never overwrites what is there unless told to, and says for every file what it did:
|
|
269
|
+
+
|
|
270
|
+
[horizontal]
|
|
271
|
+
`created`:: The file was not there, and is now.
|
|
272
|
+
`identical`:: The file was there, and matches what `init` would write, ignoring line endings and final newlines.
|
|
273
|
+
`updated`:: `apionly.yaml` was there, and lacked a kind's configuration; it does now.
|
|
274
|
+
The message names every slot added -- see <<init-additive,Adding a kind to an existing library §>>.
|
|
275
|
+
`differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
|
|
276
|
+
It is left alone.
|
|
277
|
+
`overwrote`:: The file differed, and `--force` replaced it.
|
|
278
|
+
+
|
|
279
|
+
At a terminal, `--force` first lists the files that still differ -- after any additive update apionly.yaml already got -- and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
|
|
280
|
+
|
|
281
|
+
[#init-additive]
|
|
282
|
+
=== Adding a kind to an existing library
|
|
283
|
+
|
|
284
|
+
Running `init` again, with a kind the library did not have, is how a library grows: the fragments the new kind needs, and the configuration that makes the build see them.
|
|
285
|
+
|
|
286
|
+
*Files* stay create-only, as everywhere else in `init`: a fragment not written before is written now; one already there, however it now reads, is left alone -- even where the new kind changes what the scaffold would write for it, such as `specs/openapi/paths/example/ExamplesV1.yaml` once an AsyncAPI event comes to share its schema.
|
|
287
|
+
|
|
288
|
+
*Configuration* is additive: whichever of `sources.<kind>`, `defaults.<kind>`, the kind's pin in `toolchain`, and `targets.<target>.<kind>` `apionly.yaml` lacks is added to it, in the position a library scaffolded with both kinds from the start would have it in.
|
|
289
|
+
Every value, key and comment already in the file survives untouched, and a slot that is already there, however it reads, is never touched either -- not even to add something missing from *inside* it: a `defaults.openapi` with no `lint` is a choice, and `init` leaves it that way.
|
|
290
|
+
|
|
291
|
+
[source,console]
|
|
292
|
+
----
|
|
293
|
+
$ api-only-publisher init . --yes --openapi --target orders
|
|
294
|
+
created apionly.yaml
|
|
295
|
+
created specs/openapi/...
|
|
296
|
+
|
|
297
|
+
$ api-only-publisher init . --yes --openapi --asyncapi --target orders
|
|
298
|
+
created specs/asyncapi/...
|
|
299
|
+
updated apionly.yaml (added sources.asyncapi, defaults.asyncapi, toolchain.asyncapi, targets.orders.asyncapi)
|
|
300
|
+
differs specs/openapi/paths/example/ExamplesV1.yaml (left alone; --force overwrites)
|
|
301
|
+
----
|
|
302
|
+
|
|
303
|
+
Nothing is asked before completing `apionly.yaml`: unlike `--force`, an additive update never discards anything, so there is nothing to confirm.
|
|
304
|
+
Run `init` again with the same kinds, and it reports nothing left to add.
|
|
305
|
+
|
|
306
|
+
`--force` is the other tool for an apionly.yaml that differs, and it does the opposite of the additive update: it reinitialises the file wholesale, exactly as a first run with these values would write it -- any target or key it holds that these values would not produce is gone, additive or not.
|
|
307
|
+
Use it to discard a customisation on purpose; use a plain `init` to add a kind without touching anything else.
|
|
308
|
+
|
|
181
309
|
[#what-build-does]
|
|
182
310
|
== What `build` does
|
|
183
311
|
|
|
@@ -435,7 +563,7 @@ Every target's document of one kind has the same `outputName`; keep the defaults
|
|
|
435
563
|
=== Where bundle roots go
|
|
436
564
|
|
|
437
565
|
Put every bundle root in a `bundles/` directory of its kind's source directory, named `<target>_<kind>_structure.yaml`: `specs/openapi/bundles/orders_openapi_structure.yaml` and `specs/asyncapi/bundles/orders_asyncapi_structure.yaml`.
|
|
438
|
-
The `init` scaffold lays out its
|
|
566
|
+
The `init` scaffold lays out its bundle roots this way, for each kind it writes.
|
|
439
567
|
|
|
440
568
|
* A bundle root is a table of contents rather than a fragment, and a `bundles/` directory keeps the two apart.
|
|
441
569
|
* The name says which target and which kind, so the two roots of one target are never confused, in the tree or in a review.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,7 +5,8 @@ const path = require("path");
|
|
|
5
5
|
|
|
6
6
|
const { loadFrom, ConfigError } = require("./config");
|
|
7
7
|
const { build, prepare, BuildError } = require("./pipeline");
|
|
8
|
-
const { init } = require("./init");
|
|
8
|
+
const { init, scaffold, plan } = require("./init");
|
|
9
|
+
const { resolveValues } = require("./init-questions");
|
|
9
10
|
const { PlaceholderError } = require("./placeholders");
|
|
10
11
|
const { VersionError } = require("./version");
|
|
11
12
|
const { forTargets, ClosureError } = require("./closure");
|
|
@@ -20,7 +21,11 @@ const { unreferenced } = require("./unreferenced");
|
|
|
20
21
|
const USAGE = `api-only-publisher -- build and distribute API description documents
|
|
21
22
|
|
|
22
23
|
Usage:
|
|
23
|
-
api-only-publisher init [dir] [--force]
|
|
24
|
+
api-only-publisher init [dir] [--yes] [--force] [--openapi] [--asyncapi] [--target <name>]
|
|
25
|
+
[--title <text>] [--contract-version <version>]
|
|
26
|
+
[--contact-name <text>] [--contact-url <url>]
|
|
27
|
+
[--license <spdx-id>] [--license-url <url>]
|
|
28
|
+
[--server-url <url>] [--broker-host <host[:port]>]
|
|
24
29
|
api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
|
|
25
30
|
api-only-publisher lint [--target <name>]...
|
|
26
31
|
api-only-publisher targets
|
|
@@ -37,7 +42,16 @@ Options:
|
|
|
37
42
|
rc.1, to each target's version.
|
|
38
43
|
--openapi Only build OpenAPI documents.
|
|
39
44
|
--asyncapi Only build AsyncAPI documents.
|
|
40
|
-
--force init only: overwrite files that
|
|
45
|
+
--force init only: overwrite files that differ from the scaffold;
|
|
46
|
+
at a terminal, after listing them and asking once.
|
|
47
|
+
-y, --yes init only: ask nothing, even at a terminal; take the defaults
|
|
48
|
+
for every value no flag gives. Without a terminal, init never asks.
|
|
49
|
+
--openapi, --asyncapi
|
|
50
|
+
init: the kinds of document the library holds; both flags for both.
|
|
51
|
+
--target <name> init: the target's name, in lowercase kebab-case.
|
|
52
|
+
--title, --contract-version, --contact-name, --contact-url, --license,
|
|
53
|
+
--license-url, --server-url, --broker-host
|
|
54
|
+
init only: the value of that question; a flag always wins.
|
|
41
55
|
--since <ref> changed only: the git ref to compare the working tree against.
|
|
42
56
|
--out <dir> pack/publish/split: where to write.
|
|
43
57
|
--channel <name> publish only: repeat for several. Default: every configured channel.
|
|
@@ -55,6 +69,12 @@ function parseArgs(argv) {
|
|
|
55
69
|
const options = {
|
|
56
70
|
targets: [], kinds: null, preRelease: null, quiet: false, force: false,
|
|
57
71
|
dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
|
|
72
|
+
yes: false, init: {},
|
|
73
|
+
};
|
|
74
|
+
const INIT_VALUES = {
|
|
75
|
+
"--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
|
|
76
|
+
"--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
|
|
77
|
+
"--server-url": "serverUrl", "--broker-host": "brokerHost",
|
|
58
78
|
};
|
|
59
79
|
const positional = [];
|
|
60
80
|
|
|
@@ -83,7 +103,12 @@ function parseArgs(argv) {
|
|
|
83
103
|
case "-C": options.dir = path.resolve(next()); break;
|
|
84
104
|
case "-q": case "--quiet": options.quiet = true; break;
|
|
85
105
|
case "-h": case "--help": options.help = true; break;
|
|
106
|
+
case "-y": case "--yes": options.yes = true; break;
|
|
86
107
|
default:
|
|
108
|
+
if (INIT_VALUES[arg]) {
|
|
109
|
+
options.init[INIT_VALUES[arg]] = next();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
87
112
|
if (arg.startsWith("-")) throw new ConfigError(`unrecognized option '${arg}'`);
|
|
88
113
|
positional.push(arg);
|
|
89
114
|
}
|
|
@@ -106,7 +131,59 @@ function versionsOf(config, names, preRelease) {
|
|
|
106
131
|
return new Map(names.map((target) => [target, versionOf(config, target, { preRelease })]));
|
|
107
132
|
}
|
|
108
133
|
|
|
109
|
-
|
|
134
|
+
/** The kinds --openapi and --asyncapi name, in the order the scaffold writes them. */
|
|
135
|
+
function initKinds(kinds) {
|
|
136
|
+
return kinds ? ["openapi", "asyncapi"].filter((kind) => kinds.includes(kind)) : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Scaffolds a library. At a terminal, and without --yes, it asks for every value no
|
|
141
|
+
* flag gives; anywhere else it takes the defaults, as it always has.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} io where a terminal is: `interactive`, and the `input` and `output`
|
|
144
|
+
* streams to ask on; process.stdin and process.stdout unless the caller says otherwise
|
|
145
|
+
*/
|
|
146
|
+
async function runInit(options, positional, io, log) {
|
|
147
|
+
if (options.targets.length > 1) {
|
|
148
|
+
throw new ConfigError(`init scaffolds one target; --target was given ${options.targets.length} times`);
|
|
149
|
+
}
|
|
150
|
+
const dir = path.resolve(options.dir, positional[1] || ".");
|
|
151
|
+
const input = io.input || process.stdin;
|
|
152
|
+
const output = io.output || process.stdout;
|
|
153
|
+
const interactive = io.interactive !== undefined ? io.interactive : Boolean(input.isTTY && output.isTTY);
|
|
154
|
+
const given = { ...options.init, target: options.targets[0], kinds: initKinds(options.kinds) };
|
|
155
|
+
for (const key of Object.keys(given)) if (given[key] === undefined) delete given[key];
|
|
156
|
+
|
|
157
|
+
const terminal = interactive && !options.yes
|
|
158
|
+
? require("node:readline/promises").createInterface({ input, output })
|
|
159
|
+
: null;
|
|
160
|
+
try {
|
|
161
|
+
const values = await resolveValues({
|
|
162
|
+
given,
|
|
163
|
+
ask: terminal && ((question) => terminal.question(question)),
|
|
164
|
+
tell: (message) => output.write(`${message}\n`),
|
|
165
|
+
});
|
|
166
|
+
log(`Scaffolding a specification library in ${dir}`);
|
|
167
|
+
let force = options.force;
|
|
168
|
+
if (terminal && force) {
|
|
169
|
+
// Asked, --force means "after showing me": it overwrites what differs only
|
|
170
|
+
// once the list has been seen and agreed to.
|
|
171
|
+
const differing = plan(dir, scaffold(values), values).filter((entry) => entry.status === "differs");
|
|
172
|
+
if (differing.length > 0) {
|
|
173
|
+
output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
|
|
174
|
+
const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
|
|
175
|
+
force = /^y(es)?$/i.test(answer.trim());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
init(dir, { values, force, log });
|
|
179
|
+
log(`\nNext: api-only-publisher build -C ${dir}`);
|
|
180
|
+
} finally {
|
|
181
|
+
if (terminal) terminal.close();
|
|
182
|
+
}
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main(argv, io = {}) {
|
|
110
187
|
const { options, positional } = parseArgs(argv);
|
|
111
188
|
const command = positional[0];
|
|
112
189
|
|
|
@@ -117,13 +194,7 @@ async function main(argv) {
|
|
|
117
194
|
|
|
118
195
|
const log = options.quiet ? () => {} : (message) => console.log(message);
|
|
119
196
|
|
|
120
|
-
if (command === "init")
|
|
121
|
-
const dir = path.resolve(options.dir, positional[1] || ".");
|
|
122
|
-
log(`Scaffolding a specification library in ${dir}`);
|
|
123
|
-
init(dir, { force: options.force, log });
|
|
124
|
-
log(`\nNext: api-only-publisher build -C ${dir}`);
|
|
125
|
-
return 0;
|
|
126
|
-
}
|
|
197
|
+
if (command === "init") return runInit(options, positional, io, log);
|
|
127
198
|
|
|
128
199
|
const config = loadFrom(options.dir);
|
|
129
200
|
const targets = options.targets.length > 0 ? options.targets : null;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Additively completing an apionly.yaml that already exists.
|
|
4
|
+
//
|
|
5
|
+
// `init` creates a file that is not there; this is the same rule applied to
|
|
6
|
+
// configuration. When a kind's support is missing from an existing apionly.yaml --
|
|
7
|
+
// in whole, because the library never had it, or in part, because a run was
|
|
8
|
+
// interrupted between writing the fragments and writing the config for them -- the
|
|
9
|
+
// slots it needs are added. What is already there, however it reads, is never
|
|
10
|
+
// touched: a `defaults.openapi` block with no `lint` is a choice, not an absence.
|
|
11
|
+
//
|
|
12
|
+
// The unit added is a *slot*: one of the handful of places a kind's support in
|
|
13
|
+
// apionly.yaml lives -- never a key inside one, which is why `slots()` below stops
|
|
14
|
+
// at the map a kind occupies and goes no further into it.
|
|
15
|
+
|
|
16
|
+
const YAML = require("yaml");
|
|
17
|
+
|
|
18
|
+
/** Where a kind's tool is pinned in `toolchain`; the two kinds do not share a key name. */
|
|
19
|
+
const TOOLCHAIN_KEY = { openapi: "redocly", asyncapi: "asyncapi" };
|
|
20
|
+
const TOOLCHAIN_PIN = { openapi: "@redocly/cli@2.52.0", asyncapi: "@asyncapi/cli@6.0.2" };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The slots scaffold(v) would fill for one kind, in the order config(v) declares them,
|
|
24
|
+
* each naming the sibling keys -- in the order to prefer -- a slot goes before when one
|
|
25
|
+
* of them is already there. `openapi` always precedes `asyncapi`, in every section that
|
|
26
|
+
* holds both; `asyncapi` precedes `placeholders`, the one section-closing key that is
|
|
27
|
+
* always there.
|
|
28
|
+
*/
|
|
29
|
+
function slotsFor(kind, v) {
|
|
30
|
+
const target = v.target;
|
|
31
|
+
if (kind === "openapi") {
|
|
32
|
+
return [
|
|
33
|
+
{ path: ["sources"], key: "openapi", value: "openapi", before: ["asyncapi"] },
|
|
34
|
+
{
|
|
35
|
+
path: ["defaults"], key: "openapi", before: ["asyncapi", "placeholders"],
|
|
36
|
+
value: { lint: ".redocly.yaml", outputName: "openapi.yaml" },
|
|
37
|
+
},
|
|
38
|
+
{ path: ["toolchain"], key: "redocly", value: TOOLCHAIN_PIN.openapi, before: ["asyncapi"] },
|
|
39
|
+
{
|
|
40
|
+
path: ["targets", target], key: "openapi", before: ["asyncapi"],
|
|
41
|
+
value: { bundle: `bundles/${target}_openapi_structure.yaml` },
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
{ path: ["sources"], key: "asyncapi", value: "asyncapi", before: [] },
|
|
47
|
+
{ path: ["defaults"], key: "asyncapi", value: { outputName: "asyncapi.yaml" }, before: ["placeholders"] },
|
|
48
|
+
{ path: ["toolchain"], key: "asyncapi", value: TOOLCHAIN_PIN.asyncapi, before: [] },
|
|
49
|
+
{
|
|
50
|
+
path: ["targets", target], key: "asyncapi", before: [],
|
|
51
|
+
value: { bundle: `bundles/${target}_asyncapi_structure.yaml` },
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A slot's YAML path, dot-separated, as the report names it. */
|
|
57
|
+
function slotName(slot) {
|
|
58
|
+
return [...slot.path, slot.key].join(".");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sets `key: value` in the map at `parentPath`, before the first of `before` that is
|
|
63
|
+
* already a sibling there, or at the end when none is. Creates `parentPath` itself,
|
|
64
|
+
* as `Document#setIn` does, when it is not there yet -- there being nothing in a map
|
|
65
|
+
* that does not exist yet to come before.
|
|
66
|
+
*/
|
|
67
|
+
function place(doc, parentPath, key, value, before) {
|
|
68
|
+
const parent = doc.getIn(parentPath, true);
|
|
69
|
+
if (YAML.isMap(parent)) {
|
|
70
|
+
for (const candidate of before) {
|
|
71
|
+
const sibling = parent.items.find((pair) => String(pair.key) === candidate);
|
|
72
|
+
if (sibling) {
|
|
73
|
+
parent.items.splice(parent.items.indexOf(sibling), 0, doc.createPair(key, value));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
doc.setIn([...parentPath, key], value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adds to `text` -- an existing apionly.yaml -- whatever configuration slot
|
|
83
|
+
* scaffold(values) would write and this file lacks. Every value, key and comment
|
|
84
|
+
* already there survives unchanged; a slot already present, however it reads, is
|
|
85
|
+
* never touched, and neither is anything inside it.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text apionly.yaml, as it is on disk
|
|
88
|
+
* @param {object} v the values scaffold(values) was given, resolved: every value
|
|
89
|
+
* DEFAULTS has, `values`' own where it gives one -- the shape scaffold() itself
|
|
90
|
+
* works from, so a caller that already has that object need not rebuild it
|
|
91
|
+
* @returns {{text: string, added: string[]}} the edited text, and the slots added, in
|
|
92
|
+
* the order scaffold() declares them; `added` is empty, and `text` is `text`
|
|
93
|
+
* itself, when nothing was missing.
|
|
94
|
+
*/
|
|
95
|
+
function addMissingConfig(text, v) {
|
|
96
|
+
const doc = YAML.parseDocument(text);
|
|
97
|
+
const added = [];
|
|
98
|
+
|
|
99
|
+
// Not every file that differs is a library with a kind missing -- one that does not
|
|
100
|
+
// even declare sources.root is not an apionly.yaml this can complete, whatever it
|
|
101
|
+
// holds instead; force, or a person, is what that file needs.
|
|
102
|
+
if (!doc.hasIn(["sources", "root"])) return { text, added };
|
|
103
|
+
|
|
104
|
+
for (const kind of ["openapi", "asyncapi"].filter((k) => v.kinds.includes(k))) {
|
|
105
|
+
for (const slot of slotsFor(kind, v)) {
|
|
106
|
+
if (doc.hasIn([...slot.path, slot.key])) continue;
|
|
107
|
+
place(doc, slot.path, slot.key, slot.value, slot.before);
|
|
108
|
+
added.push(slotName(slot));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return added.length === 0 ? { text, added } : { text: doc.toString(), added };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { addMissingConfig };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The values `init` scaffolds from: asked for at a terminal, taken from flags, or
|
|
4
|
+
// defaulted -- in that order of precedence, a flag always winning.
|
|
5
|
+
//
|
|
6
|
+
// Nothing here reads input. The caller hands in `ask`, a function that shows a
|
|
7
|
+
// question and resolves to the answer, which is how the CLI connects a terminal
|
|
8
|
+
// and how the tests connect a script; without one, nobody is asked.
|
|
9
|
+
|
|
10
|
+
const { DEFAULTS } = require("./init");
|
|
11
|
+
const { ConfigError } = require("./config");
|
|
12
|
+
const { parse } = require("./version-policy");
|
|
13
|
+
|
|
14
|
+
/** SPDX identifiers whose licence text has a well-known URL, so it need not be asked for. */
|
|
15
|
+
const LICENSE_URLS = Object.freeze({
|
|
16
|
+
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
|
17
|
+
"MIT": "https://opensource.org/license/mit",
|
|
18
|
+
"BSD-2-Clause": "https://opensource.org/license/bsd-2-clause",
|
|
19
|
+
"BSD-3-Clause": "https://opensource.org/license/bsd-3-clause",
|
|
20
|
+
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
|
|
21
|
+
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
|
|
22
|
+
"GPL-3.0-only": "https://www.gnu.org/licenses/gpl-3.0.html",
|
|
23
|
+
"LGPL-3.0-only": "https://www.gnu.org/licenses/lgpl-3.0.html",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// A target name becomes a file name, a properties key, a Maven artifactId, a git tag
|
|
27
|
+
// and, through the Subscriber, a Gradle task name -- which treats -, _ and . alike, so
|
|
28
|
+
// only one of them may separate words, or two targets could claim one task.
|
|
29
|
+
const TARGET = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
30
|
+
|
|
31
|
+
function checkTarget(value) {
|
|
32
|
+
return TARGET.test(value) ? null
|
|
33
|
+
: "use lowercase letters and digits, in words joined by single hyphens, starting with a letter, " +
|
|
34
|
+
"such as customer-orders";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkVersion(value) {
|
|
38
|
+
let version;
|
|
39
|
+
try {
|
|
40
|
+
version = parse(value);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return "it is not a semantic version, such as 0.1.0";
|
|
43
|
+
}
|
|
44
|
+
return version.prerelease ? "a contract starts at a release version, not a pre-release" : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkUrl(value) {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
if (url.protocol === "http:" || url.protocol === "https:") return null;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Not a URL at all; reported below like any other.
|
|
53
|
+
}
|
|
54
|
+
return "it is not an http or https URL";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checkHost(value) {
|
|
58
|
+
return /^[A-Za-z0-9.-]+(:\d{1,5})?$/.test(value) ? null : "give a host, optionally with a port, such as kafka:9092";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function checkText(value) {
|
|
62
|
+
return value.trim() ? null : "it may not be empty";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The kinds a library holds, from what someone typed; null when it names none. */
|
|
66
|
+
function parseKinds(text) {
|
|
67
|
+
switch (text.trim().toLowerCase()) {
|
|
68
|
+
case "openapi": return ["openapi"];
|
|
69
|
+
case "asyncapi": return ["asyncapi"];
|
|
70
|
+
case "both": return ["openapi", "asyncapi"];
|
|
71
|
+
default: return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hasOpenapi = (v) => v.kinds.includes("openapi");
|
|
76
|
+
const hasAsyncapi = (v) => v.kinds.includes("asyncapi");
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The questions, in the order they are asked. `when` says whether a question applies
|
|
80
|
+
* to the values chosen so far; `flag` is the command-line option that answers it.
|
|
81
|
+
*/
|
|
82
|
+
const QUESTIONS = [
|
|
83
|
+
{
|
|
84
|
+
key: "kinds", flag: "--openapi/--asyncapi", text: "Kinds of document: openapi, asyncapi or both",
|
|
85
|
+
show: (kinds) => (kinds.length === 2 ? "both" : kinds[0]),
|
|
86
|
+
read: (text) => {
|
|
87
|
+
const kinds = parseKinds(text);
|
|
88
|
+
return kinds ? { value: kinds } : { problem: `'${text}' is not openapi, asyncapi or both` };
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{ key: "target", flag: "--target", text: "Target name", check: checkTarget, name: "a target name" },
|
|
92
|
+
{ key: "title", flag: "--title", text: "API title", check: checkText, name: "a title" },
|
|
93
|
+
{ key: "contractVersion", flag: "--contract-version", text: "Initial contract version", check: checkVersion, name: "a release version" },
|
|
94
|
+
{ key: "contactName", flag: "--contact-name", text: "Contact name", check: checkText, name: "a contact name" },
|
|
95
|
+
{ key: "contactUrl", flag: "--contact-url", text: "Contact URL", check: checkUrl },
|
|
96
|
+
{ key: "license", flag: "--license", text: "Licence, as an SPDX identifier", check: checkText, name: "a licence" },
|
|
97
|
+
{
|
|
98
|
+
key: "licenseUrl", flag: "--license-url", text: "Licence URL", check: checkUrl,
|
|
99
|
+
derive: (v) => LICENSE_URLS[v.license],
|
|
100
|
+
},
|
|
101
|
+
{ key: "serverUrl", flag: "--server-url", text: "Production server URL", check: checkUrl, when: hasOpenapi },
|
|
102
|
+
{ key: "brokerHost", flag: "--broker-host", text: "Broker host, with its port", check: checkHost, when: hasAsyncapi, name: "a broker host" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/** Reads an answer to a question: the value, or what is wrong with it. */
|
|
106
|
+
function read(question, text) {
|
|
107
|
+
if (question.read) return question.read(text);
|
|
108
|
+
const problem = question.check(text);
|
|
109
|
+
if (!problem) return { value: text };
|
|
110
|
+
// "'x' is not a target name: use ..." reads better than "'x': use ...", where a
|
|
111
|
+
// question names what it asks for; "it is not ..." reasons stand on their own.
|
|
112
|
+
return { problem: problem.startsWith("it ") ? `'${text}' ${problem.slice(3)}` : `'${text}' is not ${question.name}: ${problem}` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Works out every value `init` scaffolds from.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} options
|
|
119
|
+
* @param {object} [options.given] values given as flags, by key; they are never asked
|
|
120
|
+
* @param {function(string): Promise<string>} [options.ask] shows a question, resolves to the answer
|
|
121
|
+
* @param {function(string): void} [options.tell] shows why an answer was refused
|
|
122
|
+
* @returns {Promise<object>} the values, as DEFAULTS has them
|
|
123
|
+
* @throws {ConfigError} when a flag's value is invalid, or does not apply to the kinds chosen
|
|
124
|
+
*/
|
|
125
|
+
async function resolveValues({ given = {}, ask = null, tell = () => {} } = {}) {
|
|
126
|
+
const values = {};
|
|
127
|
+
for (const question of QUESTIONS) {
|
|
128
|
+
const applies = !question.when || question.when({ ...DEFAULTS, ...values });
|
|
129
|
+
const flagged = given[question.key];
|
|
130
|
+
if (!applies) {
|
|
131
|
+
if (flagged !== undefined) {
|
|
132
|
+
throw new ConfigError(`${question.flag} applies only to a library with ${
|
|
133
|
+
question.key === "serverUrl" ? "OpenAPI" : "AsyncAPI"}`);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (flagged !== undefined) {
|
|
138
|
+
if (question.key === "kinds") {
|
|
139
|
+
values.kinds = flagged;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const answer = read(question, flagged);
|
|
143
|
+
if (answer.problem) throw new ConfigError(`${question.flag} ${answer.problem}`);
|
|
144
|
+
values[question.key] = answer.value;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// A question that derives its value is not asked when it can be derived -- a known
|
|
148
|
+
// licence brings its URL -- and has no default when it cannot: one must be given.
|
|
149
|
+
const fallback = question.derive ? question.derive({ ...DEFAULTS, ...values }) : DEFAULTS[question.key];
|
|
150
|
+
if (question.derive && fallback !== undefined) {
|
|
151
|
+
values[question.key] = fallback;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!ask) {
|
|
155
|
+
if (fallback === undefined) {
|
|
156
|
+
throw new ConfigError(`--license ${values.license} has no known URL: give one with --license-url`);
|
|
157
|
+
}
|
|
158
|
+
values[question.key] = fallback;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const prompt = fallback === undefined ? `${question.text}: `
|
|
162
|
+
: `${question.text} [${question.show ? question.show(fallback) : fallback}]: `;
|
|
163
|
+
for (;;) {
|
|
164
|
+
const text = (await ask(prompt)).trim();
|
|
165
|
+
if (!text && fallback !== undefined) {
|
|
166
|
+
values[question.key] = fallback;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (!text) {
|
|
170
|
+
tell(`${values.license} is not a licence this tool knows the URL of: give it`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const answer = read(question, text);
|
|
174
|
+
if (!answer.problem) {
|
|
175
|
+
values[question.key] = answer.value;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
tell(answer.problem);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { ...DEFAULTS, ...values };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
resolveValues, checkTarget, checkVersion, checkUrl, checkHost, parseKinds, QUESTIONS, LICENSE_URLS,
|
|
186
|
+
};
|
package/src/init.js
CHANGED
|
@@ -6,58 +6,86 @@
|
|
|
6
6
|
// layout below is the one the library documentation describes; a project that
|
|
7
7
|
// starts from it inherits the common/<product> split, the shared info block with
|
|
8
8
|
// placeholder snippets, and a configuration that already builds.
|
|
9
|
+
//
|
|
10
|
+
// What it writes follows from a handful of values -- which kinds of document the
|
|
11
|
+
// library holds, the target's name, the API's title and so on -- that
|
|
12
|
+
// init-questions.js asks for at a terminal and defaults everywhere else. This file
|
|
13
|
+
// only turns values into files and writes them; it never reads input itself.
|
|
9
14
|
|
|
10
15
|
const fs = require("fs");
|
|
11
16
|
const path = require("path");
|
|
17
|
+
const YAML = require("yaml");
|
|
18
|
+
const { addMissingConfig } = require("./init-config");
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# Fail the build on a {{token}} with no matching Markdown file, rather than
|
|
32
|
-
# emitting a marker into a published contract.
|
|
33
|
-
strict: true
|
|
34
|
-
|
|
35
|
-
build:
|
|
36
|
-
staging: build/staging
|
|
37
|
-
dist: dist
|
|
38
|
-
|
|
39
|
-
toolchain:
|
|
40
|
-
redocly: "@redocly/cli@2.52.0"
|
|
41
|
-
asyncapi: "@asyncapi/cli@6.0.2"
|
|
42
|
-
|
|
43
|
-
targets:
|
|
44
|
-
example-service:
|
|
45
|
-
openapi:
|
|
46
|
-
bundle: bundles/example-service_openapi_structure.yaml
|
|
47
|
-
`;
|
|
20
|
+
/** Every value the scaffold is written from, as it is when nobody chooses otherwise. */
|
|
21
|
+
const DEFAULTS = Object.freeze({
|
|
22
|
+
kinds: Object.freeze(["openapi"]),
|
|
23
|
+
target: "example-service",
|
|
24
|
+
title: "Example API",
|
|
25
|
+
contractVersion: "0.1.0",
|
|
26
|
+
contactName: "Example Team",
|
|
27
|
+
contactUrl: "https://example.invalid",
|
|
28
|
+
license: "Apache-2.0",
|
|
29
|
+
licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
|
|
30
|
+
serverUrl: "https://api.example.invalid",
|
|
31
|
+
brokerHost: "kafka:9092",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
|
|
35
|
+
function scalar(value) {
|
|
36
|
+
return YAML.stringify(value, { lineWidth: 0 }).trimEnd();
|
|
37
|
+
}
|
|
48
38
|
|
|
49
|
-
|
|
39
|
+
function config(v) {
|
|
40
|
+
const openapi = v.kinds.includes("openapi");
|
|
41
|
+
const asyncapi = v.kinds.includes("asyncapi");
|
|
42
|
+
const lines = [
|
|
43
|
+
"# apionly.yaml",
|
|
44
|
+
"#",
|
|
45
|
+
"# Declares what this library builds, from where, and where each document goes.",
|
|
46
|
+
"",
|
|
47
|
+
"schemaVersion: 1",
|
|
48
|
+
"",
|
|
49
|
+
"sources:",
|
|
50
|
+
" root: specs",
|
|
51
|
+
];
|
|
52
|
+
if (openapi) lines.push(" openapi: openapi");
|
|
53
|
+
if (asyncapi) lines.push(" asyncapi: asyncapi");
|
|
54
|
+
lines.push("", "defaults:");
|
|
55
|
+
if (openapi) lines.push(" openapi:", " lint: .redocly.yaml", " outputName: openapi.yaml");
|
|
56
|
+
if (asyncapi) lines.push(" asyncapi:", " outputName: asyncapi.yaml");
|
|
57
|
+
lines.push(
|
|
58
|
+
" placeholders:",
|
|
59
|
+
" # Fail the build on a {{token}} with no matching Markdown file, rather than",
|
|
60
|
+
" # emitting a marker into a published contract.",
|
|
61
|
+
" strict: true",
|
|
62
|
+
"",
|
|
63
|
+
"build:",
|
|
64
|
+
" staging: build/staging",
|
|
65
|
+
" dist: dist",
|
|
66
|
+
"",
|
|
67
|
+
"toolchain:",
|
|
68
|
+
);
|
|
69
|
+
if (openapi) lines.push(' redocly: "@redocly/cli@2.52.0"');
|
|
70
|
+
if (asyncapi) lines.push(' asyncapi: "@asyncapi/cli@6.0.2"');
|
|
71
|
+
lines.push("", "targets:", ` ${v.target}:`);
|
|
72
|
+
if (openapi) lines.push(" openapi:", ` bundle: bundles/${v.target}_openapi_structure.yaml`);
|
|
73
|
+
if (asyncapi) lines.push(" asyncapi:", ` bundle: bundles/${v.target}_asyncapi_structure.yaml`);
|
|
74
|
+
return lines.join("\n") + "\n";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const info = (v) => `title: ${scalar(v.title)}
|
|
50
78
|
version: 0.0.0
|
|
51
79
|
description: |
|
|
52
80
|
What this API is for.
|
|
53
81
|
|
|
54
82
|
{{conventions}}
|
|
55
83
|
contact:
|
|
56
|
-
name:
|
|
57
|
-
url:
|
|
84
|
+
name: ${scalar(v.contactName)}
|
|
85
|
+
url: ${scalar(v.contactUrl)}
|
|
58
86
|
license:
|
|
59
|
-
name:
|
|
60
|
-
url:
|
|
87
|
+
name: ${scalar(v.license)}
|
|
88
|
+
url: ${scalar(v.licenseUrl)}
|
|
61
89
|
`;
|
|
62
90
|
|
|
63
91
|
const CONVENTIONS = `## Conventions
|
|
@@ -68,7 +96,7 @@ with whatever your own API consumers need to know up front -- pagination, status
|
|
|
68
96
|
codes, error shapes.
|
|
69
97
|
`;
|
|
70
98
|
|
|
71
|
-
const
|
|
99
|
+
const servers = (v) => `- url: ${scalar(v.serverUrl)}
|
|
72
100
|
description: Production.
|
|
73
101
|
`;
|
|
74
102
|
|
|
@@ -117,7 +145,9 @@ content:
|
|
|
117
145
|
- status
|
|
118
146
|
`;
|
|
119
147
|
|
|
120
|
-
|
|
148
|
+
// With AsyncAPI beside it, the examples are identified by a schema both protocols
|
|
149
|
+
// share; on its own, the OpenAPI scaffold stays as small as it always was.
|
|
150
|
+
const pathFragment = (shared) => `get:
|
|
121
151
|
operationId: listExamples
|
|
122
152
|
summary: List examples.
|
|
123
153
|
responses:
|
|
@@ -128,11 +158,17 @@ const PATH_FRAGMENT = `get:
|
|
|
128
158
|
schema:
|
|
129
159
|
type: array
|
|
130
160
|
items:
|
|
131
|
-
type: string
|
|
161
|
+
${shared ? " $ref: '../../components/common/schemas/ExampleIdV1.yaml'" : " type: string"}
|
|
132
162
|
'400':
|
|
133
163
|
$ref: '../../components/common/responses/errors/InvalidRequestProblemV1.yaml'
|
|
134
164
|
`;
|
|
135
165
|
|
|
166
|
+
const EXAMPLE_ID = `# One definition, two protocols: the HTTP response and the event both refer to this
|
|
167
|
+
# fragment, so an example's identifier means the same thing wherever it appears.
|
|
168
|
+
type: string
|
|
169
|
+
description: An example's identifier.
|
|
170
|
+
`;
|
|
171
|
+
|
|
136
172
|
const REDOCLY = `# Lint rules for this library.
|
|
137
173
|
#
|
|
138
174
|
# 'recommended' is Redocly's own baseline. Narrow or widen it as the library
|
|
@@ -147,44 +183,212 @@ dist/
|
|
|
147
183
|
node_modules/
|
|
148
184
|
`;
|
|
149
185
|
|
|
150
|
-
const
|
|
186
|
+
const version = (v) => `# The version of the ${v.target} contract, for every document it builds.
|
|
151
187
|
# Semantic: major for a breaking change, minor for an additive one, patch for
|
|
152
188
|
# anything else. Change it in the same commit as the fragments it describes.
|
|
153
|
-
version
|
|
189
|
+
version=${v.contractVersion}
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
// The AsyncAPI bundle root holds its operations itself: they point into the document
|
|
193
|
+
// with #/channels/..., which only resolves in the file that contains it.
|
|
194
|
+
const asyncBundle = (v) => `asyncapi: 3.1.0
|
|
195
|
+
info:
|
|
196
|
+
title: ${scalar(v.title)}
|
|
197
|
+
version: 0.0.0
|
|
198
|
+
description: |
|
|
199
|
+
The events this API publishes.
|
|
200
|
+
|
|
201
|
+
{{conventions}}
|
|
202
|
+
contact:
|
|
203
|
+
name: ${scalar(v.contactName)}
|
|
204
|
+
url: ${scalar(v.contactUrl)}
|
|
205
|
+
license:
|
|
206
|
+
name: ${scalar(v.license)}
|
|
207
|
+
url: ${scalar(v.licenseUrl)}
|
|
208
|
+
defaultContentType: application/json
|
|
209
|
+
servers:
|
|
210
|
+
production:
|
|
211
|
+
host: ${scalar(v.brokerHost)}
|
|
212
|
+
protocol: kafka
|
|
213
|
+
description: Production.
|
|
214
|
+
channels:
|
|
215
|
+
examplesV1:
|
|
216
|
+
$ref: '../channels/example/ExamplesV1.yaml'
|
|
217
|
+
operations:
|
|
218
|
+
publishExampleCreated:
|
|
219
|
+
action: send
|
|
220
|
+
channel:
|
|
221
|
+
$ref: '#/channels/examplesV1'
|
|
222
|
+
summary: Publish an ExampleCreated event when an example is created.
|
|
223
|
+
messages:
|
|
224
|
+
- $ref: '#/channels/examplesV1/messages/exampleCreated'
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
const CHANNEL = `address: examples.v1
|
|
228
|
+
title: Examples
|
|
229
|
+
description: Events about examples.
|
|
230
|
+
messages:
|
|
231
|
+
exampleCreated:
|
|
232
|
+
$ref: '../../messages/example/ExampleCreatedMessageV1.yaml'
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
const MESSAGE = `name: ExampleCreated
|
|
236
|
+
title: An example was created.
|
|
237
|
+
contentType: application/json
|
|
238
|
+
payload:
|
|
239
|
+
$ref: '../../components/example/schemas/ExampleCreatedEventV1.yaml'
|
|
240
|
+
`;
|
|
241
|
+
|
|
242
|
+
const event = (shared) => `type: object
|
|
243
|
+
description: An example was created.
|
|
244
|
+
required:
|
|
245
|
+
- id
|
|
246
|
+
- occurredAt
|
|
247
|
+
properties:
|
|
248
|
+
id:
|
|
249
|
+
${shared
|
|
250
|
+
? " # The OpenAPI tree's schema: an event and an HTTP response mean one identifier.\n" +
|
|
251
|
+
" $ref: '../../../../openapi/components/common/schemas/ExampleIdV1.yaml'"
|
|
252
|
+
: " type: string\n description: The example's identifier."}
|
|
253
|
+
occurredAt:
|
|
254
|
+
type: string
|
|
255
|
+
format: date-time
|
|
256
|
+
description: When the example was created.
|
|
154
257
|
`;
|
|
155
258
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
259
|
+
/**
|
|
260
|
+
* The files a library of these values starts with, keyed by their path relative to it.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} values the values, as DEFAULTS has them
|
|
263
|
+
* @returns {Object<string, string>}
|
|
264
|
+
*/
|
|
265
|
+
function scaffold(values = DEFAULTS) {
|
|
266
|
+
const v = { ...DEFAULTS, ...values };
|
|
267
|
+
const openapi = v.kinds.includes("openapi");
|
|
268
|
+
const asyncapi = v.kinds.includes("asyncapi");
|
|
269
|
+
const both = openapi && asyncapi;
|
|
270
|
+
const files = { "apionly.yaml": config(v) };
|
|
271
|
+
if (openapi) files[".redocly.yaml"] = REDOCLY;
|
|
272
|
+
files[".gitignore"] = GITIGNORE;
|
|
273
|
+
|
|
274
|
+
if (openapi) {
|
|
275
|
+
Object.assign(files, {
|
|
276
|
+
"specs/openapi/shared/info.yaml": info(v),
|
|
277
|
+
"specs/openapi/shared/conventions.md": CONVENTIONS,
|
|
278
|
+
"specs/openapi/shared/servers.yaml": servers(v),
|
|
279
|
+
[`specs/openapi/bundles/${v.target}_openapi_structure.yaml`]: BUNDLE,
|
|
280
|
+
[`specs/openapi/bundles/${v.target}.bundle.properties`]: version(v),
|
|
281
|
+
"specs/openapi/paths/example/ExamplesV1.yaml": pathFragment(both),
|
|
282
|
+
"specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
|
|
283
|
+
"specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
|
|
284
|
+
});
|
|
285
|
+
if (both) files["specs/openapi/components/common/schemas/ExampleIdV1.yaml"] = EXAMPLE_ID;
|
|
286
|
+
}
|
|
287
|
+
if (asyncapi) {
|
|
288
|
+
// The version file sits beside the target's first bundle root: the OpenAPI one
|
|
289
|
+
// when there is one. The conventions snippet is found anywhere under specs/.
|
|
290
|
+
if (!openapi) {
|
|
291
|
+
files[`specs/asyncapi/bundles/${v.target}.bundle.properties`] = version(v);
|
|
292
|
+
files["specs/asyncapi/shared/conventions.md"] = CONVENTIONS;
|
|
293
|
+
}
|
|
294
|
+
Object.assign(files, {
|
|
295
|
+
[`specs/asyncapi/bundles/${v.target}_asyncapi_structure.yaml`]: asyncBundle(v),
|
|
296
|
+
"specs/asyncapi/channels/example/ExamplesV1.yaml": CHANNEL,
|
|
297
|
+
"specs/asyncapi/messages/example/ExampleCreatedMessageV1.yaml": MESSAGE,
|
|
298
|
+
"specs/asyncapi/components/example/schemas/ExampleCreatedEventV1.yaml": event(both),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return files;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const FILES = scaffold(DEFAULTS);
|
|
305
|
+
|
|
306
|
+
/** Text as a comparison sees it: line endings and final newlines are not differences. */
|
|
307
|
+
function normalised(text) {
|
|
308
|
+
return text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const CONFIG_FILE = "apionly.yaml";
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What writing these files would do to each: create it, leave it because it is
|
|
315
|
+
* identical, or find it different from what would be written.
|
|
316
|
+
*
|
|
317
|
+
* apionly.yaml gets one more thing tried, ahead of "differs": whatever slot
|
|
318
|
+
* scaffold(values) would write and it lacks is added, in place, before the
|
|
319
|
+
* comparison that decides "identical" or "differs" -- so a file that is missing only
|
|
320
|
+
* a kind it never had is never reported as differing, and neither is one that is
|
|
321
|
+
* missing nothing at all. `values`, resolved as scaffold() resolves it, is what that
|
|
322
|
+
* completion is measured against; without it, apionly.yaml is compared as every
|
|
323
|
+
* other file is.
|
|
324
|
+
*
|
|
325
|
+
* @returns {{rel: string, status: "missing"|"identical"|"differs", added?: string[], text?: string}[]}
|
|
326
|
+
* `added` and `text` -- the completed content -- are there only for apionly.yaml,
|
|
327
|
+
* and only when something was missing from it.
|
|
328
|
+
*/
|
|
329
|
+
function plan(targetDir, files, values) {
|
|
330
|
+
return Object.entries(files).map(([rel, content]) => {
|
|
175
331
|
const file = path.join(targetDir, rel);
|
|
176
|
-
if (fs.existsSync(file)
|
|
177
|
-
|
|
332
|
+
if (!fs.existsSync(file)) return { rel, status: "missing" };
|
|
333
|
+
const onDisk = fs.readFileSync(file, "utf8");
|
|
334
|
+
if (rel === CONFIG_FILE && values) {
|
|
335
|
+
const completed = addMissingConfig(onDisk, { ...DEFAULTS, ...values });
|
|
336
|
+
if (completed.added.length > 0) {
|
|
337
|
+
const status = normalised(completed.text) === normalised(content) ? "identical" : "differs";
|
|
338
|
+
return { rel, status, added: completed.added, text: completed.text };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const same = normalised(onDisk) === normalised(content);
|
|
342
|
+
return { rel, status: same ? "identical" : "differs" };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Writes the scaffold of these values into a directory, and reports each file.
|
|
348
|
+
*
|
|
349
|
+
* A file that is not there is created. One that is there already is left alone,
|
|
350
|
+
* reported as identical to what would have been written or as differing from it;
|
|
351
|
+
* with `force`, one that differs is overwritten, wholesale, as if this were the
|
|
352
|
+
* first time -- apionly.yaml included, whatever else it declares.
|
|
353
|
+
*
|
|
354
|
+
* Short of `force`, apionly.yaml gets one more chance: whatever configuration a
|
|
355
|
+
* missing kind needs and it lacks is added to it, leaving every value, key and
|
|
356
|
+
* comment already there exactly as it was. A kind already declared, however it
|
|
357
|
+
* reads, is never touched -- what is already there is not changed, only what is
|
|
358
|
+
* not there is added.
|
|
359
|
+
*/
|
|
360
|
+
function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } = {}) {
|
|
361
|
+
const files = scaffold(values);
|
|
362
|
+
const report = { created: [], overwritten: [], identical: [], differing: [], updated: [] };
|
|
363
|
+
const updates = [];
|
|
364
|
+
|
|
365
|
+
for (const { rel, status, added, text } of plan(targetDir, files, values)) {
|
|
366
|
+
if (added && !force) {
|
|
367
|
+
fs.writeFileSync(path.join(targetDir, rel), text);
|
|
368
|
+
report.updated.push(rel);
|
|
369
|
+
updates.push({ rel, added });
|
|
178
370
|
continue;
|
|
179
371
|
}
|
|
372
|
+
if (status === "identical") {
|
|
373
|
+
report.identical.push(rel);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (status === "differs" && !force) {
|
|
377
|
+
report.differing.push(rel);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const file = path.join(targetDir, rel);
|
|
180
381
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
181
|
-
fs.writeFileSync(file,
|
|
182
|
-
created.push(rel);
|
|
382
|
+
fs.writeFileSync(file, files[rel]);
|
|
383
|
+
(status === "missing" ? report.created : report.overwritten).push(rel);
|
|
183
384
|
}
|
|
184
385
|
|
|
185
|
-
for (const rel of created) log(` created
|
|
186
|
-
for (const rel of
|
|
187
|
-
|
|
386
|
+
for (const rel of report.created) log(` created ${rel}`);
|
|
387
|
+
for (const rel of report.overwritten) log(` overwrote ${rel}`);
|
|
388
|
+
for (const { rel, added } of updates) log(` updated ${rel} (added ${added.join(", ")})`);
|
|
389
|
+
for (const rel of report.identical) log(` identical ${rel}`);
|
|
390
|
+
for (const rel of report.differing) log(` differs ${rel} (left alone; --force overwrites)`);
|
|
391
|
+
return { ...report, skipped: [...report.identical, ...report.differing] };
|
|
188
392
|
}
|
|
189
393
|
|
|
190
|
-
module.exports = { init, FILES };
|
|
394
|
+
module.exports = { init, scaffold, plan, FILES, DEFAULTS };
|