@arc-e-tect/api-only-publisher 0.6.2 → 0.7.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 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.6.2
9
+ :api-only-publisher-version: 0.7.0
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,101 @@ 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
+ `differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
274
+ It is left alone.
275
+ `overwrote`:: The file differed, and `--force` replaced it.
276
+ +
277
+ At a terminal, `--force` first lists the files that differ and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
278
+
181
279
  [#what-build-does]
182
280
  == What `build` does
183
281
 
@@ -435,7 +533,7 @@ Every target's document of one kind has the same `outputName`; keep the defaults
435
533
  === Where bundle roots go
436
534
 
437
535
  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 OpenAPI bundle root this way.
536
+ The `init` scaffold lays out its bundle roots this way, for each kind it writes.
439
537
 
440
538
  * A bundle root is a table of contents rather than a fragment, and a `bundles/` directory keeps the two apart.
441
539
  * 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
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 already exist.
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
- async function main(argv) {
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)).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,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,85 @@
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");
12
18
 
13
- const CONFIG = `# apionly.yaml
14
- #
15
- # Declares what this library builds, from where, and where each document goes.
16
-
17
- schemaVersion: 1
18
-
19
- sources:
20
- root: specs
21
- openapi: openapi
22
- asyncapi: asyncapi
23
-
24
- defaults:
25
- openapi:
26
- lint: .redocly.yaml
27
- outputName: openapi.yaml
28
- asyncapi:
29
- outputName: asyncapi.yaml
30
- placeholders:
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
- `;
19
+ /** Every value the scaffold is written from, as it is when nobody chooses otherwise. */
20
+ const DEFAULTS = Object.freeze({
21
+ kinds: Object.freeze(["openapi"]),
22
+ target: "example-service",
23
+ title: "Example API",
24
+ contractVersion: "0.1.0",
25
+ contactName: "Example Team",
26
+ contactUrl: "https://example.invalid",
27
+ license: "Apache-2.0",
28
+ licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
29
+ serverUrl: "https://api.example.invalid",
30
+ brokerHost: "kafka:9092",
31
+ });
32
+
33
+ /** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
34
+ function scalar(value) {
35
+ return YAML.stringify(value, { lineWidth: 0 }).trimEnd();
36
+ }
48
37
 
49
- const INFO = `title: Example API
38
+ function config(v) {
39
+ const openapi = v.kinds.includes("openapi");
40
+ const asyncapi = v.kinds.includes("asyncapi");
41
+ const lines = [
42
+ "# apionly.yaml",
43
+ "#",
44
+ "# Declares what this library builds, from where, and where each document goes.",
45
+ "",
46
+ "schemaVersion: 1",
47
+ "",
48
+ "sources:",
49
+ " root: specs",
50
+ ];
51
+ if (openapi) lines.push(" openapi: openapi");
52
+ if (asyncapi) lines.push(" asyncapi: asyncapi");
53
+ lines.push("", "defaults:");
54
+ if (openapi) lines.push(" openapi:", " lint: .redocly.yaml", " outputName: openapi.yaml");
55
+ if (asyncapi) lines.push(" asyncapi:", " outputName: asyncapi.yaml");
56
+ lines.push(
57
+ " placeholders:",
58
+ " # Fail the build on a {{token}} with no matching Markdown file, rather than",
59
+ " # emitting a marker into a published contract.",
60
+ " strict: true",
61
+ "",
62
+ "build:",
63
+ " staging: build/staging",
64
+ " dist: dist",
65
+ "",
66
+ "toolchain:",
67
+ );
68
+ if (openapi) lines.push(' redocly: "@redocly/cli@2.52.0"');
69
+ if (asyncapi) lines.push(' asyncapi: "@asyncapi/cli@6.0.2"');
70
+ lines.push("", "targets:", ` ${v.target}:`);
71
+ if (openapi) lines.push(" openapi:", ` bundle: bundles/${v.target}_openapi_structure.yaml`);
72
+ if (asyncapi) lines.push(" asyncapi:", ` bundle: bundles/${v.target}_asyncapi_structure.yaml`);
73
+ return lines.join("\n") + "\n";
74
+ }
75
+
76
+ const info = (v) => `title: ${scalar(v.title)}
50
77
  version: 0.0.0
51
78
  description: |
52
79
  What this API is for.
53
80
 
54
81
  {{conventions}}
55
82
  contact:
56
- name: Example Team
57
- url: https://example.invalid
83
+ name: ${scalar(v.contactName)}
84
+ url: ${scalar(v.contactUrl)}
58
85
  license:
59
- name: Apache-2.0
60
- url: https://www.apache.org/licenses/LICENSE-2.0.html
86
+ name: ${scalar(v.license)}
87
+ url: ${scalar(v.licenseUrl)}
61
88
  `;
