@amritk/asyncapi 0.1.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 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, ready for the mjst generators. Full
5
- reference is [README.md](./README.md).
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,20 @@ 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
+ // Keys are the wire tags the payloads pin, not the AsyncAPI message names.
29
+ ```
30
+
31
+ Hand the two direction maps straight to `defineMessages` — that is what
32
+ `mjst --input asyncapi --message-contracts` writes out.
33
+
19
34
  ## Gotchas — where agents fail
20
35
 
21
36
  1. **It takes an already-parsed document.** No filesystem, no YAML, no network —
@@ -23,8 +38,10 @@ const schemas = listMessageSchemas(model)
23
38
  `@amritk/resolve-refs` *before* calling. External `$ref`s still present are
24
39
  reported as issues, not fetched.
25
40
  2. **Problems are collected, not thrown.** Read `model.issues`; only "this is
26
- not an AsyncAPI document" throws. An Avro/Protobuf `schemaFormat` skips that
27
- one schema with an issue.
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.
28
45
  3. **Directions are application-relative.** 2.x `publish` → `receive`,
29
46
  `subscribe` → `send` (the app is the server). Absent when no operation names
30
47
  the message.
@@ -33,3 +50,20 @@ const schemas = listMessageSchemas(model)
33
50
  message's schema never affects another.
34
51
  5. **Root type names come from message identity**, not schema `title` — two
35
52
  messages titled "Event" stay distinct.
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.
61
+ 7. **The discriminator has a priority order**: `x-mjst: { discriminator }` on
62
+ the channel, then the caller's argument, then `'type'`. The document wins
63
+ over the argument on purpose — one override covers a whole run, and a run
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @amritk/asyncapi
4
4
 
5
- **Extract JSON Schemas from AsyncAPI 2.x/3.0 documents for the mjst generators.**
5
+ **Extract JSON Schemas — and `@amritk/api` channel contracts — from AsyncAPI 2.x/3.0 documents.**
6
6
 
7
7
  ![status](https://img.shields.io/badge/status-pre--alpha-ef4444?style=flat-square)&nbsp;
8
8
  ![version](https://img.shields.io/npm/v/@amritk/asyncapi?style=flat-square&logo=npm&logoColor=white&label=version&color=6366f1)&nbsp;
@@ -21,7 +21,9 @@
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
- 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.
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
+
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.
25
27
 
26
28
  ---
27
29
 
@@ -64,16 +66,48 @@ 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 the value a frame carries on the wire, payloads
80
+ // already 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
+ **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.
96
+
67
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.
68
98
 
69
99
  ---
70
100
 
71
101
  ## API
72
102
 
73
- - **`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.
74
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).
105
+ - **`buildChannelContract(channel, options?)`** → `ChannelContract` — one channel as `{ exportName, discriminator, clientToServer, serverToClient, issues }`, ready for `defineMessages`.
106
+ - **`resolveDiscriminator(channel, override?)`** / **`DEFAULT_DISCRIMINATOR`** — the priority order above, and the `'type'` fallback.
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.
108
+ - **`sanitizeToken(value, fallback)`** — the filesystem- and import-safe token both output layouts name a channel or message with.
75
109
  - **`detectAsyncApiVersion(document)`** — the `asyncapi` version and its major, or `undefined`.
76
- - **`classifySchemaFormat(schemaFormat)`** — which JSON Schema dialect a `schemaFormat` names (`'asyncapi' | 'draft-07' | '2020-12' | 'openapi'`), or `'unsupported'`.
110
+ - **`classifySchemaFormat(schemaFormat)`** — which schema language a `schemaFormat` names (`'asyncapi' | 'draft-07' | '2020-12' | 'openapi' | 'avro'`), or `'unsupported'`.
77
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.
78
112
 
79
113
  ---
