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