@arc-e-tect/api-only-publisher 0.7.1 → 0.9.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.
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ // The `config` command's one section so far: `portfolio`.
4
+ //
5
+ // `init` only ever adds this section when it is missing (init-config.js);
6
+ // reconfiguring what is already there is this file's job instead, and it works
7
+ // the opposite way on purpose -- it always asks, offering what is already
8
+ // configured as each default, and always rewrites the keys it asked about.
9
+ //
10
+ // A second configurable section extends this the same way a second kind
11
+ // extended init-config.js's slots: its own question list, its own current()
12
+ // reader and its own writer, called from the same `config` command. Nothing here
13
+ // is written as a registry ahead of that need -- there is exactly one section
14
+ // today, and one small module reads more easily than an abstraction for a
15
+ // second case that does not exist yet.
16
+
17
+ const YAML = require("yaml");
18
+ const { ConfigError } = require("./config");
19
+
20
+ const PATH_STRATEGIES = ["target-prefix", "none"];
21
+
22
+ /** The portfolio section's questions, in the order they are asked. */
23
+ const QUESTIONS = [
24
+ {
25
+ key: "paths", flag: "--portfolio-paths",
26
+ text: "Path prefix strategy for a portfolio: target-prefix or none",
27
+ check: (v) => (PATH_STRATEGIES.includes(v) ? null : `use ${PATH_STRATEGIES.join(" or ")}`),
28
+ },
29
+ {
30
+ key: "location", flag: "--portfolio-location",
31
+ text: "Where a portfolio's generated bundle root lands, as a directory beside bundles/",
32
+ check: (v) => (v.trim() ? null : "it may not be empty"),
33
+ },
34
+ ];
35
+
36
+ /**
37
+ * Resolves the portfolio section's values: a flag always wins. At a terminal,
38
+ * each question shows `current`'s value for it in brackets -- the section's own,
39
+ * if it has one, or the documented default when it does not -- and Enter keeps
40
+ * it; without a terminal, that same value is taken as given, exactly as if it
41
+ * had been typed.
42
+ *
43
+ * @param {object} options
44
+ * @param {object} [options.given] values given as flags, by key; never asked
45
+ * @param {object} options.current the value each question defaults to
46
+ * @param {function(string): Promise<string>} [options.ask] shows a question, resolves to the answer
47
+ * @param {function(string): void} [options.tell] shows why an answer was refused
48
+ * @returns {Promise<object>} the resolved values, one per question
49
+ * @throws {ConfigError} when a flag's value is invalid
50
+ */
51
+ async function resolvePortfolioValues({ given = {}, current, ask = null, tell = () => {} } = {}) {
52
+ const values = {};
53
+ for (const question of QUESTIONS) {
54
+ const flagged = given[question.key];
55
+ if (flagged !== undefined) {
56
+ const problem = question.check(flagged);
57
+ if (problem) throw new ConfigError(`${question.flag} ${problem}`);
58
+ values[question.key] = flagged;
59
+ continue;
60
+ }
61
+ const fallback = current[question.key];
62
+ if (!ask) {
63
+ values[question.key] = fallback;
64
+ continue;
65
+ }
66
+ const prompt = `${question.text} [${fallback}]: `;
67
+ for (;;) {
68
+ const text = (await ask(prompt)).trim();
69
+ if (!text) {
70
+ values[question.key] = fallback;
71
+ break;
72
+ }
73
+ const problem = question.check(text);
74
+ if (!problem) {
75
+ values[question.key] = text;
76
+ break;
77
+ }
78
+ tell(problem);
79
+ }
80
+ }
81
+ return values;
82
+ }
83
+
84
+ /**
85
+ * Writes `values` into apionly.yaml's portfolio section, wholesale for the keys
86
+ * asked about and untouched everywhere else: a hand-set portfolio.openapi.tags,
87
+ * for one, is not something `config portfolio` asked about, so it survives.
88
+ * YAML.parseDocument, never a parse and restringify -- the file's comments, and
89
+ * every value this did not ask about, are not this command's to change.
90
+ *
91
+ * @returns {string} the edited text
92
+ */
93
+ function writePortfolioSection(text, values) {
94
+ const doc = YAML.parseDocument(text);
95
+ doc.setIn(["portfolio", "openapi", "paths"], values.paths);
96
+ doc.setIn(["portfolio", "location"], values.location);
97
+ return doc.toString();
98
+ }
99
+
100
+ module.exports = { resolvePortfolioValues, writePortfolioSection, QUESTIONS };
package/src/config.js CHANGED
@@ -12,8 +12,53 @@ const YAML = require("yaml");
12
12
  const CONFIG_NAME = "apionly.yaml";