62
89
 
63
90
  const CONVENTIONS = `## Conventions
@@ -68,7 +95,7 @@ with whatever your own API consumers need to know up front -- pagination, status
68
95
  codes, error shapes.
69
96
  `;
70
97
 
71
- const SERVERS = `- url: https://api.example.invalid
98
+ const servers = (v) => `- url: ${scalar(v.serverUrl)}
72
99
  description: Production.
73
100
  `;
74
101
 
@@ -117,7 +144,9 @@ content:
117
144
  - status
118
145
  `;
119
146
 
120
- const PATH_FRAGMENT = `get:
147
+ // With AsyncAPI beside it, the examples are identified by a schema both protocols
148
+ // share; on its own, the OpenAPI scaffold stays as small as it always was.
149
+ const pathFragment = (shared) => `get:
121
150
  operationId: listExamples
122
151
  summary: List examples.
123
152
  responses:
@@ -128,11 +157,17 @@ const PATH_FRAGMENT = `get:
128
157
  schema:
129
158
  type: array
130
159
  items:
131
- type: string
160
+ ${shared ? " $ref: '../../components/common/schemas/ExampleIdV1.yaml'" : " type: string"}
132
161
  '400':
133
162
  $ref: '../../components/common/responses/errors/InvalidRequestProblemV1.yaml'
134
163
  `;
135
164
 
165
+ const EXAMPLE_ID = `# One definition, two protocols: the HTTP response and the event both refer to this
166
+ # fragment, so an example's identifier means the same thing wherever it appears.
167
+ type: string
168
+ description: An example's identifier.
169
+ `;
170
+
136
171
  const REDOCLY = `# Lint rules for this library.
137
172
  #
138
173
  # 'recommended' is Redocly's own baseline. Narrow or widen it as the library
@@ -147,44 +182,177 @@ dist/
147
182
  node_modules/
148
183
  `;
149
184
 
150
- const VERSION = `# The version of the example-service contract, for every document it builds.
185
+ const version = (v) => `# The version of the ${v.target} contract, for every document it builds.
151
186
  # Semantic: major for a breaking change, minor for an additive one, patch for
152
187
  # anything else. Change it in the same commit as the fragments it describes.