@@ -0,0 +1,53 @@
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 — 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
14
+ * {@link ChannelContract.issues} rather than emitted broken.
15
+ */
16
+ export type ChannelContract = {
17
+ /** The `export const <name> = defineMessages(...)` identifier for this channel. */
18
+ readonly exportName: string;
19
+ readonly discriminator: string;
20
+ /** What the client may send — AsyncAPI's `receive`, from the application's side. */
21
+ readonly clientToServer: ContractDirection;
22
+ /** What the server may send — AsyncAPI's `send`. */
23
+ readonly serverToClient: ContractDirection;
24
+ readonly issues: readonly ExtractionIssue[];
25
+ };
26
+ export type BuildChannelContractOptions = {
27
+ /** Fallback discriminator when the channel does not name one (the CLI's `--discriminator`). */
28
+ readonly discriminator?: string;
29
+ };
30
+ /**
31
+ * Projects one normalized channel onto a `defineMessages`-shaped contract.
32
+ *
33
+ * The two models nearly line up already — AsyncAPI 3.0 and `@amritk/api` name
34
+ * directions from the same end — so the work is in the two places they do not:
35
+ *
36
+ * - **Direction.** `receive` (the application receives it) is what a client
37
+ * sends, hence `clientToServer`; `send` is `serverToClient`. A message no
38
+ * operation names has no direction at all, and a contract cannot guess one:
39
+ * putting it in the wrong half would validate frames flowing the wrong way
40
+ * and reject the ones that arrive.
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.
47
+ *
48
+ * Everything skipped comes back as an issue naming the message and the reason,
49
+ * so the caller can warn per message instead of failing the channel. A channel
50
+ * that ends up with no messages at all is still returned: an empty contract is
51
+ * a truthful one, and the issues say why it is empty.
52
+ */
53
+ export declare const buildChannelContract: (channel: NormalizedChannel, options?: BuildChannelContractOptions) => ChannelContract;
@@ -0,0 +1,60 @@
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
+ const target = message.direction === "receive" ? clientToServer : serverToClient;
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
+ };
37
+ if (message.payload === void 0) {
38
+ place(message.name, { type: "object" });
39
+ continue;
40
+ }
41
+ const projected = stripDiscriminator(message.payload, discriminator);
42
+ if (projected.issue !== void 0) {
43
+ skip(projected.issue);
44
+ continue;
45
+ }
46
+ place(projected.tag ?? message.name, projected.schema);
47
+ }
48
+ return {
49
+ exportName: toExportName(sanitizeToken(channel.key, "channel")),
50
+ discriminator,
51
+ // `Object.fromEntries` defines own properties rather than assigning them,
52
+ // so even a hostile name lands as data on the object it belongs to.
53
+ clientToServer: Object.fromEntries(clientToServer),
54
+ serverToClient: Object.fromEntries(serverToClient),
55
+ issues
56
+ };
57
+ };
58
+ export {
59
+ buildChannelContract
60
+ };
@@ -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 — an Avro payload, a dangling `$ref`, a malformed
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[];
@@ -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";
@@ -13,7 +14,7 @@ const resolveTraits = (document, traits, issues, path) => {
13
14
  }
14
15
  return resolved;
15
16
  };
16
- const extractChannelsV2 = (document, issues) => {
17
+ const extractChannelsV2 = (document, issues, options = {}) => {
17
18
  const channelsMap = readKey(document, "channels");
18
19
  if (typeof channelsMap !== "object" || channelsMap === null)
19
20
  return [];
@@ -59,10 +60,16 @@ const extractChannelsV2 = (document, issues) => {
59
60
  payloadSchemaFormat: readKey(merged, "schemaFormat"),
60
61
  payload: readKey(merged, "payload"),
61
62
  headers: readKey(merged, "headers")
62
- }, document, issues, itemPath));
63
+ }, document, issues, itemPath, options));
63
64
  }
64
65
  }
65
- channels.push({ key: channelKey, address: channelKey, messages });
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 { 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[];
@@ -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";
@@ -157,7 +158,7 @@ const collectDirections = (document, channelsMap, issues) => {
157
158
  }
158
159
  return directions;
159
160
  };
