@@ -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
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial local-only OpenAPI 3.0/3.1 inspection release.
|
- Initial local-only OpenAPI 3.0/3.1 inspection release.
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
# API Tools
|
# API Tools
|
||||||
|
|
||||||
API Tools is a local-first OpenAPI workbench. Version 0.1.0 parses bounded JSON
|
API Tools is a local-first HTTP and event API workbench. It parses bounded JSON
|
||||||
or YAML OpenAPI 3.0/3.1 descriptions, navigates operations, resolves `$ref`
|
or YAML OpenAPI 3.0–3.2 and AsyncAPI 2.0–3.1 descriptions, navigates path and
|
||||||
values from explicitly supplied local files, inventories security schemes,
|
webhook receiver operations, channels, and messages, resolves `$ref` values
|
||||||
generates schema/request/response examples and inert client command text,
|
from explicitly supplied local files, inventories security schemes, generates
|
||||||
compares revisions, and summarizes saved HTTP exchanges or HAR 1.2 files.
|
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
|
It never executes HTTP, connects or subscribes to a broker, follows a URL,
|
||||||
credential or loads an external asset. Generated curl, Fetch and Python snippets
|
resolves a remote reference, stores a credential, or loads an external asset.
|
||||||
are text for review and may reproduce untrusted description values safely
|
Generated curl, Fetch and Python snippets are text for review and may reproduce
|
||||||
quoted for their target syntax.
|
untrusted description values safely quoted for their target syntax.
|
||||||
|
|
||||||
## Development and release
|
## Development and release
|
||||||
|
|
||||||
@@ -22,20 +24,26 @@ npm run test:browser
|
|||||||
npm run release:artifact
|
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/`.
|
SHA-256 sidecar. Relative assets are tested at `/deep/nested/api/`.
|
||||||
|
|
||||||
## v0.1 boundary
|
## v0.1 boundary
|
||||||
|
|
||||||
- JSON/YAML files up to 4 MiB each, 20 files/16 MiB per selected workspace,
|
- 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;
|
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;
|
- fragment/relative-file JSON Pointer resolution with missing/cycle reports;
|
||||||
- bounded sample generation for common schema composition and scalar formats;
|
- bounded sample generation for common schema composition and scalar formats;
|
||||||
- path/query/request/response examples and inert curl/Fetch/Python output;
|
- path/query/request/response examples and inert curl/Fetch/Python output;
|
||||||
- security scheme inventory, conservative operation compatibility report;
|
- 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
|
This is not a complete OpenAPI, AsyncAPI, or JSON Schema validator, code
|
||||||
client, proxy or security scanner. See the documented limitations and review
|
generator, API client, broker client, proxy, or security scanner. See the
|
||||||
generated values before use. Licensed under GPL-3.0-or-later.
|
documented limitations and review generated values before use. Licensed under
|
||||||
|
GPL-3.0-or-later.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# 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
|
Build with Node.js 22+, npm 11+, and `package-lock.json` by running
|
||||||
`npm ci && npm run release:artifact`.
|
`npm ci && npm run release:artifact`.
|
||||||
|
|||||||
+21
-3
@@ -2,11 +2,29 @@
|
|||||||
|
|
||||||
The shell lazy-loads a React workbench. Pure core modules parse and validate
|
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
|
JSON-like values, hold a named in-memory document map, resolve only relative
|
||||||
local references, collect operations, derive bounded examples, compare
|
local references, collect OpenAPI HTTP operations or AsyncAPI channels,
|
||||||
operation contracts and summarize saved exchanges. No module exposes a request
|
send/receive operations and messages, derive bounded examples, compare
|
||||||
executor.
|
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
|
JSON uses hardened shared helpers. YAML is converted with bounded alias count
|
||||||
and then recursively checked for depth, node count, dangerous keys and plain
|
and then recursively checked for depth, node count, dangerous keys and plain
|
||||||
JSON values. Rendering uses React text nodes and read-only textareas. The
|
JSON values. Rendering uses React text nodes and read-only textareas. The
|
||||||
same-origin service worker caches only packaged application resources.
|
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.
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# Privacy and security
|
# Privacy and security
|
||||||
|
|
||||||
Descriptions and exchanges remain in memory and are never transmitted or
|
Descriptions and exchanges remain in memory and are never transmitted or
|
||||||
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs are text,
|
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs, broker
|
||||||
not links. There is no Try It button, OAuth flow, DNS lookup, HTTP execution,
|
hosts, channel addresses, protocol bindings, and generated commands are text,
|
||||||
telemetry or external asset.
|
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
|
HAR and saved exchanges commonly contain tokens, cookies, personal data and
|
||||||
payloads. The summary does not render header values, yet the source editor still
|
payloads. Header and cookie values may be used transiently to check parameter
|
||||||
contains them. Clear it before sharing. Generated examples are heuristic and
|
presence and shape, and JSON bodies may be parsed for local schema checks, but
|
||||||
must not be treated as valid production data. Validation and compatibility
|
the summary and contract report never retain or render those values. The source
|
||||||
checks cover a useful subset, not every OpenAPI or JSON Schema rule.
|
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.
|
||||||
|
|||||||
Generated
+20
-20
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "api-tools",
|
"name": "api-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "api-tools",
|
"name": "api-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -43,24 +43,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
"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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
"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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"license": "GPL-3.0-or-later"
|
"license": "GPL-3.0-or-later"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"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",
|
"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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -68,13 +68,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
"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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "api-tools",
|
"name": "api-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, compare and derive examples from OpenAPI descriptions locally in the browser.",
|
"description": "Inspect, compare and derive examples from OpenAPI descriptions locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial local-only OpenAPI 3.0/3.1 inspection release.
|
- Initial local-only OpenAPI 3.0/3.1 inspection release.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+23
-15
@@ -1,15 +1,17 @@
|
|||||||
# API Tools
|
# API Tools
|
||||||
|
|
||||||
API Tools is a local-first OpenAPI workbench. Version 0.1.0 parses bounded JSON
|
API Tools is a local-first HTTP and event API workbench. It parses bounded JSON
|
||||||
or YAML OpenAPI 3.0/3.1 descriptions, navigates operations, resolves `$ref`
|
or YAML OpenAPI 3.0–3.2 and AsyncAPI 2.0–3.1 descriptions, navigates path and
|
||||||
values from explicitly supplied local files, inventories security schemes,
|
webhook receiver operations, channels, and messages, resolves `$ref` values
|
||||||
generates schema/request/response examples and inert client command text,
|
from explicitly supplied local files, inventories security schemes, generates
|
||||||
compares revisions, and summarizes saved HTTP exchanges or HAR 1.2 files.
|
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
|
It never executes HTTP, connects or subscribes to a broker, follows a URL,
|
||||||
credential or loads an external asset. Generated curl, Fetch and Python snippets
|
resolves a remote reference, stores a credential, or loads an external asset.
|
||||||
are text for review and may reproduce untrusted description values safely
|
Generated curl, Fetch and Python snippets are text for review and may reproduce
|
||||||
quoted for their target syntax.
|
untrusted description values safely quoted for their target syntax.
|
||||||
|
|
||||||
## Development and release
|
## Development and release
|
||||||
|
|
||||||
@@ -22,20 +24,26 @@ npm run test:browser
|
|||||||
npm run release:artifact
|
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/`.
|
SHA-256 sidecar. Relative assets are tested at `/deep/nested/api/`.
|
||||||
|
|
||||||
## v0.1 boundary
|
## v0.1 boundary
|
||||||
|
|
||||||
- JSON/YAML files up to 4 MiB each, 20 files/16 MiB per selected workspace,
|
- 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;
|
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;
|
- fragment/relative-file JSON Pointer resolution with missing/cycle reports;
|
||||||
- bounded sample generation for common schema composition and scalar formats;
|
- bounded sample generation for common schema composition and scalar formats;
|
||||||
- path/query/request/response examples and inert curl/Fetch/Python output;
|
- path/query/request/response examples and inert curl/Fetch/Python output;
|
||||||
- security scheme inventory, conservative operation compatibility report;
|
- 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
|
This is not a complete OpenAPI, AsyncAPI, or JSON Schema validator, code
|
||||||
client, proxy or security scanner. See the documented limitations and review
|
generator, API client, broker client, proxy, or security scanner. See the
|
||||||
generated values before use. Licensed under GPL-3.0-or-later.
|
documented limitations and review generated values before use. Licensed under
|
||||||
|
GPL-3.0-or-later.
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# 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
|
Build with Node.js 22+, npm 11+, and `package-lock.json` by running
|
||||||
`npm ci && npm run release:artifact`.
|
`npm ci && npm run release:artifact`.
|
||||||
|
|||||||
@@ -2,11 +2,29 @@
|
|||||||
|
|
||||||
The shell lazy-loads a React workbench. Pure core modules parse and validate
|
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
|
JSON-like values, hold a named in-memory document map, resolve only relative
|
||||||
local references, collect operations, derive bounded examples, compare
|
local references, collect OpenAPI HTTP operations or AsyncAPI channels,
|
||||||
operation contracts and summarize saved exchanges. No module exposes a request
|
send/receive operations and messages, derive bounded examples, compare
|
||||||
executor.
|
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
|
JSON uses hardened shared helpers. YAML is converted with bounded alias count
|
||||||
and then recursively checked for depth, node count, dangerous keys and plain
|
and then recursively checked for depth, node count, dangerous keys and plain
|
||||||
JSON values. Rendering uses React text nodes and read-only textareas. The
|
JSON values. Rendering uses React text nodes and read-only textareas. The
|
||||||
same-origin service worker caches only packaged application resources.
|
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.
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# Privacy and security
|
# Privacy and security
|
||||||
|
|
||||||
Descriptions and exchanges remain in memory and are never transmitted or
|
Descriptions and exchanges remain in memory and are never transmitted or
|
||||||
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs are text,
|
persisted. All remote/absolute `$ref` forms fail closed. Displayed URLs, broker
|
||||||
not links. There is no Try It button, OAuth flow, DNS lookup, HTTP execution,
|
hosts, channel addresses, protocol bindings, and generated commands are text,
|
||||||
telemetry or external asset.
|
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
|
HAR and saved exchanges commonly contain tokens, cookies, personal data and
|
||||||
payloads. The summary does not render header values, yet the source editor still
|
payloads. Header and cookie values may be used transiently to check parameter
|
||||||
contains them. Clear it before sharing. Generated examples are heuristic and
|
presence and shape, and JSON bodies may be parsed for local schema checks, but
|
||||||
must not be treated as valid production data. Validation and compatibility
|
the summary and contract report never retain or render those values. The source
|
||||||
checks cover a useful subset, not every OpenAPI or JSON Schema rule.
|
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
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "api-tools-shell-";
|
const CACHE_PREFIX = "api-tools-shell-";
|
||||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||||
const CORE = [
|
const CORE = [
|
||||||
"./",
|
"./",
|
||||||
"./manifest.webmanifest",
|
"./manifest.webmanifest",
|
||||||
|
|||||||
+12
-3
@@ -3,12 +3,12 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.api-tools",
|
"id": "de.add-ideas.api-tools",
|
||||||
"name": "API Tools",
|
"name": "API Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and compare API descriptions locally.",
|
"description": "Inspect and compare HTTP and event API descriptions locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["developer", "data", "network"],
|
"categories": ["developer", "data", "network"],
|
||||||
"tags": ["openapi", "swagger", "har", "schema", "http"],
|
"tags": ["openapi", "asyncapi", "har", "schema", "http", "events"],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -21,6 +21,15 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
@@ -31,13 +31,16 @@ export function HelpDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Open JSON or YAML OpenAPI 3.0/3.1 descriptions, resolve references from
|
Open JSON or YAML OpenAPI 3.0–3.2 or AsyncAPI 2.0–3.1 descriptions,
|
||||||
files you explicitly add, inspect operations and security schemes,
|
resolve references from files you explicitly add, inspect path and
|
||||||
|
webhook receiver operations, channels, messages, and security schemes,
|
||||||
generate examples, and compare revisions.
|
generate examples, and compare revisions.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Generated curl, Fetch and Python requests are inert text. Version 0.1
|
Generated curl, Fetch and Python requests are inert text. Broker,
|
||||||
never executes HTTP, follows URLs, or resolves remote references.
|
channel, and binding information is also inert. The application never
|
||||||
|
executes HTTP, connects to a broker, follows URLs, or resolves remote
|
||||||
|
references.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Validation is a focused structural review, not full conformance
|
Validation is a focused structural review, not full conformance
|
||||||
|
|||||||
+252
-33
@@ -3,7 +3,12 @@ import {
|
|||||||
stableStringify,
|
stableStringify,
|
||||||
triggerBlobDownload,
|
triggerBlobDownload,
|
||||||
} from "@add-ideas/toolbox-helpers";
|
} 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 {
|
import {
|
||||||
inspectHar,
|
inspectHar,
|
||||||
inspectRawExchange,
|
inspectRawExchange,
|
||||||
@@ -14,7 +19,11 @@ import {
|
|||||||
operationExample,
|
operationExample,
|
||||||
securityInventory,
|
securityInventory,
|
||||||
} from "../core/operations";
|
} from "../core/operations";
|
||||||
import { parseApiDocument, validateOpenApi } from "../core/parse";
|
import {
|
||||||
|
apiDescriptionKind,
|
||||||
|
parseApiDocument,
|
||||||
|
validateApiDescription,
|
||||||
|
} from "../core/parse";
|
||||||
import { createWorkspace } from "../core/refs";
|
import { createWorkspace } from "../core/refs";
|
||||||
import type { ApiDocument, ApiOperation } from "../core/types";
|
import type { ApiDocument, ApiOperation } from "../core/types";
|
||||||
|
|
||||||
@@ -114,6 +123,7 @@ function OperationList({
|
|||||||
<span>
|
<span>
|
||||||
<strong>{operation.path}</strong>
|
<strong>{operation.path}</strong>
|
||||||
<small>
|
<small>
|
||||||
|
{operation.kind === "webhook" ? "Webhook · " : ""}
|
||||||
{operation.summary ?? operation.operationId ?? "No summary"}
|
{operation.summary ?? operation.operationId ?? "No summary"}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</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() {
|
export function Workbench() {
|
||||||
const initial = useMemo(() => parseApiDocument(SAMPLE, "openapi.yaml"), []);
|
const initial = useMemo(() => parseApiDocument(SAMPLE, "openapi.yaml"), []);
|
||||||
const [source, setSource] = useState(SAMPLE);
|
const [source, setSource] = useState(SAMPLE);
|
||||||
@@ -135,20 +175,38 @@ export function Workbench() {
|
|||||||
const [selectedKey, setSelectedKey] = useState("GET /books/{bookId}");
|
const [selectedKey, setSelectedKey] = useState("GET /books/{bookId}");
|
||||||
const [compareSource, setCompareSource] = useState(COMPARE_SAMPLE);
|
const [compareSource, setCompareSource] = useState(COMPARE_SAMPLE);
|
||||||
const [comparison, setComparison] = useState(() =>
|
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 [exchangeSource, setExchangeSource] = useState(HAR_SAMPLE);
|
||||||
const [exchanges, setExchanges] = useState<ExchangeSummary[]>(() =>
|
const [exchanges, setExchanges] = useState<ExchangeSummary[]>(() =>
|
||||||
inspectHar(HAR_SAMPLE),
|
inspectHar(HAR_SAMPLE),
|
||||||
);
|
);
|
||||||
|
const [contractReport, setContractReport] = useState<
|
||||||
|
HarContractReport | undefined
|
||||||
|
>(() => validateHarAgainstOpenApi(HAR_SAMPLE, createWorkspace(initial)));
|
||||||
const [exchangeError, setExchangeError] = useState<string>();
|
const [exchangeError, setExchangeError] = useState<string>();
|
||||||
|
|
||||||
const workspace = useMemo(
|
const workspace = useMemo(
|
||||||
() => createWorkspace(entry, supporting),
|
() => createWorkspace(entry, supporting),
|
||||||
[entry, supporting],
|
[entry, supporting],
|
||||||
);
|
);
|
||||||
const problems = useMemo(() => validateOpenApi(entry), [entry]);
|
const descriptionKind = apiDescriptionKind(entry);
|
||||||
const operations = useMemo(() => collectOperations(entry.value), [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 visibleOperations = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
return needle
|
return needle
|
||||||
@@ -165,6 +223,23 @@ export function Workbench() {
|
|||||||
const selected =
|
const selected =
|
||||||
operations.find((operation) => operation.key === selectedKey) ??
|
operations.find((operation) => operation.key === selectedKey) ??
|
||||||
operations[0];
|
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(
|
const example = useMemo(
|
||||||
() => (selected ? operationExample(workspace, selected) : undefined),
|
() => (selected ? operationExample(workspace, selected) : undefined),
|
||||||
[selected, workspace],
|
[selected, workspace],
|
||||||
@@ -228,7 +303,10 @@ export function Workbench() {
|
|||||||
const runComparison = () => {
|
const runComparison = () => {
|
||||||
try {
|
try {
|
||||||
setComparison(
|
setComparison(
|
||||||
compareApis(entry, parseApiDocument(compareSource, "comparison.yaml")),
|
compareApiDescriptions(
|
||||||
|
entry,
|
||||||
|
parseApiDocument(compareSource, "comparison.yaml"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
@@ -241,10 +319,14 @@ export function Workbench() {
|
|||||||
};
|
};
|
||||||
const inspectExchange = () => {
|
const inspectExchange = () => {
|
||||||
try {
|
try {
|
||||||
const value = /^\s*\{/u.test(exchangeSource)
|
if (/^\s*\{/u.test(exchangeSource) && !isAsyncApi) {
|
||||||
? inspectHar(exchangeSource)
|
const report = validateHarAgainstOpenApi(exchangeSource, workspace);
|
||||||
: inspectRawExchange(exchangeSource);
|
setExchanges(report.exchanges);
|
||||||
setExchanges(value);
|
setContractReport(report);
|
||||||
|
} else {
|
||||||
|
setExchanges(inspectRawExchange(exchangeSource));
|
||||||
|
setContractReport(undefined);
|
||||||
|
}
|
||||||
setExchangeError(undefined);
|
setExchangeError(undefined);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setExchangeError(
|
setExchangeError(
|
||||||
@@ -262,9 +344,9 @@ export function Workbench() {
|
|||||||
<p className="eyebrow">Offline API description laboratory</p>
|
<p className="eyebrow">Offline API description laboratory</p>
|
||||||
<h1>API Tools</h1>
|
<h1>API Tools</h1>
|
||||||
<p>
|
<p>
|
||||||
Navigate OpenAPI descriptions, resolve explicitly supplied local
|
Navigate OpenAPI and AsyncAPI descriptions, resolve explicitly
|
||||||
references, derive examples and compare revisions—without sending a
|
supplied local references, derive examples and compare
|
||||||
request.
|
revisions—without connecting to an API or broker.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="privacy-pill">Generation only</span>
|
<span className="privacy-pill">Generation only</span>
|
||||||
@@ -312,7 +394,7 @@ export function Workbench() {
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
data-testid="source-editor"
|
data-testid="source-editor"
|
||||||
aria-label="OpenAPI source"
|
aria-label="API description source"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(event) => setSource(event.target.value)}
|
onChange={(event) => setSource(event.target.value)}
|
||||||
rows={11}
|
rows={11}
|
||||||
@@ -341,7 +423,8 @@ export function Workbench() {
|
|||||||
>
|
>
|
||||||
{problems.filter((item) => item.level === "error").length} errors,{" "}
|
{problems.filter((item) => item.level === "error").length} errors,{" "}
|
||||||
{problems.filter((item) => item.level === "warning").length}{" "}
|
{problems.filter((item) => item.level === "warning").length}{" "}
|
||||||
warnings · {operations.length} operations.
|
warnings · {isAsyncApi ? asyncOperations.length : operations.length}{" "}
|
||||||
|
operations.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -366,7 +449,11 @@ export function Workbench() {
|
|||||||
<aside className="panel">
|
<aside className="panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<h2>Operations</h2>
|
<h2>Operations</h2>
|
||||||
<span className="count-pill">{visibleOperations.length}</span>
|
<span className="count-pill">
|
||||||
|
{isAsyncApi
|
||||||
|
? visibleAsyncOperations.length
|
||||||
|
: visibleOperations.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="search"
|
className="search"
|
||||||
@@ -375,18 +462,102 @@ export function Workbench() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<OperationList
|
{isAsyncApi ? (
|
||||||
operations={visibleOperations}
|
<AsyncOperationList
|
||||||
selected={selected?.key}
|
operations={visibleAsyncOperations}
|
||||||
onSelect={setSelectedKey}
|
selected={selectedAsync?.key}
|
||||||
/>
|
onSelect={setSelectedKey}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OperationList
|
||||||
|
operations={visibleOperations}
|
||||||
|
selected={selected?.key}
|
||||||
|
onSelect={setSelectedKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
<article className="panel operation-detail">
|
<article className="panel operation-detail">
|
||||||
{selected && example ? (
|
{isAsyncApi && selectedAsync ? (
|
||||||
<>
|
<>
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<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>
|
<h2>{selected.path}</h2>
|
||||||
<p>
|
<p>
|
||||||
{selected.summary ??
|
{selected.summary ??
|
||||||
@@ -501,11 +672,11 @@ export function Workbench() {
|
|||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2>Comparison revision</h2>
|
<h2>Comparison revision</h2>
|
||||||
<p>
|
<p>
|
||||||
Paste another JSON/YAML OpenAPI description. The comparison is
|
Paste another JSON/YAML description of the same family. The
|
||||||
operation-aware and deliberately conservative.
|
comparison is operation-aware and deliberately conservative.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Comparison OpenAPI source"
|
aria-label="Comparison API description source"
|
||||||
value={compareSource}
|
value={compareSource}
|
||||||
onChange={(event) => setCompareSource(event.target.value)}
|
onChange={(event) => setCompareSource(event.target.value)}
|
||||||
rows={20}
|
rows={20}
|
||||||
@@ -546,8 +717,9 @@ export function Workbench() {
|
|||||||
)}
|
)}
|
||||||
<p className="disclosure">
|
<p className="disclosure">
|
||||||
This does not fully compare schema compatibility, callbacks,
|
This does not fully compare schema compatibility, callbacks,
|
||||||
links, webhooks, extensions, serialization or behavioural
|
links, protocol bindings, extensions, serialization or behavioural
|
||||||
contracts.
|
contracts. OpenAPI 3.1/3.2 webhooks are inventoried and compared
|
||||||
|
as receiver operations.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -559,7 +731,8 @@ export function Workbench() {
|
|||||||
<h2>Saved exchange / HAR</h2>
|
<h2>Saved exchange / HAR</h2>
|
||||||
<p>
|
<p>
|
||||||
Paste a HAR 1.2 JSON object or a raw request followed by “---
|
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>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Saved HTTP exchange"
|
aria-label="Saved HTTP exchange"
|
||||||
@@ -583,7 +756,19 @@ export function Workbench() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<h2>Exchange inventory</h2>
|
<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>
|
<span className="count-pill">{exchanges.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-scroll" tabIndex={0}>
|
<div className="table-scroll" tabIndex={0}>
|
||||||
@@ -592,6 +777,7 @@ export function Workbench() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Method</th>
|
<th>Method</th>
|
||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
|
<th>Operation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
@@ -602,6 +788,7 @@ export function Workbench() {
|
|||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<th>{exchange.method}</th>
|
<th>{exchange.method}</th>
|
||||||
<td>{exchange.url}</td>
|
<td>{exchange.url}</td>
|
||||||
|
<td>{exchange.operation ?? "unmatched"}</td>
|
||||||
<td>{exchange.status ?? "—"}</td>
|
<td>{exchange.status ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
{exchange.duration === undefined
|
{exchange.duration === undefined
|
||||||
@@ -609,15 +796,47 @@ export function Workbench() {
|
|||||||
: `${exchange.duration} ms`}
|
: `${exchange.duration} ms`}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{exchange.responseBytes === undefined
|
<span>
|
||||||
? "—"
|
{exchange.responseBytes === undefined
|
||||||
: `${exchange.responseBytes} B`}
|
? "—"
|
||||||
|
: `${exchange.responseBytes} B`}
|
||||||
|
</span>
|
||||||
|
{exchange.errors || exchange.warnings ? (
|
||||||
|
<small>
|
||||||
|
{exchange.errors ?? 0} errors ·{" "}
|
||||||
|
{exchange.warnings ?? 0} warnings
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<p className="disclosure">
|
||||||
Authorization and cookie values are not rendered by this summary,
|
Authorization and cookie values are not rendered by this summary,
|
||||||
but the pasted source can still contain secrets. Clear it before
|
but the pasted source can still contain secrets. Clear it before
|
||||||
|
|||||||
@@ -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.0–2.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
@@ -1,4 +1,7 @@
|
|||||||
import { collectOperations } from "./operations";
|
import { collectOperations } from "./operations";
|
||||||
|
import { collectAsyncOperations } from "./asyncapi";
|
||||||
|
import { apiDescriptionKind } from "./parse";
|
||||||
|
import { createWorkspace } from "./refs";
|
||||||
import type { ApiDocument, ApiOperation, JsonObject, JsonValue } from "./types";
|
import type { ApiDocument, ApiOperation, JsonObject, JsonValue } from "./types";
|
||||||
|
|
||||||
export interface ApiChange {
|
export interface ApiChange {
|
||||||
@@ -31,6 +34,247 @@ function responseCodes(operation: ApiOperation): Set<string> {
|
|||||||
return new Set(Object.keys(asObject(operation.value.responses) ?? {}));
|
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(
|
export function compareApis(
|
||||||
before: ApiDocument,
|
before: ApiDocument,
|
||||||
after: ApiDocument,
|
after: ApiDocument,
|
||||||
@@ -73,6 +317,14 @@ export function compareApis(
|
|||||||
path: key,
|
path: key,
|
||||||
message: "Request body became required.",
|
message: "Request body became required.",
|
||||||
});
|
});
|
||||||
|
changes.push(
|
||||||
|
...compareContent(
|
||||||
|
operation.value.requestBody,
|
||||||
|
current.value.requestBody,
|
||||||
|
`${key}/requestBody`,
|
||||||
|
"request",
|
||||||
|
),
|
||||||
|
);
|
||||||
for (const code of responseCodes(operation))
|
for (const code of responseCodes(operation))
|
||||||
if (!responseCodes(current).has(code))
|
if (!responseCodes(current).has(code))
|
||||||
changes.push({
|
changes.push({
|
||||||
@@ -80,9 +332,36 @@ export function compareApis(
|
|||||||
path: key,
|
path: key,
|
||||||
message: `Response ${code} was removed.`,
|
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 (
|
if (
|
||||||
JSON.stringify(operation.value) !== JSON.stringify(current.value) &&
|
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({
|
changes.push({
|
||||||
level: "review",
|
level: "review",
|
||||||
@@ -99,3 +378,56 @@ export function compareApis(
|
|||||||
});
|
});
|
||||||
return changes.slice(0, 5_000);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export interface ExchangeSummary {
|
|||||||
requestBytes?: number;
|
requestBytes?: number;
|
||||||
responseBytes?: number;
|
responseBytes?: number;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
operation?: string;
|
||||||
|
errors?: number;
|
||||||
|
warnings?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inspectHar(source: string): ExchangeSummary[] {
|
export function inspectHar(source: string): ExchangeSummary[] {
|
||||||
|
|||||||
+68
-7
@@ -16,6 +16,7 @@ const METHODS = [
|
|||||||
"head",
|
"head",
|
||||||
"patch",
|
"patch",
|
||||||
"trace",
|
"trace",
|
||||||
|
"query",
|
||||||
] as const;
|
] as const;
|
||||||
function asObject(value: JsonValue | undefined): JsonObject | undefined {
|
function asObject(value: JsonValue | undefined): JsonObject | undefined {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
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[] {
|
export function collectOperations(root: JsonObject): ApiOperation[] {
|
||||||
const paths = asObject(root.paths) ?? {};
|
const paths = asObject(root.paths) ?? {};
|
||||||
const result: ApiOperation[] = [];
|
const result: ApiOperation[] = [];
|
||||||
for (const [path, pathValue] of Object.entries(paths)) {
|
const collectPathItem = (
|
||||||
|
path: string,
|
||||||
|
pathValue: JsonValue,
|
||||||
|
kind: ApiOperation["kind"],
|
||||||
|
) => {
|
||||||
const pathItem = asObject(pathValue);
|
const pathItem = asObject(pathValue);
|
||||||
if (!pathItem || !path.startsWith("/")) continue;
|
if (!pathItem || (kind === "path" && !path.startsWith("/"))) return;
|
||||||
const inheritedParameters = Array.isArray(pathItem.parameters)
|
const inheritedParameters = Array.isArray(pathItem.parameters)
|
||||||
? pathItem.parameters
|
? pathItem.parameters
|
||||||
: [];
|
: [];
|
||||||
@@ -36,9 +41,13 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
|
|||||||
const value = asObject(pathItem[method]);
|
const value = asObject(pathItem[method]);
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
result.push({
|
result.push({
|
||||||
key: `${method.toUpperCase()} ${path}`,
|
key:
|
||||||
|
kind === "webhook"
|
||||||
|
? `WEBHOOK ${path} ${method.toUpperCase()}`
|
||||||
|
: `${method.toUpperCase()} ${path}`,
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
path,
|
path,
|
||||||
|
kind,
|
||||||
operationId:
|
operationId:
|
||||||
typeof value.operationId === "string" ? value.operationId : undefined,
|
typeof value.operationId === "string" ? value.operationId : undefined,
|
||||||
summary: typeof value.summary === "string" ? value.summary : undefined,
|
summary: typeof value.summary === "string" ? value.summary : undefined,
|
||||||
@@ -51,6 +60,49 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
|
|||||||
if (result.length > 2_000)
|
if (result.length > 2_000)
|
||||||
throw new RangeError("Operation count exceeds 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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -123,14 +175,23 @@ export function operationExample(
|
|||||||
? asObject(root.servers[0])
|
? asObject(root.servers[0])
|
||||||
: undefined;
|
: undefined;
|
||||||
const base =
|
const base =
|
||||||
typeof server?.url === "string" && !server.url.includes("{")
|
operation.kind === "webhook"
|
||||||
? server.url.replace(/\/$/u, "")
|
? "https://receiver.example.test"
|
||||||
: "https://api.example.test";
|
: typeof server?.url === "string" && !server.url.includes("{")
|
||||||
|
? server.url.replace(/\/$/u, "")
|
||||||
|
: "https://api.example.test";
|
||||||
if (base !== "https://api.example.test")
|
if (base !== "https://api.example.test")
|
||||||
notices.push(
|
notices.push(
|
||||||
"The declared server URL is reproduced as inert text; it is not contacted.",
|
"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 = [
|
const parameters = [
|
||||||
...operation.inheritedParameters,
|
...operation.inheritedParameters,
|
||||||
...(Array.isArray(operation.value.parameters)
|
...(Array.isArray(operation.value.parameters)
|
||||||
|
|||||||
+58
-2
@@ -6,6 +6,7 @@ import type {
|
|||||||
JsonValue,
|
JsonValue,
|
||||||
ValidationProblem,
|
ValidationProblem,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { validateAsyncApi } from "./asyncapi";
|
||||||
|
|
||||||
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||||
const MAX_SOURCE = 4 * 1024 * 1024;
|
const MAX_SOURCE = 4 * 1024 * 1024;
|
||||||
@@ -121,11 +122,11 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
|
|||||||
path: "/openapi",
|
path: "/openapi",
|
||||||
message: "Missing OpenAPI version string.",
|
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({
|
problems.push({
|
||||||
level: "error",
|
level: "error",
|
||||||
path: "/openapi",
|
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);
|
const info = object(root.info);
|
||||||
if (!info)
|
if (!info)
|
||||||
@@ -167,6 +168,45 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
|
|||||||
path: "/swagger",
|
path: "/swagger",
|
||||||
message: "Swagger 2.0 fields are not interpreted by this OpenAPI 3 tool.",
|
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 refs: string[] = [];
|
||||||
const visit = (value: JsonValue, path: string, depth: number) => {
|
const visit = (value: JsonValue, path: string, depth: number) => {
|
||||||
if (depth > 64) return;
|
if (depth > 64) return;
|
||||||
@@ -200,3 +240,19 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
|
|||||||
});
|
});
|
||||||
return problems.slice(0, 1_000);
|
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
@@ -139,7 +139,7 @@ export function generateSchemaSample(
|
|||||||
active.delete(resolved.key);
|
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 (object[key] !== undefined) return object[key]!;
|
||||||
if (Array.isArray(object.enum) && object.enum.length)
|
if (Array.isArray(object.enum) && object.enum.length)
|
||||||
return object.enum[0]!;
|
return object.enum[0]!;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ApiOperation {
|
|||||||
key: string;
|
key: string;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
kind: "path" | "webhook";
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|||||||
@@ -504,6 +504,9 @@ iframe {
|
|||||||
.diagnostic-list li.warning {
|
.diagnostic-list li.warning {
|
||||||
border-left-color: var(--mail-warning);
|
border-left-color: var(--mail-warning);
|
||||||
}
|
}
|
||||||
|
.diagnostic-list li.error {
|
||||||
|
border-left-color: var(--toolbox-danger);
|
||||||
|
}
|
||||||
.diagnostic-list code {
|
.diagnostic-list code {
|
||||||
color: var(--toolbox-muted);
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -557,6 +560,20 @@ iframe {
|
|||||||
.top-gap {
|
.top-gap {
|
||||||
margin-top: 0.7rem;
|
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 {
|
.report-preview {
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.api-tools",
|
"id": "de.add-ideas.api-tools",
|
||||||
"name": "API Tools",
|
"name": "API Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and compare API descriptions locally.",
|
"description": "Inspect and compare HTTP and event API descriptions locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["developer", "data", "network"],
|
"categories": ["developer", "data", "network"],
|
||||||
"tags": ["openapi", "swagger", "har", "schema", "http"],
|
"tags": ["openapi", "asyncapi", "har", "schema", "http", "events"],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -21,6 +21,32 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ test("retains a valid model, compares revisions and inspects HAR", async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/deep/nested/api/");
|
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 page.getByRole("button", { name: "Analyse description" }).click();
|
||||||
await expect(page.getByRole("alert")).toContainText("last successful");
|
await expect(page.getByRole("alert")).toContainText("last successful");
|
||||||
await expect(
|
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");
|
const manifest = await request.get("/deep/nested/api/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.api-tools",
|
id: "de.add-ideas.api-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
privacy: { processing: "local", telemetry: false },
|
privacy: { processing: "local", telemetry: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Workbench } from "../../src/components/Workbench";
|
import { Workbench } from "../../src/components/Workbench";
|
||||||
@@ -12,9 +12,9 @@ describe("API workbench", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("GET", { exact: true })).toHaveLength(2);
|
expect(screen.getAllByText("GET", { exact: true })).toHaveLength(2);
|
||||||
expect(screen.getByText(/curl --request GET/iu)).toBeInTheDocument();
|
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(
|
await user.type(
|
||||||
screen.getByLabelText("OpenAPI source"),
|
screen.getByLabelText("API description source"),
|
||||||
'not: "unterminated',
|
'not: "unterminated',
|
||||||
);
|
);
|
||||||
await user.click(
|
await user.click(
|
||||||
@@ -26,6 +26,36 @@ describe("API workbench", () => {
|
|||||||
).toHaveLength(3);
|
).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 () => {
|
it("shows security, comparison and exchange workspaces", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<Workbench />);
|
render(<Workbench />);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { inspectHar, inspectRawExchange } from "../../src/core/har";
|
||||||
import {
|
import {
|
||||||
collectOperations,
|
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", () => {
|
it("reports a missing local reference without aborting generation", () => {
|
||||||
const document = parseApiDocument(
|
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'}}}}}}}}",
|
"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", () => {
|
it("summarizes HAR and raw exchanges without exposing headers", () => {
|
||||||
const har = inspectHar(
|
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"}}}]}}',
|
'{"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],
|
)[0],
|
||||||
).toMatchObject({ method: "GET", url: "/x", status: 204 });
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
generateSchemaSample,
|
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", () => {
|
it("rejects dangerous YAML keys and alias expansion", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseApiDocument("__proto__: { polluted: true }", "bad.yaml"),
|
parseApiDocument("__proto__: { polluted: true }", "bad.yaml"),
|
||||||
@@ -72,4 +94,68 @@ describe("OpenAPI parsing and local references", () => {
|
|||||||
});
|
});
|
||||||
expect(sample.notices[0]).toMatch(/cycle/iu);
|
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" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user