153
- version=0.1.0
188
+ version=${v.contractVersion}
189
+ `;
190
+
191
+ // The AsyncAPI bundle root holds its operations itself: they point into the document
192
+ // with #/channels/..., which only resolves in the file that contains it.
193
+ const asyncBundle = (v) => `asyncapi: 3.1.0
194
+ info:
195
+ title: ${scalar(v.title)}
196
+ version: 0.0.0
197
+ description: |
198
+ The events this API publishes.
199
+
200
+ {{conventions}}
201
+ contact:
202
+ name: ${scalar(v.contactName)}
203
+ url: ${scalar(v.contactUrl)}
204
+ license:
205
+ name: ${scalar(v.license)}
206
+ url: ${scalar(v.licenseUrl)}
207
+ defaultContentType: application/json
208
+ servers:
209
+ production:
210
+ host: ${scalar(v.brokerHost)}
211
+ protocol: kafka
212
+ description: Production.
213
+ channels:
214
+ examplesV1:
215
+ $ref: '../channels/example/ExamplesV1.yaml'
216
+ operations:
217
+ publishExampleCreated:
218
+ action: send
219
+ channel:
220
+ $ref: '#/channels/examplesV1'
221
+ summary: Publish an ExampleCreated event when an example is created.
222
+ messages:
223
+ - $ref: '#/channels/examplesV1/messages/exampleCreated'
224
+ `;
225
+
226
+ const CHANNEL = `address: examples.v1
227
+ title: Examples
228
+ description: Events about examples.
229
+ messages:
230
+ exampleCreated:
231
+ $ref: '../../messages/example/ExampleCreatedMessageV1.yaml'
232
+ `;
233
+
234
+ const MESSAGE = `name: ExampleCreated
235
+ title: An example was created.
236
+ contentType: application/json
237
+ payload:
238
+ $ref: '../../components/example/schemas/ExampleCreatedEventV1.yaml'
154
239
  `;
155
240
 
156
- const FILES = {
157
- "apionly.yaml": CONFIG,
158
- ".redocly.yaml": REDOCLY,
159
- ".gitignore": GITIGNORE,
160
- "specs/openapi/shared/info.yaml": INFO,
161
- "specs/openapi/shared/conventions.md": CONVENTIONS,
162
- "specs/openapi/shared/servers.yaml": SERVERS,
163
- "specs/openapi/bundles/example-service_openapi_structure.yaml": BUNDLE,
164
- "specs/openapi/bundles/example-service.bundle.properties": VERSION,
165
- "specs/openapi/paths/example/ExamplesV1.yaml": PATH_FRAGMENT,
166
- "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
167
- "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
168
- };
169
-
170
- function init(targetDir, { force = false, log = () => {} } = {}) {
171
- const created = [];
172
- const skipped = [];
173
-
174
- for (const [rel, content] of Object.entries(FILES)) {
241
+ const event = (shared) => `type: object
242
+ description: An example was created.
243
+ required:
244
+ - id
245
+ - occurredAt
246
+ properties:
247
+ id:
248
+ ${shared
249
+ ? " # The OpenAPI tree's schema: an event and an HTTP response mean one identifier.\n" +
250
+ " $ref: '../../../../openapi/components/common/schemas/ExampleIdV1.yaml'"
251
+ : " type: string\n description: The example's identifier."}
252
+ occurredAt:
253
+ type: string
254
+ format: date-time
255
+ description: When the example was created.
256
+ `;
257
+
258
+ /**
259
+ * The files a library of these values starts with, keyed by their path relative to it.
260
+ *
261
+ * @param {object} values the values, as DEFAULTS has them
262
+ * @returns {Object<string, string>}
263
+ */
264
+ function scaffold(values = DEFAULTS) {
265
+ const v = { ...DEFAULTS, ...values };
266
+ const openapi = v.kinds.includes("openapi");
267
+ const asyncapi = v.kinds.includes("asyncapi");
268
+ const both = openapi && asyncapi;
269
+ const files = { "apionly.yaml": config(v) };
270
+ if (openapi) files[".redocly.yaml"] = REDOCLY;
271
+ files[".gitignore"] = GITIGNORE;
272
+
273
+ if (openapi) {
274
+ Object.assign(files, {
275
+ "specs/openapi/shared/info.yaml": info(v),
276
+ "specs/openapi/shared/conventions.md": CONVENTIONS,
277
+ "specs/openapi/shared/servers.yaml": servers(v),
278
+ [`specs/openapi/bundles/${v.target}_openapi_structure.yaml`]: BUNDLE,
279
+ [`specs/openapi/bundles/${v.target}.bundle.properties`]: version(v),
280
+ "specs/openapi/paths/example/ExamplesV1.yaml": pathFragment(both),
281
+ "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
282
+ "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
283
+ });
284
+ if (both) files["specs/openapi/components/common/schemas/ExampleIdV1.yaml"] = EXAMPLE_ID;
285
+ }
286
+ if (asyncapi) {
287
+ // The version file sits beside the target's first bundle root: the OpenAPI one
288
+ // when there is one. The conventions snippet is found anywhere under specs/.
289
+ if (!openapi) {
290
+ files[`specs/asyncapi/bundles/${v.target}.bundle.properties`] = version(v);
291
+ files["specs/asyncapi/shared/conventions.md"] = CONVENTIONS;
292
+ }
293
+ Object.assign(files, {
294
+ [`specs/asyncapi/bundles/${v.target}_asyncapi_structure.yaml`]: asyncBundle(v),
295
+ "specs/asyncapi/channels/example/ExamplesV1.yaml": CHANNEL,
296
+ "specs/asyncapi/messages/example/ExampleCreatedMessageV1.yaml": MESSAGE,
297
+ "specs/asyncapi/components/example/schemas/ExampleCreatedEventV1.yaml": event(both),
298
+ });
299
+ }
300
+ return files;
301
+ }
302
+
303
+ const FILES = scaffold(DEFAULTS);
304
+
305
+ /** Text as a comparison sees it: line endings and final newlines are not differences. */
306
+ function normalised(text) {
307
+ return text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
308
+ }
309
+
310
+ /**
311
+ * What writing these files would do to each: create it, leave it because it is
312
+ * identical, or find it different from what would be written.
313
+ *
314
+ * @returns {{rel: string, status: "missing"|"identical"|"differs"}[]}
315
+ */
316
+ function plan(targetDir, files) {
317
+ return Object.entries(files).map(([rel, content]) => {
175
318
  const file = path.join(targetDir, rel);
176
- if (fs.existsSync(file) && !force) {
177
- skipped.push(rel);
319
+ if (!fs.existsSync(file)) return { rel, status: "missing" };
320
+ const same = normalised(fs.readFileSync(file, "utf8")) === normalised(content);
321
+ return { rel, status: same ? "identical" : "differs" };
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Writes the scaffold of these values into a directory, and reports each file.
327
+ *
328
+ * A file that is not there is created. One that is there already is left alone,
329
+ * reported as identical to what would have been written or as differing from it;
330
+ * with `force`, one that differs is overwritten.
331
+ */
332
+ function init(targetDir, { values = DEFAULTS, force = false, log = () => {} } = {}) {
333
+ const files = scaffold(values);
334
+ const report = { created: [], overwritten: [], identical: [], differing: [] };
335
+
336
+ for (const { rel, status } of plan(targetDir, files)) {
337
+ if (status === "identical") {
338
+ report.identical.push(rel);
339
+ continue;
340
+ }
341
+ if (status === "differs" && !force) {
342
+ report.differing.push(rel);
178
343
  continue;
179
344
  }
345
+ const file = path.join(targetDir, rel);
180
346
  fs.mkdirSync(path.dirname(file), { recursive: true });
181
- fs.writeFileSync(file, content);
182
- created.push(rel);
347
+ fs.writeFileSync(file, files[rel]);
348
+ (status === "missing" ? report.created : report.overwritten).push(rel);
183
349
  }
184
350
 
185
- for (const rel of created) log(` created ${rel}`);
186
- for (const rel of skipped) log(` exists ${rel} (left alone; --force overwrites)`);
187
- return { created, skipped };
351
+ for (const rel of report.created) log(` created ${rel}`);
352
+ for (const rel of report.overwritten) log(` overwrote ${rel}`);
353
+ for (const rel of report.identical) log(` identical ${rel}`);
354
+ for (const rel of report.differing) log(` differs ${rel} (left alone; --force overwrites)`);
355
+ return { ...report, skipped: [...report.identical, ...report.differing] };
188
356
  }
189
357
 
190
- module.exports = { init, FILES };
358
+ module.exports = { init, scaffold, plan, FILES, DEFAULTS };