@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 +30 -1
- package/package.json +1 -1
- package/src/config.js +17 -1
- package/src/examples.js +172 -0
- package/src/pipeline.js +34 -1
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.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
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
|
},
|
package/src/examples.js
ADDED
|
@@ -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 = {
|
|
351
|
+
module.exports = {
|
|
352
|
+
build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, checkExamples, distribute, BuildError,
|
|
353
|
+
};
|