@arc-e-tect/api-only-publisher 0.8.0 → 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.
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.8.0
9
+ :api-only-publisher-version: 0.9.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]
@@ -687,6 +687,9 @@ reports:
687
687
  lint: build/reports/lint
688
688
  lint:
689
689
  unreferenced: error # a fragment no target reaches: error, warn or off
690
+ examples:
691
+ asyncapi: warn # a message with no example: error, warn (the default) or off
692
+ openapi: warn # a request body or response with no example: error, warn (the default) or off
690
693
 
691
694
  toolchain:
692
695
  redocly: "@redocly/cli@2.52.0"
@@ -781,6 +784,32 @@ Any other YAML file under the source root counts.
781
784
 
782
785
  `lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
783
786
 
787
+ [#examples-check]
788
+ === An operation with no example is reported apart from lint
789
+
790
+ `build` also checks, after linting, whether each operation's example is one the rest of the API-Only toolchain could use -- and reports that separately from lint's own findings, since "your contract is wrong" and "your contract limits what you can do with it downstream" are different claims.
791
+ Neither specification requires an example, and this is not a specification-validity rule: it is never run as a Redocly plugin or a Spectral ruleset.
792
+
793
+ For AsyncAPI, it is whether a message's own `examples` array is non-empty -- what Microcks reads to build its async mocks and its conformance tests:
794
+
795
+ [source,text]
796
+ ----
797
+ AsyncAPI operation '<operationId>': message '<messageKey>' (<fragment, or where in the bundle>) carries no example, so this bundle cannot be used for Microcks-based conformance testing.
798
+ ----
799
+
800
+ For OpenAPI, it is whether a request body's or response's media type declares `example`/`examples`, or the schema it references carries its own top-level `examples`.
801
+ Nothing downstream currently reads an OpenAPI example, so the message never claims the bundle cannot be used -- only that the generated documentation is weaker without one:
802
+
803
+ [source,text]
804
+ ----
805
+ OpenAPI operation '<operationId>': <request body, or response <status>> (<fragment, or where in the bundle>) carries no example, which makes the generated documentation harder to read.
806
+ ----
807
+
808
+ A body or response with no `content` at all, such as a `204`, has nothing to exemplify and is never reported.
809
+
810
+ `lint.examples.asyncapi` and `lint.examples.openapi` each default to `warn`; `error` fails the build, `off` does not look.
811
+ A library that has adopted the Microcks-based conformance emitter will usually want `lint.examples.asyncapi: error`: publishing a bundle its own toolchain cannot use is a real defect there, not merely a gap (link:../docs/reference/authoring-a-specification-library.adoc#give-every-operation-an-example[Give every operation an example →]).
812
+
784
813
  [#version-stamping]
785
814
  === Version stamping edits one scalar
786
815
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.8.0",
3
+ "version": "0.9.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/config.js CHANGED
@@ -27,7 +27,8 @@ const DEFAULTS_KIND_KEYS = { openapi: ["lint", "outputName", "fragmentPaths"], a
27
27
  const PLACEHOLDERS_KEYS = ["strict"];
28
28
  const BUILD_KEYS = ["staging", "dist"];
29
29
  const REPORTS_KEYS = ["lint"];
30
- const LINT_KEYS = ["unreferenced"];
30
+ const LINT_KEYS = ["unreferenced", "examples"];
31
+ const LINT_EXAMPLES_KEYS = ["openapi", "asyncapi"];
31
32
  const DISTRIBUTION_KEYS = ["root", "layout"];
32
33
  const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
33
34
  const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
@@ -121,6 +122,7 @@ function load(configPath) {
121
122
  checkKeys(reports, REPORTS_KEYS, "reports");
122
123
  const lint = parsed.lint || {};
123
124
  checkKeys(lint, LINT_KEYS, "lint");
125
+ if (lint.examples) checkKeys(lint.examples, LINT_EXAMPLES_KEYS, "lint.examples");
124
126
  if (parsed.distribution) checkKeys(parsed.distribution, DISTRIBUTION_KEYS, "distribution");
125
127
 
126
128
  const portfolio = parsed.portfolio || {};
@@ -284,6 +286,20 @@ function load(configPath) {
284
286
  }
285
287
  return mode;
286
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
+ },
287
303
  tool(name) {
288
304
  return requireString(this.toolchain[name], `toolchain.${name}`);
289
305
  },
@@ -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
+ };
package/src/pipeline.js CHANGED
@@ -11,6 +11,9 @@ const { substituteFile } = require("./placeholders");
11
11
  const { stampFile } = require("./version");
12
12
  const { generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate } = require("./aggregate");
13
13
  const { stampFiles, componentPaths, strayPaths, fragmentStamps, unresolvedStamps, FragmentPathError, KEY } = require("./fragment-paths");
14
+ const {
15
+ asyncapiOperationsWithoutExamples, openapiOperationsWithoutExamples, asyncapiMessage, openapiMessage,
16
+ } = require("./examples");
14
17
 
15
18
  class BuildError extends Error {}
16
19
 
@@ -205,6 +208,33 @@ function lint(config, kind, file, log, { report = false, reportFile = null } = {
205
208
  if (report && output.trim()) log(output.trimEnd());
206
209
  }
207
210
 
211
+ /**
212
+ * Reports operations of a built document whose example this kind's toolchain
213
+ * could use, and does not have -- separately from lint's own findings, since
214
+ * "your contract is wrong" and "your contract limits what you can do with it
215
+ * downstream" are different claims and read worse conflated.
216
+ *
217
+ * Governed by `lint.examples.<kind>`: `warn`, the default, logs each finding and
218
+ * continues; `error` logs them and fails the build; `off` does not look.
219
+ */
220
+ function checkExamples(config, kind, file, log) {
221
+ const mode = config.lintExamples(kind);
222
+ if (mode === "off") return;
223
+
224
+ const document = YAML.parse(fs.readFileSync(file, "utf8"));
225
+ const findings = kind === "asyncapi"
226
+ ? asyncapiOperationsWithoutExamples(document).map(asyncapiMessage)
227
+ : openapiOperationsWithoutExamples(document).map(openapiMessage);
228
+ if (findings.length === 0) return;
229
+
230
+ for (const message of findings) log(`-- ${message}`);
231
+ if (mode === "error") {
232
+ throw new BuildError(
233
+ `${findings.length} operation(s) in ${path.basename(file)} have no example ` +
234
+ "(lint.examples." + kind + " is error):\n" + findings.map((m) => ` ${m}`).join("\n"));
235
+ }
236
+ }
237
+
208
238
  /**
209
239
  * Copy a built document to the project that implements the target.
210
240
  *
@@ -304,6 +334,7 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
304
334
  stampFile(outFile, version);
305
335
  }
306
336
  lint(config, kind, outFile, log);
337
+ checkExamples(config, kind, outFile, log);
307
338
 
308
339
  let distributed = null;
309
340
  if (config.isPublished(target)) {
@@ -317,4 +348,6 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
317
348
  return results;
318
349
  }
319
350
 
320
- module.exports = { build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, distribute, BuildError };
351
+ module.exports = {
352
+ build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, checkExamples, distribute, BuildError,
353
+ };