13
13
  const SUPPORTED_SCHEMA_VERSION = 1;
14
14
 
15
+ // The keys allowed at the levels of apionly.yaml that have a fixed shape. `channels`
16
+ // and `toolchain` are deliberately absent: a channel's settings depend on its type,
17
+ // and a tool's name is whatever the author calls it, so neither has a closed
18
+ // vocabulary to check against. Everywhere else, a key nothing reads is far more
19
+ // often a typo or a bad indent than a value nobody has used yet -- see checkKeys.
20
+ const ROOT_KEYS = [
21
+ "schemaVersion", "sources", "defaults", "build", "reports", "lint",
22
+ "distribution", "channels", "targets", "toolchain", "portfolio",
23
+ ];
24
+ const SOURCES_KEYS = ["root", "openapi", "asyncapi"];
25
+ const DEFAULTS_KEYS = ["openapi", "asyncapi", "placeholders"];
26
+ const DEFAULTS_KIND_KEYS = { openapi: ["lint", "outputName", "fragmentPaths"], asyncapi: ["lint", "outputName", "fragmentPaths"] };
27
+ const PLACEHOLDERS_KEYS = ["strict"];
28
+ const BUILD_KEYS = ["staging", "dist"];
29
+ const REPORTS_KEYS = ["lint"];
30
+ const LINT_KEYS = ["unreferenced", "examples"];
31
+ const LINT_EXAMPLES_KEYS = ["openapi", "asyncapi"];
32
+ const DISTRIBUTION_KEYS = ["root", "layout"];
33
+ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
34
+ const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
35
+ const PORTFOLIO_KEYS = ["openapi", "security", "location"];
36
+ const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
37
+
38
+ // What every portfolio setting is when apionly.yaml says nothing: safe for the
39
+ // ordinary case (a gateway routing /<target>/** to each service), and named rather
40
+ // than boolean, so a third strategy can be added later without breaking the schema.
41
+ // Because a portfolio is regenerated on every build, none of this needs a migration
42
+ // when it changes -- the next build simply produces a different portfolio.
43
+ const PORTFOLIO_DEFAULTS = Object.freeze({
44
+ openapi: Object.freeze({ paths: "target-prefix", operationIds: "target-prefix", tags: "reconcile" }),
45
+ security: "push-down",
46
+ location: "portfolios",
47
+ });
48
+ const PATH_STRATEGIES = ["target-prefix", "none"];
49
+
15
50
  class ConfigError extends Error {}
16
51
 
52
+ /** Refuses a key `obj` has that is not in `allowed`, naming the key and where it is. */
53
+ function checkKeys(obj, allowed, where) {
54
+ if (!obj || typeof obj !== "object") return;
55
+ for (const key of Object.keys(obj)) {
56
+ if (!allowed.includes(key)) {
57
+ throw new ConfigError(`unknown key '${key}' in ${where}`);
58
+ }
59
+ }
60
+ }
61
+
17
62
  // Walk up from `startDir` looking for apionly.yaml, so the CLI can be run from
18
63
  // anywhere inside the specification repository.
19
64
  function locate(startDir) {
@@ -57,10 +102,39 @@ function load(configPath) {
57
102
  );
58
103
  }
59
104
 
105
+ checkKeys(parsed, ROOT_KEYS, CONFIG_NAME);
106
+
60
107
  const root = path.dirname(configPath);
