@amritk/asyncapi 0.2.0 → 0.3.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/AI.md +18 -9
- package/README.md +11 -7
- package/dist/build-channel-contract.d.ts +10 -5
- package/dist/build-channel-contract.js +17 -13
- package/dist/extract-async-api.d.ts +8 -2
- package/dist/extract-async-api.js +2 -2
- package/dist/extract-channels-v2.d.ts +2 -1
- package/dist/extract-channels-v2.js +2 -2
- package/dist/extract-channels-v3.d.ts +2 -1
- package/dist/extract-channels-v3.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/normalize-message.d.ts +22 -2
- package/dist/normalize-message.js +13 -1
- package/dist/normalize-schema.d.ts +5 -1
- package/dist/rebase-component-refs.d.ts +1 -1
- package/dist/rebase-component-refs.js +4 -3
- package/dist/schema-format.d.ts +7 -7
- package/dist/schema-format.js +3 -0
- package/dist/strip-discriminator.d.ts +30 -17
- package/dist/strip-discriminator.js +18 -8
- package/dist/types.d.ts +4 -2
- package/dist/unwrap-multi-format.d.ts +12 -3
- package/dist/unwrap-multi-format.js +2 -5
- package/package.json +3 -2
package/AI.md
CHANGED
|
@@ -25,6 +25,7 @@ import { buildChannelContract } from '@amritk/asyncapi'
|
|
|
25
25
|
const contract = buildChannelContract(model.channels[0], { discriminator: 'event' })
|
|
26
26
|
// → { exportName: 'lobbyMessages', discriminator: 'event',
|
|
27
27
|
// clientToServer: { say: schema }, serverToClient: { said: schema }, issues: [] }
|
|
28
|
+
// Keys are the wire tags the payloads pin, not the AsyncAPI message names.
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
Hand the two direction maps straight to `defineMessages` — that is what
|
|
@@ -37,8 +38,10 @@ Hand the two direction maps straight to `defineMessages` — that is what
|
|
|
37
38
|
`@amritk/resolve-refs` *before* calling. External `$ref`s still present are
|
|
38
39
|
reported as issues, not fetched.
|
|
39
40
|
2. **Problems are collected, not thrown.** Read `model.issues`; only "this is
|
|
40
|
-
not an AsyncAPI document" throws. An Avro
|
|
41
|
-
|
|
41
|
+
not an AsyncAPI document" throws. An Avro `schemaFormat` is *converted* via
|
|
42
|
+
`@amritk/adapters` (pass `{ avroEncoding: 'avro-json' }` for the wire shape
|
|
43
|
+
instead of the decoded one); a Protobuf one, or an Avro schema the converter
|
|
44
|
+
rejects, skips that one schema with an issue.
|
|
42
45
|
3. **Directions are application-relative.** 2.x `publish` → `receive`,
|
|
43
46
|
`subscribe` → `send` (the app is the server). Absent when no operation names
|
|
44
47
|
the message.
|
|
@@ -47,14 +50,20 @@ Hand the two direction maps straight to `defineMessages` — that is what
|
|
|
47
50
|
message's schema never affects another.
|
|
48
51
|
5. **Root type names come from message identity**, not schema `title` — two
|
|
49
52
|
messages titled "Event" stay distinct.
|
|
50
|
-
6. **A contract's map key is the wire tag.** `receive` →
|
|
51
|
-
`send` → `serverToClient`, and the discriminator property is
|
|
52
|
-
each payload: `@amritk/api` reads the tag off the frame to
|
|
53
|
-
then removes it before validating, and refuses a schema
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
only
|
|
53
|
+
6. **A contract's map key is the wire tag, not the message name.** `receive` →
|
|
54
|
+
`clientToServer`, `send` → `serverToClient`, and the discriminator property is
|
|
55
|
+
*stripped* from each payload: `@amritk/api` reads the tag off the frame to
|
|
56
|
+
pick the message, then removes it before validating, and refuses a schema
|
|
57
|
+
that still declares it. The key comes from the value the payload pins the tag
|
|
58
|
+
to (`type: { const: 'bot_added' }` → key `bot_added`), and falls back to the
|
|
59
|
+
message name only when the payload pins nothing. Two messages pinning the
|
|
60
|
+
same tag in one direction collide: first wins, second reported.
|
|
57
61
|
7. **The discriminator has a priority order**: `x-mjst: { discriminator }` on
|
|
58
62
|
the channel, then the caller's argument, then `'type'`. The document wins
|
|
59
63
|
over the argument on purpose — one override covers a whole run, and a run
|
|
60
64
|
may span channels that disagree.
|
|
65
|
+
8. **Contracts carry payloads only, never headers.** `listMessageSchemas` gives
|
|
66
|
+
a message's `headers` its own `<message>-headers/` tree, but
|
|
67
|
+
`buildChannelContract` ignores headers: an `@amritk/api` contract describes
|
|
68
|
+
WebSocket frames, which have none. For a Kafka/MQTT document the headers
|
|
69
|
+
types are generated and yours to apply at the broker boundary.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ What "self-contained" buys: the AsyncAPI default schema dialect (a draft-07 supe
|
|
|
23
23
|
|
|
24
24
|
It also projects each channel onto a [`@amritk/api`](../api) **messages contract**: the two directions AsyncAPI declares become `clientToServer`/`serverToClient`, each message's name becomes its wire discriminator value, and the discriminator property is stripped out of the payload — which is exactly the shape `defineMessages` takes. That projection is what `mjst --input asyncapi --message-contracts` writes to disk.
|
|
25
25
|
|
|
26
|
-
Both majors normalize into one 3.0-shaped model. Directions are named from the application's point of view (2.x `publish` → `receive`, `subscribe` → `send`), matching [`@amritk/api`](../api)'s message contracts.
|
|
26
|
+
Both majors normalize into one 3.0-shaped model. Directions are named from the application's point of view (2.x `publish` → `receive`, `subscribe` → `send`), matching [`@amritk/api`](../api)'s message contracts. An Avro `schemaFormat` is **converted**, not skipped — [`@amritk/adapters`](../adapters) already reads Avro, so those payloads reach the generators like any other. A payload in a language nothing here reads (Protobuf, RAML) is skipped per message with a recorded issue, and so is an Avro schema the converter rejects — one bad payload never costs the document's other messages.
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
@@ -76,8 +76,8 @@ for (const channel of model.channels) {
|
|
|
76
76
|
const contract = buildChannelContract(channel)
|
|
77
77
|
for (const issue of contract.issues) console.warn(`${issue.path}: ${issue.message}`)
|
|
78
78
|
|
|
79
|
-
// The two maps are keyed by
|
|
80
|
-
// stripped of the tag — hand them straight to defineMessages.
|
|
79
|
+
// The two maps are keyed by the value a frame carries on the wire, payloads
|
|
80
|
+
// already stripped of the tag — hand them straight to defineMessages.
|
|
81
81
|
const messages = defineMessages({
|
|
82
82
|
discriminator: contract.discriminator,
|
|
83
83
|
clientToServer: contract.clientToServer,
|
|
@@ -88,7 +88,11 @@ for (const channel of model.channels) {
|
|
|
88
88
|
|
|
89
89
|
The discriminator is resolved in priority order: `x-mjst: { discriminator }` on the channel, then the `discriminator` option, then `'type'` (matching `@amritk/api`'s default). The document wins over the option deliberately — one option covers a whole run, and a run may span channels that disagree.
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
**The key is the tag, not the name.** A payload usually states its own tag — `type: { const: 'bot_added' }` is how a channel of alternatives says which message is which — and that value becomes the contract key, because it is what actually arrives on the wire. The AsyncAPI message *name* is only the fallback for a payload that pins nothing; it is a document-authoring handle, and 2.x messages inside a `oneOf` frequently have none at all. Slack's RTM document names a message `botAdded` and tags it `bot_added`; keying on the name would emit a contract listening for a frame that never comes.
|
|
92
|
+
|
|
93
|
+
Two messages that pin the *same* tag in the *same* direction are one frame shape with two descriptions, so the first wins and the second is reported (Slack declares two messages for its single `bot_added` event). A payload that constrains the tag without pinning it to one string — `type: { type: 'string' }`, a multi-member `enum`, a non-string `const` — names no message the runtime could select, and is skipped with an issue.
|
|
94
|
+
|
|
95
|
+
**Headers are not part of a contract.** `listMessageSchemas` emits a message's `headers` schema as its own generatable tree, but `buildChannelContract` projects payloads only: `@amritk/api` message contracts describe WebSocket frames, which carry no headers of their own. For a Kafka or MQTT document the headers types are still generated (under `<message>-headers/`) — they are simply yours to apply at the broker boundary, not something the socket runtime validates.
|
|
92
96
|
|
|
93
97
|
Cross-file and remote `$ref`s are the loader's job: resolve them first (for example with [`@amritk/resolve-refs`](../resolve-refs)); a still-unresolved external reference is reported as an issue, never fetched.
|
|
94
98
|
|
|
@@ -96,14 +100,14 @@ Cross-file and remote `$ref`s are the loader's job: resolve them first (for exam
|
|
|
96
100
|
|
|
97
101
|
## API
|
|
98
102
|
|
|
99
|
-
- **`extractAsyncApi(document)`** → `AsyncApiModel` — the normalized document: `version`, `major`, `title?`, `channels` (each with `key`, `address?`, `messages`), and collected `issues`. Throws only when the input is not an AsyncAPI document at all.
|
|
103
|
+
- **`extractAsyncApi(document, options?)`** → `AsyncApiModel` — the normalized document: `version`, `major`, `title?`, `channels` (each with `key`, `address?`, `messages`), and collected `issues`. Throws only when the input is not an AsyncAPI document at all. `options.avroEncoding` picks which JSON shape an Avro payload describes: `'json'` (default) is the decoded object an application works with, `'avro-json'` is the spec's JSON encoding as it travels on the wire.
|
|
100
104
|
- **`listMessageSchemas(model, issues?)`** → `ExtractedSchema[]` — one `{ subDir, rootTypeName, schema }` per generatable payload/headers, with deterministic collision-suffixed directory tokens; collision issues are appended to `model.issues` (or to the `issues` array you pass).
|
|
101
105
|
- **`buildChannelContract(channel, options?)`** → `ChannelContract` — one channel as `{ exportName, discriminator, clientToServer, serverToClient, issues }`, ready for `defineMessages`.
|
|
102
106
|
- **`resolveDiscriminator(channel, override?)`** / **`DEFAULT_DISCRIMINATOR`** — the priority order above, and the `'type'` fallback.
|
|
103
|
-
- **`stripDiscriminator(payload, discriminator
|
|
107
|
+
- **`stripDiscriminator(payload, discriminator)`** → `{ schema, tag? }` or `{ issue }` — the payload with its tag removed plus the value it pinned the tag to, or why neither could be read.
|
|
104
108
|
- **`sanitizeToken(value, fallback)`** — the filesystem- and import-safe token both output layouts name a channel or message with.
|
|
105
109
|
- **`detectAsyncApiVersion(document)`** — the `asyncapi` version and its major, or `undefined`.
|
|
106
|
-
- **`classifySchemaFormat(schemaFormat)`** — which
|
|
110
|
+
- **`classifySchemaFormat(schemaFormat)`** — which schema language a `schemaFormat` names (`'asyncapi' | 'draft-07' | '2020-12' | 'openapi' | 'avro'`), or `'unsupported'`.
|
|
107
111
|
- **`mergeTraits(target, traits, precedence)`** — trait application as an RFC 7386 JSON Merge Patch (recursive, so nested contributions from both sides survive); `precedence` is `'trait'` for 2.x (traits override the target) or `'target'` for 3.0 (the target wins). Applied before `schemaFormat` is read.
|
|
108
112
|
|
|
109
113
|
---
|
|
@@ -7,8 +7,10 @@ export type ContractDirection = {
|
|
|
7
7
|
* One channel projected onto an `@amritk/api` messages contract.
|
|
8
8
|
*
|
|
9
9
|
* The map keys *are* the wire discriminator values — that is what
|
|
10
|
-
* `defineMessages` means by a message name —
|
|
11
|
-
*
|
|
10
|
+
* `defineMessages` means by a message name — taken from the value the payload
|
|
11
|
+
* pins the discriminator to, and falling back to the AsyncAPI message name only
|
|
12
|
+
* when the payload pins nothing. A message that cannot be given a legal key or a
|
|
13
|
+
* legal payload is left out and its reason recorded in
|
|
12
14
|
* {@link ChannelContract.issues} rather than emitted broken.
|
|
13
15
|
*/
|
|
14
16
|
export type ChannelContract = {
|
|
@@ -36,9 +38,12 @@ export type BuildChannelContractOptions = {
|
|
|
36
38
|
* operation names has no direction at all, and a contract cannot guess one:
|
|
37
39
|
* putting it in the wrong half would validate frames flowing the wrong way
|
|
38
40
|
* and reject the ones that arrive.
|
|
39
|
-
* - **The tag.** The
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* - **The tag.** The key is the value a frame carries on the wire, which the
|
|
42
|
+
* payload usually states itself as `type: { const: 'bot_added' }`; that
|
|
43
|
+
* value is taken as the key and stripped out of the payload (see
|
|
44
|
+
* {@link stripDiscriminator}), because the runtime removes it from the frame
|
|
45
|
+
* before validating. Only a payload that pins nothing falls back to the
|
|
46
|
+
* message's name.
|
|
42
47
|
*
|
|
43
48
|
* Everything skipped comes back as an issue naming the message and the reason,
|
|
44
49
|
* so the caller can warn per message instead of failing the channel. A channel
|
|
@@ -21,25 +21,29 @@ const buildChannelContract = (channel, options = {}) => {
|
|
|
21
21
|
skip(`payload was not extracted as JSON Schema (schemaFormat "${message.schemaFormat}")`);
|
|
22
22
|
continue;
|
|
23
23
|
}
|
|
24
|
-
if (message.name === "__proto__") {
|
|
25
|
-
skip('a message named "__proto__" cannot be written as an object key in the generated contract');
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
24
|
const target = message.direction === "receive" ? clientToServer : serverToClient;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const half = message.direction === "receive" ? "clientToServer" : "serverToClient";
|
|
26
|
+
const place = (key, schema) => {
|
|
27
|
+
if (key === "__proto__") {
|
|
28
|
+
skip('the wire tag "__proto__" cannot be written as an object key in the generated contract');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (target.has(key)) {
|
|
32
|
+
skip(`two ${half} messages carry the wire tag "${key}"; keeping the first`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
target.set(key, schema);
|
|
36
|
+
};
|
|
33
37
|
if (message.payload === void 0) {
|
|
34
|
-
|
|
38
|
+
place(message.name, { type: "object" });
|
|
35
39
|
continue;
|
|
36
40
|
}
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
skip(
|
|
41
|
+
const projected = stripDiscriminator(message.payload, discriminator);
|
|
42
|
+
if (projected.issue !== void 0) {
|
|
43
|
+
skip(projected.issue);
|
|
40
44
|
continue;
|
|
41
45
|
}
|
|
42
|
-
|
|
46
|
+
place(projected.tag ?? message.name, projected.schema);
|
|
43
47
|
}
|
|
44
48
|
return {
|
|
45
49
|
exportName: toExportName(sanitizeToken(channel.key, "channel")),
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import type { NormalizeMessageOptions } from './normalize-message.js';
|
|
1
2
|
import type { AsyncApiModel } from './types.js';
|
|
3
|
+
/** Options for {@link extractAsyncApi}. */
|
|
4
|
+
export type ExtractAsyncApiOptions = NormalizeMessageOptions;
|
|
2
5
|
/**
|
|
3
6
|
* Extracts an already-parsed AsyncAPI document (2.x or 3.x) into the
|
|
4
7
|
* normalized model: channels, their messages, and each message's payload and
|
|
5
8
|
* headers as self-contained JSON Schema 2020-12 documents.
|
|
6
9
|
*
|
|
7
|
-
* Per-message problems —
|
|
10
|
+
* Per-message problems — a Protobuf payload, a dangling `$ref`, a malformed
|
|
8
11
|
* trait — are collected on `issues` so one bad message never costs the rest of
|
|
9
12
|
* the document. Only a document that is not AsyncAPI at all throws: there is
|
|
10
13
|
* nothing to extract, and a silent empty model would read as "no channels"
|
|
11
14
|
* rather than "wrong file".
|
|
15
|
+
*
|
|
16
|
+
* `options` tunes the one conversion that has more than one right answer: which
|
|
17
|
+
* JSON shape an Avro payload describes. See {@link ExtractAsyncApiOptions}.
|
|
12
18
|
*/
|
|
13
|
-
export declare const extractAsyncApi: (document: unknown) => AsyncApiModel;
|
|
19
|
+
export declare const extractAsyncApi: (document: unknown, options?: ExtractAsyncApiOptions) => AsyncApiModel;
|
|
@@ -2,14 +2,14 @@ import { readKey } from "@amritk/helpers/read-key";
|
|
|
2
2
|
import { detectAsyncApiVersion } from "./detect-version.js";
|
|
3
3
|
import { extractChannelsV2 } from "./extract-channels-v2.js";
|
|
4
4
|
import { extractChannelsV3 } from "./extract-channels-v3.js";
|
|
5
|
-
const extractAsyncApi = (document) => {
|
|
5
|
+
const extractAsyncApi = (document, options = {}) => {
|
|
6
6
|
const detected = detectAsyncApiVersion(document);
|
|
7
7
|
if (!detected) {
|
|
8
8
|
throw new Error("Not an AsyncAPI document: expected a top-level `asyncapi` field declaring version 2.x or 3.x.");
|
|
9
9
|
}
|
|
10
10
|
const record = document;
|
|
11
11
|
const issues = [];
|
|
12
|
-
const channels = detected.major === 2 ? extractChannelsV2(record, issues) : extractChannelsV3(record, issues);
|
|
12
|
+
const channels = detected.major === 2 ? extractChannelsV2(record, issues, options) : extractChannelsV3(record, issues, options);
|
|
13
13
|
const info = readKey(record, "info");
|
|
14
14
|
const title = typeof info === "object" && info !== null ? readKey(info, "title") : void 0;
|
|
15
15
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NormalizeMessageOptions } from './normalize-message.js';
|
|
1
2
|
import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* Walks an AsyncAPI 2.x document's channels into the normalized model.
|
|
@@ -13,4 +14,4 @@ import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
|
13
14
|
* `n` counting across both operations of the channel so a `oneOf` list and a
|
|
14
15
|
* publish/subscribe pair cannot collide.
|
|
15
16
|
*/
|
|
16
|
-
export declare const extractChannelsV2: (document: Record<string, unknown>, issues: ExtractionIssue[]) => NormalizedChannel[];
|
|
17
|
+
export declare const extractChannelsV2: (document: Record<string, unknown>, issues: ExtractionIssue[], options?: NormalizeMessageOptions) => NormalizedChannel[];
|
|
@@ -14,7 +14,7 @@ const resolveTraits = (document, traits, issues, path) => {
|
|
|
14
14
|
}
|
|
15
15
|
return resolved;
|
|
16
16
|
};
|
|
17
|
-
const extractChannelsV2 = (document, issues) => {
|
|
17
|
+
const extractChannelsV2 = (document, issues, options = {}) => {
|
|
18
18
|
const channelsMap = readKey(document, "channels");
|
|
19
19
|
if (typeof channelsMap !== "object" || channelsMap === null)
|
|
20
20
|
return [];
|
|
@@ -60,7 +60,7 @@ const extractChannelsV2 = (document, issues) => {
|
|
|
60
60
|
payloadSchemaFormat: readKey(merged, "schemaFormat"),
|
|
61
61
|
payload: readKey(merged, "payload"),
|
|
62
62
|
headers: readKey(merged, "headers")
|
|
63
|
-
}, document, issues, itemPath));
|
|
63
|
+
}, document, issues, itemPath, options));
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
const discriminator = getMjstDiscriminator(channel);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NormalizeMessageOptions } from './normalize-message.js';
|
|
1
2
|
import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* Walks an AsyncAPI 3.0 document's channels into the normalized model.
|
|
@@ -7,4 +8,4 @@ import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
|
7
8
|
* format travels with the schema it labels. Message names are the channel's
|
|
8
9
|
* `messages` map keys, which 3.0 makes mandatory and unique per channel.
|
|
9
10
|
*/
|
|
10
|
-
export declare const extractChannelsV3: (document: Record<string, unknown>, issues: ExtractionIssue[]) => NormalizedChannel[];
|
|
11
|
+
export declare const extractChannelsV3: (document: Record<string, unknown>, issues: ExtractionIssue[], options?: NormalizeMessageOptions) => NormalizedChannel[];
|
|
@@ -158,7 +158,7 @@ const collectDirections = (document, channelsMap, issues) => {
|
|
|
158
158
|
}
|
|
159
159
|
return directions;
|
|
160
160
|
};
|
|
161
|
-
const extractChannelsV3 = (document, issues) => {
|
|
161
|
+
const extractChannelsV3 = (document, issues, options = {}) => {
|
|
162
162
|
const channelsMap = readKey(document, "channels");
|
|
163
163
|
if (typeof channelsMap !== "object" || channelsMap === null)
|
|
164
164
|
return [];
|
|
@@ -193,7 +193,7 @@ const extractChannelsV3 = (document, issues) => {
|
|
|
193
193
|
payload: payload.schema,
|
|
194
194
|
headersSchemaFormat: headers.schemaFormat,
|
|
195
195
|
headers: headers.schema
|
|
196
|
-
}, document, issues, messagePath));
|
|
196
|
+
}, document, issues, messagePath, options));
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
const discriminator = getMjstDiscriminator(channel);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type BuildChannelContractOptions, buildChannelContract, type ChannelContract, type ContractDirection, } from './build-channel-contract.js';
|
|
2
2
|
export { type DetectedVersion, detectAsyncApiVersion } from './detect-version.js';
|
|
3
|
-
export { extractAsyncApi } from './extract-async-api.js';
|
|
3
|
+
export { type ExtractAsyncApiOptions, extractAsyncApi } from './extract-async-api.js';
|
|
4
4
|
export { mergeTraits } from './merge-traits.js';
|
|
5
5
|
export { listMessageSchemas } from './message-schemas.js';
|
|
6
6
|
export { DEFAULT_DISCRIMINATOR, resolveDiscriminator } from './resolve-discriminator.js';
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
+
import { type AvroEncoding } from '@amritk/adapters/avro-to-json-schema';
|
|
1
2
|
import type { ExtractionIssue, MessageDirection, NormalizedMessage } from './types.js';
|
|
3
|
+
/** Knobs the version walkers pass straight through from `extractAsyncApi`. */
|
|
4
|
+
export type NormalizeMessageOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Which JSON shape an Avro payload is converted into. `'json'` (the default)
|
|
7
|
+
* describes the decoded object an application works with, which is what the
|
|
8
|
+
* generators exist to produce; `'avro-json'` describes the spec's JSON
|
|
9
|
+
* encoding — the bytes that actually travel under
|
|
10
|
+
* `application/vnd.apache.avro+json`, with unions wrapped in their branch
|
|
11
|
+
* name. Pick the wire shape only if you are validating raw frames.
|
|
12
|
+
*/
|
|
13
|
+
readonly avroEncoding?: AvroEncoding;
|
|
14
|
+
};
|
|
2
15
|
export type RawMessage = {
|
|
3
16
|
readonly name: string;
|
|
4
17
|
readonly channelKey: string;
|
|
@@ -20,10 +33,17 @@ export type RawMessage = {
|
|
|
20
33
|
* walkers) into a {@link NormalizedMessage}, normalizing its payload and
|
|
21
34
|
* headers into self-contained 2020-12 schemas.
|
|
22
35
|
*
|
|
23
|
-
*
|
|
36
|
+
* An Avro `schemaFormat` is converted rather than refused: Avro is a schema
|
|
37
|
+
* language `@amritk/adapters` already reads, and the whole point of the
|
|
38
|
+
* extraction layer is to hand the generators JSON Schema whatever the document
|
|
39
|
+
* wrote. A conversion that fails — an illegal Avro name, a construct with no
|
|
40
|
+
* JSON Schema reading — becomes an issue rather than an exception, so one bad
|
|
41
|
+
* record schema costs only its own message.
|
|
42
|
+
*
|
|
43
|
+
* A schema whose format is neither a JSON Schema dialect nor Avro — Protobuf, a
|
|
24
44
|
* malformed value — is skipped with an issue naming the format, keeping the
|
|
25
45
|
* message itself in the model so a consumer can still see it exists. A
|
|
26
46
|
* non-object schema (AsyncAPI allows boolean schemas; the generators need an
|
|
27
47
|
* object root) is skipped the same way.
|
|
28
48
|
*/
|
|
29
|
-
export declare const normalizeMessage: (raw: RawMessage, document: unknown, issues: ExtractionIssue[], path: string) => NormalizedMessage;
|
|
49
|
+
export declare const normalizeMessage: (raw: RawMessage, document: unknown, issues: ExtractionIssue[], path: string, options?: NormalizeMessageOptions) => NormalizedMessage;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { avroToJsonSchema } from "@amritk/adapters/avro-to-json-schema";
|
|
1
2
|
import { normalizeSchema } from "./normalize-schema.js";
|
|
2
3
|
import { rebaseComponentRefs } from "./rebase-component-refs.js";
|
|
3
4
|
import { classifySchemaFormat } from "./schema-format.js";
|
|
4
|
-
const normalizeMessage = (raw, document, issues, path) => {
|
|
5
|
+
const normalizeMessage = (raw, document, issues, path, options = {}) => {
|
|
5
6
|
const normalizeOne = (value, schemaFormat, label) => {
|
|
6
7
|
if (value === void 0)
|
|
7
8
|
return void 0;
|
|
@@ -13,6 +14,17 @@ const normalizeMessage = (raw, document, issues, path) => {
|
|
|
13
14
|
});
|
|
14
15
|
return void 0;
|
|
15
16
|
}
|
|
17
|
+
if (family === "avro") {
|
|
18
|
+
try {
|
|
19
|
+
return avroToJsonSchema(value, { encoding: options.avroEncoding ?? "json" });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
issues.push({
|
|
22
|
+
path: `${path}/${label}`,
|
|
23
|
+
message: `skipped: Avro ${label} could not be converted (${error instanceof Error ? error.message : String(error)})`
|
|
24
|
+
});
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
16
28
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
17
29
|
issues.push({ path: `${path}/${label}`, message: `skipped: ${label} is not an object schema` });
|
|
18
30
|
return void 0;
|
|
@@ -13,6 +13,10 @@ import type { SchemaFormatFamily } from './schema-format.js';
|
|
|
13
13
|
* the generators would otherwise read as "never null".
|
|
14
14
|
* - `'2020-12'` — pass through.
|
|
15
15
|
*
|
|
16
|
+
* Avro never reaches here: it is a different schema language rather than a
|
|
17
|
+
* dialect of this one, so `normalize-message` hands it to `@amritk/adapters`
|
|
18
|
+
* and receives 2020-12 back already converted.
|
|
19
|
+
*
|
|
16
20
|
* Keywords the AsyncAPI dialect adds beyond draft-07 (and draft-07 spellings
|
|
17
21
|
* the upgrade does not rewrite, like array-form `items`) pass through
|
|
18
22
|
* unchanged: `@amritk/runtime-validators` implements them directly, and an
|
|
@@ -21,4 +25,4 @@ import type { SchemaFormatFamily } from './schema-format.js';
|
|
|
21
25
|
* An empty `$defs` left behind by the upgrade is dropped so a schema with no
|
|
22
26
|
* definitions round-trips without growing keys.
|
|
23
27
|
*/
|
|
24
|
-
export declare const normalizeSchema: (schema: Record<string, unknown>, family: Exclude<SchemaFormatFamily, 'unsupported'>) => Record<string, unknown>;
|
|
28
|
+
export declare const normalizeSchema: (schema: Record<string, unknown>, family: Exclude<SchemaFormatFamily, 'unsupported' | 'avro'>) => Record<string, unknown>;
|
|
@@ -37,4 +37,4 @@ import type { ExtractionIssue } from './types.js';
|
|
|
37
37
|
* (`{}`, match-anything) definition plus an issue — one dangling pointer
|
|
38
38
|
* should cost precision on one branch, not the whole message.
|
|
39
39
|
*/
|
|
40
|
-
export declare const rebaseComponentRefs: (root: Record<string, unknown>, document: unknown, family: Exclude<SchemaFormatFamily, 'unsupported'>, issues: ExtractionIssue[], path: string) => Record<string, unknown>;
|
|
40
|
+
export declare const rebaseComponentRefs: (root: Record<string, unknown>, document: unknown, family: Exclude<SchemaFormatFamily, 'unsupported' | 'avro'>, issues: ExtractionIssue[], path: string) => Record<string, unknown>;
|
|
@@ -5,6 +5,7 @@ import { readKey } from "@amritk/helpers/read-key";
|
|
|
5
5
|
import { normalizeSchema } from "./normalize-schema.js";
|
|
6
6
|
import { classifySchemaFormat } from "./schema-format.js";
|
|
7
7
|
import { unwrapMultiFormat } from "./unwrap-multi-format.js";
|
|
8
|
+
const isCopyableFamily = (family) => family !== "unsupported" && family !== "avro";
|
|
8
9
|
const COMPONENT_SCHEMA_REF = /^#\/components\/schemas\/([^/]+)(\/.*)?$/;
|
|
9
10
|
const TAIL_THROUGH_DEFS = /^\/(definitions|\$defs)\/([^/]+)(\/.*)?$/;
|
|
10
11
|
const LOCAL_DEFS_REF = /^#\/(\$defs|definitions)\/([^/]+)(\/.*)?$/;
|
|
@@ -116,7 +117,7 @@ const rebaseComponentRefs = (root, document, family, issues, path) => {
|
|
|
116
117
|
return void 0;
|
|
117
118
|
const { schemaFormat, schema } = unwrapMultiFormat(raw);
|
|
118
119
|
const componentFamily = schemaFormat === void 0 ? family : classifySchemaFormat(schemaFormat);
|
|
119
|
-
if (componentFamily
|
|
120
|
+
if (!isCopyableFamily(componentFamily) || typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
120
121
|
return void 0;
|
|
121
122
|
}
|
|
122
123
|
const names = { definitions: /* @__PURE__ */ new Set(), $defs: /* @__PURE__ */ new Set() };
|
|
@@ -258,10 +259,10 @@ const rebaseComponentRefs = (root, document, family, issues, path) => {
|
|
|
258
259
|
}
|
|
259
260
|
const { schemaFormat, schema } = unwrapMultiFormat(raw);
|
|
260
261
|
const componentFamily = schemaFormat === void 0 ? family : classifySchemaFormat(schemaFormat);
|
|
261
|
-
if (componentFamily
|
|
262
|
+
if (!isCopyableFamily(componentFamily) || typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
262
263
|
issues.push({
|
|
263
264
|
path,
|
|
264
|
-
message: componentFamily
|
|
265
|
+
message: isCopyableFamily(componentFamily) ? `component "${name}" is not an object schema; treated as an unconstrained schema` : `component "${name}" uses unsupported schemaFormat ${JSON.stringify(schemaFormat)}; treated as an unconstrained schema`
|
|
265
266
|
});
|
|
266
267
|
assignKey(copiedDefs, defsKey, {});
|
|
267
268
|
return;
|
package/dist/schema-format.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The schema language a `schemaFormat` names, or `'unsupported'` for everything
|
|
3
|
+
* the pipeline cannot turn into JSON Schema (Protobuf, RAML, unrecognized media
|
|
4
|
+
* types). Each supported family gets its own normalization: the AsyncAPI default
|
|
5
|
+
* dialect and declared draft-07 go through the draft-07 upgrade, OpenAPI schema
|
|
6
|
+
* objects get `nullable` folded, 2020-12 passes through, and Avro is converted
|
|
7
|
+
* by `@amritk/adapters`.
|
|
8
8
|
*/
|
|
9
|
-
export type SchemaFormatFamily = 'asyncapi' | 'draft-07' | '2020-12' | 'openapi' | 'unsupported';
|
|
9
|
+
export type SchemaFormatFamily = 'asyncapi' | 'draft-07' | '2020-12' | 'openapi' | 'avro' | 'unsupported';
|
|
10
10
|
/**
|
|
11
11
|
* Classifies a message's effective `schemaFormat`. An absent format means the
|
|
12
12
|
* AsyncAPI default dialect — the spec's own rule — so `undefined` is
|
package/dist/schema-format.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const ASYNCAPI_DIALECT = /^application\/vnd\.aai\.asyncapi([+;]|$)/;
|
|
2
2
|
const OPENAPI_DIALECT = /^application\/vnd\.oai\.openapi([+;]|$)/;
|
|
3
|
+
const AVRO_DIALECT = /^application\/vnd\.apache\.avro([+;]|$)/;
|
|
3
4
|
const JSON_SCHEMA_DIALECT = /^application\/schema\+(?:json|yaml);\s*version=(draft-07|draft-2020-12)\s*$/;
|
|
4
5
|
const classifySchemaFormat = (schemaFormat) => {
|
|
5
6
|
if (schemaFormat === void 0)
|
|
@@ -10,6 +11,8 @@ const classifySchemaFormat = (schemaFormat) => {
|
|
|
10
11
|
return "asyncapi";
|
|
11
12
|
if (OPENAPI_DIALECT.test(schemaFormat))
|
|
12
13
|
return "openapi";
|
|
14
|
+
if (AVRO_DIALECT.test(schemaFormat))
|
|
15
|
+
return "avro";
|
|
13
16
|
const jsonSchema = JSON_SCHEMA_DIALECT.exec(schemaFormat);
|
|
14
17
|
if (jsonSchema)
|
|
15
18
|
return jsonSchema[1] === "draft-07" ? "draft-07" : "2020-12";
|
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Either a payload schema fit to stand as a message schema
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Either a payload schema fit to stand as a message schema — with the wire tag
|
|
3
|
+
* it declared, when it declared one — or the reason it is not. Never both: a
|
|
4
|
+
* message whose payload cannot be made contract-legal is skipped with the
|
|
5
|
+
* issue, rather than emitted as something that would throw at
|
|
5
6
|
* `prepareMessages` time or reject every frame at runtime.
|
|
6
7
|
*/
|
|
7
8
|
export type StripDiscriminatorResult = {
|
|
8
9
|
readonly schema: Record<string, unknown>;
|
|
10
|
+
/**
|
|
11
|
+
* The value the payload pinned the discriminator to, which *is* the
|
|
12
|
+
* frame's tag on the wire. Absent when the payload never mentions the
|
|
13
|
+
* discriminator, leaving the caller to fall back to the message name.
|
|
14
|
+
*/
|
|
15
|
+
readonly tag?: string;
|
|
9
16
|
readonly issue?: undefined;
|
|
10
17
|
} | {
|
|
11
18
|
readonly schema?: undefined;
|
|
19
|
+
readonly tag?: undefined;
|
|
12
20
|
readonly issue: string;
|
|
13
21
|
};
|
|
14
22
|
/**
|
|
15
23
|
* Removes the discriminator property from a message payload, so what is left
|
|
16
|
-
* describes the payload alone
|
|
24
|
+
* describes the payload alone, and reports the tag value the payload pinned it
|
|
25
|
+
* to.
|
|
17
26
|
*
|
|
18
27
|
* `@amritk/api` reads the tag off the frame to *select* the message, then
|
|
19
28
|
* removes it before validating — so a schema that still declares the tag is
|
|
@@ -22,24 +31,28 @@ export type StripDiscriminatorResult = {
|
|
|
22
31
|
* a channel carrying a `oneOf` of messages has nothing *but* the tag to tell
|
|
23
32
|
* them apart, so `type: { const: 'hello' }` is how the document says "this is
|
|
24
33
|
* the hello message". The two conventions are the same fact written twice, and
|
|
25
|
-
* this reconciles them by
|
|
26
|
-
*
|
|
34
|
+
* this reconciles them by taking the document's word for what goes on the wire
|
|
35
|
+
* and dropping the copy.
|
|
27
36
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
37
|
+
* The pinned value is returned rather than checked against the message name,
|
|
38
|
+
* because the two answer different questions. A message's *name* is a
|
|
39
|
+
* document-authoring handle — 2.x messages inside a `oneOf` often have none at
|
|
40
|
+
* all, and get a positional `message-3` — while `const` is a statement about
|
|
41
|
+
* the bytes. Slack's RTM API names a message `botAdded` and tags it
|
|
42
|
+
* `bot_added`; keying on the name would emit a contract listening for a frame
|
|
43
|
+
* that never arrives, so the tag wins and the name is only the fallback for a
|
|
44
|
+
* payload that pins nothing.
|
|
30
45
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* - `type: { type: 'string' }` constrains the tag without naming a value, so
|
|
38
|
-
* nothing here can confirm it ever carries this message's name.
|
|
46
|
+
* What is still refused, because no tag can be recovered from it:
|
|
47
|
+
*
|
|
48
|
+
* - `type: { type: 'string' }` constrains the tag without naming a value.
|
|
49
|
+
* - A multi-member `enum` names several, which identifies no single message.
|
|
50
|
+
* - `type: { const: 7 }` names a non-string, and the runtime selects a message
|
|
51
|
+
* by a string tag — a numeric one matches no key it could be given.
|
|
39
52
|
* - A payload that is not an object schema has nowhere for a tag to live, which
|
|
40
53
|
* `assertMessageSchema` refuses too.
|
|
41
54
|
*
|
|
42
55
|
* The input is never mutated: the model's schemas are shared with the parser
|
|
43
56
|
* generators, which must keep seeing the payload as the document wrote it.
|
|
44
57
|
*/
|
|
45
|
-
export declare const stripDiscriminator: (payload: unknown, discriminator: string
|
|
58
|
+
export declare const stripDiscriminator: (payload: unknown, discriminator: string) => StripDiscriminatorResult;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
-
const
|
|
3
|
-
|
|
2
|
+
const pinnedValue = (branch) => {
|
|
3
|
+
if (Object.hasOwn(branch, "const"))
|
|
4
|
+
return { value: readKey(branch, "const") };
|
|
5
|
+
const enumValue = readKey(branch, "enum");
|
|
6
|
+
if (Array.isArray(enumValue) && enumValue.length === 1)
|
|
7
|
+
return { value: enumValue[0] };
|
|
8
|
+
return void 0;
|
|
9
|
+
};
|
|
10
|
+
const stripDiscriminator = (payload, discriminator) => {
|
|
4
11
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
5
12
|
return { issue: "payload is not an object schema, so it cannot describe a message" };
|
|
6
13
|
const schema = payload;
|
|
@@ -16,15 +23,18 @@ const stripDiscriminator = (payload, discriminator, messageName) => {
|
|
|
16
23
|
return { schema };
|
|
17
24
|
if (declaration === void 0)
|
|
18
25
|
return {
|
|
19
|
-
issue: `payload requires "${discriminator}" without declaring it, so
|
|
26
|
+
issue: `payload requires "${discriminator}" without declaring it, so the tag it carries cannot be read`
|
|
20
27
|
};
|
|
21
28
|
if (typeof declaration !== "object" || declaration === null)
|
|
22
29
|
return { issue: `payload declares "${discriminator}" as ${JSON.stringify(declaration)}, not a schema` };
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
const pinned = pinnedValue(declaration);
|
|
31
|
+
if (pinned === void 0)
|
|
32
|
+
return {
|
|
33
|
+
issue: `payload constrains "${discriminator}" without pinning it to one value, so the message it tags cannot be identified`
|
|
34
|
+
};
|
|
35
|
+
if (typeof pinned.value !== "string")
|
|
26
36
|
return {
|
|
27
|
-
issue: `payload
|
|
37
|
+
issue: `payload pins "${discriminator}" to ${JSON.stringify(pinned.value)}, which is not a string; a frame is routed by a string tag, so this one could never select a message`
|
|
28
38
|
};
|
|
29
39
|
const stripped = { ...schema };
|
|
30
40
|
const remaining = Object.fromEntries(Object.entries(declaredProperties).filter(([key]) => key !== discriminator));
|
|
@@ -39,7 +49,7 @@ const stripDiscriminator = (payload, discriminator, messageName) => {
|
|
|
39
49
|
else
|
|
40
50
|
stripped["required"] = rest;
|
|
41
51
|
}
|
|
42
|
-
return { schema: stripped };
|
|
52
|
+
return { schema: stripped, tag: pinned.value };
|
|
43
53
|
};
|
|
44
54
|
export {
|
|
45
55
|
stripDiscriminator
|
package/dist/types.d.ts
CHANGED
|
@@ -47,8 +47,10 @@ export type NormalizedMessage = {
|
|
|
47
47
|
/**
|
|
48
48
|
* The payload as a self-contained JSON Schema 2020-12 document: the dialect
|
|
49
49
|
* normalized, and every `#/components/schemas/...` reference rebased into a
|
|
50
|
-
* local `$defs`.
|
|
51
|
-
*
|
|
50
|
+
* local `$defs`. An Avro `schemaFormat` arrives here converted, not skipped.
|
|
51
|
+
* Absent when the message declares no payload, when its `schemaFormat` names
|
|
52
|
+
* a language this cannot read (Protobuf, say), or when an Avro conversion
|
|
53
|
+
* failed — an issue records which.
|
|
52
54
|
*/
|
|
53
55
|
readonly payload?: Record<string, unknown>;
|
|
54
56
|
/** The headers schema, normalized the same way as {@link payload}. */
|
|
@@ -7,8 +7,17 @@ export type UnwrappedSchema = {
|
|
|
7
7
|
* Unwraps an AsyncAPI 3.0 Multi Format Schema Object (`{ schemaFormat,
|
|
8
8
|
* schema }`) into its parts, passing a bare Schema Object through untouched.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* An own `schema` key alone makes it a wrapper, which is exactly how the 3.0
|
|
11
|
+
* meta-schema decides (`anySchema.json`: `if: { required: ['schema'] }` → the
|
|
12
|
+
* Multi Format Schema Object, else a plain Schema Object). `schemaFormat` is
|
|
13
|
+
* *optional* on the wrapper and defaults to the AsyncAPI dialect, so requiring
|
|
14
|
+
* it here read `{ schema: … }` as a schema whose only keyword is one no dialect
|
|
15
|
+
* defines — the payload vanished and the message generated an empty type.
|
|
16
|
+
*
|
|
17
|
+
* Note what this still does not claim: `{ schemaFormat: 'x', type: 'object' }`
|
|
18
|
+
* has no `schema` key, so it stays a bare Schema Object that happens to carry
|
|
19
|
+
* an unknown keyword, rather than a wrapper with nothing inside it. That is the
|
|
20
|
+
* meta-schema's reading too, and it is why the check is on `schema` rather than
|
|
21
|
+
* on either key being present.
|
|
13
22
|
*/
|
|
14
23
|
export declare const unwrapMultiFormat: (node: unknown) => UnwrappedSchema;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { readKey } from "@amritk/helpers/read-key";
|
|
2
2
|
const unwrapMultiFormat = (node) => {
|
|
3
|
-
if (typeof node === "object" && node !== null && !Array.isArray(node)) {
|
|
3
|
+
if (typeof node === "object" && node !== null && !Array.isArray(node) && Object.hasOwn(node, "schema")) {
|
|
4
4
|
const record = node;
|
|
5
|
-
|
|
6
|
-
if (schemaFormat !== void 0 && Object.hasOwn(record, "schema")) {
|
|
7
|
-
return { schemaFormat, schema: readKey(record, "schema") };
|
|
8
|
-
}
|
|
5
|
+
return { schemaFormat: readKey(record, "schemaFormat"), schema: readKey(record, "schema") };
|
|
9
6
|
}
|
|
10
7
|
return { schema: node };
|
|
11
8
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/asyncapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Extract JSON Schemas from AsyncAPI 2.x/3.0 documents for the mjst generators.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"test": "NODE_ENV=production vitest run --root ../.. packages/asyncapi"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@amritk/
|
|
42
|
+
"@amritk/adapters": "^0.6.2",
|
|
43
|
+
"@amritk/helpers": "^0.21.0"
|
|
43
44
|
},
|
|
44
45
|
"exports": {
|
|
45
46
|
"./package.json": "./package.json",
|