@amritk/asyncapi 0.1.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 +35 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/detect-version.d.ts +14 -0
- package/dist/detect-version.js +16 -0
- package/dist/extract-async-api.d.ts +13 -0
- package/dist/extract-async-api.js +25 -0
- package/dist/extract-channels-v2.d.ts +16 -0
- package/dist/extract-channels-v2.js +71 -0
- package/dist/extract-channels-v3.d.ts +10 -0
- package/dist/extract-channels-v3.js +208 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +12 -0
- package/dist/merge-traits.d.ts +25 -0
- package/dist/merge-traits.js +51 -0
- package/dist/message-schemas.d.ts +19 -0
- package/dist/message-schemas.js +62 -0
- package/dist/normalize-message.d.ts +29 -0
- package/dist/normalize-message.js +36 -0
- package/dist/normalize-schema.d.ts +24 -0
- package/dist/normalize-schema.js +18 -0
- package/dist/rebase-component-refs.d.ts +40 -0
- package/dist/rebase-component-refs.js +349 -0
- package/dist/resolve-pointer.d.ts +21 -0
- package/dist/resolve-pointer.js +61 -0
- package/dist/schema-format.d.ts +16 -0
- package/dist/schema-format.js +20 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +0 -0
- package/dist/unwrap-multi-format.d.ts +14 -0
- package/dist/unwrap-multi-format.js +14 -0
- package/package.json +58 -0
package/AI.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @amritk/asyncapi — notes for AI coding agents
|
|
2
|
+
|
|
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).
|
|
6
|
+
|
|
7
|
+
> Pre-alpha: APIs change in **minor** versions.
|
|
8
|
+
|
|
9
|
+
## Minimal example
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { extractAsyncApi, listMessageSchemas } from '@amritk/asyncapi'
|
|
13
|
+
|
|
14
|
+
const model = extractAsyncApi(parsedDocument) // takes a VALUE, not a path
|
|
15
|
+
const schemas = listMessageSchemas(model)
|
|
16
|
+
// → [{ subDir: 'channels/lighting-measured/light-measured', rootTypeName: 'LightMeasured', schema }, ...]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Gotchas — where agents fail
|
|
20
|
+
|
|
21
|
+
1. **It takes an already-parsed document.** No filesystem, no YAML, no network —
|
|
22
|
+
parse with `@amritk/yaml` / `JSON.parse` and resolve cross-file refs with
|
|
23
|
+
`@amritk/resolve-refs` *before* calling. External `$ref`s still present are
|
|
24
|
+
reported as issues, not fetched.
|
|
25
|
+
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.
|
|
28
|
+
3. **Directions are application-relative.** 2.x `publish` → `receive`,
|
|
29
|
+
`subscribe` → `send` (the app is the server). Absent when no operation names
|
|
30
|
+
the message.
|
|
31
|
+
4. **Extracted schemas are copies.** `#/components/schemas/X` refs are rebased
|
|
32
|
+
to `#/$defs/X` with components copied in per message — mutating one
|
|
33
|
+
message's schema never affects another.
|
|
34
|
+
5. **Root type names come from message identity**, not schema `title` — two
|
|
35
|
+
messages titled "Event" stay distinct.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 amritk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @amritk/asyncapi
|
|
4
|
+
|
|
5
|
+
**Extract JSON Schemas from AsyncAPI 2.x/3.0 documents for the mjst generators.**
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
`@amritk/asyncapi` walks an AsyncAPI document — 2.0 through 2.6, or 3.0 — and pulls every message's payload and headers out as **self-contained JSON Schema 2020-12 documents**, ready for [`@amritk/generate-parsers`](../generate-parsers), [`@amritk/generate-validators`](../generate-validators), [`@amritk/generate-examples`](../generate-examples), or [`@amritk/runtime-validators`](../runtime-validators). It is the extraction layer behind `mjst --input asyncapi`.
|
|
21
|
+
|
|
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
|
+
|
|
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.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @amritk/asyncapi
|
|
32
|
+
# or
|
|
33
|
+
pnpm add @amritk/asyncapi
|
|
34
|
+
# or
|
|
35
|
+
yarn add @amritk/asyncapi
|
|
36
|
+
# or
|
|
37
|
+
bun add @amritk/asyncapi
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { extractAsyncApi, listMessageSchemas } from '@amritk/asyncapi'
|
|
46
|
+
|
|
47
|
+
// Parse the document yourself (JSON.parse, @amritk/yaml, ...) — this package
|
|
48
|
+
// takes the already-parsed value and never touches the filesystem or network.
|
|
49
|
+
const model = extractAsyncApi(document)
|
|
50
|
+
|
|
51
|
+
for (const issue of model.issues) {
|
|
52
|
+
console.warn(`${issue.path}: ${issue.message}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const channel of model.channels) {
|
|
56
|
+
for (const message of channel.messages) {
|
|
57
|
+
// message.payload / message.headers are self-contained JSON Schema 2020-12
|
|
58
|
+
console.log(channel.key, message.name, message.direction)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Flatten into generator inputs: one { subDir, rootTypeName, schema } per
|
|
63
|
+
// payload/headers, laid out as channels/<channel>/<message>[-headers].
|
|
64
|
+
const schemas = listMessageSchemas(model)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
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
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
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.
|
|
74
|
+
- **`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).
|
|
75
|
+
- **`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'`.
|
|
77
|
+
- **`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
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type DetectedVersion = {
|
|
2
|
+
readonly major: 2 | 3;
|
|
3
|
+
/** The declared version string, verbatim. */
|
|
4
|
+
readonly version: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Reads the document's `asyncapi` version and classifies its major.
|
|
8
|
+
*
|
|
9
|
+
* The major is matched with an anchored `M.` so a hypothetical `20.0.0` is not
|
|
10
|
+
* mistaken for 2.x — the same guard the lint format detectors carry for minors.
|
|
11
|
+
* Returns `undefined` for anything that is not an AsyncAPI 2.x/3.x document, so
|
|
12
|
+
* the caller owns the error message.
|
|
13
|
+
*/
|
|
14
|
+
export declare const detectAsyncApiVersion: (document: unknown) => DetectedVersion | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
const detectAsyncApiVersion = (document) => {
|
|
3
|
+
if (typeof document !== "object" || document === null || Array.isArray(document))
|
|
4
|
+
return void 0;
|
|
5
|
+
const version = readKey(document, "asyncapi");
|
|
6
|
+
if (typeof version !== "string")
|
|
7
|
+
return void 0;
|
|
8
|
+
if (/^2\.\d/.test(version))
|
|
9
|
+
return { major: 2, version };
|
|
10
|
+
if (/^3\.\d/.test(version))
|
|
11
|
+
return { major: 3, version };
|
|
12
|
+
return void 0;
|
|
13
|
+
};
|
|
14
|
+
export {
|
|
15
|
+
detectAsyncApiVersion
|
|
16
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AsyncApiModel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts an already-parsed AsyncAPI document (2.x or 3.x) into the
|
|
4
|
+
* normalized model: channels, their messages, and each message's payload and
|
|
5
|
+
* headers as self-contained JSON Schema 2020-12 documents.
|
|
6
|
+
*
|
|
7
|
+
* Per-message problems — an Avro payload, a dangling `$ref`, a malformed
|
|
8
|
+
* trait — are collected on `issues` so one bad message never costs the rest of
|
|
9
|
+
* the document. Only a document that is not AsyncAPI at all throws: there is
|
|
10
|
+
* nothing to extract, and a silent empty model would read as "no channels"
|
|
11
|
+
* rather than "wrong file".
|
|
12
|
+
*/
|
|
13
|
+
export declare const extractAsyncApi: (document: unknown) => AsyncApiModel;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
import { detectAsyncApiVersion } from "./detect-version.js";
|
|
3
|
+
import { extractChannelsV2 } from "./extract-channels-v2.js";
|
|
4
|
+
import { extractChannelsV3 } from "./extract-channels-v3.js";
|
|
5
|
+
const extractAsyncApi = (document) => {
|
|
6
|
+
const detected = detectAsyncApiVersion(document);
|
|
7
|
+
if (!detected) {
|
|
8
|
+
throw new Error("Not an AsyncAPI document: expected a top-level `asyncapi` field declaring version 2.x or 3.x.");
|
|
9
|
+
}
|
|
10
|
+
const record = document;
|
|
11
|
+
const issues = [];
|
|
12
|
+
const channels = detected.major === 2 ? extractChannelsV2(record, issues) : extractChannelsV3(record, issues);
|
|
13
|
+
const info = readKey(record, "info");
|
|
14
|
+
const title = typeof info === "object" && info !== null ? readKey(info, "title") : void 0;
|
|
15
|
+
return {
|
|
16
|
+
version: detected.version,
|
|
17
|
+
major: detected.major,
|
|
18
|
+
...typeof title === "string" ? { title } : {},
|
|
19
|
+
channels,
|
|
20
|
+
issues
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
extractAsyncApi
|
|
25
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Walks an AsyncAPI 2.x document's channels into the normalized model.
|
|
4
|
+
*
|
|
5
|
+
* Directions are mapped to the application's point of view: `publish` declares
|
|
6
|
+
* what clients publish — so the application *receives* it — and `subscribe`
|
|
7
|
+
* declares what it *sends*. A channel's key doubles as its address; 2.x has no
|
|
8
|
+
* separate `address` field.
|
|
9
|
+
*
|
|
10
|
+
* `schemaFormat` is read off the message only *after* trait merging, so a
|
|
11
|
+
* trait-contributed format gates its payload exactly like an inline one.
|
|
12
|
+
* Message names follow `name` → `messageId` → positional `message-<n>`, the
|
|
13
|
+
* `n` counting across both operations of the channel so a `oneOf` list and a
|
|
14
|
+
* publish/subscribe pair cannot collide.
|
|
15
|
+
*/
|
|
16
|
+
export declare const extractChannelsV2: (document: Record<string, unknown>, issues: ExtractionIssue[]) => NormalizedChannel[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
import { mergeTraits } from "./merge-traits.js";
|
|
3
|
+
import { normalizeMessage } from "./normalize-message.js";
|
|
4
|
+
import { resolveNode } from "./resolve-pointer.js";
|
|
5
|
+
const resolveTraits = (document, traits, issues, path) => {
|
|
6
|
+
if (!Array.isArray(traits))
|
|
7
|
+
return [];
|
|
8
|
+
const resolved = [];
|
|
9
|
+
for (const [index, trait] of traits.entries()) {
|
|
10
|
+
const node = resolveNode(document, trait, issues, `${path}/traits/${index}`);
|
|
11
|
+
if (node !== void 0)
|
|
12
|
+
resolved.push(node);
|
|
13
|
+
}
|
|
14
|
+
return resolved;
|
|
15
|
+
};
|
|
16
|
+
const extractChannelsV2 = (document, issues) => {
|
|
17
|
+
const channelsMap = readKey(document, "channels");
|
|
18
|
+
if (typeof channelsMap !== "object" || channelsMap === null)
|
|
19
|
+
return [];
|
|
20
|
+
const defaultContentType = readKey(document, "defaultContentType");
|
|
21
|
+
const channels = [];
|
|
22
|
+
for (const [channelKey, rawChannel] of Object.entries(channelsMap)) {
|
|
23
|
+
const channelPath = `#/channels/${channelKey}`;
|
|
24
|
+
const channel = resolveNode(document, rawChannel, issues, channelPath);
|
|
25
|
+
if (channel === void 0)
|
|
26
|
+
continue;
|
|
27
|
+
const messages = [];
|
|
28
|
+
let positional = 0;
|
|
29
|
+
for (const operationKey of ["publish", "subscribe"]) {
|
|
30
|
+
const operation = readKey(channel, operationKey);
|
|
31
|
+
if (typeof operation !== "object" || operation === null)
|
|
32
|
+
continue;
|
|
33
|
+
const direction = operationKey === "publish" ? "receive" : "send";
|
|
34
|
+
const operationPath = `${channelPath}/${operationKey}`;
|
|
35
|
+
const rawMessage = readKey(operation, "message");
|
|
36
|
+
if (rawMessage === void 0)
|
|
37
|
+
continue;
|
|
38
|
+
const messageNode = resolveNode(document, rawMessage, issues, `${operationPath}/message`);
|
|
39
|
+
if (messageNode === void 0)
|
|
40
|
+
continue;
|
|
41
|
+
const oneOf = readKey(messageNode, "oneOf");
|
|
42
|
+
const items = Array.isArray(oneOf) ? oneOf : [messageNode];
|
|
43
|
+
for (const [index, item] of items.entries()) {
|
|
44
|
+
const itemPath = Array.isArray(oneOf) ? `${operationPath}/message/oneOf/${index}` : `${operationPath}/message`;
|
|
45
|
+
const resolved = resolveNode(document, item, issues, itemPath);
|
|
46
|
+
if (resolved === void 0)
|
|
47
|
+
continue;
|
|
48
|
+
const merged = mergeTraits(resolved, resolveTraits(document, readKey(resolved, "traits"), issues, itemPath), "trait");
|
|
49
|
+
positional++;
|
|
50
|
+
const declaredName = readKey(merged, "name");
|
|
51
|
+
const messageId = readKey(merged, "messageId");
|
|
52
|
+
const name = typeof declaredName === "string" && declaredName !== "" ? declaredName : typeof messageId === "string" && messageId !== "" ? messageId : `message-${positional}`;
|
|
53
|
+
const contentType = readKey(merged, "contentType") ?? defaultContentType;
|
|
54
|
+
messages.push(normalizeMessage({
|
|
55
|
+
name,
|
|
56
|
+
channelKey,
|
|
57
|
+
direction,
|
|
58
|
+
...typeof contentType === "string" ? { contentType } : {},
|
|
59
|
+
payloadSchemaFormat: readKey(merged, "schemaFormat"),
|
|
60
|
+
payload: readKey(merged, "payload"),
|
|
61
|
+
headers: readKey(merged, "headers")
|
|
62
|
+
}, document, issues, itemPath));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
channels.push({ key: channelKey, address: channelKey, messages });
|
|
66
|
+
}
|
|
67
|
+
return channels;
|
|
68
|
+
};
|
|
69
|
+
export {
|
|
70
|
+
extractChannelsV2
|
|
71
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExtractionIssue, NormalizedChannel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Walks an AsyncAPI 3.0 document's channels into the normalized model.
|
|
4
|
+
*
|
|
5
|
+
* Payload and headers values may each be a Multi Format Schema Object — 3.0
|
|
6
|
+
* moved `schemaFormat` into that wrapper — so both are unwrapped here and the
|
|
7
|
+
* format travels with the schema it labels. Message names are the channel's
|
|
8
|
+
* `messages` map keys, which 3.0 makes mandatory and unique per channel.
|
|
9
|
+
*/
|
|
10
|
+
export declare const extractChannelsV3: (document: Record<string, unknown>, issues: ExtractionIssue[]) => NormalizedChannel[];
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
import { mergeTraits } from "./merge-traits.js";
|
|
3
|
+
import { normalizeMessage } from "./normalize-message.js";
|
|
4
|
+
import { resolveNode } from "./resolve-pointer.js";
|
|
5
|
+
import { unwrapMultiFormat } from "./unwrap-multi-format.js";
|
|
6
|
+
const CHANNEL_REF = /^#\/channels\/([^/]+)$/;
|
|
7
|
+
const CHANNEL_MESSAGE_REF = /^#\/channels\/([^/]+)\/messages\/([^/]+)$/;
|
|
8
|
+
const decodeSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
9
|
+
const memberKey = (map, segment) => {
|
|
10
|
+
const raw = decodeSegment(segment);
|
|
11
|
+
if (readKey(map, raw) !== void 0)
|
|
12
|
+
return raw;
|
|
13
|
+
if (raw.includes("%")) {
|
|
14
|
+
try {
|
|
15
|
+
const decoded = decodeURIComponent(raw);
|
|
16
|
+
if (readKey(map, decoded) !== void 0)
|
|
17
|
+
return decoded;
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return void 0;
|
|
22
|
+
};
|
|
23
|
+
const resolveTraits = (document, traits, issues, path) => {
|
|
24
|
+
if (!Array.isArray(traits))
|
|
25
|
+
return [];
|
|
26
|
+
const resolved = [];
|
|
27
|
+
for (const [index, trait] of traits.entries()) {
|
|
28
|
+
const node = resolveNode(document, trait, issues, `${path}/traits/${index}`);
|
|
29
|
+
if (node !== void 0)
|
|
30
|
+
resolved.push(node);
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
};
|
|
34
|
+
const collectDirections = (document, channelsMap, issues) => {
|
|
35
|
+
const directions = /* @__PURE__ */ new Map();
|
|
36
|
+
const operationsMap = readKey(document, "operations");
|
|
37
|
+
if (typeof operationsMap !== "object" || operationsMap === null)
|
|
38
|
+
return directions;
|
|
39
|
+
const channelKeyOf = (channelNode, path) => {
|
|
40
|
+
if (typeof channelNode === "object" && channelNode !== null) {
|
|
41
|
+
const ref = readKey(channelNode, "$ref");
|
|
42
|
+
if (typeof ref === "string") {
|
|
43
|
+
const match = CHANNEL_REF.exec(ref);
|
|
44
|
+
if (match) {
|
|
45
|
+
const key = memberKey(channelsMap, match[1]);
|
|
46
|
+
if (key !== void 0)
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const scratch = [];
|
|
52
|
+
const resolved = resolveNode(document, channelNode, scratch, path);
|
|
53
|
+
if (resolved !== void 0) {
|
|
54
|
+
for (const [key, value] of Object.entries(channelsMap)) {
|
|
55
|
+
if (value === channelNode || value === resolved)
|
|
56
|
+
return key;
|
|
57
|
+
if (resolveNode(document, value, scratch, path) === resolved)
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return void 0;
|
|
62
|
+
};
|
|
63
|
+
const processOperationLike = (operation, action, operationPath, kind) => {
|
|
64
|
+
const channelNode = readKey(operation, "channel");
|
|
65
|
+
let channelKey;
|
|
66
|
+
let channelMessages;
|
|
67
|
+
if (channelNode !== void 0 || kind === "operation") {
|
|
68
|
+
channelKey = channelKeyOf(channelNode, `${operationPath}/channel`);
|
|
69
|
+
if (channelKey === void 0) {
|
|
70
|
+
issues.push({
|
|
71
|
+
path: `${operationPath}/channel`,
|
|
72
|
+
message: `${kind} channel does not resolve; direction skipped`
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const channel = resolveNode(document, readKey(channelsMap, channelKey), issues, `#/channels/${channelKey}`);
|
|
77
|
+
channelMessages = channel === void 0 ? void 0 : readKey(channel, "messages");
|
|
78
|
+
}
|
|
79
|
+
const opMessages = readKey(operation, "messages");
|
|
80
|
+
const messageTargets = [];
|
|
81
|
+
if (Array.isArray(opMessages)) {
|
|
82
|
+
for (const [index, item] of opMessages.entries()) {
|
|
83
|
+
const ref = typeof item === "object" && item !== null ? readKey(item, "$ref") : void 0;
|
|
84
|
+
const match = typeof ref === "string" ? CHANNEL_MESSAGE_REF.exec(ref) : null;
|
|
85
|
+
if (match && channelKey === void 0) {
|
|
86
|
+
const refChannelKey = memberKey(channelsMap, match[1]);
|
|
87
|
+
const refChannel = refChannelKey === void 0 ? void 0 : resolveNode(document, readKey(channelsMap, refChannelKey), issues, `#/channels/${refChannelKey}`);
|
|
88
|
+
const refMessages = refChannel === void 0 ? void 0 : readKey(refChannel, "messages");
|
|
89
|
+
const messageKey = typeof refMessages === "object" && refMessages !== null ? memberKey(refMessages, match[2]) : void 0;
|
|
90
|
+
if (refChannelKey !== void 0 && messageKey !== void 0) {
|
|
91
|
+
messageTargets.push([refChannelKey, messageKey]);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (match && channelKey !== void 0 && typeof channelMessages === "object" && channelMessages !== null) {
|
|
96
|
+
const key = memberKey(channelMessages, match[2]);
|
|
97
|
+
if (key !== void 0) {
|
|
98
|
+
messageTargets.push([channelKey, key]);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let found = false;
|
|
103
|
+
if (channelKey !== void 0 && typeof channelMessages === "object" && channelMessages !== null) {
|
|
104
|
+
const scratch = [];
|
|
105
|
+
const itemPath = `${operationPath}/messages/${index}`;
|
|
106
|
+
const resolvedItem = resolveNode(document, item, scratch, itemPath);
|
|
107
|
+
for (const [key, value] of Object.entries(channelMessages)) {
|
|
108
|
+
const matches = value === item || resolvedItem !== void 0 && (value === resolvedItem || resolveNode(document, value, scratch, itemPath) === resolvedItem);
|
|
109
|
+
if (matches) {
|
|
110
|
+
messageTargets.push([channelKey, key]);
|
|
111
|
+
found = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!found) {
|
|
117
|
+
issues.push({
|
|
118
|
+
path: `${operationPath}/messages/${index}`,
|
|
119
|
+
message: `${kind} message reference does not resolve to a channel message; direction skipped`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else if (channelKey !== void 0 && typeof channelMessages === "object" && channelMessages !== null) {
|
|
124
|
+
for (const key of Object.keys(channelMessages)) {
|
|
125
|
+
messageTargets.push([channelKey, key]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [messageChannelKey, messageKey] of messageTargets) {
|
|
129
|
+
const key = `${messageChannelKey}\0${messageKey}`;
|
|
130
|
+
const existing = directions.get(key);
|
|
131
|
+
if (existing === void 0) {
|
|
132
|
+
directions.set(key, action);
|
|
133
|
+
} else if (existing !== action) {
|
|
134
|
+
issues.push({
|
|
135
|
+
path: operationPath,
|
|
136
|
+
message: `message "${messageKey}" on channel "${messageChannelKey}" is both sent and received; keeping "${existing}"`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const [operationKey, rawOperation] of Object.entries(operationsMap)) {
|
|
142
|
+
const operationPath = `#/operations/${operationKey}`;
|
|
143
|
+
const operation = resolveNode(document, rawOperation, issues, operationPath);
|
|
144
|
+
if (operation === void 0)
|
|
145
|
+
continue;
|
|
146
|
+
const action = readKey(operation, "action");
|
|
147
|
+
if (action !== "send" && action !== "receive")
|
|
148
|
+
continue;
|
|
149
|
+
processOperationLike(operation, action, operationPath, "operation");
|
|
150
|
+
const rawReply = readKey(operation, "reply");
|
|
151
|
+
if (rawReply !== void 0) {
|
|
152
|
+
const reply = resolveNode(document, rawReply, issues, `${operationPath}/reply`);
|
|
153
|
+
if (reply !== void 0) {
|
|
154
|
+
processOperationLike(reply, action === "send" ? "receive" : "send", `${operationPath}/reply`, "reply");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return directions;
|
|
159
|
+
};
|
|
160
|
+
const extractChannelsV3 = (document, issues) => {
|
|
161
|
+
const channelsMap = readKey(document, "channels");
|
|
162
|
+
if (typeof channelsMap !== "object" || channelsMap === null)
|
|
163
|
+
return [];
|
|
164
|
+
const defaultContentType = readKey(document, "defaultContentType");
|
|
165
|
+
const directions = collectDirections(document, channelsMap, issues);
|
|
166
|
+
const channels = [];
|
|
167
|
+
for (const [channelKey, rawChannel] of Object.entries(channelsMap)) {
|
|
168
|
+
const channelPath = `#/channels/${channelKey}`;
|
|
169
|
+
const channel = resolveNode(document, rawChannel, issues, channelPath);
|
|
170
|
+
if (channel === void 0)
|
|
171
|
+
continue;
|
|
172
|
+
const address = readKey(channel, "address");
|
|
173
|
+
const messages = [];
|
|
174
|
+
const messagesMap = readKey(channel, "messages");
|
|
175
|
+
if (typeof messagesMap === "object" && messagesMap !== null) {
|
|
176
|
+
for (const [messageKey, rawMessage] of Object.entries(messagesMap)) {
|
|
177
|
+
const messagePath = `${channelPath}/messages/${messageKey}`;
|
|
178
|
+
const resolved = resolveNode(document, rawMessage, issues, messagePath);
|
|
179
|
+
if (resolved === void 0)
|
|
180
|
+
continue;
|
|
181
|
+
const merged = mergeTraits(resolved, resolveTraits(document, readKey(resolved, "traits"), issues, messagePath), "target");
|
|
182
|
+
const payload = unwrapMultiFormat(readKey(merged, "payload"));
|
|
183
|
+
const headers = unwrapMultiFormat(readKey(merged, "headers"));
|
|
184
|
+
const contentType = readKey(merged, "contentType") ?? defaultContentType;
|
|
185
|
+
const direction = directions.get(`${channelKey}\0${messageKey}`);
|
|
186
|
+
messages.push(normalizeMessage({
|
|
187
|
+
name: messageKey,
|
|
188
|
+
channelKey,
|
|
189
|
+
...direction !== void 0 ? { direction } : {},
|
|
190
|
+
...typeof contentType === "string" ? { contentType } : {},
|
|
191
|
+
payloadSchemaFormat: payload.schemaFormat,
|
|
192
|
+
payload: payload.schema,
|
|
193
|
+
headersSchemaFormat: headers.schemaFormat,
|
|
194
|
+
headers: headers.schema
|
|
195
|
+
}, document, issues, messagePath));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
channels.push({
|
|
199
|
+
key: channelKey,
|
|
200
|
+
...typeof address === "string" && address !== "" ? { address } : {},
|
|
201
|
+
messages
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return channels;
|
|
205
|
+
};
|
|
206
|
+
export {
|
|
207
|
+
extractChannelsV3
|
|
208
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { type DetectedVersion, detectAsyncApiVersion } from './detect-version.js';
|
|
2
|
+
export { extractAsyncApi } from './extract-async-api.js';
|
|
3
|
+
export { mergeTraits } from './merge-traits.js';
|
|
4
|
+
export { listMessageSchemas } from './message-schemas.js';
|
|
5
|
+
export { classifySchemaFormat, type SchemaFormatFamily } from './schema-format.js';
|
|
6
|
+
export type { AsyncApiModel, ExtractedSchema, ExtractionIssue, MessageDirection, NormalizedChannel, NormalizedMessage, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { detectAsyncApiVersion } from "./detect-version.js";
|
|
2
|
+
import { extractAsyncApi } from "./extract-async-api.js";
|
|
3
|
+
import { mergeTraits } from "./merge-traits.js";
|
|
4
|
+
import { listMessageSchemas } from "./message-schemas.js";
|
|
5
|
+
import { classifySchemaFormat } from "./schema-format.js";
|
|
6
|
+
export {
|
|
7
|
+
classifySchemaFormat,
|
|
8
|
+
detectAsyncApiVersion,
|
|
9
|
+
extractAsyncApi,
|
|
10
|
+
listMessageSchemas,
|
|
11
|
+
mergeTraits
|
|
12
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which side of a key conflict wins when a trait and its target both set it.
|
|
3
|
+
* Both majors define trait application via JSON Merge Patch (RFC 7386), but
|
|
4
|
+
* they disagree on who patches whom — and 3.0 changed the answer on purpose:
|
|
5
|
+
*
|
|
6
|
+
* - `'trait'` — AsyncAPI 2.x: traits "MUST be merged into the message object
|
|
7
|
+
* using the JSON Merge Patch algorithm in the same order they are defined",
|
|
8
|
+
* so each trait is the *patch* and its values override the target's.
|
|
9
|
+
* - `'target'` — AsyncAPI 3.0: "A property on a trait MUST NOT override the
|
|
10
|
+
* same property on the target object" — the target is applied last.
|
|
11
|
+
*/
|
|
12
|
+
export type TraitPrecedence = 'trait' | 'target';
|
|
13
|
+
/**
|
|
14
|
+
* Applies a message's (or operation's) traits per the requested precedence:
|
|
15
|
+
* with `'trait'` each trait patches the accumulating target in declaration
|
|
16
|
+
* order; with `'target'` the traits accumulate first and the target overlays
|
|
17
|
+
* them, its own values — authored `null`s included — kept verbatim. Merging
|
|
18
|
+
* happens *before* anything reads `schemaFormat` off the result: a
|
|
19
|
+
* trait-contributed format is just as binding as an inline one, and reading
|
|
20
|
+
* it pre-merge is how an Avro payload gets misjudged as JSON Schema.
|
|
21
|
+
*
|
|
22
|
+
* The `traits` key itself is dropped from the result — it has been applied,
|
|
23
|
+
* and a leftover copy would read as still-pending.
|
|
24
|
+
*/
|
|
25
|
+
export declare const mergeTraits: (target: Record<string, unknown>, traits: readonly Record<string, unknown>[], precedence: TraitPrecedence) => Record<string, unknown>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { assignKey } from "@amritk/helpers/assign-key";
|
|
2
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
3
|
+
const applyMergePatch = (target, patch) => {
|
|
4
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch))
|
|
5
|
+
return patch;
|
|
6
|
+
const base = typeof target === "object" && target !== null && !Array.isArray(target) ? target : {};
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const [key, value] of Object.entries(base))
|
|
9
|
+
assignKey(result, key, value);
|
|
10
|
+
for (const key of Object.keys(patch)) {
|
|
11
|
+
const value = readKey(patch, key);
|
|
12
|
+
if (value === null) {
|
|
13
|
+
delete result[key];
|
|
14
|
+
} else {
|
|
15
|
+
assignKey(result, key, applyMergePatch(readKey(result, key), value));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
const overlayTarget = (base, target) => {
|
|
21
|
+
if (typeof target !== "object" || target === null || Array.isArray(target))
|
|
22
|
+
return target;
|
|
23
|
+
if (typeof base !== "object" || base === null || Array.isArray(base))
|
|
24
|
+
return structuredClone(target);
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const [key, value] of Object.entries(base))
|
|
27
|
+
assignKey(result, key, value);
|
|
28
|
+
for (const key of Object.keys(target)) {
|
|
29
|
+
assignKey(result, key, overlayTarget(readKey(result, key), readKey(target, key)));
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
const mergeTraits = (target, traits, precedence) => {
|
|
34
|
+
let merged;
|
|
35
|
+
if (precedence === "trait") {
|
|
36
|
+
merged = structuredClone(target);
|
|
37
|
+
for (const trait of traits)
|
|
38
|
+
merged = applyMergePatch(merged, trait);
|
|
39
|
+
} else {
|
|
40
|
+
merged = {};
|
|
41
|
+
for (const trait of traits)
|
|
42
|
+
merged = applyMergePatch(merged, trait);
|
|
43
|
+
merged = overlayTarget(merged, target);
|
|
44
|
+
}
|
|
45
|
+
const result = merged;
|
|
46
|
+
delete result["traits"];
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
export {
|
|
50
|
+
mergeTraits
|
|
51
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AsyncApiModel, ExtractedSchema, ExtractionIssue } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Flattens the model into the list of generatable schemas, each with the
|
|
4
|
+
* output subdirectory and root type name the CLI hands to `buildSchema` —
|
|
5
|
+
* `channels/<channel>/<message>/` per payload, with headers in a sibling
|
|
6
|
+
* `<message>-headers/` tree, mirroring how `--schema-dir` gives every schema
|
|
7
|
+
* its own directory.
|
|
8
|
+
*
|
|
9
|
+
* The root type name comes from the message identity (`LightMeasured`), never
|
|
10
|
+
* the schema's `title`: two messages titled "Event" are distinct messages, and
|
|
11
|
+
* the directory layout already says which is which. Messages without a usable
|
|
12
|
+
* payload or headers schema (skipped formats, absent payloads) contribute
|
|
13
|
+
* nothing here — their issues already sit on the model.
|
|
14
|
+
*
|
|
15
|
+
* Name collisions append to `issues`, which the model shares by reference —
|
|
16
|
+
* callers reading `model.issues` after this call see them; pass an array to
|
|
17
|
+
* collect them separately.
|
|
18
|
+
*/
|
|
19
|
+
export declare const listMessageSchemas: (model: AsyncApiModel, issues?: ExtractionIssue[]) => ExtractedSchema[];
|