61
108
  const sources = parsed.sources || {};
109
+ checkKeys(sources, SOURCES_KEYS, "sources");
62
110
  requireString(sources.root, "sources.root");
63
111
 
112
+ const defaults = parsed.defaults || {};
113
+ checkKeys(defaults, DEFAULTS_KEYS, "defaults");
114
+ for (const kind of ["openapi", "asyncapi"]) {
115
+ if (defaults[kind]) checkKeys(defaults[kind], DEFAULTS_KIND_KEYS[kind], `defaults.${kind}`);
116
+ }
117
+ if (defaults.placeholders) checkKeys(defaults.placeholders, PLACEHOLDERS_KEYS, "defaults.placeholders");
118
+
119
+ const build = parsed.build || {};
120
+ checkKeys(build, BUILD_KEYS, "build");
121
+ const reports = parsed.reports || {};
122
+ checkKeys(reports, REPORTS_KEYS, "reports");
123
+ const lint = parsed.lint || {};
124
+ checkKeys(lint, LINT_KEYS, "lint");
125
+ if (lint.examples) checkKeys(lint.examples, LINT_EXAMPLES_KEYS, "lint.examples");
126
+ if (parsed.distribution) checkKeys(parsed.distribution, DISTRIBUTION_KEYS, "distribution");
127
+
128
+ const portfolio = parsed.portfolio || {};
129
+ checkKeys(portfolio, PORTFOLIO_KEYS, "portfolio");
130
+ if (portfolio.openapi) checkKeys(portfolio.openapi, PORTFOLIO_OPENAPI_KEYS, "portfolio.openapi");
131
+ const pathStrategy = portfolio.openapi && portfolio.openapi.paths;
132
+ if (pathStrategy !== undefined && !PATH_STRATEGIES.includes(pathStrategy)) {
133
+ throw new ConfigError(
134
+ `portfolio.openapi.paths must be ${PATH_STRATEGIES.join(" or ")}, not ${JSON.stringify(pathStrategy)}`
135
+ );
136
+ }
137
+
64
138
  const targets = parsed.targets;
65
139
  if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
66
140
  throw new ConfigError(`${configPath} declares no targets`);
