Releases: plexara/api-test
api-test-v1.1.2
Highlights
Bug fix — OIDC JWTs now authenticate on /v1/*. Previously the inbound auth chain held only the API-key and static-bearer authenticators; the OIDC validator was built but only wired into the portal browser-session flow. Any Authorization: Bearer <jwt> from the configured IdP was rejected with 401 {"error":"invalid credential"}, even when the JWT carried the correct issuer and audience. This was the only auth mode the config advertised as production-ready, so deployments with oidc.enabled: true, auth.allow_anonymous: false were effectively unable to authenticate API clients.
The chain is now ordered apikey → oidc → bearer:
- A real JWT from the configured IdP authenticates as the JWT subject.
- A foreign-issuer JWT 401s without falling through to the static bearer list.
- A static dev bearer token still works — the OIDC adapter returns "no credential" for non-JWT bearers so the chain falls through.
Fixes #10. The OIDC validator is constructed once at startup and shared with the portal's BrowserAuth, so discovery + JWKS fetch only runs once.
Changelog
Upgrade notes
No config changes required. Deployments already running with oidc.enabled: true will start accepting OIDC JWTs on /v1/* immediately after upgrade. Static bearer tokens, API keys, and portal session login continue to work unchanged.
Installation
Container
docker pull ghcr.io/plexara/api-test:v1.1.2Binary (macOS / Linux)
curl -L -o api-test.tar.gz \
https://github.com/plexara/api-test/releases/download/v1.1.2/api-test_1.1.2_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz
tar -xzf api-test.tar.gz
./api-test --versionDocumentation
Full docs at https://api-test.plexara.io.
Open source by Plexara, the commercial MCP + API gateway with configurable enrichment built in.
api-test-v1.1.1
Highlights
Bug-fix release. Two defects in v1.1.0 were visible on every fresh deployment — the audit detail panel rendered captured bodies as base64 strings, and the portal Try-It widget returned 401 missing credential for a logged-in operator. Both are fixed here.
Fixes
Audit detail bodies now render as decoded JSON, not base64
audit_payloads.request_body / response_body are stored as BYTEA. Go's default JSON encoder emits []byte as base64, so the portal's audit detail panel was showing operators a wall of base64 instead of the captured request and response.
Payload.MarshalJSON now emits utf-8 bodies as JSON strings (the SPA's existing tryParseJSON then renders them as pretty-printed JSON). Non-utf-8 bodies fall back to base64 with a sibling request_body_encoding=base64 / response_body_encoding=base64 flag so binary payloads round-trip unambiguously.
// v1.1.0 — what audit detail showed
"request_body": "eyJoZWxsbyI6Indvcmxk..."
// v1.1.1
"request_body": "{\"hello\":\"world\",\"n\":42}"Try-It returns 200 when you're logged in
The Try-It dispatcher re-enters the local mux via replayTarget.ServeHTTP, but the dispatched request carried no credential header (Try-It deliberately strips operator-supplied Authorization/Cookie). The inbound auth middleware would 401 a request the portal had already accepted via session cookie.
portal_tryit now translates the portal-resolved auth.Identity into an inbound.Identity on the dispatched context, and httpmw.Identity short-circuits the chain when one is already present. The bypass yields when a real credential is on the wire — so typing X-API-Key: <other-key> into the Try-It headers field to "test as a different principal" still resolves through the chain.
Audit replay no longer 401s on redacted credentials
The audit middleware redacts Authorization / X-API-Key / Cookie header values and ?api_key= query values to the string [redacted] before persisting. Replaying a captured request verbatim would put [redacted] on the wire, which the inbound chain rejected as an invalid credential.
The replay handler now drops those credential headers and the api_key query param before re-emission and carries identity through the dispatched context — the same trust model as Try-It.
Tests
Payload.MarshalJSON: utf-8, binary, empty cases.httpmw.Identity: pre-set bypass; wire-credential override of pre-set.portal_audit_replay: end-to-end through the realhttpmw.Identitymiddleware with a chain that rejects[redacted]— would 401 if the redacted-header filter ever regresses.
Known limitation
hasInboundCredential and the replay handler's redacted-header filter hardcode the default api-key header/query names (X-API-Key, api_key). Deployments that customize APIKeysConfig.HeaderName / QueryParamName will silently lose Try-It "test as someone else" semantics under the custom name, and replay will leak the captured redaction sentinel for the custom header. The reference api-test-server.plexara.io deployment runs the defaults, so this isn't a regression for the typical rollout. Follow-up will either thread the configured names through Identity() or add a HasCredential(r) method to inbound.Chain that each authenticator implements. Tracked in code with TODO markers at both call sites.
Changelog
- 19021e7: audit,portal: render bodies as utf-8 strings; propagate identity through Try-It and replay dispatch (@cjimti)
Installation
Container
docker pull ghcr.io/plexara/api-test:v1.1.1Binary (macOS / Linux)
curl -L -o api-test.tar.gz \
https://github.com/plexara/api-test/releases/download/v1.1.1/api-test_1.1.1_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz
tar -xzf api-test.tar.gz
./api-test --versionDocumentation
Full docs at https://api-test.plexara.io.
Open source by Plexara, the commercial MCP + API gateway with configurable enrichment built in.
api-test-v1.1.0
api-test v1.1.0
Adds five new endpoint groups (16 routes), a self-describing OpenAPI 3.x surface with an in-tree generator and boot-time self-check, portal Try-It, audit replay, audit SSE stream + NDJSON export, audit aggregations (timeseries / breakdown / stats), and a Discovery page. Plus a verify-time SPA bundle gate to keep the embedded UI in sync with source.
Open source by Plexara. Sister project to mcp-test, which plays the same role for MCP gateways.
New endpoint groups
Five additional groups land in this release. Behavior is the same shape as v1.0: predictable input → predictable output, validate-and-reject on bounds violations, deterministic content from (seed, index) so cross-style assertions are falsifiable.
streaming — 3 routes
| Method | Path | Content-Type |
|---|---|---|
GET |
/v1/streaming/chunked |
text/plain; charset=utf-8 |
GET |
/v1/streaming/sse |
text/event-stream |
GET |
/v1/streaming/ndjson |
application/x-ndjson |
Shared query parameters: count (0–1000), delay_ms (0–5000), seed (any string). The word emitted at index i is a stable function of (seed, i), re-seeded per item, so requesting index 7 of a 100-item stream returns the same word it would in a 10-item stream. Two requests with identical (count, seed) produce bit-identical bodies. Each chunk is flushed independently via http.Flusher.
methods — 1 route
| Method | Path |
|---|---|
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
/v1/method/echo |
Echoes the verb the server observed: { "method": "POST", "path": "/v1/method/echo", "query": {...} }. HEAD returns headers only per RFC 7231. OPTIONS returns the body plus an Allow header. CONNECT and TRACE are not registered; http.ServeMux answers them with 405.
pagination — 3 routes
| Method | Path | Style | Pagination signal |
|---|---|---|---|
GET |
/v1/pagination/link |
RFC 5988 | Link: <url>; rel="next" (also first, prev, last) |
GET |
/v1/pagination/odata |
OData v4 | @odata.nextLink in body |
GET |
/v1/pagination/cursor |
Opaque cursor | Base64 next_cursor field |
All three slice the same synthetic dataset of (id, value) pairs where value = hex(sha256(id))[:16]. Same id → same value across styles, builds, and process restarts, so a client can assert "items I got from $skip=20 match items from ?cursor=... after walking the cursor 20 steps." Defaults: total=100, max 10000. per_page / $top / limit capped at 1000. Requesting past the end returns 400. Malformed cursors return 400.
security — 5 routes
Probe shapes a gateway URL/path/header filter should refuse to forward. Handlers are inert: they never fetch URLs, never escalate, never emit smuggling-shaped responses.
| Method | Path | Probe shape |
|---|---|---|
GET |
/v1/security/admin/secret |
Privileged-looking path |
GET |
/v1/security/fetch?url=... |
SSRF-shape query parameter (always returns {"would_have_fetched": false}) |
GET |
/v1/security/big-headers |
~32 KiB of response headers |
POST |
/v1/security/redirect-to?url= |
Open-redirect shape (status 200 + custom X-Would-Redirect-To, never Location, by design) |
GET |
/v1/security/control-chars?q= |
Control bytes in query parameter |
A correctly-configured gateway never forwards these. The api-test server only sees them if the gateway is misconfigured.
export — 3 routes
Targets for the Plexara gateway's api_export tool. Exercise body-size and slow-first-byte forwarding.
| Method | Path | Stresses |
|---|---|---|
GET |
/v1/export/big-body?size_kb=N&seed=S |
Large response body forwarding (max 10 MiB) |
GET |
/v1/export/csv?rows=N&seed=S |
Non-JSON content type, large text (max 250000 rows) |
GET |
/v1/export/long-running?duration_ms=N |
Slow first-byte (max 60 s) |
big-body and csv check r.Context().Done() between rows and stop writing on client disconnect. long-running uses select on a timer + context, so a 60-s wait aborts instantly instead of pinning a goroutine. Row fill values share the same hex(sha256(seed:index))[:16] deterministic function as the pagination group, so the same fixture data shows up across both.
OpenAPI 3.x surface
A new pkg/oapi package generates an OpenAPI 3.x document in-tree from the same endpoints.Registry metadata the portal uses. No external spec to keep in sync.
GET /openapi.json— JSON document.GET /openapi.yaml— YAML rendering.GET /docs— Redoc viewer; auto-switches between light and dark from the URL fragment (#dark,#light).- Boot self-check —
pkg/oapi/selfcheck.goasserts that the rendered document and the source registry describe the same set of(method, path)routes. Mismatches abort startup, catching either a generator regression or a future caller mutating the document betweenBuildand serve.
Path parameters are extracted from {name} segments and typed via PathParams when supplied. Query parameters come from QueryParams struct fields. Request and response shapes come from RequestBody / ResponseBody, both defaulting to application/json.
Portal additions
Try-It panel
POST /api/v1/portal/tryit/{group}/{route} dispatches a request against any registered endpoint from inside the portal. Method, path parameters, query, headers, and body are taken from the UI panel and replayed through the running server's mux. The Endpoints page gains a Try-It side panel per route with prefilled curl-equivalent parameters. Works under both portal auth modes (OIDC session + X-API-Key).
Audit replay
POST /api/v1/portal/audit/replay/{id} reconstructs a captured request from its audit_payloads row (method, path, query, headers, body) and replays it. The new event lands in the audit log as a fresh row with its own request id. Useful for re-running a failing gateway scenario without recrafting the curl by hand.
Audit SSE stream
GET /api/v1/portal/audit/stream is a long-lived text/event-stream that emits audit events as they're written. The dashboard and audit views use this where available; otherwise the existing 5-second poll runs.
Audit NDJSON export
GET /api/v1/portal/audit/export.ndjson streams the current filter set as application/x-ndjson for offline analysis. Honors the same filter shape as /audit/events. Streamed; not buffered.
Audit aggregations — 3 endpoints
| Path | Returns |
|---|---|
GET /api/v1/portal/audit/timeseries |
Bucketed counts (bucket, total, errors) over a window. |
GET /api/v1/portal/audit/breakdown |
Counts grouped by auth_type, method, status, or endpoint_group. |
GET /api/v1/portal/audit/stats |
Aggregate totals for a window (calls / successes / errors / error rate / p50 / p95 / p99 duration). |
Surfaces a new AuditMeta.features.stats capability flag the SPA reads to decide whether to render the aggregation widgets.
Discovery page
New SPA page mounting a Redoc viewer over /openapi.yaml. Routed at /portal/discovery. Dark mode is native: the page picks the theme from the SPA's current setting rather than relying on Redoc's URL-fragment fallback, so the viewer matches the surrounding chrome.
UI polish
- Audit list column layout is stable across light/dark and narrow viewports (no jitter when a long path or large duration value lands in a row).
- Audit detail panel is sticky inside a fixed-height main scroll container, so the detail card stays visible while the event list scrolls.
- OpenAPI viewer (Discovery + Endpoints detail) honors the SPA's theme without forcing a page reload.
- Portal screenshots in the docs are regenerated for every page in both themes.
Build, verify, and CI
SPA embed gate
make verify and make build now refuse to proceed if internal/ui/dist/ is stale relative to ui/src/. The check compares the most-recent mtime of source files against the bundle and fails fast if source is newer. Closes the failure mode where a UI change shipped without make ui being re-run.
CodeQL
The CodeQL workflow now runs a manual go build ./... instead of autobuild. The default autobuild path was invoking make, which in turn invoked the SPA gate, which required Node tooling CodeQL's runner doesn't ship with. Manual build skips the gate; the verify pipeline still enforces it locally and in the main CI workflow.
Config-fixture load test
pkg/config/fixtures_test.go loads every committed config under configs/ and asserts the loader accepts it. Catches drift between the loader and the example/dev/live profiles before a release builds against them.
AuditMeta.features.stats TypeScript field
The SPA's portal client now carries a features.stats boolean in its AuditMeta type, mirroring the server response, so feature-gated widgets type-check.
Documentation
New per-group endpoint docs:
docs/endpoints/streaming.mddocs/endpoints/methods.mddocs/endpoints/pagination.mddocs/endpoints/security.mddocs/endpoints/export.md
Plus regenerated portal screenshots (all pages, light + dark), a Discovery section under operations, and a docs cleanup pass that strips stale milestone markers (M3+, M4+) from prose now that those features have shipped.
Full docs at https://api-test.plexara.io.
Distribution
Container image (multi-arch)
docker pull ghcr.io/plexara/api-test:v1.1.0
# or :v1, :latestlinux/amd64 and linux/arm64. Scratch base + ca-certs, non-root (UID 1000), binary doubles as HEALTHCHECK.
docker run --rm -p 8080:8080 \
-v $(pwd)/api-test.yaml:/app/configs/api-test.yaml:ro \
-e APITEST_DB_URL=postgres://... \
ghcr.io/plexara/api-test:v1.1.0Binary (macOS / Linux)
curl -L -o api-test.tar.gz \
https://github.com/plexara/api-test/releases...api-test-v1.0.0
api-test v1.0.0
A controllable HTTP REST fixture built specifically as an upstream for testing API gateways. Endpoint groups for identity, deterministic data, controlled failure modes, and echo; four inbound authentication modes that match every credential type a real gateway forwards; a Postgres-backed audit log of every request with full headers and body capture; and an embedded React portal for live inspection.
Open source by Plexara. Sister project to mcp-test, which plays the same role for MCP gateways. api-test is what we use to verify Plexara's API-gateway behavior end-to-end; we ship it as OSS so anyone building API gateways can use the same fixture.
What's in the box
Endpoint groups — 14 routes shaped for gateway assertions
Every endpoint is intentionally boring. Predictable input → predictable output. That's enough to write end-to-end assertions about a gateway's behavior without depending on real upstream data.
| Group | Routes | Purpose |
|---|---|---|
| identity | GET /v1/whoami, GET /v1/headers |
Echo the identity the gateway forwarded (subject, email, auth_type, scopes, claims) and the inbound HTTP headers (with sensitive values redacted). |
| data | GET /v1/fixed/{key}, GET /v1/sized?bytes=N, GET /v1/lorem?words=N&seed=... |
Deterministic bodies: same key/seed → same bytes. Capped at 32 MiB (sized) and 5000 words (lorem) with validate-and-reject sanitization (the CodeQL-recognized shape, not clamp-and-continue). |
| echo | GET/POST/PUT/PATCH/DELETE/HEAD /v1/echo |
Mirrors method, path, query, headers, and body back. One route per HTTP method so the gateway's method-handling can be probed independently. |
| failure | GET /v1/status/{code}, GET /v1/slow?ms=N, GET /v1/flaky?fail_rate=&seed= |
Returns the requested status verbatim; sleeps the requested ms (capped at 60 s, honors context cancellation); fails on a deterministic schedule. Same seed + call id → same outcome. |
Pagination styles, methods coverage, security probes, and export targets land in follow-up minor releases.
Inbound auth, four ways
Match every credential type a real gateway forwards on its way to an upstream:
- File API keys — Plain
X-API-Keykeys loaded from config; constant-time compare. Header or query placement. - Postgres bcrypt API keys — Bcrypt-hashed keys in the
api_keystable; created and revoked from the portal or admin endpoints. Plaintext shown once. - Static bearer tokens —
Authorization: Bearer <token>matched against a config list. - OIDC JWT — Full RFC 7519 / 8725 validation against a configured issuer's JWKS, with caching + singleflight + stale-while-revalidate. Audience and clock-skew configurable.
Auth is a chain composer: every mode listed in config gets a shot; first match wins. Anonymous can be opt-in per surface (require_for_api, require_for_portal).
Audit log — every request, structured
Postgres-backed timeline with full request/response capture:
audit_events— request metadata: timestamp, duration, request id, session id, identity (subject / email / auth_type / api_key_name), method, path, route_name, endpoint_group, status, bytes in/out, success, error message/category, remote address, user-agent.audit_payloads— full body capture: request headers (JSONB), query (JSONB), content-type, raw body (BYTEA, truncated atmax_payload_bytes), response headers, response body. Sensitive request headers (Authorization,Cookie, and configurable extras) are stored as[redacted]literals — the secret is never persisted.- 7-day default retention via a background job.
- GIN indexes on JSONB headers for path-aware filtering.
The audit pipeline is the load-bearing piece for writing gateway assertions: every request your gateway forwards lands here, with the exact bytes, so a test can SELECT … WHERE path = '/v1/echo' AND auth_type = 'bearer' and confirm what actually arrived.
Embedded React portal
A React 19 + Vite + Tailwind 4 SPA, compiled into the binary via go:embed. Mounts at /portal/ when portal.enabled is true. Two ways in: browser-flow OIDC PKCE (real IdP, real session cookie) or an X-API-Key paste for headless operators.
| Page | What it does |
|---|---|
| Dashboard | 1-hour totals (calls / successes / errors / error rate) + the most recent activity table. Auto-refreshes every 5 s. |
| Endpoints | Catalog of every registered route, grouped by behavior. Click any row for method/path/group/auth-required/description + curl hint. |
| Audit | Filterable, paginated event view. Filter by method, path-contains, success/error. Click any row for the side-pane detail card with timestamp / duration / identity / remote / bytes plus full request/response header & body trees. |
| API Keys | Create or revoke Postgres-backed bcrypt keys. Plaintext shown once, then never again. |
| Config | Read-only view of the running server config with secrets masked. |
| About | Build info plus the well-known metadata a client sees: api endpoint, OIDC issuer, audience. |
Light + dark themes; theme preference persists to localStorage.
Self-describing OpenAPI
Every route is published in an OpenAPI 3.x document at /openapi.json, generated in-tree from the same endpoint metadata the portal uses. The Plexara gateway's api_list_endpoints tool sees an exact contract — there's no separate spec to keep in sync.
Operational basics
- Config loader — YAML with
${VAR:-default}interpolation. Three profiles ship inconfigs/:api-test.dev.yaml(anonymous, no DB),api-test.live.yaml(full stack),api-test.example.yaml(annotated reference). - Middleware chain —
RequestID → AccessLog → CORS → mux, withIdentity → Auditper-route. Request ID is forwarded asX-Request-Id. - Healthcheck —
GET /healthzreturns 200 when the DB is reachable. The binary doubles as its own probe viaapi-test --healthcheck, used by the container'sHEALTHCHECKinstruction. - Graceful shutdown — composition root drains in-flight requests on SIGTERM with a configurable timeout.
- CSRF — portal write endpoints require
X-Requested-With: XMLHttpRequest.
Quality gates
Every release is gated by a 40-target Makefile (make verify) that mirrors CI exactly:
- Lint —
golangci-lint v2.11.4with 17 linters enabled. - Security —
gosec v2.25.0,govulncheck,Semgrep 1.110.0(community + local rules), CodeQLsecurity-and-qualitysuite with a configured exclusion for the audit pipeline. - Test —
go test -race -count=1 ./...against a deterministic seed, plus integration tests against testcontainers Postgres (-tags=integration). - Coverage — gate at ≥ 80 % over the testable subset (Postgres-dependent packages excluded; those are exercised by the integration suite). Currently 89.0 %.
- Module hygiene —
go mod tidydrift check,go mod verify,go build -v ./....
The local verify-passed sentinel is only written after the FULL set passes. If CI catches something local missed, that's a bug in the pipeline, not in the code.
Distribution
Container image (multi-arch)
docker pull ghcr.io/plexara/api-test:v1.0.0
# or :v1, :latestAvailable for linux/amd64 and linux/arm64. Scratch base + ca-certs; non-root (UID 1000); the binary doubles as the HEALTHCHECK. The image bakes no config — operators mount one at /app/configs/api-test.yaml:
docker run --rm -p 8080:8080 \
-v $(pwd)/api-test.yaml:/app/configs/api-test.yaml:ro \
-e APITEST_DB_URL=postgres://... \
ghcr.io/plexara/api-test:v1.0.0A starter config to copy from lives at configs/api-test.example.yaml in this repo.
Binary (macOS / Linux)
curl -L -o api-test.tar.gz \
https://github.com/plexara/api-test/releases/download/v1.0.0/api-test_1.0.0_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz
tar -xzf api-test.tar.gz
./api-test --versionBinary (Windows)
Invoke-WebRequest -Uri "https://github.com/plexara/api-test/releases/download/v1.0.0/api-test_1.0.0_windows_amd64.zip" -OutFile api-test.zip
Expand-Archive api-test.zip
.\api-test\api-test.exe --versionFrom source
git clone https://github.com/plexara/api-test
cd api-test
make build
./bin/api-test --versionChecksums
checksums.txt (sha256) ships alongside the archives. Verify:
shasum -a 256 -c checksums.txtBuild flags: CGO_ENABLED=0 -trimpath -a -s -w, mod_timestamp set to commit time so the same source produces byte-identical binaries.
Configuration & docs
Full documentation at https://api-test.plexara.io. Highlights:
- Quickstart —
make devbrings up Postgres + Keycloak + the binary onlocalhost:8080in one command.make dev-anonskips both for the fastest iteration loop. - Configuration reference — every YAML key documented with type, default, env-var override, and a "why this exists" line.
- Auth modes — file keys, DB keys, bearer, OIDC; the precise inbound-chain composition rules.
- Audit log — schema, retention, redaction rules, JSONB query examples.
- Portal — pages, authentication, screenshots of every screen in light + dark mode.
- Testing a gateway against api-test — patterns for end-to-end assertions using the audit log as the verification surface.
Supported platforms
| OS | amd64 | arm64 |
|---|---|---|
| Linux (binary + container) | ✓ | ✓ |
| macOS (binary) | ✓ | ✓ |
| Windows (binary) | ✓ | — |
Built with Go 1.26.3.
-...