160
- const extractChannelsV3 = (document, issues) => {
161
+ const extractChannelsV3 = (document, issues, options = {}) => {
161
162
  const channelsMap = readKey(document, "channels");
162
163
  if (typeof channelsMap !== "object" || channelsMap === null)
163
164
  return [];
@@ -192,12 +193,14 @@ const extractChannelsV3 = (document, issues) => {
192
193
  payload: payload.schema,
193
194
  headersSchemaFormat: headers.schemaFormat,
194
195
  headers: headers.schema
195
- }, document, issues, messagePath));
196
+ }, document, issues, messagePath, options));
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
- export { extractAsyncApi } from './extract-async-api.js';
3
+ export { type ExtractAsyncApiOptions, 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
  };
@@ -1,9 +1,5 @@
1
- import { toKebabCase } from "@amritk/helpers/ref-to-filename";
2
1
  import { refToName } from "@amritk/helpers/ref-to-name";
3
- const sanitizeToken = (value, fallback) => {
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++) {
@@ -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
- * A schema whose format is not a JSON Schema dialect — Avro, Protobuf, a
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 === "unsupported" || typeof schema !== "object" || schema === null || Array.isArray(schema)) {
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 === "unsupported" || typeof schema !== "object" || schema === null || Array.isArray(schema)) {
262
+ if (!isCopyableFamily(componentFamily) || typeof schema !== "object" || schema === null || Array.isArray(schema)) {
262
263
  issues.push({
263
264
  path,
264
- message: componentFamily === "unsupported" ? `component "${name}" uses unsupported schemaFormat ${JSON.stringify(schemaFormat)}; treated as an unconstrained schema` : `component "${name}" is not an object schema; treated as an unconstrained schema`
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;
@@ -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,6 @@
1
+ const DEFAULT_DISCRIMINATOR = "type";
2
+ const resolveDiscriminator = (channel, override) => channel.discriminator ?? override ?? DEFAULT_DISCRIMINATOR;
3
+ export {
4
+ DEFAULT_DISCRIMINATOR,
5
+ resolveDiscriminator
6
+ };
@@ -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
+ };
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The JSON Schema dialect a `schemaFormat` names, or `'unsupported'` for
3
- * everything the pipeline cannot turn into JSON Schema (Avro, Protobuf, RAML,
4
- * unrecognized media types). The four supported families each get their own
5
- * normalization: the AsyncAPI default dialect and declared draft-07 go through
6
- * the draft-07 upgrade, OpenAPI schema objects get `nullable` folded, and
7
- * 2020-12 passes through.
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
@@ -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";
@@ -0,0 +1,58 @@
1
+ /**
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
6
+ * `prepareMessages` time or reject every frame at runtime.
7
+ */
8
+ export type StripDiscriminatorResult = {
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;
16
+ readonly issue?: undefined;
17
+ } | {
18
+ readonly schema?: undefined;
19
+ readonly tag?: undefined;
20
+ readonly issue: string;
21
+ };
22
+ /**
23
+ * Removes the discriminator property from a message payload, so what is left
24
+ * describes the payload alone, and reports the tag value the payload pinned it
25
+ * to.
26
+ *
27
+ * `@amritk/api` reads the tag off the frame to *select* the message, then
28
+ * removes it before validating — so a schema that still declares the tag is
29
+ * refused at setup time by `assertMessageSchema`, and would be unsatisfiable
30
+ * even if it were not. AsyncAPI documents, meanwhile, almost always declare it:
31
+ * a channel carrying a `oneOf` of messages has nothing *but* the tag to tell
32
+ * them apart, so `type: { const: 'hello' }` is how the document says "this is
33
+ * the hello message". The two conventions are the same fact written twice, and
34
+ * this reconciles them by taking the document's word for what goes on the wire
35
+ * and dropping the copy.
36
+ *
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.
45
+ *
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.
52
+ * - A payload that is not an object schema has nowhere for a tag to live, which
53
+ * `assertMessageSchema` refuses too.
54
+ *
55
+ * The input is never mutated: the model's schemas are shared with the parser
56
+ * generators, which must keep seeing the payload as the document wrote it.
57
+ */
58
+ export declare const stripDiscriminator: (payload: unknown, discriminator: string) => StripDiscriminatorResult;
@@ -0,0 +1,56 @@
1
+ import { readKey } from "@amritk/helpers/read-key";
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) => {
11
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
12
+ return { issue: "payload is not an object schema, so it cannot describe a message" };
13
+ const schema = payload;
14
+ const type = readKey(schema, "type");
15
+ if (type !== void 0 && type !== "object")
16
+ return { issue: `payload declares type ${JSON.stringify(type)}; a message schema must be type 'object'` };
17
+ const properties = readKey(schema, "properties");
18
+ const declaredProperties = typeof properties === "object" && properties !== null ? properties : void 0;
19
+ const declaration = declaredProperties === void 0 ? void 0 : readKey(declaredProperties, discriminator);
20
+ const required = readKey(schema, "required");
21
+ const requiresDiscriminator = Array.isArray(required) && required.includes(discriminator);
22
+ if (declaration === void 0 && !requiresDiscriminator)
23
+ return { schema };
24
+ if (declaration === void 0)
25
+ return {
26
+ issue: `payload requires "${discriminator}" without declaring it, so the tag it carries cannot be read`
27
+ };
28
+ if (typeof declaration !== "object" || declaration === null)
29
+ return { issue: `payload declares "${discriminator}" as ${JSON.stringify(declaration)}, not a schema` };
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")
36
+ return {
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`
38
+ };
39
+ const stripped = { ...schema };
40
+ const remaining = Object.fromEntries(Object.entries(declaredProperties).filter(([key]) => key !== discriminator));
41
+ if (Object.keys(remaining).length === 0)
42
+ delete stripped["properties"];
43
+ else
44
+ stripped["properties"] = remaining;
45
+ if (requiresDiscriminator) {
46
+ const rest = required.filter((entry) => entry !== discriminator);
47
+ if (rest.length === 0)
48
+ delete stripped["required"];
49
+ else
50
+ stripped["required"] = rest;
51
+ }
52
+ return { schema: stripped, tag: pinned.value };
53
+ };
54
+ export {
55
+ stripDiscriminator
56
+ };
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`. Absent when the message declares none, or when its
51
- * `schemaFormat` is not a JSON Schema dialect (an issue records which).
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}. */
@@ -59,6 +61,14 @@ export type NormalizedChannel = {
59
61
  readonly key: string;
60
62
  /** The 3.0 `address`; for 2.x the channel key, which *is* the topic/path. */
61
63
  readonly address?: string;
64
+ /**
65
+ * The property naming which message a frame is, when the channel declares one
66
+ * via `x-mjst: { discriminator }`. AsyncAPI itself has no way to say this —
67
+ * it lists a channel's messages and stops — but a message contract has to
68
+ * pick one property, so a document whose frames are tagged `event` rather
69
+ * than `type` can say so where the messages are.
70
+ */
71
+ readonly discriminator?: string;
62
72
  readonly messages: readonly NormalizedMessage[];
63
73
  };
64
74
  export type AsyncApiModel = {
@@ -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
- * The wrapper is recognized only when *both* keys are present: `schemaFormat`
11
- * is required on the wrapper, and demanding `schema` too keeps a plain schema
12
- * that merely declares a property named `schemaFormat` from losing its body.
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
- const schemaFormat = readKey(record, "schemaFormat");
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.1.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/helpers": "^0.19.0"
42
+ "@amritk/adapters": "^0.6.2",
43
+ "@amritk/helpers": "^0.21.0"
43
44
  },
44
45
  "exports": {
45
46
  "./package.json": "./package.json",