@@ -72,14 +146,84 @@ function load(configPath) {
72
146
  if (!target || typeof target !== "object") {
73
147
  throw new ConfigError(`target '${name}' is not a mapping`);
74
148
  }
149
+ checkKeys(target, TARGET_KEYS, `targets.${name}`);
75
150
  const kinds = ["openapi", "asyncapi"].filter((k) => target[k]);
76
151
  if (kinds.length === 0) {
77
152
  throw new ConfigError(
78
153
  `target '${name}' declares neither an openapi nor an asyncapi bundle`
79
154
  );
80
155
  }
156
+
157
+ // A portfolio is a catalogue, never a contract of its own: declaring an
158
+ // aggregate for one kind means the target IS a portfolio, so it must
159
+ // aggregate every kind it declares, and it is never published.
160
+ const aggregateKinds = kinds.filter((k) => target[k].aggregate !== undefined);
161
+ if (aggregateKinds.length > 0 && aggregateKinds.length !== kinds.length) {
162
+ const bundled = kinds.find((k) => target[k].aggregate === undefined);
163
+ throw new ConfigError(
164
+ `target '${name}' aggregates ${aggregateKinds[0]} but has a hand-written bundle for ${bundled}; ` +
165
+ "a target may not mix an aggregate with a hand-written bundle across kinds -- a hand-written " +
166
+ "AsyncAPI portfolio is an ordinary bundle, not an aggregate that happens to look like one"
167
+ );
168
+ }
169
+ if (aggregateKinds.length > 0 && target.publish === true) {
170
+ throw new ConfigError(
171
+ `target '${name}' declares an aggregate; it is never published, whatever publish says. ` +
172
+ "Prefixed operation ids match no member's, transcribed classes would not match a member's " +
173
+ "either, and a portfolio's version answers no useful question, since it moves whenever " +
174
+ "anything anywhere moves. A combined surface that genuinely needs publishing is a bundle " +
175
+ "somebody authors deliberately, with its own owner and version -- author one instead."
176
+ );
177
+ }
178
+
81
179
  for (const kind of kinds) {
82
- requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
180
+ checkKeys(target[kind], TARGET_KIND_KEYS, `targets.${name}.${kind}`);
181
+ if (target[kind].aggregate !== undefined) {
182
+ if (target[kind].bundle !== undefined) {
183
+ throw new ConfigError(
184
+ `target '${name}' declares both aggregate and bundle for ${kind}; an aggregate is ` +
185
+ "generated whole, so it needs no hand-written bundle root"
186
+ );
187
+ }
188
+ const members = target[kind].aggregate;
189
+ if (!Array.isArray(members) || members.length === 0) {
190
+ throw new ConfigError(`targets.${name}.${kind}.aggregate must list at least one target`);
191
+ }
192
+ if (members.includes(name)) {
193
+ throw new ConfigError(`target '${name}' aggregates itself`);
194
+ }
195
+ } else {
196
+ requireString(target[kind].bundle, `targets.${name}.${kind}.bundle`);
197
+ if (target[kind].info !== undefined) {
198
+ throw new ConfigError(`targets.${name}.${kind}.info only applies to an aggregate`);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // Every member an aggregate lists is checked by name, and a member that is
205
+ // itself an aggregate is refused outright: it has no hand-written bundle for
206
+ // the generator to read, and reading its *generated* one would mean generating
207
+ // aggregates in dependency order, which nothing here attempts. Because an
208
+ // aggregate can only ever reach a hand-written bundle this way, refusing that
209
+ // one step also catches every transitive self-reference, not only the direct
210
+ // one the loop above already refused.
211
+ for (const [name, target] of Object.entries(targets)) {
212
+ for (const kind of ["openapi", "asyncapi"].filter((k) => target[k] && target[k].aggregate !== undefined)) {
213
+ for (const member of target[kind].aggregate) {
214
+ const memberTarget = targets[member];
215
+ if (!memberTarget || memberTarget[kind] === undefined) {
216
+ throw new ConfigError(
217
+ `target '${name}': aggregate member '${member}' declares no ${kind} bundle`
218
+ );
219
+ }
220
+ if (memberTarget[kind].aggregate !== undefined) {
221
+ throw new ConfigError(
222
+ `target '${name}' aggregates '${member}', which is itself an aggregate; ` +
223
+ "nesting is not supported"
224
+ );
225
+ }
226
+ }
83
227
  }
84
228
  }
85
229
 
@@ -88,13 +232,14 @@ function load(configPath) {
88
232
  root,
89
233
  schemaVersion: parsed.schemaVersion,
90
234
  sources,
91
- defaults: parsed.defaults || {},
235
+ defaults,
92
236
  toolchain: parsed.toolchain || {},
93
- build: parsed.build || {},
94
- reports: parsed.reports || {},
95
- lint: parsed.lint || {},
237
+ build,
238
+ reports,
239
+ lint,
96
240
  distribution: parsed.distribution || null,
97
241
  channels: parsed.channels || {},
242
+ portfolio,
98
243
  targets,
99
244
 
100
245
  // --- derived accessors, so callers never re-derive a path themselves ---
@@ -141,6 +286,20 @@ function load(configPath) {
141
286
  }
142
287
  return mode;
143
288
  },
289
+ // What the build does about an operation whose example this kind's
290
+ // toolchain could use, and does not have: `warn`, the default, reports it;
291
+ // `error` fails the build; `off` does not look. Examples are never
292
+ // mandatory to the specification, so the default suits a library that has
293
+ // not adopted Microcks; a library that has wants `error` for asyncapi.
294
+ lintExamples(kind) {
295
+ const configured = (this.lint.examples || {})[kind];
296
+ const mode = configured === undefined ? "warn" : configured;
297
+ if (!["error", "warn", "off"].includes(mode)) {
298
+ throw new ConfigError(
299
+ `lint.examples.${kind} must be error, warn or off, not ${JSON.stringify(configured)}`);
300
+ }
301
+ return mode;
302
+ },
144
303
  tool(name) {
145
304
  return requireString(this.toolchain[name], `toolchain.${name}`);
146
305
  },
@@ -148,19 +307,79 @@ function load(configPath) {
148
307
  targetsFor(kind) {
149
308
  return Object.keys(this.targets).filter((t) => this.targets[t][kind]);
150
309
  },
310
+ // Whether this target is itself an aggregate, for any kind it declares.
311
+ // load() already refuses a target that aggregates one kind and hand-writes
312
+ // another, so any one kind answers for the whole target.
313
+ isAggregate(target) {
314
+ return ["openapi", "asyncapi"].some((k) => this.targets[target][k] && this.targets[target][k].aggregate !== undefined);
315
+ },
151
316
  // A target with `publish: false` is a documentation view rather than a
152
317
  // contract any one project implements: built and linted, never shipped.
318
+ // An aggregate is never published either, whether or not it says so --
319
+ // load() already refuses the one case that would contradict this, `publish:
320
+ // true` alongside an aggregate, so nothing here needs to re-check that.
153
321
  isPublished(target) {
154
- return this.targets[target].publish !== false;
322
+ return this.targets[target].publish !== false && !this.isAggregate(target);
155
323
  },
156
324
  // A target's bundle root in a staged tree: the build's own, unless another
157
- // staged copy is named.
325
+ // staged copy is named. Only a hand-written bundle has one; an aggregate's
326
+ // generated root is aggregatePath(), below.
158
327
  bundlePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
159
328
  return path.join(
160
329
  stagingRoot,
161
330
  requireString(this.sources[kind], `sources.${kind}`),
162
331
  this.targets[target][kind].bundle);
163
332
  },
333
+ // Where an aggregate's generated bundle root lands in a staged tree: outside
334
+ // bundles/, at the same depth, so every $ref a member contributes -- carried
335
+ // over verbatim -- still resolves. The path is the tool's to decide, not the
336
+ // author's: an aggregate declares its members and its info, never where its
337
+ // generated root lands.
338
+ aggregatePath(target, kind, stagingRoot = this.stagingRoot(kind)) {
339
+ return path.join(
340
+ stagingRoot,
341
+ requireString(this.sources[kind], `sources.${kind}`),
342
+ this.portfolioLocation(),
343
+ `${target}_${kind}_structure.yaml`);
344
+ },
345
+ // Where a target's document root sits in a staged tree, hand-written or
346
+ // generated -- whichever this target is.
347
+ bundleRootPath(target, kind, stagingRoot = this.stagingRoot(kind)) {
348
+ return this.isAggregate(target)
349
+ ? this.aggregatePath(target, kind, stagingRoot)
350
+ : this.bundlePath(target, kind, stagingRoot);
351
+ },
352
+ // The path prefix strategy an OpenAPI portfolio merges its members' paths
353
+ // and operation ids with: `target-prefix` (the default) for a gateway
354
+ // routing /<target>/** to each service, `none` where routing is by host and
355
+ // a prefixed path would exist nowhere.
356
+ portfolioPathStrategy() {
357
+ return (this.portfolio.openapi && this.portfolio.openapi.paths) || PORTFOLIO_DEFAULTS.openapi.paths;
358
+ },
359
+ // How a portfolio's operation ids are told apart: currently always prefixed
360
+ // with the target, the same way paths are -- named, like every portfolio
361
+ // setting, so a second strategy can be added later without a breaking change.
362
+ portfolioOperationIdStrategy() {
363
+ return (this.portfolio.openapi && this.portfolio.openapi.operationIds) || PORTFOLIO_DEFAULTS.openapi.operationIds;
364
+ },
365
+ // How two members contributing the same tag name are merged: `reconcile`,
366
+ // today the only mode -- an identical body merges silently, a differing one
367
+ // merges too, reported, with the first contributing member's body winning.
368
+ portfolioTagStrategy() {
369
+ return (this.portfolio.openapi && this.portfolio.openapi.tags) || PORTFOLIO_DEFAULTS.openapi.tags;
370
+ },
371
+ // How a portfolio merges root-level `security`: `push-down`, today the only
372
+ // mode -- each member's root security becomes each of its own contributed
373
+ // operations' security, so the portfolio never has to say something false
374
+ // about any one of them. See the Security section of the README.
375
+ portfolioSecurityStrategy() {
376
+ return this.portfolio.security || PORTFOLIO_DEFAULTS.security;
377
+ },
378
+ // The directory an aggregate's generated bundle root lands in, sibling to
379
+ // bundles/, at the same depth under each kind's own source tree.
380
+ portfolioLocation() {
381
+ return this.portfolio.location || PORTFOLIO_DEFAULTS.location;
382
+ },
164
383
  // Whether built documents of this kind carry x-fragment-path: on each
165
384
  // component of an OpenAPI document, on each fragment of an AsyncAPI one.
166
385
  // On unless turned off, for both kinds.
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+
3
+ // Whether a bundled document's operations carry examples the rest of the API-Only
4
+ // toolchain can do something with.
5
+ //
6
+ // Both OpenAPI and AsyncAPI make examples optional, and the specification linters
7
+ // are right to accept their absence: a contract without one is a valid contract.
8
+ // This is a toolchain-compatibility check instead -- can Microcks, or the
9
+ // TranscriberJ, do anything useful with what is about to be published -- and the
10
+ // answer, and so the message, differs per kind. Neither runs from a Redocly plugin
11
+ // or a Spectral ruleset: one implementation, one message shape, aware of why it
12
+ // cares, reported apart from either linter's own findings.
13
+ //
14
+ // AsyncAPI: Microcks builds its async mocks and its conformance tests from a
15
+ // message's own `examples` -- an array of {name, summary, payload} on the Message
16
+ // Object itself. A message with none cannot be used for Microcks-based conformance
17
+ // testing at all, so the message says exactly that.
18
+ //
19
+ // OpenAPI: nothing downstream currently reads an example. The TranscriberJ
20
+ // processes a bundle without one perfectly well -- bodies take arguments,
21
+ // constraints become constants. So the OpenAPI message claims only what is true:
22
+ // weaker documentation, never that the bundle cannot be used.
23
+
24
+ const KEY = "x-fragment-path";
25
+
26
+ /** The value at a local JSON pointer (`#/a/b/0`) within `document`, or undefined. */
27
+ function pointer(document, ref) {
28
+ if (typeof ref !== "string" || !ref.startsWith("#/")) return undefined;
29
+ let node = document;
30
+ for (const raw of ref.slice(2).split("/")) {
31
+ if (node === null || typeof node !== "object") return undefined;
32
+ const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~");
33
+ node = node[segment];
34
+ }
35
+ return node;
36
+ }
37
+
38
+ /** `node`, or what it points to when it is a `{ $ref }`. */
39
+ function resolve(document, node) {
40
+ if (node && typeof node === "object" && typeof node.$ref === "string") {
41
+ return pointer(document, node.$ref);
42
+ }
43
+ return node;
44
+ }
45
+
46
+ /**
47
+ * Every AsyncAPI operation whose message carries no example, in operation order.
48
+ *
49
+ * An operation naming no message explicitly acts on every message of its channel,
50
+ * so each of those is checked in its place. A message this document cannot resolve
51
+ * -- a dangling `$ref`, which lint would already have refused -- is skipped rather
52
+ * than reported here.
53
+ *
54
+ * @returns {Array<{operationId: string, messageKey: string, fragmentPath: string|null, at: string}>}
55
+ */
56
+ function asyncapiOperationsWithoutExamples(document) {
57
+ const findings = [];
58
+ const operations = document.operations || {};
59
+ for (const [operationId, operation] of Object.entries(operations)) {
60
+ if (!operation || typeof operation !== "object") continue;
61
+ const channel = resolve(document, operation.channel);
62
+ const named = Array.isArray(operation.messages) ? operation.messages : [];
63
+ const entries = named.length > 0
64
+ ? named.map((ref) => [
65
+ typeof ref.$ref === "string" ? ref.$ref.split("/").pop() : null,
66
+ resolve(document, ref),
67
+ ])
68
+ : Object.entries((channel && channel.messages) || {});
69
+
70
+ for (const [messageKey, message] of entries) {
71
+ if (!message || typeof message !== "object" || messageKey === null) continue;
72
+ const examples = message.examples;
73
+ if (Array.isArray(examples) && examples.length > 0) continue;
74
+ findings.push({
75
+ operationId,
76
+ messageKey,
77
+ fragmentPath: message[KEY] || null,
78
+ at: `/operations/${operationId}`,
79
+ });
80
+ }
81
+ }
82
+ return findings;
83
+ }
84
+
85
+ /** Whether any media type of a request body or response object has an example. */
86
+ function hasExample(document, bodyOrResponse) {
87
+ const content = bodyOrResponse.content;
88
+ if (!content || typeof content !== "object") return true; // nothing to exemplify
89
+ return Object.values(content).some((mediaType) => {
90
+ if (!mediaType || typeof mediaType !== "object") return false;
91
+ if (mediaType.example !== undefined) return true;
92
+ if (mediaType.examples && Object.keys(mediaType.examples).length > 0) return true;
93
+ const schema = resolve(document, mediaType.schema);
94
+ return !!(schema && Array.isArray(schema.examples) && schema.examples.length > 0);
95
+ });
96
+ }
97
+
98
+ const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
99
+
100
+ /**
101
+ * Every OpenAPI operation whose request body or a response carries no example, in
102
+ * path-and-method order.
103
+ *
104
+ * An entity counts as having one when a media type declares `example` or
105
+ * `examples` directly, or when the schema that media type's `schema` resolves to
106
+ * -- not a property inside it -- carries its own top-level `examples`. A body or
107
+ * response with no `content` at all, such as a 204, has nothing to exemplify and
108
+ * is not reported.
109
+ *
110
+ * @returns {Array<{operationId: string, part: string, fragmentPath: string|null, at: string}>}
111
+ */
112
+ function openapiOperationsWithoutExamples(document) {
113
+ const findings = [];
114
+ const paths = document.paths || {};
115
+ for (const [route, pathItem] of Object.entries(paths)) {
116
+ if (!pathItem || typeof pathItem !== "object") continue;
117
+ for (const method of METHODS) {
118
+ const operation = pathItem[method];
119
+ if (!operation || typeof operation !== "object") continue;
120
+ const operationId = operation.operationId || `${method.toUpperCase()} ${route}`;
121
+ const at = `/paths/${route.replace(/~/g, "~0").replace(/\//g, "~1")}/${method}`;
122
+
123
+ if (operation.requestBody) {
124
+ const body = resolve(document, operation.requestBody);
125
+ if (body && !hasExample(document, body)) {
126
+ findings.push({ operationId, part: "request body", fragmentPath: body[KEY] || null, at });
127
+ }
128
+ }
129
+
130
+ for (const [status, raw] of Object.entries(operation.responses || {})) {
131
+ const response = resolve(document, raw);
132
+ if (response && !hasExample(document, response)) {
133
+ findings.push({
134
+ operationId, part: `response ${status}`, fragmentPath: response[KEY] || null, at,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return findings;
141
+ }
142
+
143
+ /**
144
+ * The warning text for one AsyncAPI finding.
145
+ *
146
+ * @param {{operationId: string, messageKey: string, fragmentPath: string|null}} finding
147
+ * @returns {string}
148
+ */
149
+ function asyncapiMessage(finding) {
150
+ const where = finding.fragmentPath || `at ${finding.at}`;
151
+ return `AsyncAPI operation '${finding.operationId}': message '${finding.messageKey}' (${where}) ` +
152
+ "carries no example, so this bundle cannot be used for Microcks-based conformance testing.";
153
+ }
154
+
155
+ /**
156
+ * The warning text for one OpenAPI finding.
157
+ *
158
+ * @param {{operationId: string, part: string, fragmentPath: string|null}} finding
159
+ * @returns {string}
160
+ */
161
+ function openapiMessage(finding) {
162
+ const where = finding.fragmentPath || `at ${finding.at}`;
163
+ return `OpenAPI operation '${finding.operationId}': ${finding.part} (${where}) ` +
164
+ "carries no example, which makes the generated documentation harder to read.";
165
+ }
166
+
167
+ module.exports = {
168
+ asyncapiOperationsWithoutExamples,
169
+ openapiOperationsWithoutExamples,
170
+ asyncapiMessage,
171
+ openapiMessage,
172
+ };
@@ -84,22 +84,33 @@ function place(doc, parentPath, key, value, before) {
84
84
  * already there survives unchanged; a slot already present, however it reads, is
85
85
  * never touched, and neither is anything inside it.
86
86
  *
87
+ * `portfolio`, unlike a kind, is never added on its own account: whether a
88
+ * library is about to have more than one target, and so whether the question is
89
+ * even asked, is the caller's decision, made once, before this runs -- give the
90
+ * strategies it resolved here to have the section written with them, or leave
91
+ * this out entirely to leave the section alone, present or not.
92
+ *
87
93
  * @param {string} text apionly.yaml, as it is on disk
88
94
  * @param {object} v the values scaffold(values) was given, resolved: every value
89
95
  * DEFAULTS has, `values`' own where it gives one -- the shape scaffold() itself
90
96
  * works from, so a caller that already has that object need not rebuild it
97
+ * @param {{paths: string, location: string}} [portfolio] the portfolio section to
98
+ * add, with every strategy resolved, when it is missing; omitted, the section
99
+ * is never considered at all, whether or not the file already has one
91
100
  * @returns {{text: string, added: string[]}} the edited text, and the slots added, in
92
- * the order scaffold() declares them; `added` is empty, and `text` is `text`
93
- * itself, when nothing was missing.
101
+ * the order scaffold() declares them, portfolio last; `added` is empty, and
102
+ * `text` is `text` itself, when nothing was missing.
94
103
  */
95
- function addMissingConfig(text, v) {
104
+ function addMissingConfig(text, v, portfolio) {
96
105
  const doc = YAML.parseDocument(text);
97
106
  const added = [];
98
107
 
99
- // Not every file that differs is a library with a kind missing -- 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 };
108
+ // Not every file that differs is a library with a kind missing. One with a YAML
109
+ // error cannot be edited at all -- parseDocument does not throw on one, it just
110
+ // records it, and a document with errors refuses to be stringified back. One that
111
+ // does not even declare sources.root, whatever it parses to, is not an apionly.yaml
112
+ // this can complete either way; force, or a person, is what either file needs.
113
+ if (doc.errors.length > 0 || !doc.hasIn(["sources", "root"])) return { text, added };
103
114
 
104
115
  for (const kind of ["openapi", "asyncapi"].filter((k) => v.kinds.includes(k))) {
105
116
  for (const slot of slotsFor(kind, v)) {
@@ -109,6 +120,15 @@ function addMissingConfig(text, v) {
109
120
  }
110
121
  }
111
122
 
123
+ if (portfolio && !doc.hasIn(["portfolio"])) {
124
+ doc.setIn(["portfolio"], {
125
+ openapi: { paths: portfolio.paths, operationIds: "target-prefix", tags: "reconcile" },
126
+ security: "push-down",
127
+ location: portfolio.location,
128
+ });
129
+ added.push("portfolio");
130
+ }
131
+
112
132
  return added.length === 0 ? { text, added } : { text: doc.toString(), added };
113
133
  }
114
134