@amritk/asyncapi 0.1.0 → 0.2.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 +27 -2
- package/README.md +31 -1
- package/dist/build-channel-contract.d.ts +48 -0
- package/dist/build-channel-contract.js +56 -0
- package/dist/extract-channels-v2.js +8 -1
- package/dist/extract-channels-v3.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +10 -1
- package/dist/message-schemas.js +1 -5
- package/dist/resolve-discriminator.d.ts +29 -0
- package/dist/resolve-discriminator.js +6 -0
- package/dist/sanitize-token.d.ts +11 -0
- package/dist/sanitize-token.js +8 -0
- package/dist/strip-discriminator.d.ts +45 -0
- package/dist/strip-discriminator.js +46 -0
- package/dist/types.d.ts +8 -0
- package/package.json +2 -2
package/AI.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @amritk/asyncapi — notes for AI coding agents
|
|
2
2
|
|
|
3
3
|
Extract every message payload/headers schema from an AsyncAPI 2.x/3.0 document
|
|
4
|
-
as self-contained JSON Schema 2020-12
|
|
5
|
-
reference is
|
|
4
|
+
as self-contained JSON Schema 2020-12 for the mjst generators, and project each
|
|
5
|
+
channel onto an `@amritk/api` `defineMessages` contract. Full reference is
|
|
6
|
+
[README.md](./README.md).
|
|
6
7
|
|
|
7
8
|
> Pre-alpha: APIs change in **minor** versions.
|
|
8
9
|
|
|
@@ -16,6 +17,19 @@ const schemas = listMessageSchemas(model)
|
|
|
16
17
|
// → [{ subDir: 'channels/lighting-measured/light-measured', rootTypeName: 'LightMeasured', schema }, ...]
|
|
17
18
|
```
|
|
18
19
|
|
|
20
|
+
## Channel contracts
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { buildChannelContract } from '@amritk/asyncapi'
|
|
24
|
+
|
|
25
|
+
const contract = buildChannelContract(model.channels[0], { discriminator: 'event' })
|
|
26
|
+
// → { exportName: 'lobbyMessages', discriminator: 'event',
|
|
27
|
+
// clientToServer: { say: schema }, serverToClient: { said: schema }, issues: [] }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Hand the two direction maps straight to `defineMessages` — that is what
|
|
31
|
+
`mjst --input asyncapi --message-contracts` writes out.
|
|
32
|
+
|
|
19
33
|
## Gotchas — where agents fail
|
|
20
34
|
|
|
21
35
|
1. **It takes an already-parsed document.** No filesystem, no YAML, no network —
|
|
@@ -33,3 +47,14 @@ const schemas = listMessageSchemas(model)
|
|
|
33
47
|
message's schema never affects another.
|
|
34
48
|
5. **Root type names come from message identity**, not schema `title` — two
|
|
35
49
|
messages titled "Event" stay distinct.
|
|
50
|
+
6. **A contract's map key is the wire tag.** `receive` → `clientToServer`,
|
|
51
|
+
`send` → `serverToClient`, and the discriminator property is *stripped* from
|
|
52
|
+
each payload: `@amritk/api` reads the tag off the frame to pick the message,
|
|
53
|
+
then removes it before validating, and refuses a schema that still declares
|
|
54
|
+
it. A payload pinning the tag to something other than the message name is an
|
|
55
|
+
issue, not a rewrite — Slack's RTM document renames nearly every one, and
|
|
56
|
+
only three of its messages survive.
|
|
57
|
+
7. **The discriminator has a priority order**: `x-mjst: { discriminator }` on
|
|
58
|
+
the channel, then the caller's argument, then `'type'`. The document wins
|
|
59
|
+
over the argument on purpose — one override covers a whole run, and a run
|
|
60
|
+
may span channels that disagree.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# @amritk/asyncapi
|
|
4
4
|
|
|
5
|
-
**Extract JSON Schemas from AsyncAPI 2.x/3.0 documents
|
|
5
|
+
**Extract JSON Schemas — and `@amritk/api` channel contracts — from AsyncAPI 2.x/3.0 documents.**
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|

|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
What "self-contained" buys: the AsyncAPI default schema dialect (a draft-07 superset) is upgraded to 2020-12 conventions, OpenAPI-format payloads get `nullable` folded into their `type`, and every `$ref` into the document's `#/components/schemas/...` is rebased into a local `$defs` with the referenced components copied in transitively — so each extracted schema stands alone as a generator input.
|
|
23
23
|
|
|
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
|
+
|
|
24
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. Non-JSON-Schema payloads (`schemaFormat`: Avro, Protobuf, …) are skipped per message with a recorded issue — one Avro payload never costs the document's other messages.
|
|
25
27
|
|
|
26
28
|
---
|
|
@@ -64,6 +66,30 @@ for (const channel of model.channels) {
|
|
|
64
66
|
const schemas = listMessageSchemas(model)
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
### Channel contracts
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { buildChannelContract } from '@amritk/asyncapi'
|
|
73
|
+
import { defineMessages } from '@amritk/api'
|
|
74
|
+
|
|
75
|
+
for (const channel of model.channels) {
|
|
76
|
+
const contract = buildChannelContract(channel)
|
|
77
|
+
for (const issue of contract.issues) console.warn(`${issue.path}: ${issue.message}`)
|
|
78
|
+
|
|
79
|
+
// The two maps are keyed by wire discriminator value, payloads already
|
|
80
|
+
// stripped of the tag — hand them straight to defineMessages.
|
|
81
|
+
const messages = defineMessages({
|
|
82
|
+
discriminator: contract.discriminator,
|
|
83
|
+
clientToServer: contract.clientToServer,
|
|
84
|
+
serverToClient: contract.serverToClient,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
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
|
+
|
|
91
|
+
A message whose payload pins the tag to something *other* than its own name is skipped with an issue rather than rewritten: the wire tag and the contract key would disagree, so the emitted contract would listen for a frame that never arrives. Slack's RTM document renames nearly every one of its tags this way, and only three of its messages project cleanly.
|
|
92
|
+
|
|
67
93
|
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.
|
|
68
94
|
|
|
69
95
|
---
|
|
@@ -72,6 +98,10 @@ Cross-file and remote `$ref`s are the loader's job: resolve them first (for exam
|
|
|
72
98
|
|
|
73
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.
|
|
74
100
|
- **`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
|
+
- **`buildChannelContract(channel, options?)`** → `ChannelContract` — one channel as `{ exportName, discriminator, clientToServer, serverToClient, issues }`, ready for `defineMessages`.
|
|
102
|
+
- **`resolveDiscriminator(channel, override?)`** / **`DEFAULT_DISCRIMINATOR`** — the priority order above, and the `'type'` fallback.
|
|
103
|
+
- **`stripDiscriminator(payload, discriminator, messageName)`** → `{ schema }` or `{ issue }` — the payload with its tag removed, or why it could not be.
|
|
104
|
+
- **`sanitizeToken(value, fallback)`** — the filesystem- and import-safe token both output layouts name a channel or message with.
|
|
75
105
|
- **`detectAsyncApiVersion(document)`** — the `asyncapi` version and its major, or `undefined`.
|
|
76
106
|
- **`classifySchemaFormat(schemaFormat)`** — which JSON Schema dialect a `schemaFormat` names (`'asyncapi' | 'draft-07' | '2020-12' | 'openapi'`), or `'unsupported'`.
|
|
77
107
|
- **`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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
2
|
+
/** One direction's message map: wire name → payload schema, ready for `defineMessages`. */
|
|
3
|
+
export type ContractDirection = {
|
|
4
|
+
readonly [name: string]: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* One channel projected onto an `@amritk/api` messages contract.
|
|
8
|
+
*
|
|
9
|
+
* The map keys *are* the wire discriminator values — that is what
|
|
10
|
+
* `defineMessages` means by a message name — so a message that cannot be given
|
|
11
|
+
* a legal key or a legal payload is left out and its reason recorded in
|
|
12
|
+
* {@link ChannelContract.issues} rather than emitted broken.
|
|
13
|
+
*/
|
|
14
|
+
export type ChannelContract = {
|
|
15
|
+
/** The `export const <name> = defineMessages(...)` identifier for this channel. */
|
|
16
|
+
readonly exportName: string;
|
|
17
|
+
readonly discriminator: string;
|
|
18
|
+
/** What the client may send — AsyncAPI's `receive`, from the application's side. */
|
|
19
|
+
readonly clientToServer: ContractDirection;
|
|
20
|
+
/** What the server may send — AsyncAPI's `send`. */
|
|
21
|
+
readonly serverToClient: ContractDirection;
|
|
22
|
+
readonly issues: readonly ExtractionIssue[];
|
|
23
|
+
};
|
|
24
|
+
export type BuildChannelContractOptions = {
|
|
25
|
+
/** Fallback discriminator when the channel does not name one (the CLI's `--discriminator`). */
|
|
26
|
+
readonly discriminator?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Projects one normalized channel onto a `defineMessages`-shaped contract.
|
|
30
|
+
*
|
|
31
|
+
* The two models nearly line up already — AsyncAPI 3.0 and `@amritk/api` name
|
|
32
|
+
* directions from the same end — so the work is in the two places they do not:
|
|
33
|
+
*
|
|
34
|
+
* - **Direction.** `receive` (the application receives it) is what a client
|
|
35
|
+
* sends, hence `clientToServer`; `send` is `serverToClient`. A message no
|
|
36
|
+
* operation names has no direction at all, and a contract cannot guess one:
|
|
37
|
+
* putting it in the wrong half would validate frames flowing the wrong way
|
|
38
|
+
* and reject the ones that arrive.
|
|
39
|
+
* - **The tag.** The message *name* becomes the wire key, and the payload has
|
|
40
|
+
* the tag stripped out of it (see {@link stripDiscriminator}), because the
|
|
41
|
+
* runtime removes it from the frame before validating.
|
|
42
|
+
*
|
|
43
|
+
* Everything skipped comes back as an issue naming the message and the reason,
|
|
44
|
+
* so the caller can warn per message instead of failing the channel. A channel
|
|
45
|
+
* that ends up with no messages at all is still returned: an empty contract is
|
|
46
|
+
* a truthful one, and the issues say why it is empty.
|
|
47
|
+
*/
|
|
48
|
+
export declare const buildChannelContract: (channel: NormalizedChannel, options?: BuildChannelContractOptions) => ChannelContract;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { resolveDiscriminator } from "./resolve-discriminator.js";
|
|
2
|
+
import { sanitizeToken } from "./sanitize-token.js";
|
|
3
|
+
import { stripDiscriminator } from "./strip-discriminator.js";
|
|
4
|
+
const toExportName = (token) => {
|
|
5
|
+
const camel = token.replace(/[^A-Za-z0-9]+(.)?/g, (_, next) => next === void 0 ? "" : next.toUpperCase());
|
|
6
|
+
return `${/^[A-Za-z_$]/.test(camel) ? camel : `_${camel}`}Messages`;
|
|
7
|
+
};
|
|
8
|
+
const buildChannelContract = (channel, options = {}) => {
|
|
9
|
+
const discriminator = resolveDiscriminator(channel, options.discriminator);
|
|
10
|
+
const issues = [];
|
|
11
|
+
const clientToServer = /* @__PURE__ */ new Map();
|
|
12
|
+
const serverToClient = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const message of channel.messages) {
|
|
14
|
+
const path = `#/channels/${channel.key}/messages/${message.name}`;
|
|
15
|
+
const skip = (reason) => void issues.push({ path, message: reason });
|
|
16
|
+
if (message.direction === void 0) {
|
|
17
|
+
skip("message has no direction (no operation names it), so it belongs to neither half of the contract");
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (message.payload === void 0 && message.schemaFormat !== void 0) {
|
|
21
|
+
skip(`payload was not extracted as JSON Schema (schemaFormat "${message.schemaFormat}")`);
|
|
22
|
+
continue;
|
|
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
|
+
const target = message.direction === "receive" ? clientToServer : serverToClient;
|
|
29
|
+
if (target.has(message.name)) {
|
|
30
|
+
skip(`two ${message.direction === "receive" ? "clientToServer" : "serverToClient"} messages share the name`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (message.payload === void 0) {
|
|
34
|
+
target.set(message.name, { type: "object" });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const stripped = stripDiscriminator(message.payload, discriminator, message.name);
|
|
38
|
+
if (stripped.issue !== void 0) {
|
|
39
|
+
skip(stripped.issue);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
target.set(message.name, stripped.schema);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
exportName: toExportName(sanitizeToken(channel.key, "channel")),
|
|
46
|
+
discriminator,
|
|
47
|
+
// `Object.fromEntries` defines own properties rather than assigning them,
|
|
48
|
+
// so even a hostile name lands as data on the object it belongs to.
|
|
49
|
+
clientToServer: Object.fromEntries(clientToServer),
|
|
50
|
+
serverToClient: Object.fromEntries(serverToClient),
|
|
51
|
+
issues
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export {
|
|
55
|
+
buildChannelContract
|
|
56
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstDiscriminator } from "@amritk/helpers/mjst-extension";
|
|
1
2
|
import { readKey } from "@amritk/helpers/read-key";
|
|
2
3
|
import { mergeTraits } from "./merge-traits.js";
|
|
3
4
|
import { normalizeMessage } from "./normalize-message.js";
|
|
@@ -62,7 +63,13 @@ const extractChannelsV2 = (document, issues) => {
|
|
|
62
63
|
}, document, issues, itemPath));
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
+
const discriminator = getMjstDiscriminator(channel);
|
|
67
|
+
channels.push({
|
|
68
|
+
key: channelKey,
|
|
69
|
+
address: channelKey,
|
|
70
|
+
...discriminator !== void 0 ? { discriminator } : {},
|
|
71
|
+
messages
|
|
72
|
+
});
|
|
66
73
|
}
|
|
67
74
|
return channels;
|
|
68
75
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstDiscriminator } from "@amritk/helpers/mjst-extension";
|
|
1
2
|
import { readKey } from "@amritk/helpers/read-key";
|
|
2
3
|
import { mergeTraits } from "./merge-traits.js";
|
|
3
4
|
import { normalizeMessage } from "./normalize-message.js";
|
|
@@ -195,9 +196,11 @@ const extractChannelsV3 = (document, issues) => {
|
|
|
195
196
|
}, document, issues, messagePath));
|
|
196
197
|
}
|
|
197
198
|
}
|
|
199
|
+
const discriminator = getMjstDiscriminator(channel);
|
|
198
200
|
channels.push({
|
|
199
201
|
key: channelKey,
|
|
200
202
|
...typeof address === "string" && address !== "" ? { address } : {},
|
|
203
|
+
...discriminator !== void 0 ? { discriminator } : {},
|
|
201
204
|
messages
|
|
202
205
|
});
|
|
203
206
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
export { type BuildChannelContractOptions, buildChannelContract, type ChannelContract, type ContractDirection, } from './build-channel-contract.js';
|
|
1
2
|
export { type DetectedVersion, detectAsyncApiVersion } from './detect-version.js';
|
|
2
3
|
export { extractAsyncApi } from './extract-async-api.js';
|
|
3
4
|
export { mergeTraits } from './merge-traits.js';
|
|
4
5
|
export { listMessageSchemas } from './message-schemas.js';
|
|
6
|
+
export { DEFAULT_DISCRIMINATOR, resolveDiscriminator } from './resolve-discriminator.js';
|
|
7
|
+
export { sanitizeToken } from './sanitize-token.js';
|
|
5
8
|
export { classifySchemaFormat, type SchemaFormatFamily } from './schema-format.js';
|
|
9
|
+
export { type StripDiscriminatorResult, stripDiscriminator } from './strip-discriminator.js';
|
|
6
10
|
export type { AsyncApiModel, ExtractedSchema, ExtractionIssue, MessageDirection, NormalizedChannel, NormalizedMessage, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { buildChannelContract } from "./build-channel-contract.js";
|
|
1
2
|
import { detectAsyncApiVersion } from "./detect-version.js";
|
|
2
3
|
import { extractAsyncApi } from "./extract-async-api.js";
|
|
3
4
|
import { mergeTraits } from "./merge-traits.js";
|
|
4
5
|
import { listMessageSchemas } from "./message-schemas.js";
|
|
6
|
+
import { DEFAULT_DISCRIMINATOR, resolveDiscriminator } from "./resolve-discriminator.js";
|
|
7
|
+
import { sanitizeToken } from "./sanitize-token.js";
|
|
5
8
|
import { classifySchemaFormat } from "./schema-format.js";
|
|
9
|
+
import { stripDiscriminator } from "./strip-discriminator.js";
|
|
6
10
|
export {
|
|
11
|
+
DEFAULT_DISCRIMINATOR,
|
|
12
|
+
buildChannelContract,
|
|
7
13
|
classifySchemaFormat,
|
|
8
14
|
detectAsyncApiVersion,
|
|
9
15
|
extractAsyncApi,
|
|
10
16
|
listMessageSchemas,
|
|
11
|
-
mergeTraits
|
|
17
|
+
mergeTraits,
|
|
18
|
+
resolveDiscriminator,
|
|
19
|
+
sanitizeToken,
|
|
20
|
+
stripDiscriminator
|
|
12
21
|
};
|
package/dist/message-schemas.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { toKebabCase } from "@amritk/helpers/ref-to-filename";
|
|
2
1
|
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
3
|
-
|
|
4
|
-
const token = toKebabCase(value).replace(/[^\p{ID_Continue}.]+/gu, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
5
|
-
return token === "" ? fallback : token;
|
|
6
|
-
};
|
|
2
|
+
import { sanitizeToken } from "./sanitize-token.js";
|
|
7
3
|
const claimToken = (base, taken, issues, path) => {
|
|
8
4
|
let token = base;
|
|
9
5
|
for (let n = 2; taken.has(token); n++) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NormalizedChannel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The property a message contract falls back to when nothing names one.
|
|
4
|
+
*
|
|
5
|
+
* Kept as a literal rather than imported from `@amritk/api`: this package's
|
|
6
|
+
* only dependency is `@amritk/helpers`, and the runtime it feeds must stay a
|
|
7
|
+
* *peer* of the generated code, not a dependency of the extractor. The value
|
|
8
|
+
* mirrors `DEFAULT_DISCRIMINATOR` there, and the contract tests hold the two
|
|
9
|
+
* together.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_DISCRIMINATOR = "type";
|
|
12
|
+
/**
|
|
13
|
+
* Decides which property tags a channel's messages, in the order the answer
|
|
14
|
+
* gets more specific:
|
|
15
|
+
*
|
|
16
|
+
* 1. `x-mjst: { discriminator }` on the channel itself. The document is the one
|
|
17
|
+
* place that knows the wire, and it travels with the document — a consumer
|
|
18
|
+
* regenerating from it does not have to be told again.
|
|
19
|
+
* 2. The caller's override (the CLI's `--discriminator`), for a document you do
|
|
20
|
+
* not control and cannot annotate.
|
|
21
|
+
* 3. `'type'`, matching `@amritk/api`'s default, so the common case needs no
|
|
22
|
+
* declaration at all.
|
|
23
|
+
*
|
|
24
|
+
* The document wins over the flag deliberately: one `--discriminator` covers a
|
|
25
|
+
* whole run, and a run may span channels that disagree. A channel that has
|
|
26
|
+
* written its answer down should not have it overwritten by a blanket default
|
|
27
|
+
* meant for the channels that have not.
|
|
28
|
+
*/
|
|
29
|
+
export declare const resolveDiscriminator: (channel: NormalizedChannel, override?: string) => string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds an arbitrary channel key or message name into a filesystem- and
|
|
3
|
+
* import-safe kebab token: camelCase splits, and everything a filename cannot
|
|
4
|
+
* carry — a topic's `/` separators, `{param}` braces, spaces — becomes a
|
|
5
|
+
* dash. `fallback` covers the value that normalizes away entirely.
|
|
6
|
+
*
|
|
7
|
+
* Shared by the schema layout (`channels/<channel>/<message>/`) and the channel
|
|
8
|
+
* contracts (`contracts/<channel>.ts`) so the two name the same channel the
|
|
9
|
+
* same way — a reader moving between the trees should not have to translate.
|
|
10
|
+
*/
|
|
11
|
+
export declare const sanitizeToken: (value: string, fallback: string) => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { toKebabCase } from "@amritk/helpers/ref-to-filename";
|
|
2
|
+
const sanitizeToken = (value, fallback) => {
|
|
3
|
+
const token = toKebabCase(value).replace(/[^\p{ID_Continue}.]+/gu, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
4
|
+
return token === "" ? fallback : token;
|
|
5
|
+
};
|
|
6
|
+
export {
|
|
7
|
+
sanitizeToken
|
|
8
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Either a payload schema fit to stand as a message schema, or the reason it is
|
|
3
|
+
* not. Never both — a message whose payload cannot be made contract-legal is
|
|
4
|
+
* skipped with the issue, rather than emitted as something that would throw at
|
|
5
|
+
* `prepareMessages` time or reject every frame at runtime.
|
|
6
|
+
*/
|
|
7
|
+
export type StripDiscriminatorResult = {
|
|
8
|
+
readonly schema: Record<string, unknown>;
|
|
9
|
+
readonly issue?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
readonly schema?: undefined;
|
|
12
|
+
readonly issue: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Removes the discriminator property from a message payload, so what is left
|
|
16
|
+
* describes the payload alone.
|
|
17
|
+
*
|
|
18
|
+
* `@amritk/api` reads the tag off the frame to *select* the message, then
|
|
19
|
+
* removes it before validating — so a schema that still declares the tag is
|
|
20
|
+
* refused at setup time by `assertMessageSchema`, and would be unsatisfiable
|
|
21
|
+
* even if it were not. AsyncAPI documents, meanwhile, almost always declare it:
|
|
22
|
+
* a channel carrying a `oneOf` of messages has nothing *but* the tag to tell
|
|
23
|
+
* them apart, so `type: { const: 'hello' }` is how the document says "this is
|
|
24
|
+
* the hello message". The two conventions are the same fact written twice, and
|
|
25
|
+
* this reconciles them by trusting the message name — the key the contract is
|
|
26
|
+
* built on — and dropping the copy.
|
|
27
|
+
*
|
|
28
|
+
* Only a declaration that *agrees* with the message name is dropped. Anything
|
|
29
|
+
* else is an issue rather than a silent rewrite:
|
|
30
|
+
*
|
|
31
|
+
* - `type: { const: 'bot_added' }` on a message named `botChanged` means the
|
|
32
|
+
* wire tag is not the message name, and stripping it would emit a contract
|
|
33
|
+
* that quietly listens for the wrong frame. (Real documents do this: Slack's
|
|
34
|
+
* RTM API names two messages after one `bot_added` event.) Give the channel
|
|
35
|
+
* an `x-mjst` discriminator naming a different property, or rename the
|
|
36
|
+
* message to match the tag.
|
|
37
|
+
* - `type: { type: 'string' }` constrains the tag without naming a value, so
|
|
38
|
+
* nothing here can confirm it ever carries this message's name.
|
|
39
|
+
* - A payload that is not an object schema has nowhere for a tag to live, which
|
|
40
|
+
* `assertMessageSchema` refuses too.
|
|
41
|
+
*
|
|
42
|
+
* The input is never mutated: the model's schemas are shared with the parser
|
|
43
|
+
* generators, which must keep seeing the payload as the document wrote it.
|
|
44
|
+
*/
|
|
45
|
+
export declare const stripDiscriminator: (payload: unknown, discriminator: string, messageName: string) => StripDiscriminatorResult;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
const isSingletonEnum = (enumValue, value) => Array.isArray(enumValue) && enumValue.length === 1 && enumValue[0] === value;
|
|
3
|
+
const stripDiscriminator = (payload, discriminator, messageName) => {
|
|
4
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
5
|
+
return { issue: "payload is not an object schema, so it cannot describe a message" };
|
|
6
|
+
const schema = payload;
|
|
7
|
+
const type = readKey(schema, "type");
|
|
8
|
+
if (type !== void 0 && type !== "object")
|
|
9
|
+
return { issue: `payload declares type ${JSON.stringify(type)}; a message schema must be type 'object'` };
|
|
10
|
+
const properties = readKey(schema, "properties");
|
|
11
|
+
const declaredProperties = typeof properties === "object" && properties !== null ? properties : void 0;
|
|
12
|
+
const declaration = declaredProperties === void 0 ? void 0 : readKey(declaredProperties, discriminator);
|
|
13
|
+
const required = readKey(schema, "required");
|
|
14
|
+
const requiresDiscriminator = Array.isArray(required) && required.includes(discriminator);
|
|
15
|
+
if (declaration === void 0 && !requiresDiscriminator)
|
|
16
|
+
return { schema };
|
|
17
|
+
if (declaration === void 0)
|
|
18
|
+
return {
|
|
19
|
+
issue: `payload requires "${discriminator}" without declaring it, so its value cannot be checked against the message name`
|
|
20
|
+
};
|
|
21
|
+
if (typeof declaration !== "object" || declaration === null)
|
|
22
|
+
return { issue: `payload declares "${discriminator}" as ${JSON.stringify(declaration)}, not a schema` };
|
|
23
|
+
const branch = declaration;
|
|
24
|
+
const matchesName = readKey(branch, "const") === messageName || isSingletonEnum(readKey(branch, "enum"), messageName);
|
|
25
|
+
if (!matchesName)
|
|
26
|
+
return {
|
|
27
|
+
issue: `payload's "${discriminator}" is not pinned to this message's name, so the wire tag and the contract key would disagree`
|
|
28
|
+
};
|
|
29
|
+
const stripped = { ...schema };
|
|
30
|
+
const remaining = Object.fromEntries(Object.entries(declaredProperties).filter(([key]) => key !== discriminator));
|
|
31
|
+
if (Object.keys(remaining).length === 0)
|
|
32
|
+
delete stripped["properties"];
|
|
33
|
+
else
|
|
34
|
+
stripped["properties"] = remaining;
|
|
35
|
+
if (requiresDiscriminator) {
|
|
36
|
+
const rest = required.filter((entry) => entry !== discriminator);
|
|
37
|
+
if (rest.length === 0)
|
|
38
|
+
delete stripped["required"];
|
|
39
|
+
else
|
|
40
|
+
stripped["required"] = rest;
|
|
41
|
+
}
|
|
42
|
+
return { schema: stripped };
|
|
43
|
+
};
|
|
44
|
+
export {
|
|
45
|
+
stripDiscriminator
|
|
46
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -59,6 +59,14 @@ export type NormalizedChannel = {
|
|
|
59
59
|
readonly key: string;
|
|
60
60
|
/** The 3.0 `address`; for 2.x the channel key, which *is* the topic/path. */
|
|
61
61
|
readonly address?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The property naming which message a frame is, when the channel declares one
|
|
64
|
+
* via `x-mjst: { discriminator }`. AsyncAPI itself has no way to say this —
|
|
65
|
+
* it lists a channel's messages and stops — but a message contract has to
|
|
66
|
+
* pick one property, so a document whose frames are tagged `event` rather
|
|
67
|
+
* than `type` can say so where the messages are.
|
|
68
|
+
*/
|
|
69
|
+
readonly discriminator?: string;
|
|
62
70
|
readonly messages: readonly NormalizedMessage[];
|
|
63
71
|
};
|
|
64
72
|
export type AsyncApiModel = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/asyncapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,7 @@
|
|
|
39
39
|
"test": "NODE_ENV=production vitest run --root ../.. packages/asyncapi"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@amritk/helpers": "^0.
|
|
42
|
+
"@amritk/helpers": "^0.20.0"
|
|
43
43
|
},
|
|
44
44
|
"exports": {
|
|
45
45
|
"./package.json": "./package.json",
|