1 Commits
Author SHA1 Message Date
zemion 98d1ff4afc Release API Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 10:18:45 +02:00
35 changed files with 2483 additions and 147 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added OpenAPI 3.1/3.2 webhook receiver inventory, inert placeholder examples,
and operation-level revision comparison without treating webhooks as captured
client requests.
## 0.1.0 - 2026-09-01
- Initial local-only OpenAPI 3.0/3.1 inspection release.
+23 -15
View File
@@ -1,15 +1,17 @@
# API Tools
API Tools is a local-first OpenAPI workbench. Version 0.1.0 parses bounded JSON
or YAML OpenAPI 3.0/3.1 descriptions, navigates operations, resolves `$ref`
values from explicitly supplied local files, inventories security schemes,
generates schema/request/response examples and inert client command text,
compares revisions, and summarizes saved HTTP exchanges or HAR 1.2 files.
API Tools is a local-first HTTP and event API workbench. It parses bounded JSON
or YAML OpenAPI 3.03.2 and AsyncAPI 2.03.1 descriptions, navigates path and
webhook receiver operations, channels, and messages, resolves `$ref` values
from explicitly supplied local files, inventories security schemes, generates
focused schema examples, compares revisions, and checks saved HAR 1.2 traffic
against a loaded OpenAPI contract while keeping request and broker execution
disabled.
It never executes HTTP, follows a URL, resolves a remote reference, stores a
credential or loads an external asset. Generated curl, Fetch and Python snippets
are text for review and may reproduce untrusted description values safely
quoted for their target syntax.
It never executes HTTP, connects or subscribes to a broker, follows a URL,
resolves a remote reference, stores a credential, or loads an external asset.
Generated curl, Fetch and Python snippets are text for review and may reproduce
untrusted description values safely quoted for their target syntax.
## Development and release
@@ -22,20 +24,26 @@ npm run test:browser
npm run release:artifact
```
The release command writes deterministic `release/api-tools-0.1.0.zip` and a
The release command writes deterministic `release/api-tools-0.2.0.zip` and a
SHA-256 sidecar. Relative assets are tested at `/deep/nested/api/`.
## v0.1 boundary
- JSON/YAML files up to 4 MiB each, 20 files/16 MiB per selected workspace,
depth 64, 100,000 values, 50 YAML aliases, 2,000 operations;
- focused OpenAPI identity/info/paths checks and remote-reference rejection;
- focused OpenAPI identity/info/paths checks, including 3.2 `query` and
`additionalOperations` and 3.1/3.2 webhook receiver inventory/comparison,
plus AsyncAPI 2.x/3.x channel/operation/message inventories and
remote-reference rejection;
- fragment/relative-file JSON Pointer resolution with missing/cycle reports;
- bounded sample generation for common schema composition and scalar formats;
- path/query/request/response examples and inert curl/Fetch/Python output;
- security scheme inventory, conservative operation compatibility report;
- HAR or raw saved-exchange summary without rendering bodies or secrets.
- HAR coverage and focused method/path/parameter/status/media-type/JSON-body
contract checks without rendering bodies or secrets; raw exchanges retain a
summary-only mode.
This is not a complete OpenAPI/JSON Schema validator, code generator, API
client, proxy or security scanner. See the documented limitations and review
generated values before use. Licensed under GPL-3.0-or-later.
This is not a complete OpenAPI, AsyncAPI, or JSON Schema validator, code
generator, API client, broker client, proxy, or security scanner. See the
documented limitations and review generated values before use. Licensed under
GPL-3.0-or-later.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source
The corresponding source for API Tools 0.1.0 is available at:
The corresponding source for API Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/api-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/api-tools/src/tag/v0.2.0
Build with Node.js 22+, npm 11+, and `package-lock.json` by running
`npm ci && npm run release:artifact`.
+21 -3
View File
@@ -2,11 +2,29 @@
The shell lazy-loads a React workbench. Pure core modules parse and validate
JSON-like values, hold a named in-memory document map, resolve only relative
local references, collect operations, derive bounded examples, compare
operation contracts and summarize saved exchanges. No module exposes a request
executor.
local references, collect OpenAPI HTTP operations or AsyncAPI channels,
send/receive operations and messages, derive bounded examples, compare
operation contracts and summarize saved exchanges. OpenAPI 3.2 fixed `query`
and bounded custom `additionalOperations` method tokens are retained. OpenAPI
3.1/3.2 webhook Path Items are inventoried and compared as receiver operations;
their names do not declare real delivery URLs, so examples use an explicit
placeholder and HAR matching excludes them. AsyncAPI 3.x root
operation/channel/message references and the older 2.x
publish/subscribe channel shape have separate, explicit adapters. HAR validation matches
captured URLs to the most specific path template, checks required parameters,
status and media declarations, and applies a bounded JSON Schema subset to
available JSON bodies. It emits only locations and messages: captured header
and body values never enter the report. No module exposes a request executor.
JSON uses hardened shared helpers. YAML is converted with bounded alias count
and then recursively checked for depth, node count, dangerous keys and plain
JSON values. Rendering uses React text nodes and read-only textareas. The
same-origin service worker caches only packaged application resources.
Contract checking is limited to 2,000 exchanges, 100,000 schema evaluation
steps, 5,000 diagnostics, 2 MiB per decoded JSON body, local references, and a
focused type/composition/object/array/scalar subset. It is evidence and coverage
analysis rather than a claim of complete OpenAPI conformance. AsyncAPI bindings,
traits, correlation expressions and multi-format schemas are inventoried as
inert data; only JSON-Schema-like payload/header objects receive heuristic
sample generation.
+12 -7
View File
@@ -1,12 +1,17 @@
# Privacy and security
Descriptions and exchanges remain in memory and are never transmitted or
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs are text,
not links. There is no Try It button, OAuth flow, DNS lookup, HTTP execution,
telemetry or external asset.
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs, broker
hosts, channel addresses, protocol bindings, and generated commands are text,
not live controls. There is no Try It button, broker connection, subscription,
publish action, OAuth flow, DNS lookup, HTTP execution, telemetry, or external
asset.
HAR and saved exchanges commonly contain tokens, cookies, personal data and
payloads. The summary does not render header values, yet the source editor still
contains them. Clear it before sharing. Generated examples are heuristic and
must not be treated as valid production data. Validation and compatibility
checks cover a useful subset, not every OpenAPI or JSON Schema rule.
payloads. Header and cookie values may be used transiently to check parameter
presence and shape, and JSON bodies may be parsed for local schema checks, but
the summary and contract report never retain or render those values. The source
editor still contains the original capture; clear it before sharing. Generated
examples are heuristic and must not be treated as valid production data.
Validation and compatibility checks cover a useful bounded subset, not every
OpenAPI or JSON Schema rule.
+20 -20
View File
@@ -1,23 +1,23 @@
{
"name": "api-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "api-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"yaml": "2.9.0"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -43,24 +43,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -68,13 +68,13 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "api-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, compare and derive examples from OpenAPI descriptions locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,15 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"yaml": "2.9.0"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added OpenAPI 3.1/3.2 webhook receiver inventory, inert placeholder examples,
and operation-level revision comparison without treating webhooks as captured
client requests.
## 0.1.0 - 2026-09-01
- Initial local-only OpenAPI 3.0/3.1 inspection release.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+23 -15
View File
@@ -1,15 +1,17 @@
# API Tools
API Tools is a local-first OpenAPI workbench. Version 0.1.0 parses bounded JSON
or YAML OpenAPI 3.0/3.1 descriptions, navigates operations, resolves `$ref`
values from explicitly supplied local files, inventories security schemes,
generates schema/request/response examples and inert client command text,
compares revisions, and summarizes saved HTTP exchanges or HAR 1.2 files.
API Tools is a local-first HTTP and event API workbench. It parses bounded JSON
or YAML OpenAPI 3.03.2 and AsyncAPI 2.03.1 descriptions, navigates path and
webhook receiver operations, channels, and messages, resolves `$ref` values
from explicitly supplied local files, inventories security schemes, generates
focused schema examples, compares revisions, and checks saved HAR 1.2 traffic
against a loaded OpenAPI contract while keeping request and broker execution
disabled.
It never executes HTTP, follows a URL, resolves a remote reference, stores a
credential or loads an external asset. Generated curl, Fetch and Python snippets
are text for review and may reproduce untrusted description values safely
quoted for their target syntax.
It never executes HTTP, connects or subscribes to a broker, follows a URL,
resolves a remote reference, stores a credential, or loads an external asset.
Generated curl, Fetch and Python snippets are text for review and may reproduce
untrusted description values safely quoted for their target syntax.
## Development and release
@@ -22,20 +24,26 @@ npm run test:browser
npm run release:artifact
```
The release command writes deterministic `release/api-tools-0.1.0.zip` and a
The release command writes deterministic `release/api-tools-0.2.0.zip` and a
SHA-256 sidecar. Relative assets are tested at `/deep/nested/api/`.
## v0.1 boundary
- JSON/YAML files up to 4 MiB each, 20 files/16 MiB per selected workspace,
depth 64, 100,000 values, 50 YAML aliases, 2,000 operations;
- focused OpenAPI identity/info/paths checks and remote-reference rejection;
- focused OpenAPI identity/info/paths checks, including 3.2 `query` and
`additionalOperations` and 3.1/3.2 webhook receiver inventory/comparison,
plus AsyncAPI 2.x/3.x channel/operation/message inventories and
remote-reference rejection;
- fragment/relative-file JSON Pointer resolution with missing/cycle reports;
- bounded sample generation for common schema composition and scalar formats;
- path/query/request/response examples and inert curl/Fetch/Python output;
- security scheme inventory, conservative operation compatibility report;
- HAR or raw saved-exchange summary without rendering bodies or secrets.
- HAR coverage and focused method/path/parameter/status/media-type/JSON-body
contract checks without rendering bodies or secrets; raw exchanges retain a
summary-only mode.
This is not a complete OpenAPI/JSON Schema validator, code generator, API
client, proxy or security scanner. See the documented limitations and review
generated values before use. Licensed under GPL-3.0-or-later.
This is not a complete OpenAPI, AsyncAPI, or JSON Schema validator, code
generator, API client, broker client, proxy, or security scanner. See the
documented limitations and review generated values before use. Licensed under
GPL-3.0-or-later.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source
The corresponding source for API Tools 0.1.0 is available at:
The corresponding source for API Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/api-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/api-tools/src/tag/v0.2.0
Build with Node.js 22+, npm 11+, and `package-lock.json` by running
`npm ci && npm run release:artifact`.
+21 -3
View File
@@ -2,11 +2,29 @@
The shell lazy-loads a React workbench. Pure core modules parse and validate
JSON-like values, hold a named in-memory document map, resolve only relative
local references, collect operations, derive bounded examples, compare
operation contracts and summarize saved exchanges. No module exposes a request
executor.
local references, collect OpenAPI HTTP operations or AsyncAPI channels,
send/receive operations and messages, derive bounded examples, compare
operation contracts and summarize saved exchanges. OpenAPI 3.2 fixed `query`
and bounded custom `additionalOperations` method tokens are retained. OpenAPI
3.1/3.2 webhook Path Items are inventoried and compared as receiver operations;
their names do not declare real delivery URLs, so examples use an explicit
placeholder and HAR matching excludes them. AsyncAPI 3.x root
operation/channel/message references and the older 2.x
publish/subscribe channel shape have separate, explicit adapters. HAR validation matches
captured URLs to the most specific path template, checks required parameters,
status and media declarations, and applies a bounded JSON Schema subset to
available JSON bodies. It emits only locations and messages: captured header
and body values never enter the report. No module exposes a request executor.
JSON uses hardened shared helpers. YAML is converted with bounded alias count
and then recursively checked for depth, node count, dangerous keys and plain
JSON values. Rendering uses React text nodes and read-only textareas. The
same-origin service worker caches only packaged application resources.
Contract checking is limited to 2,000 exchanges, 100,000 schema evaluation
steps, 5,000 diagnostics, 2 MiB per decoded JSON body, local references, and a
focused type/composition/object/array/scalar subset. It is evidence and coverage
analysis rather than a claim of complete OpenAPI conformance. AsyncAPI bindings,
traits, correlation expressions and multi-format schemas are inventoried as
inert data; only JSON-Schema-like payload/header objects receive heuristic
sample generation.
+12 -7
View File
@@ -1,12 +1,17 @@
# Privacy and security
Descriptions and exchanges remain in memory and are never transmitted or
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs are text,
not links. There is no Try It button, OAuth flow, DNS lookup, HTTP execution,
telemetry or external asset.
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs, broker
hosts, channel addresses, protocol bindings, and generated commands are text,
not live controls. There is no Try It button, broker connection, subscription,
publish action, OAuth flow, DNS lookup, HTTP execution, telemetry, or external
asset.
HAR and saved exchanges commonly contain tokens, cookies, personal data and
payloads. The summary does not render header values, yet the source editor still
contains them. Clear it before sharing. Generated examples are heuristic and
must not be treated as valid production data. Validation and compatibility
checks cover a useful subset, not every OpenAPI or JSON Schema rule.
payloads. Header and cookie values may be used transiently to check parameter
presence and shape, and JSON bodies may be parsed for local schema checks, but
the summary and contract report never retain or render those values. The source
editor still contains the original capture; clear it before sharing. Generated
examples are heuristic and must not be treated as valid production data.
Validation and compatibility checks cover a useful bounded subset, not every
OpenAPI or JSON Schema rule.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "api-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = [
"./",
"./manifest.webmanifest",
+12 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.api-tools",
"name": "API Tools",
"version": "0.1.0",
"description": "Inspect and compare API descriptions locally.",
"version": "0.2.0",
"description": "Inspect and compare HTTP and event API descriptions locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "data", "network"],
"tags": ["openapi", "swagger", "har", "schema", "http"],
"tags": ["openapi", "asyncapi", "har", "schema", "http", "events"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,15 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/json", "extensions": [".json", ".har"] },
{ "mediaType": "application/yaml", "extensions": [".yaml", ".yml"] },
{ "mediaType": "text/plain", "extensions": [".txt", ".http"] }
],
"produces": [{ "mediaType": "application/json", "extensions": [".json"] }]
},
"capabilities": { "required": [], "optional": ["workers"] },
"privacy": {
"processing": "local",
"fileUploads": true,
+7 -4
View File
@@ -31,13 +31,16 @@ export function HelpDialog({
</button>
</div>
<p>
Open JSON or YAML OpenAPI 3.0/3.1 descriptions, resolve references from
files you explicitly add, inspect operations and security schemes,
Open JSON or YAML OpenAPI 3.03.2 or AsyncAPI 2.03.1 descriptions,
resolve references from files you explicitly add, inspect path and
webhook receiver operations, channels, messages, and security schemes,
generate examples, and compare revisions.
</p>
<p>
Generated curl, Fetch and Python requests are inert text. Version 0.1
never executes HTTP, follows URLs, or resolves remote references.
Generated curl, Fetch and Python requests are inert text. Broker,
channel, and binding information is also inert. The application never
executes HTTP, connects to a broker, follows URLs, or resolves remote
references.
</p>
<p>
Validation is a focused structural review, not full conformance
+243 -24
View File
@@ -3,7 +3,12 @@ import {
stableStringify,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import { compareApis } from "../core/compare";
import { collectAsyncOperations, type AsyncOperation } from "../core/asyncapi";
import { compareApiDescriptions } from "../core/compare";
import {
validateHarAgainstOpenApi,
type HarContractReport,
} from "../core/contract";
import {
inspectHar,
inspectRawExchange,
@@ -14,7 +19,11 @@ import {
operationExample,
securityInventory,
} from "../core/operations";
import { parseApiDocument, validateOpenApi } from "../core/parse";
import {
apiDescriptionKind,
parseApiDocument,
validateApiDescription,
} from "../core/parse";
import { createWorkspace } from "../core/refs";
import type { ApiDocument, ApiOperation } from "../core/types";
@@ -114,6 +123,7 @@ function OperationList({
<span>
<strong>{operation.path}</strong>
<small>
{operation.kind === "webhook" ? "Webhook · " : ""}
{operation.summary ?? operation.operationId ?? "No summary"}
</small>
</span>
@@ -124,6 +134,36 @@ function OperationList({
);
}
function AsyncOperationList({
operations,
selected,
onSelect,
}: {
operations: AsyncOperation[];
selected?: string;
onSelect: (key: string) => void;
}) {
return (
<ul className="operation-list">
{operations.map((operation) => (
<li key={operation.key}>
<button
type="button"
aria-pressed={operation.key === selected}
onClick={() => onSelect(operation.key)}
>
<span className="method">{operation.action.toUpperCase()}</span>
<span>
<strong>{operation.address}</strong>
<small>{operation.summary ?? operation.operationId}</small>
</span>
</button>
</li>
))}
</ul>
);
}
export function Workbench() {
const initial = useMemo(() => parseApiDocument(SAMPLE, "openapi.yaml"), []);
const [source, setSource] = useState(SAMPLE);
@@ -135,20 +175,38 @@ export function Workbench() {
const [selectedKey, setSelectedKey] = useState("GET /books/{bookId}");
const [compareSource, setCompareSource] = useState(COMPARE_SAMPLE);
const [comparison, setComparison] = useState(() =>
compareApis(initial, parseApiDocument(COMPARE_SAMPLE, "comparison.yaml")),
compareApiDescriptions(
initial,
parseApiDocument(COMPARE_SAMPLE, "comparison.yaml"),
),
);
const [exchangeSource, setExchangeSource] = useState(HAR_SAMPLE);
const [exchanges, setExchanges] = useState<ExchangeSummary[]>(() =>
inspectHar(HAR_SAMPLE),
);
const [contractReport, setContractReport] = useState<
HarContractReport | undefined
>(() => validateHarAgainstOpenApi(HAR_SAMPLE, createWorkspace(initial)));
const [exchangeError, setExchangeError] = useState<string>();
const workspace = useMemo(
() => createWorkspace(entry, supporting),
[entry, supporting],
);
const problems = useMemo(() => validateOpenApi(entry), [entry]);
const operations = useMemo(() => collectOperations(entry.value), [entry]);
const descriptionKind = apiDescriptionKind(entry);
const isAsyncApi = descriptionKind === "asyncapi";
const problems = useMemo(() => validateApiDescription(entry), [entry]);
const operations = useMemo(
() =>
apiDescriptionKind(entry) === "openapi"
? collectOperations(entry.value)
: [],
[entry],
);
const asyncOperations = useMemo(
() => (isAsyncApi ? collectAsyncOperations(workspace) : []),
[isAsyncApi, workspace],
);
const visibleOperations = useMemo(() => {
const needle = query.trim().toLowerCase();
return needle
@@ -165,6 +223,23 @@ export function Workbench() {
const selected =
operations.find((operation) => operation.key === selectedKey) ??
operations[0];
const visibleAsyncOperations = useMemo(() => {
const needle = query.trim().toLowerCase();
return needle
? asyncOperations.filter((operation) =>
[
operation.key,
operation.operationId,
operation.summary,
operation.channelId,
...operation.protocols,
].some((value) => value?.toLowerCase().includes(needle)),
)
: asyncOperations;
}, [asyncOperations, query]);
const selectedAsync =
asyncOperations.find((operation) => operation.key === selectedKey) ??
asyncOperations[0];
const example = useMemo(
() => (selected ? operationExample(workspace, selected) : undefined),
[selected, workspace],
@@ -228,7 +303,10 @@ export function Workbench() {
const runComparison = () => {
try {
setComparison(
compareApis(entry, parseApiDocument(compareSource, "comparison.yaml")),
compareApiDescriptions(
entry,
parseApiDocument(compareSource, "comparison.yaml"),
),
);
setError(undefined);
} catch (reason) {
@@ -241,10 +319,14 @@ export function Workbench() {
};
const inspectExchange = () => {
try {
const value = /^\s*\{/u.test(exchangeSource)
? inspectHar(exchangeSource)
: inspectRawExchange(exchangeSource);
setExchanges(value);
if (/^\s*\{/u.test(exchangeSource) && !isAsyncApi) {
const report = validateHarAgainstOpenApi(exchangeSource, workspace);
setExchanges(report.exchanges);
setContractReport(report);
} else {
setExchanges(inspectRawExchange(exchangeSource));
setContractReport(undefined);
}
setExchangeError(undefined);
} catch (reason) {
setExchangeError(
@@ -262,9 +344,9 @@ export function Workbench() {
<p className="eyebrow">Offline API description laboratory</p>
<h1>API Tools</h1>
<p>
Navigate OpenAPI descriptions, resolve explicitly supplied local
references, derive examples and compare revisionswithout sending a
request.
Navigate OpenAPI and AsyncAPI descriptions, resolve explicitly
supplied local references, derive examples and compare
revisionswithout connecting to an API or broker.
</p>
</div>
<span className="privacy-pill">Generation only</span>
@@ -312,7 +394,7 @@ export function Workbench() {
</div>
<textarea
data-testid="source-editor"
aria-label="OpenAPI source"
aria-label="API description source"
value={source}
onChange={(event) => setSource(event.target.value)}
rows={11}
@@ -341,7 +423,8 @@ export function Workbench() {
>
{problems.filter((item) => item.level === "error").length} errors,{" "}
{problems.filter((item) => item.level === "warning").length}{" "}
warnings · {operations.length} operations.
warnings · {isAsyncApi ? asyncOperations.length : operations.length}{" "}
operations.
</p>
)}
</section>
@@ -366,7 +449,11 @@ export function Workbench() {
<aside className="panel">
<div className="panel-heading">
<h2>Operations</h2>
<span className="count-pill">{visibleOperations.length}</span>
<span className="count-pill">
{isAsyncApi
? visibleAsyncOperations.length
: visibleOperations.length}
</span>
</div>
<input
className="search"
@@ -375,18 +462,102 @@ export function Workbench() {
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{isAsyncApi ? (
<AsyncOperationList
operations={visibleAsyncOperations}
selected={selectedAsync?.key}
onSelect={setSelectedKey}
/>
) : (
<OperationList
operations={visibleOperations}
selected={selected?.key}
onSelect={setSelectedKey}
/>
)}
</aside>
<article className="panel operation-detail">
{selected && example ? (
{isAsyncApi && selectedAsync ? (
<>
<div className="panel-heading">
<div>
<p className="eyebrow">{selected.method}</p>
<p className="eyebrow">AsyncAPI · {selectedAsync.action}</p>
<h2>{selectedAsync.address}</h2>
<p>
{selectedAsync.summary ??
selectedAsync.title ??
selectedAsync.operationId}
</p>
</div>
<button
type="button"
onClick={() =>
downloadJson(selectedAsync, "asyncapi-operation.json")
}
>
Download evidence
</button>
</div>
<dl className="facts">
<div>
<dt>Operation ID</dt>
<dd>{selectedAsync.operationId}</dd>
</div>
<div>
<dt>Channel ID</dt>
<dd>{selectedAsync.channelId}</dd>
</div>
<div>
<dt>Protocols</dt>
<dd>
{selectedAsync.protocols.join(", ") || "unspecified"}
</dd>
</div>
</dl>
<h3>Messages</h3>
{selectedAsync.messages.length ? (
selectedAsync.messages.map((item) => (
<section className="message-card" key={item.id}>
<h4>{item.name ?? item.id}</h4>
<p>{item.contentType ?? "Content type not declared"}</p>
{item.headers !== undefined ? (
<>
<strong>Generated/example headers</strong>
<pre>{stableStringify(item.headers, 2)}</pre>
</>
) : null}
{item.payload !== undefined ? (
<>
<strong>Generated/example payload</strong>
<pre>{stableStringify(item.payload, 2)}</pre>
</>
) : null}
{item.notices.map((notice) => (
<p className="warning" key={notice}>
{notice}
</p>
))}
</section>
))
) : (
<p className="empty">
This operation does not expose a locally resolvable message.
</p>
)}
<p className="disclosure">
Channel bindings and broker addresses are retained as inert
description data. No connection, subscription, or publish
action is available.
</p>
</>
) : selected && example ? (
<>
<div className="panel-heading">
<div>
<p className="eyebrow">
{selected.kind === "webhook" ? "Webhook · " : ""}
{selected.method}
</p>
<h2>{selected.path}</h2>
<p>
{selected.summary ??
@@ -501,11 +672,11 @@ export function Workbench() {
<article className="panel">
<h2>Comparison revision</h2>
<p>
Paste another JSON/YAML OpenAPI description. The comparison is
operation-aware and deliberately conservative.
Paste another JSON/YAML description of the same family. The
comparison is operation-aware and deliberately conservative.
</p>
<textarea
aria-label="Comparison OpenAPI source"
aria-label="Comparison API description source"
value={compareSource}
onChange={(event) => setCompareSource(event.target.value)}
rows={20}
@@ -546,8 +717,9 @@ export function Workbench() {
)}
<p className="disclosure">
This does not fully compare schema compatibility, callbacks,
links, webhooks, extensions, serialization or behavioural
contracts.
links, protocol bindings, extensions, serialization or behavioural
contracts. OpenAPI 3.1/3.2 webhooks are inventoried and compared
as receiver operations.
</p>
</article>
</section>
@@ -559,7 +731,8 @@ export function Workbench() {
<h2>Saved exchange / HAR</h2>
<p>
Paste a HAR 1.2 JSON object or a raw request followed by ---
response ---. URLs and headers remain inert.
response ---. URLs and headers remain inert. Contract matching is
available for OpenAPI descriptions; AsyncAPI gets inventory only.
</p>
<textarea
aria-label="Saved HTTP exchange"
@@ -583,7 +756,19 @@ export function Workbench() {
</article>
<article className="panel">
<div className="panel-heading">
<div>
<h2>Exchange inventory</h2>
{contractReport ? (
<p>
{contractReport.coveredOperations}/
{contractReport.operations} operations covered ·{" "}
{contractReport.errors} errors · {contractReport.warnings}{" "}
warnings
</p>
) : (
<p>Raw exchanges receive inventory only.</p>
)}
</div>
<span className="count-pill">{exchanges.length}</span>
</div>
<div className="table-scroll" tabIndex={0}>
@@ -592,6 +777,7 @@ export function Workbench() {
<tr>
<th>Method</th>
<th>URL</th>
<th>Operation</th>
<th>Status</th>
<th>Time</th>
<th>Size</th>
@@ -602,6 +788,7 @@ export function Workbench() {
<tr key={index}>
<th>{exchange.method}</th>
<td>{exchange.url}</td>
<td>{exchange.operation ?? "unmatched"}</td>
<td>{exchange.status ?? "—"}</td>
<td>
{exchange.duration === undefined
@@ -609,15 +796,47 @@ export function Workbench() {
: `${exchange.duration} ms`}
</td>
<td>
<span>
{exchange.responseBytes === undefined
? "—"
: `${exchange.responseBytes} B`}
</span>
{exchange.errors || exchange.warnings ? (
<small>
{exchange.errors ?? 0} errors ·{" "}
{exchange.warnings ?? 0} warnings
</small>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
{contractReport?.diagnostics.length ? (
<ul
className="diagnostic-list"
aria-label="HAR contract diagnostics"
>
{contractReport.diagnostics.map((item, index) => (
<li
className={item.level}
key={`${item.exchange}-${item.location}-${index}`}
>
<code>
#{item.exchange + 1} · {item.location}
</code>
<strong>{item.message}</strong>
{item.operation ? <span>{item.operation}</span> : null}
</li>
))}
</ul>
) : contractReport ? (
<p className="success">
Captured methods, paths, parameters, status codes, media types,
and available JSON bodies satisfy the focused contract checks.
</p>
) : null}
<p className="disclosure">
Authorization and cookie values are not rendered by this summary,
but the pasted source can still contain secrets. Clear it before
+365
View File
@@ -0,0 +1,365 @@
import { generateSchemaSample, resolveLocalReference } from "./refs";
import type {
ApiDocument,
JsonObject,
JsonValue,
LocalWorkspace,
ValidationProblem,
} from "./types";
export interface AsyncMessageExample {
id: string;
name?: string;
contentType?: string;
payload?: JsonValue;
headers?: JsonValue;
notices: string[];
}
export interface AsyncOperation {
key: string;
operationId: string;
action: "send" | "receive" | "publish" | "subscribe";
channelId: string;
address: string;
title?: string;
summary?: string;
protocols: string[];
messages: AsyncMessageExample[];
}
interface ResolvedObject {
value: JsonObject;
filename: string;
key?: string;
}
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value
: undefined;
}
function resolveObject(
workspace: LocalWorkspace,
raw: JsonValue | undefined,
filename = workspace.entry,
depth = 0,
): ResolvedObject | undefined {
const value = asObject(raw);
if (!value) return undefined;
if (typeof value.$ref !== "string") return { value, filename };
if (depth > 20)
throw new RangeError("AsyncAPI reference chain exceeds 20 links.");
const resolved = resolveLocalReference(workspace, value.$ref, filename);
const object = resolveObject(
workspace,
resolved.value,
resolved.document.filename,
depth + 1,
);
return object ? { ...object, key: resolved.key } : undefined;
}
function scanReferences(root: JsonValue): string[] {
const references: string[] = [];
const stack: Array<{ value: JsonValue; depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (stack.length) {
const current = stack.pop()!;
nodes += 1;
if (nodes > 100_000 || current.depth > 64) break;
if (Array.isArray(current.value))
for (const child of current.value)
stack.push({ value: child, depth: current.depth + 1 });
else {
const object = asObject(current.value);
if (!object) continue;
for (const [key, child] of Object.entries(object))
if (key === "$ref" && typeof child === "string") references.push(child);
else stack.push({ value: child, depth: current.depth + 1 });
}
}
return references;
}
export function validateAsyncApi(document: ApiDocument): ValidationProblem[] {
const root = document.value;
const problems: ValidationProblem[] = [];
const version = typeof root.asyncapi === "string" ? root.asyncapi : undefined;
if (!version)
problems.push({
level: "error",
path: "/asyncapi",
message: "Missing AsyncAPI version string.",
});
else if (!/^(?:2\.[0-6]\.\d+|3\.[01]\.\d+)$/u.test(version))
problems.push({
level: "error",
path: "/asyncapi",
message: `Version “${version}” is outside the focused AsyncAPI 2.02.6 and 3.0/3.1 families.`,
});
const info = asObject(root.info);
if (
!info ||
typeof info.title !== "string" ||
!info.title.trim() ||
typeof info.version !== "string" ||
!info.version.trim()
)
problems.push({
level: "error",
path: "/info",
message: "AsyncAPI info needs non-empty title and version strings.",
});
const channels = asObject(root.channels);
if (!channels)
problems.push({
level: "error",
path: "/channels",
message: "Missing channels object.",
});
else if (!Object.keys(channels).length)
problems.push({
level: "warning",
path: "/channels",
message: "No channels are declared.",
});
if (/^3\./u.test(version ?? "") && !asObject(root.operations))
problems.push({
level: "warning",
path: "/operations",
message: "No root operations are declared.",
});
for (const reference of scanReferences(root))
if (
/^[a-z][a-z0-9+.-]*:/iu.test(reference) ||
reference.startsWith("//") ||
reference.startsWith("/")
)
problems.push({
level: "error",
path: "/$ref",
message: `Remote or absolute reference is prohibited: ${reference}`,
});
if (!problems.some((item) => item.level === "error"))
problems.unshift({
level: "info",
path: "/",
message: `Focused structural checks passed for AsyncAPI ${version}.`,
});
return problems.slice(0, 1_000);
}
function protocolInventory(root: JsonObject): string[] {
const servers = asObject(root.servers) ?? {};
return [
...new Set(
Object.values(servers).flatMap((raw) => {
const server = asObject(raw);
return typeof server?.protocol === "string" ? [server.protocol] : [];
}),
),
].slice(0, 100);
}
function messageExample(
workspace: LocalWorkspace,
id: string,
raw: JsonValue,
filename: string,
defaultContentType?: string,
): AsyncMessageExample {
const notices: string[] = [];
try {
const resolved = resolveObject(workspace, raw, filename);
if (!resolved)
return { id, notices: ["Message is not an object or local reference."] };
const message = resolved.value;
const examples = Array.isArray(message.examples) ? message.examples : [];
const firstExample = asObject(examples[0]);
const derive = (schema: JsonValue | undefined, label: string) => {
if (schema === undefined) return undefined;
try {
const generated = generateSchemaSample(
workspace,
schema,
resolved.filename,
);
notices.push(
...generated.notices.map((notice) => `${label}: ${notice}`),
);
return generated.value;
} catch (reason) {
notices.push(
`${label}: ${reason instanceof Error ? reason.message : "sample generation failed"}.`,
);
return undefined;
}
};
return {
id,
name: typeof message.name === "string" ? message.name : undefined,
contentType:
typeof message.contentType === "string"
? message.contentType
: defaultContentType,
payload:
firstExample?.payload ?? derive(message.payload, "Payload sample"),
headers:
firstExample?.headers ?? derive(message.headers, "Header sample"),
notices: [...new Set(notices)],
};
} catch (reason) {
return {
id,
notices: [
reason instanceof Error ? reason.message : "Message reference failed.",
],
};
}
}
function channelIdFromReference(reference: string): string {
const match = /#\/channels\/([^/]+)$/u.exec(reference);
if (!match) return reference;
try {
return decodeURIComponent(
match[1]!.replaceAll("~1", "/").replaceAll("~0", "~"),
);
} catch {
return match[1]!;
}
}
export function collectAsyncOperations(
workspace: LocalWorkspace,
): AsyncOperation[] {
const root = workspace.documents.get(workspace.entry)?.value;
if (!root) throw new ReferenceError("AsyncAPI entry document is missing.");
const version = String(root.asyncapi ?? "");
const channels = asObject(root.channels) ?? {};
const defaultContentType =
typeof root.defaultContentType === "string"
? root.defaultContentType
: undefined;
const protocols = protocolInventory(root);
const output: AsyncOperation[] = [];
if (/^3\./u.test(version)) {
const operations = asObject(root.operations) ?? {};
for (const [operationId, rawOperation] of Object.entries(operations)) {
if (output.length >= 2_000)
throw new RangeError("AsyncAPI operation count exceeds 2,000.");
try {
const resolvedOperation = resolveObject(workspace, rawOperation);
const operation = resolvedOperation?.value;
const action = operation?.action;
const channelReference = asObject(operation?.channel)?.$ref;
if (
(action !== "send" && action !== "receive") ||
typeof channelReference !== "string"
)
continue;
const resolvedChannel = resolveObject(
workspace,
operation!.channel,
resolvedOperation?.filename,
);
const channel = resolvedChannel?.value;
if (!channel) continue;
const channelId = channelIdFromReference(channelReference);
const selectedMessages = Array.isArray(operation!.messages)
? operation!.messages.map(
(raw, index) => [`message-${index + 1}`, raw] as const,
)
: Object.entries(asObject(channel.messages) ?? {});
const messages = selectedMessages
.slice(0, 100)
.map(([id, raw]) =>
messageExample(
workspace,
id,
raw,
resolvedChannel.filename,
defaultContentType,
),
);
const address =
typeof channel.address === "string"
? channel.address
: "(dynamic/unknown)";
output.push({
key: `${action.toUpperCase()} ${address} · ${operationId}`,
operationId,
action,
channelId,
address,
title:
typeof operation!.title === "string" ? operation!.title : undefined,
summary:
typeof operation!.summary === "string"
? operation!.summary
: typeof channel.summary === "string"
? channel.summary
: undefined,
protocols,
messages,
});
} catch {
// The validation report inventories blocked/unresolved references. A
// single invalid operation must not hide other usable operations.
}
}
} else {
for (const [channelId, rawChannel] of Object.entries(channels)) {
const resolvedChannel = resolveObject(workspace, rawChannel);
const channel = resolvedChannel?.value;
if (!channel) continue;
for (const action of ["publish", "subscribe"] as const) {
const operation = asObject(channel[action]);
if (!operation) continue;
if (output.length >= 2_000)
throw new RangeError("AsyncAPI operation count exceeds 2,000.");
const rawMessages = Array.isArray(operation.message)
? operation.message
: asObject(operation.message)?.oneOf &&
Array.isArray(asObject(operation.message)?.oneOf)
? (asObject(operation.message)!.oneOf as JsonValue[])
: operation.message !== undefined
? [operation.message]
: [];
const messages = rawMessages
.slice(0, 100)
.map((raw, index) =>
messageExample(
workspace,
`message-${index + 1}`,
raw,
resolvedChannel.filename,
defaultContentType,
),
);
const address = channelId;
output.push({
key: `${action.toUpperCase()} ${address}`,
operationId:
typeof operation.operationId === "string"
? operation.operationId
: `${action}-${channelId}`,
action,
channelId,
address,
summary:
typeof operation.summary === "string"
? operation.summary
: undefined,
protocols,
messages,
});
}
}
}
return output;
}
+333 -1
View File
@@ -1,4 +1,7 @@
import { collectOperations } from "./operations";
import { collectAsyncOperations } from "./asyncapi";
import { apiDescriptionKind } from "./parse";
import { createWorkspace } from "./refs";
import type { ApiDocument, ApiOperation, JsonObject, JsonValue } from "./types";
export interface ApiChange {
@@ -31,6 +34,247 @@ function responseCodes(operation: ApiOperation): Set<string> {
return new Set(Object.keys(asObject(operation.value.responses) ?? {}));
}
type SchemaDirection = "request" | "response";
function stringSet(value: JsonValue | undefined): Set<string> {
return new Set(
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [],
);
}
function schemaTypes(schema: JsonObject): Set<string> {
return new Set(
Array.isArray(schema.type)
? schema.type.filter((item): item is string => typeof item === "string")
: typeof schema.type === "string"
? [schema.type]
: [],
);
}
function schemaChanges(
before: JsonValue | undefined,
after: JsonValue | undefined,
path: string,
direction: SchemaDirection,
depth = 0,
budget = { nodes: 0 },
): ApiChange[] {
budget.nodes += 1;
if (budget.nodes > 10_000 || depth > 24)
return [
{
level: "review",
path,
message: "Schema comparison reached its bounded depth/node limit.",
},
];
const oldSchema = asObject(before),
newSchema = asObject(after);
if (!oldSchema || !newSchema)
return JSON.stringify(before) === JSON.stringify(after)
? []
: [
{
level: "review",
path,
message: "Boolean, reference, or non-object schema changed.",
},
];
if (oldSchema.$ref !== undefined || newSchema.$ref !== undefined)
return oldSchema.$ref === newSchema.$ref
? []
: [
{
level: "review",
path,
message:
"Schema reference changed; dereferenced compatibility requires review.",
},
];
const changes: ApiChange[] = [];
const oldTypes = schemaTypes(oldSchema),
newTypes = schemaTypes(newSchema);
if (
oldTypes.size &&
newTypes.size &&
[...oldTypes].some((type) => !newTypes.has(type))
)
changes.push({
level: "breaking",
path: `${path}/type`,
message:
direction === "request"
? "Request schema no longer accepts every previously accepted type."
: "Response schema no longer promises every previously declared type.",
});
const oldEnum = Array.isArray(oldSchema.enum)
? new Set(oldSchema.enum.map((item) => JSON.stringify(item)))
: undefined;
const newEnum = Array.isArray(newSchema.enum)
? new Set(newSchema.enum.map((item) => JSON.stringify(item)))
: undefined;
if (oldEnum && newEnum) {
const removed = [...oldEnum].some((item) => !newEnum.has(item));
const added = [...newEnum].some((item) => !oldEnum.has(item));
if (
(direction === "request" && removed) ||
(direction === "response" && added)
)
changes.push({
level: "breaking",
path: `${path}/enum`,
message:
direction === "request"
? "Request enum removed a previously accepted value."
: "Response enum added a value clients may not handle.",
});
if (
(direction === "request" && added) ||
(direction === "response" && removed)
)
changes.push({
level: "non-breaking",
path: `${path}/enum`,
message:
direction === "request"
? "Request enum accepts an additional value."
: "Response enum was narrowed.",
});
}
const oldRequired = stringSet(oldSchema.required),
newRequired = stringSet(newSchema.required);
const requiredRegression =
direction === "request"
? [...newRequired].filter((name) => !oldRequired.has(name))
: [...oldRequired].filter((name) => !newRequired.has(name));
for (const name of requiredRegression)
changes.push({
level: "breaking",
path: `${path}/required/${name}`,
message:
direction === "request"
? `Request property ${name} became required.`
: `Required response property ${name} is no longer promised.`,
});
const oldProperties = asObject(oldSchema.properties) ?? {};
const newProperties = asObject(newSchema.properties) ?? {};
for (const [name, oldProperty] of Object.entries(oldProperties)) {
if (!Object.hasOwn(newProperties, name)) {
if (direction === "response")
changes.push({
level: oldRequired.has(name) ? "breaking" : "review",
path: `${path}/properties/${name}`,
message: "Response property declaration was removed.",
});
continue;
}
changes.push(
...schemaChanges(
oldProperty,
newProperties[name],
`${path}/properties/${name}`,
direction,
depth + 1,
budget,
),
);
}
if (
direction === "request" &&
oldSchema.additionalProperties !== false &&
newSchema.additionalProperties === false
)
changes.push({
level: "breaking",
path: `${path}/additionalProperties`,
message: "Request schema now rejects undeclared properties.",
});
const lowerBounds = ["minimum", "minLength", "minItems"] as const;
const upperBounds = ["maximum", "maxLength", "maxItems"] as const;
for (const keyword of lowerBounds) {
const oldValue = oldSchema[keyword],
newValue = newSchema[keyword];
if (
typeof oldValue === "number" &&
typeof newValue === "number" &&
((direction === "request" && newValue > oldValue) ||
(direction === "response" && newValue < oldValue))
)
changes.push({
level: "breaking",
path: `${path}/${keyword}`,
message: `${direction === "request" ? "Request acceptance" : "Response guarantee"} narrowed from ${oldValue} to ${newValue}.`,
});
}
for (const keyword of upperBounds) {
const oldValue = oldSchema[keyword],
newValue = newSchema[keyword];
if (
typeof oldValue === "number" &&
typeof newValue === "number" &&
((direction === "request" && newValue < oldValue) ||
(direction === "response" && newValue > oldValue))
)
changes.push({
level: "breaking",
path: `${path}/${keyword}`,
message: `${direction === "request" ? "Request acceptance" : "Response guarantee"} narrowed from ${oldValue} to ${newValue}.`,
});
}
return changes;
}
function contentSchemas(
value: JsonValue | undefined,
): Map<string, JsonValue | undefined> {
const content = asObject(asObject(value)?.content) ?? {};
return new Map(
Object.entries(content).map(([mediaType, raw]) => [
mediaType,
asObject(raw)?.schema,
]),
);
}
function compareContent(
before: JsonValue | undefined,
after: JsonValue | undefined,
path: string,
direction: SchemaDirection,
): ApiChange[] {
const prior = contentSchemas(before),
next = contentSchemas(after),
changes: ApiChange[] = [];
for (const [mediaType, schema] of prior) {
if (!next.has(mediaType))
changes.push({
level: "breaking",
path: `${path}/content/${mediaType}`,
message: `${direction === "request" ? "Accepted request" : "Declared response"} media type was removed.`,
});
else
changes.push(
...schemaChanges(
schema,
next.get(mediaType),
`${path}/content/${mediaType}/schema`,
direction,
),
);
}
for (const mediaType of next.keys())
if (!prior.has(mediaType))
changes.push({
level: direction === "request" ? "non-breaking" : "review",
path: `${path}/content/${mediaType}`,
message: `${direction === "request" ? "Accepted request" : "Possible response"} media type was added.`,
});
return changes;
}
export function compareApis(
before: ApiDocument,
after: ApiDocument,
@@ -73,6 +317,14 @@ export function compareApis(
path: key,
message: "Request body became required.",
});
changes.push(
...compareContent(
operation.value.requestBody,
current.value.requestBody,
`${key}/requestBody`,
"request",
),
);
for (const code of responseCodes(operation))
if (!responseCodes(current).has(code))
changes.push({
@@ -80,9 +332,36 @@ export function compareApis(
path: key,
message: `Response ${code} was removed.`,
});
else {
const oldResponse = asObject(operation.value.responses)?.[code],
newResponse = asObject(current.value.responses)?.[code];
changes.push(
...compareContent(
oldResponse,
newResponse,
`${key}/responses/${code}`,
"response",
),
);
}
const oldSecurity = operation.value.security,
newSecurity = current.value.security;
if (
(oldSecurity === undefined ||
(Array.isArray(oldSecurity) && oldSecurity.length === 0)) &&
Array.isArray(newSecurity) &&
newSecurity.length > 0
)
changes.push({
level: "breaking",
path: `${key}/security`,
message: "Operation now requires an explicit security alternative.",
});
if (
JSON.stringify(operation.value) !== JSON.stringify(current.value) &&
!changes.some((change) => change.path === key)
!changes.some(
(change) => change.path === key || change.path.startsWith(`${key}/`),
)
)
changes.push({
level: "review",
@@ -99,3 +378,56 @@ export function compareApis(
});
return changes.slice(0, 5_000);
}
export function compareApiDescriptions(
before: ApiDocument,
after: ApiDocument,
): ApiChange[] {
const beforeKind = apiDescriptionKind(before);
const afterKind = apiDescriptionKind(after);
if (beforeKind !== afterKind)
return [
{
level: "breaking",
path: "/",
message: `Description family changed from ${beforeKind} to ${afterKind}.`,
},
];
if (beforeKind !== "asyncapi") return compareApis(before, after);
const prior = new Map(
collectAsyncOperations(createWorkspace(before)).map((operation) => [
operation.key,
operation,
]),
);
const next = new Map(
collectAsyncOperations(createWorkspace(after)).map((operation) => [
operation.key,
operation,
]),
);
const changes: ApiChange[] = [];
for (const [key, operation] of prior) {
const current = next.get(key);
if (!current)
changes.push({
level: "breaking",
path: key,
message: "Operation was removed.",
});
else if (JSON.stringify(operation) !== JSON.stringify(current))
changes.push({
level: "review",
path: key,
message: "Channel, message, protocol, or operation metadata changed.",
});
}
for (const key of next.keys())
if (!prior.has(key))
changes.push({
level: "non-breaking",
path: key,
message: "Operation was added.",
});
return changes.slice(0, 5_000);
}
+809
View File
@@ -0,0 +1,809 @@
import { assertBoundedText, safeJsonParse } from "@add-ideas/toolbox-helpers";
import { inspectHar, type ExchangeSummary } from "./har";
import { collectOperations } from "./operations";
import { resolveLocalReference } from "./refs";
import type {
ApiOperation,
JsonObject,
JsonValue,
LocalWorkspace,
} from "./types";
export type ContractLevel = "error" | "warning" | "info";
export interface ContractDiagnostic {
exchange: number;
level: ContractLevel;
operation?: string;
location: string;
message: string;
}
export interface HarContractReport {
exchanges: ExchangeSummary[];
diagnostics: ContractDiagnostic[];
operations: number;
coveredOperations: number;
unmatchedExchanges: number;
errors: number;
warnings: number;
}
interface ResolvedObject {
value: JsonObject;
filename: string;
}
interface ValidationState {
steps: number;
diagnostics: string[];
active: Set<string>;
}
const MAX_SCHEMA_STEPS = 100_000;
const MAX_DIAGNOSTICS = 5_000;
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value
: undefined;
}
function dereferenceObject(
workspace: LocalWorkspace,
value: JsonValue | undefined,
filename = workspace.entry,
depth = 0,
): ResolvedObject | undefined {
const object = asObject(value);
if (!object) return undefined;
if (typeof object.$ref !== "string") return { value: object, filename };
if (depth > 20) throw new RangeError("Reference chain exceeds 20 links.");
const resolved = resolveLocalReference(workspace, object.$ref, filename);
return dereferenceObject(
workspace,
resolved.value,
resolved.document.filename,
depth + 1,
);
}
function canonical(value: JsonValue): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(value[key]!)}`)
.join(",")}}`;
}
function instanceType(value: JsonValue): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "number" && Number.isInteger(value)) return "integer";
return typeof value;
}
function acceptsType(declared: string, value: JsonValue): boolean {
if (declared === "number") return typeof value === "number";
if (declared === "integer")
return typeof value === "number" && Number.isInteger(value);
return instanceType(value) === declared;
}
function schemaValid(
workspace: LocalWorkspace,
schema: JsonValue,
instance: JsonValue,
filename: string,
path: string,
state: ValidationState,
): boolean {
state.steps += 1;
if (state.steps > MAX_SCHEMA_STEPS)
throw new RangeError(
`Contract validation exceeded ${MAX_SCHEMA_STEPS.toLocaleString()} schema steps.`,
);
if (typeof schema === "boolean") {
if (!schema)
state.diagnostics.push(`${path}: false schema rejects the value`);
return schema;
}
const object = asObject(schema);
if (!object) return true;
let valid = true;
if (typeof object.$ref === "string") {
const resolved = resolveLocalReference(workspace, object.$ref, filename);
const key = `${resolved.key}\u0000${path}`;
if (state.active.has(key))
throw new TypeError(
`Reference cycle does not advance the value at ${path}.`,
);
state.active.add(key);
try {
valid =
schemaValid(
workspace,
resolved.value,
instance,
resolved.document.filename,
path,
state,
) && valid;
} finally {
state.active.delete(key);
}
}
const declared = Array.isArray(object.type)
? object.type.filter((item): item is string => typeof item === "string")
: typeof object.type === "string"
? [object.type]
: [];
if (
declared.length &&
!declared.some((type) => acceptsType(type, instance))
) {
state.diagnostics.push(`${path}: expected ${declared.join(" or ")}`);
valid = false;
}
if (
object.const !== undefined &&
canonical(object.const) !== canonical(instance)
) {
state.diagnostics.push(`${path}: value differs from const`);
valid = false;
}
if (
Array.isArray(object.enum) &&
!object.enum.some(
(candidate) => canonical(candidate) === canonical(instance),
)
) {
state.diagnostics.push(`${path}: value is outside enum`);
valid = false;
}
if (Array.isArray(object.allOf))
for (const child of object.allOf)
valid =
schemaValid(workspace, child, instance, filename, path, state) && valid;
for (const keyword of ["anyOf", "oneOf"] as const)
if (Array.isArray(object[keyword])) {
let matches = 0;
for (const child of object[keyword]!) {
const branch: ValidationState = {
steps: state.steps,
diagnostics: [],
active: new Set(state.active),
};
if (schemaValid(workspace, child, instance, filename, path, branch))
matches += 1;
state.steps = Math.max(state.steps, branch.steps);
}
if (
(keyword === "anyOf" && matches === 0) ||
(keyword === "oneOf" && matches !== 1)
) {
state.diagnostics.push(
`${path}: ${keyword} expected ${keyword === "oneOf" ? "exactly one" : "at least one"} matching branch`,
);
valid = false;
}
}
if (typeof instance === "string") {
const length = Array.from(instance).length;
if (typeof object.minLength === "number" && length < object.minLength) {
state.diagnostics.push(
`${path}: string is shorter than ${object.minLength}`,
);
valid = false;
}
if (typeof object.maxLength === "number" && length > object.maxLength) {
state.diagnostics.push(
`${path}: string is longer than ${object.maxLength}`,
);
valid = false;
}
}
if (typeof instance === "number") {
if (typeof object.minimum === "number" && instance < object.minimum) {
state.diagnostics.push(`${path}: number is below ${object.minimum}`);
valid = false;
}
if (typeof object.maximum === "number" && instance > object.maximum) {
state.diagnostics.push(`${path}: number exceeds ${object.maximum}`);
valid = false;
}
}
if (Array.isArray(instance)) {
if (
typeof object.minItems === "number" &&
instance.length < object.minItems
) {
state.diagnostics.push(
`${path}: array has fewer than ${object.minItems} items`,
);
valid = false;
}
if (
typeof object.maxItems === "number" &&
instance.length > object.maxItems
) {
state.diagnostics.push(
`${path}: array has more than ${object.maxItems} items`,
);
valid = false;
}
if (object.items !== undefined && !Array.isArray(object.items))
for (let index = 0; index < instance.length; index += 1)
valid =
schemaValid(
workspace,
object.items,
instance[index]!,
filename,
`${path}/${index}`,
state,
) && valid;
}
if (asObject(instance)) {
const record = instance as JsonObject;
const properties = asObject(object.properties) ?? {};
if (Array.isArray(object.required))
for (const name of object.required)
if (typeof name === "string" && !Object.hasOwn(record, name)) {
state.diagnostics.push(
`${path}: missing required property ${JSON.stringify(name)}`,
);
valid = false;
}
for (const [name, child] of Object.entries(properties))
if (Object.hasOwn(record, name))
valid =
schemaValid(
workspace,
child,
record[name]!,
filename,
`${path}/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`,
state,
) && valid;
if (object.additionalProperties === false)
for (const name of Object.keys(record))
if (!Object.hasOwn(properties, name)) {
state.diagnostics.push(
`${path}: unexpected property ${JSON.stringify(name)}`,
);
valid = false;
}
}
return valid;
}
function validateValue(
workspace: LocalWorkspace,
schema: JsonValue,
value: JsonValue,
filename: string,
path: string,
): string[] {
const state: ValidationState = {
steps: 0,
diagnostics: [],
active: new Set(),
};
schemaValid(workspace, schema, value, filename, path, state);
return state.diagnostics.slice(0, 100);
}
function headerMap(value: JsonValue | undefined): Map<string, string[]> {
const output = new Map<string, string[]>();
if (!Array.isArray(value)) return output;
for (const raw of value) {
const header = asObject(raw);
if (typeof header?.name !== "string" || typeof header.value !== "string")
continue;
const name = header.name.toLowerCase();
output.set(name, [...(output.get(name) ?? []), header.value]);
}
return output;
}
function cookieMap(headers: Map<string, string[]>): Map<string, string[]> {
const output = new Map<string, string[]>();
for (const line of headers.get("cookie") ?? [])
for (const part of line.split(";")) {
const index = part.indexOf("=");
if (index <= 0) continue;
const name = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
output.set(name, [...(output.get(name) ?? []), value]);
}
return output;
}
function decodedSegments(pathname: string): string[] {
return pathname
.split("/")
.filter(Boolean)
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
}
function matchOperation(
operations: readonly ApiOperation[],
method: string,
url: URL,
serverBasePaths: readonly string[],
): { operation: ApiOperation; pathValues: Map<string, string> } | undefined {
const candidatePaths = [
url.pathname,
...serverBasePaths
.filter(
(base) =>
base !== "/" &&
(url.pathname === base || url.pathname.startsWith(`${base}/`)),
)
.map((base) => url.pathname.slice(base.length) || "/"),
];
const matches: Array<{
operation: ApiOperation;
pathValues: Map<string, string>;
literals: number;
}> = [];
for (const operation of operations) {
if (
operation.method !== method &&
operation.method !== method.toUpperCase()
)
continue;
const expected = decodedSegments(operation.path);
for (const candidatePath of candidatePaths) {
const actual = decodedSegments(candidatePath);
if (expected.length !== actual.length) continue;
const pathValues = new Map<string, string>();
let literals = 0;
let valid = true;
for (let index = 0; index < expected.length; index += 1) {
const template = /^\{([^{}]+)\}$/u.exec(expected[index]!);
if (template) pathValues.set(template[1]!, actual[index]!);
else if (expected[index] === actual[index]) literals += 1;
else {
valid = false;
break;
}
}
if (valid) matches.push({ operation, pathValues, literals });
}
}
matches.sort((left, right) => right.literals - left.literals);
return matches[0];
}
function parameterValue(
location: string,
name: string,
pathValues: Map<string, string>,
url: URL,
headers: Map<string, string[]>,
cookies: Map<string, string[]>,
): string[] {
if (location === "path")
return pathValues.has(name) ? [pathValues.get(name)!] : [];
if (location === "query") return url.searchParams.getAll(name);
if (location === "querystring")
return url.search.length ? [url.search.slice(1)] : [];
if (location === "header") return headers.get(name.toLowerCase()) ?? [];
if (location === "cookie") return cookies.get(name) ?? [];
return [];
}
function coerceParameter(values: string[], schema: JsonObject): JsonValue {
const declared = schema.type;
if (declared === "array") {
const items = values.flatMap((value) => value.split(","));
const itemSchema = asObject(schema.items) ?? {};
return items.map((value) => coerceParameter([value], itemSchema));
}
const value = values[0] ?? "";
if (declared === "integer" && /^-?(?:0|[1-9]\d*)$/u.test(value))
return Number(value);
if (declared === "number" && value.trim() && Number.isFinite(Number(value)))
return Number(value);
if (declared === "boolean" && /^(?:true|false)$/u.test(value))
return value === "true";
return value;
}
function mediaType(value: string | undefined): string {
return (value ?? "").split(";", 1)[0]!.trim().toLowerCase();
}
function contentSchema(
workspace: LocalWorkspace,
container: JsonObject | undefined,
actualType: string,
filename: string,
): { schema?: JsonValue; filename: string; declared?: string } {
const content = asObject(container?.content);
if (!content) return { filename };
const keys = Object.keys(content);
const declared =
keys.find((key) => mediaType(key) === actualType) ??
keys.find((key) => key === "*/*") ??
keys.find(
(key) => key.endsWith("/*") && actualType.startsWith(key.slice(0, -1)),
) ??
keys.find(
(key) => key === "application/json" && /[/+]json$/u.test(actualType),
);
if (!declared) return { filename };
const resolved = dereferenceObject(workspace, content[declared], filename);
return {
schema: resolved?.value.schema,
filename: resolved?.filename ?? filename,
declared,
};
}
function parseJsonBody(
value: JsonValue | undefined,
label: string,
): JsonValue | undefined {
const body = asObject(value);
if (!body || typeof body.text !== "string") return undefined;
if (body.encoding === "base64") {
if (body.text.length > 4 * 1024 * 1024)
throw new RangeError(`${label} base64 body exceeds 4 MiB.`);
const binary = atob(body.text);
const bytes = Uint8Array.from(binary, (character) =>
character.charCodeAt(0),
);
return safeJsonParse(new TextDecoder().decode(bytes), {
maxTextChars: 2 * 1024 * 1024,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
}) as JsonValue;
}
return safeJsonParse(assertBoundedText(body.text, 2 * 1024 * 1024, label), {
maxTextChars: 2 * 1024 * 1024,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
}) as JsonValue;
}
export function validateHarAgainstOpenApi(
source: string,
workspace: LocalWorkspace,
): HarContractReport {
const exchanges = inspectHar(source);
const parsed = safeJsonParse(
assertBoundedText(source, 8 * 1024 * 1024, "HAR length"),
{
maxTextChars: 8 * 1024 * 1024,
maxDepth: 64,
maxNodes: 200_000,
rejectDangerousKeys: true,
},
) as JsonValue;
const entries = asObject(asObject(parsed)?.log)?.entries;
if (!Array.isArray(entries)) throw new TypeError("Expected HAR log entries.");
const root = workspace.documents.get(workspace.entry)?.value;
if (!root) throw new ReferenceError("OpenAPI entry document is unavailable.");
const operations = collectOperations(root);
const requestOperations = operations.filter(
(operation) => operation.kind === "path",
);
const serverBasePaths = Array.isArray(root.servers)
? root.servers.map(asObject).flatMap((server) => {
if (typeof server?.url !== "string" || server.url.includes("{"))
return [];
try {
return [
new URL(server.url, "https://local.invalid").pathname.replace(
/\/$/u,
"",
) || "/",
];
} catch {
return [];
}
})
: [];
const diagnostics: ContractDiagnostic[] = [];
const covered = new Set<string>();
let unmatchedExchanges = 0;
const add = (item: ContractDiagnostic) => {
if (diagnostics.length < MAX_DIAGNOSTICS) diagnostics.push(item);
};
entries.forEach((raw, exchange) => {
const entry = asObject(raw);
const request = asObject(entry?.request);
const response = asObject(entry?.response);
if (typeof request?.method !== "string" || typeof request.url !== "string")
return;
let url: URL;
try {
url = new URL(request.url, "https://local.invalid");
} catch {
add({
exchange,
level: "error",
location: "request.url",
message: "URL is invalid.",
});
unmatchedExchanges += 1;
return;
}
const match = matchOperation(
requestOperations,
request.method,
url,
serverBasePaths,
);
if (!match) {
add({
exchange,
level: "error",
location: "request",
message: `${request.method.toUpperCase()} ${url.pathname} does not match a declared operation.`,
});
unmatchedExchanges += 1;
return;
}
const { operation, pathValues } = match;
covered.add(operation.key);
exchanges[exchange] = { ...exchanges[exchange]!, operation: operation.key };
const headers = headerMap(request.headers);
const cookies = cookieMap(headers);
const parameters = [
...operation.inheritedParameters,
...(Array.isArray(operation.value.parameters)
? operation.value.parameters
: []),
];
for (const rawParameter of parameters) {
let resolved: ResolvedObject | undefined;
try {
resolved = dereferenceObject(workspace, rawParameter);
} catch (reason) {
add({
exchange,
level: "error",
operation: operation.key,
location: "parameter",
message:
reason instanceof Error
? reason.message
: "Parameter reference failed.",
});
continue;
}
const parameter = resolved?.value;
if (
!parameter ||
typeof parameter.name !== "string" ||
typeof parameter.in !== "string"
)
continue;
const values = parameterValue(
parameter.in,
parameter.name,
pathValues,
url,
headers,
cookies,
);
if (!values.length && parameter.required === true) {
add({
exchange,
level: "error",
operation: operation.key,
location: `${parameter.in}.${parameter.name}`,
message: "Required parameter is missing.",
});
} else if (values.length && parameter.schema !== undefined) {
const schema = asObject(parameter.schema) ?? parameter.schema;
for (const message of validateValue(
workspace,
schema,
coerceParameter(values, asObject(schema) ?? {}),
resolved?.filename ?? workspace.entry,
`${parameter.in}.${parameter.name}`,
))
add({
exchange,
level: "error",
operation: operation.key,
location: `${parameter.in}.${parameter.name}`,
message,
});
}
}
let requestBody: ResolvedObject | undefined;
try {
requestBody = dereferenceObject(workspace, operation.value.requestBody);
} catch (reason) {
add({
exchange,
level: "error",
operation: operation.key,
location: "request.body",
message:
reason instanceof Error
? reason.message
: "Request-body reference failed.",
});
}
const postData = asObject(request.postData);
if (
requestBody?.value.required === true &&
typeof postData?.text !== "string"
)
add({
exchange,
level: "error",
operation: operation.key,
location: "request.body",
message: "Required request body is missing.",
});
if (postData && requestBody) {
const actualType = mediaType(
typeof postData.mimeType === "string"
? postData.mimeType
: headers.get("content-type")?.[0],
);
const selected = contentSchema(
workspace,
requestBody.value,
actualType,
requestBody.filename,
);
if (!selected.declared)
add({
exchange,
level: "warning",
operation: operation.key,
location: "request.content-type",
message: `Captured media type ${actualType || "(missing)"} is not declared.`,
});
else if (selected.schema !== undefined && /[/+]json$/u.test(actualType))
try {
const body = parseJsonBody(postData, "HAR request JSON body");
if (body !== undefined)
for (const message of validateValue(
workspace,
selected.schema,
body,
selected.filename,
"request.body",
))
add({
exchange,
level: "error",
operation: operation.key,
location: "request.body",
message,
});
} catch (reason) {
add({
exchange,
level: "error",
operation: operation.key,
location: "request.body",
message:
reason instanceof Error
? reason.message
: "Request JSON could not be parsed.",
});
}
}
const status =
typeof response?.status === "number" ? String(response.status) : "";
const responses = asObject(operation.value.responses) ?? {};
const responseKey =
(status && Object.hasOwn(responses, status) ? status : undefined) ??
(status && Object.hasOwn(responses, `${status[0]}XX`)
? `${status[0]}XX`
: undefined) ??
(Object.hasOwn(responses, "default") ? "default" : undefined);
if (!responseKey) {
add({
exchange,
level: "error",
operation: operation.key,
location: "response.status",
message: `Captured status ${status || "(missing)"} is not declared.`,
});
} else {
try {
const responseObject = dereferenceObject(
workspace,
responses[responseKey],
);
const content = asObject(response?.content);
const responseHeaders = headerMap(response?.headers);
const actualType = mediaType(
typeof content?.mimeType === "string"
? content.mimeType
: responseHeaders.get("content-type")?.[0],
);
if (responseObject && content && typeof content.text === "string") {
const selected = contentSchema(
workspace,
responseObject.value,
actualType,
responseObject.filename,
);
if (!selected.declared)
add({
exchange,
level: "warning",
operation: operation.key,
location: "response.content-type",
message: `Captured media type ${actualType || "(missing)"} is not declared for status ${responseKey}.`,
});
else if (
selected.schema !== undefined &&
/[/+]json$/u.test(actualType)
) {
const body = parseJsonBody(content, "HAR response JSON body");
if (body !== undefined)
for (const message of validateValue(
workspace,
selected.schema,
body,
selected.filename,
"response.body",
))
add({
exchange,
level: "error",
operation: operation.key,
location: "response.body",
message,
});
}
}
} catch (reason) {
add({
exchange,
level: "error",
operation: operation.key,
location: "response",
message:
reason instanceof Error
? reason.message
: "Response contract could not be inspected.",
});
}
}
});
for (let index = 0; index < exchanges.length; index += 1) {
const related = diagnostics.filter((item) => item.exchange === index);
exchanges[index] = {
...exchanges[index]!,
errors: related.filter((item) => item.level === "error").length,
warnings: related.filter((item) => item.level === "warning").length,
};
}
return {
exchanges,
diagnostics,
operations: requestOperations.length,
coveredOperations: covered.size,
unmatchedExchanges,
errors: diagnostics.filter((item) => item.level === "error").length,
warnings: diagnostics.filter((item) => item.level === "warning").length,
};
}
+3
View File
@@ -16,6 +16,9 @@ export interface ExchangeSummary {
requestBytes?: number;
responseBytes?: number;
mimeType?: string;
operation?: string;
errors?: number;
warnings?: number;
}
export function inspectHar(source: string): ExchangeSummary[] {
+66 -5
View File
@@ -16,6 +16,7 @@ const METHODS = [
"head",
"patch",
"trace",
"query",
] as const;
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
@@ -26,9 +27,13 @@ function asObject(value: JsonValue | undefined): JsonObject | undefined {
export function collectOperations(root: JsonObject): ApiOperation[] {
const paths = asObject(root.paths) ?? {};
const result: ApiOperation[] = [];
for (const [path, pathValue] of Object.entries(paths)) {
const collectPathItem = (
path: string,
pathValue: JsonValue,
kind: ApiOperation["kind"],
) => {
const pathItem = asObject(pathValue);
if (!pathItem || !path.startsWith("/")) continue;
if (!pathItem || (kind === "path" && !path.startsWith("/"))) return;
const inheritedParameters = Array.isArray(pathItem.parameters)
? pathItem.parameters
: [];
@@ -36,9 +41,13 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
const value = asObject(pathItem[method]);
if (!value) continue;
result.push({
key: `${method.toUpperCase()} ${path}`,
key:
kind === "webhook"
? `WEBHOOK ${path} ${method.toUpperCase()}`
: `${method.toUpperCase()} ${path}`,
method: method.toUpperCase(),
path,
kind,
operationId:
typeof value.operationId === "string" ? value.operationId : undefined,
summary: typeof value.summary === "string" ? value.summary : undefined,
@@ -51,6 +60,49 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
if (result.length > 2_000)
throw new RangeError("Operation count exceeds 2,000");
}
if (/^3\.2\./u.test(String(root.openapi ?? ""))) {
const additional = asObject(pathItem.additionalOperations) ?? {};
for (const [method, rawValue] of Object.entries(additional)) {
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/u.test(method)) continue;
if (
METHODS.some((known) => known.toUpperCase() === method.toUpperCase())
)
continue;
const value = asObject(rawValue);
if (!value) continue;
result.push({
key:
kind === "webhook"
? `WEBHOOK ${path} ${method}`
: `${method} ${path}`,
method,
path,
kind,
operationId:
typeof value.operationId === "string"
? value.operationId
: undefined,
summary:
typeof value.summary === "string" ? value.summary : undefined,
tags: Array.isArray(value.tags)
? value.tags.filter((tag): tag is string => typeof tag === "string")
: [],
value,
inheritedParameters,
});
if (result.length > 2_000)
throw new RangeError("Operation count exceeds 2,000");
}
}
};
for (const [path, pathValue] of Object.entries(paths)) {
collectPathItem(path, pathValue, "path");
}
if (/^3\.(?:1|2)\./u.test(String(root.openapi ?? ""))) {
const webhooks = asObject(root.webhooks) ?? {};
for (const [name, pathValue] of Object.entries(webhooks)) {
collectPathItem(name, pathValue, "webhook");
}
}
return result;
}
@@ -123,14 +175,23 @@ export function operationExample(
? asObject(root.servers[0])
: undefined;
const base =
typeof server?.url === "string" && !server.url.includes("{")
operation.kind === "webhook"
? "https://receiver.example.test"
: typeof server?.url === "string" && !server.url.includes("{")
? server.url.replace(/\/$/u, "")
: "https://api.example.test";
if (base !== "https://api.example.test")
notices.push(
"The declared server URL is reproduced as inert text; it is not contacted.",
);
let path = operation.path;
let path =
operation.kind === "webhook"
? `/webhooks/${encodeURIComponent(operation.path)}`
: operation.path;
if (operation.kind === "webhook")
notices.push(
"Webhook receiver URL is a local example placeholder because OpenAPI webhook names do not declare a delivery URL.",
);
const parameters = [
...operation.inheritedParameters,
...(Array.isArray(operation.value.parameters)
+58 -2
View File
@@ -6,6 +6,7 @@ import type {
JsonValue,
ValidationProblem,
} from "./types";
import { validateAsyncApi } from "./asyncapi";
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const MAX_SOURCE = 4 * 1024 * 1024;
@@ -121,11 +122,11 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
path: "/openapi",
message: "Missing OpenAPI version string.",
});
else if (!/^3\.(?:0|1)\.\d+(?:[-+].*)?$/u.test(version))
else if (!/^3\.(?:0|1|2)\.\d+(?:[-+].*)?$/u.test(version))
problems.push({
level: "error",
path: "/openapi",
message: `Version “${version}” is outside the supported OpenAPI 3.0/3.1 families.`,
message: `Version “${version}” is outside the supported OpenAPI 3.0/3.1/3.2 families.`,
});
const info = object(root.info);
if (!info)
@@ -167,6 +168,45 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
path: "/swagger",
message: "Swagger 2.0 fields are not interpreted by this OpenAPI 3 tool.",
});
if (/^3\.2\./u.test(version ?? "") && paths)
for (const [path, rawPathItem] of Object.entries(paths)) {
const additional = object(object(rawPathItem)?.additionalOperations);
if (!additional) continue;
for (const [method, operation] of Object.entries(additional)) {
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/u.test(method))
problems.push({
level: "error",
path: `/paths/${path}/additionalOperations/${method}`,
message:
"Additional-operation key is not a valid bounded HTTP method token.",
});
else if (
[
"GET",
"PUT",
"POST",
"DELETE",
"OPTIONS",
"HEAD",
"PATCH",
"TRACE",
"QUERY",
].includes(method.toUpperCase())
)
problems.push({
level: "error",
path: `/paths/${path}/additionalOperations/${method}`,
message:
"A fixed Path Item operation must use its dedicated field.",
});
else if (!object(operation))
problems.push({
level: "error",
path: `/paths/${path}/additionalOperations/${method}`,
message: "Additional operation must be an object.",
});
}
}
const refs: string[] = [];
const visit = (value: JsonValue, path: string, depth: number) => {
if (depth > 64) return;
@@ -200,3 +240,19 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
});
return problems.slice(0, 1_000);
}
export function apiDescriptionKind(
document: ApiDocument,
): "openapi" | "asyncapi" | "unknown" {
if (typeof document.value.openapi === "string") return "openapi";
if (typeof document.value.asyncapi === "string") return "asyncapi";
return "unknown";
}
export function validateApiDescription(
document: ApiDocument,
): ValidationProblem[] {
return apiDescriptionKind(document) === "asyncapi"
? validateAsyncApi(document)
: validateOpenApi(document);
}
+1 -1
View File
@@ -139,7 +139,7 @@ export function generateSchemaSample(
active.delete(resolved.key);
}
}
for (const key of ["example", "default"] as const)
for (const key of ["example", "default", "const"] as const)
if (object[key] !== undefined) return object[key]!;
if (Array.isArray(object.enum) && object.enum.length)
return object.enum[0]!;
+1
View File
@@ -21,6 +21,7 @@ export interface ApiOperation {
key: string;
method: string;
path: string;
kind: "path" | "webhook";
operationId?: string;
summary?: string;
tags: string[];
+17
View File
@@ -504,6 +504,9 @@ iframe {
.diagnostic-list li.warning {
border-left-color: var(--mail-warning);
}
.diagnostic-list li.error {
border-left-color: var(--toolbox-danger);
}
.diagnostic-list code {
color: var(--toolbox-muted);
font-size: 0.7rem;
@@ -557,6 +560,20 @@ iframe {
.top-gap {
margin-top: 0.7rem;
}
.message-card {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface-soft);
}
.message-card h4,
.message-card p,
.message-card pre {
margin: 0;
}
.report-preview {
min-height: 12rem;
}
+29 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.api-tools",
"name": "API Tools",
"version": "0.1.0",
"description": "Inspect and compare API descriptions locally.",
"version": "0.2.0",
"description": "Inspect and compare HTTP and event API descriptions locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "data", "network"],
"tags": ["openapi", "swagger", "har", "schema", "http"],
"tags": ["openapi", "asyncapi", "har", "schema", "http", "events"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,32 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/json",
"extensions": [".json", ".har"]
},
{
"mediaType": "application/yaml",
"extensions": [".yaml", ".yml"]
},
{
"mediaType": "text/plain",
"extensions": [".txt", ".http"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": ["workers"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+2 -2
View File
@@ -69,7 +69,7 @@ test("retains a valid model, compares revisions and inspects HAR", async ({
page,
}) => {
await page.goto("/deep/nested/api/");
await page.getByLabel("OpenAPI source").fill('not: "unterminated');
await page.getByLabel("API description source").fill('not: "unterminated');
await page.getByRole("button", { name: "Analyse description" }).click();
await expect(page.getByRole("alert")).toContainText("last successful");
await expect(
@@ -124,7 +124,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/api/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.api-tools",
version: "0.1.0",
version: "0.2.0",
privacy: { processing: "local", telemetry: false },
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/api/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+33 -3
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Workbench } from "../../src/components/Workbench";
@@ -12,9 +12,9 @@ describe("API workbench", () => {
).toBeInTheDocument();
expect(screen.getAllByText("GET", { exact: true })).toHaveLength(2);
expect(screen.getByText(/curl --request GET/iu)).toBeInTheDocument();
await user.clear(screen.getByLabelText("OpenAPI source"));
await user.clear(screen.getByLabelText("API description source"));
await user.type(
screen.getByLabelText("OpenAPI source"),
screen.getByLabelText("API description source"),
'not: "unterminated',
);
await user.click(
@@ -26,6 +26,36 @@ describe("API workbench", () => {
).toHaveLength(3);
});
it("opens AsyncAPI operations and derives local message examples", async () => {
const user = userEvent.setup();
render(<Workbench />);
fireEvent.change(screen.getByLabelText("API description source"), {
target: {
value: `asyncapi: 3.1.0
info: { title: Events, version: '1' }
defaultContentType: application/json
channels:
users:
address: users.signed-up
messages:
signedUp: { payload: { type: object, properties: { id: { const: user-1 } } } }
operations:
emit:
action: send
channel: { $ref: '#/channels/users' }
`,
},
});
await user.click(
screen.getByRole("button", { name: "Analyse description" }),
);
expect(screen.getAllByText("SEND", { exact: true })).not.toHaveLength(0);
expect(
screen.getAllByText("users.signed-up", { exact: true }),
).toHaveLength(2);
expect(screen.getAllByText(/user-1/iu)).toHaveLength(2);
});
it("shows security, comparison and exchange workspaces", async () => {
const user = userEvent.setup();
render(<Workbench />);
+171 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { compareApis } from "../../src/core/compare";
import { compareApiDescriptions, compareApis } from "../../src/core/compare";
import { validateHarAgainstOpenApi } from "../../src/core/contract";
import { inspectHar, inspectRawExchange } from "../../src/core/har";
import {
collectOperations,
@@ -38,6 +39,46 @@ describe("operation derivation, comparisons and saved exchanges", () => {
]);
});
it("inventories and compares OpenAPI 3.1 webhook receiver operations", () => {
const document = parseApiDocument(`openapi: 3.1.0
info: { title: Hooks, version: '1' }
paths: {}
webhooks:
orderChanged:
post:
operationId: receiveOrderChange
requestBody:
content:
application/json:
schema: { type: object, properties: { id: { type: string } } }
responses: { '204': { description: accepted } }
`);
const operations = collectOperations(document.value);
expect(operations).toEqual([
expect.objectContaining({
key: "WEBHOOK orderChanged POST",
kind: "webhook",
method: "POST",
path: "orderChanged",
}),
]);
const example = operationExample(createWorkspace(document), operations[0]!);
expect(example.url).toBe(
"https://receiver.example.test/webhooks/orderChanged",
);
expect(example.notices.join(" ")).toMatch(/placeholder/iu);
const removed = parseApiDocument(
"openapi: 3.1.0\ninfo: {title: Hooks, version: '2'}\npaths: {}\n",
);
expect(compareApis(document, removed)).toContainEqual(
expect.objectContaining({
level: "breaking",
path: "WEBHOOK orderChanged POST",
}),
);
});
it("reports a missing local reference without aborting generation", () => {
const document = parseApiDocument(
"openapi: 3.1.0\ninfo: {title: Missing, version: '1'}\npaths: {/x: {get: {responses: {'200': {description: ok, content: {application/json: {schema: {$ref: 'missing.yaml#/Result'}}}}}}}}",
@@ -66,6 +107,90 @@ describe("operation derivation, comparisons and saved exchanges", () => {
);
});
it("reports directional request and response schema regressions", () => {
const before = parseApiDocument(`openapi: 3.1.0
info: { title: Contract, version: '1' }
paths:
/items:
post:
requestBody:
content:
application/json:
schema: { type: object, properties: { kind: { type: string, enum: [a, b] } } }
responses:
'200':
description: ok
content:
application/json:
schema: { type: object, required: [id], properties: { id: { type: string } } }
`);
const after = parseApiDocument(`openapi: 3.1.0
info: { title: Contract, version: '2' }
paths:
/items:
post:
requestBody:
content:
application/json:
schema: { type: object, required: [kind], properties: { kind: { type: string, enum: [a] } } }
responses:
'200':
description: ok
content:
application/json:
schema: { type: object, properties: {} }
`);
const changes = compareApis(before, after);
expect(changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("Request enum removed"),
}),
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("became required"),
}),
expect.objectContaining({
level: "breaking",
message: expect.stringContaining("Required response property"),
}),
]),
);
});
it("collects OpenAPI 3.2 QUERY and custom methods", () => {
const document = parseApiDocument(
`openapi: 3.2.0
info: { title: Extended, version: '1' }
paths:
/items:
query: { responses: { '200': { description: ok } } }
additionalOperations:
COPY: { responses: { '204': { description: copied } } }
`,
);
expect(collectOperations(document.value).map((item) => item.key)).toEqual([
"QUERY /items",
"COPY /items",
]);
});
it("compares AsyncAPI operations without treating them as HTTP paths", () => {
const before = parseApiDocument(
"asyncapi: 3.1.0\ninfo: {title: E, version: '1'}\nchannels: { c: {address: events, messages: {}} }\noperations: { send: {action: send, channel: {$ref: '#/channels/c'}} }",
);
const after = parseApiDocument(
"asyncapi: 3.1.0\ninfo: {title: E, version: '2'}\nchannels: { c: {address: events, messages: {}} }\noperations: {}",
);
expect(compareApiDescriptions(before, after)).toContainEqual(
expect.objectContaining({
level: "breaking",
message: "Operation was removed.",
}),
);
});
it("summarizes HAR and raw exchanges without exposing headers", () => {
const har = inspectHar(
'{"log":{"entries":[{"time":7,"request":{"method":"POST","url":"https://example.test/x","headers":[{"name":"Authorization","value":"secret"}],"postData":{"text":"abc"}},"response":{"status":201,"headers":[],"content":{"size":9,"mimeType":"application/json"}}}]}}',
@@ -84,4 +209,49 @@ describe("operation derivation, comparisons and saved exchanges", () => {
)[0],
).toMatchObject({ method: "GET", url: "/x", status: 204 });
});
it("matches HAR exchanges to operations and validates captured response JSON", () => {
const document = parseApiDocument(SPEC, "openapi.yaml");
const report = validateHarAgainstOpenApi(
JSON.stringify({
log: {
entries: [
{
request: {
method: "GET",
url: "https://api.example.test/items/item-1",
headers: [
{ name: "Authorization", value: "Bearer do-not-report" },
],
},
response: {
status: 200,
headers: [],
content: {
mimeType: "application/json",
text: JSON.stringify({ id: 42 }),
},
},
},
{
request: {
method: "POST",
url: "https://api.example.test/not-declared",
headers: [],
},
response: { status: 404, headers: [], content: {} },
},
],
},
}),
createWorkspace(document),
);
expect(report.coveredOperations).toBe(1);
expect(report.unmatchedExchanges).toBe(1);
expect(report.exchanges[0]?.operation).toBe("GET /items/{id}");
expect(report.diagnostics.map((item) => item.message).join("\n")).toMatch(
/expected string|does not match a declared operation/iu,
);
expect(JSON.stringify(report)).not.toContain("do-not-report");
});
});
+87 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseApiDocument, validateOpenApi } from "../../src/core/parse";
import { collectAsyncOperations } from "../../src/core/asyncapi";
import {
parseApiDocument,
validateApiDescription,
validateOpenApi,
} from "../../src/core/parse";
import {
createWorkspace,
generateSchemaSample,
@@ -23,6 +28,23 @@ describe("OpenAPI parsing and local references", () => {
);
});
it("recognizes OpenAPI 3.2 QUERY and bounded additional operations", () => {
const document = parseApiDocument(
`openapi: 3.2.0
info: { title: Extended, version: '1' }
paths:
/search:
query: { responses: { '200': { description: ok } } }
additionalOperations:
COPY: { responses: { '204': { description: copied } } }
`,
"extended.yaml",
);
expect(
validateOpenApi(document).some((item) => item.level === "error"),
).toBe(false);
});
it("rejects dangerous YAML keys and alias expansion", () => {
expect(() =>
parseApiDocument("__proto__: { polluted: true }", "bad.yaml"),
@@ -72,4 +94,68 @@ describe("OpenAPI parsing and local references", () => {
});
expect(sample.notices[0]).toMatch(/cycle/iu);
});
it("inventories AsyncAPI 3.1 channels, operations, and local message samples", () => {
const document = parseApiDocument(
`asyncapi: 3.1.0
info: { title: Events, version: '1' }
defaultContentType: application/json
servers:
broker: { host: broker.example.test, protocol: mqtt }
channels:
signedUp:
address: users.signed-up
messages:
user: { $ref: '#/components/messages/User' }
operations:
sendUser:
action: send
channel: { $ref: '#/channels/signedUp' }
components:
messages:
User:
name: UserSignedUp
payload:
type: object
properties: { id: { type: string, example: user-1 } }
`,
"asyncapi.yaml",
);
expect(
validateApiDescription(document).some((item) => item.level === "error"),
).toBe(false);
expect(collectAsyncOperations(createWorkspace(document))).toEqual([
expect.objectContaining({
operationId: "sendUser",
action: "send",
address: "users.signed-up",
protocols: ["mqtt"],
messages: [
expect.objectContaining({
name: "UserSignedUp",
payload: { id: "user-1" },
}),
],
}),
]);
});
it("retains focused AsyncAPI 2.x publish/subscribe support", () => {
const document = parseApiDocument(
`asyncapi: 2.6.0
info: { title: Legacy events, version: '1' }
channels:
users:
publish:
operationId: publishUser
message: { payload: { type: string, example: hello } }
`,
);
expect(collectAsyncOperations(createWorkspace(document))[0]).toMatchObject({
action: "publish",
address: "users",
operationId: "publishUser",
messages: [{ payload: "hello" }],
});
});
});