Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 86 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# api-test

[![CI](https://github.com/plexara/api-test/actions/workflows/ci.yml/badge.svg)](https://github.com/plexara/api-test/actions/workflows/ci.yml)
[![CodeQL](https://github.com/plexara/api-test/actions/workflows/codeql.yml/badge.svg)](https://github.com/plexara/api-test/actions/workflows/codeql.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/plexara/api-test.svg)](https://pkg.go.dev/github.com/plexara/api-test)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

A controllable HTTP REST fixture used to exercise API gateways (Plexara's
in particular). Sister project to [mcp-test](../mcp-test), which plays the
same role for the MCP gateway.

📖 **Documentation: <https://api-test.plexara.io/>**

## Why

Plexara MCP exposes two gateway capabilities:
Expand All @@ -17,9 +24,29 @@ Plexara MCP exposes two gateway capabilities:
`api-test` is the upstream HTTP fixture the API gateway calls. Endpoints
are deliberately simple and deterministic; their job is not to compute
anything useful, it's to make the gateway's behavior observable. Every
request will be recorded in a Postgres-backed audit log so you can
compare what a client sent through Plexara, what reached this server, and
what came back.
request is recorded in a Postgres-backed audit log so you can compare
what a client sent through Plexara, what reached this server, and what
came back.

### Why not httpbin / mockoon / Prism?

Those are great mocks. api-test is a different shape:

- **Audit log of every request** — sanitized headers, query params,
request and response bodies, identity, latency, status — queryable in
Postgres and browsable from the embedded portal. Mocks tell you they
served a request; api-test tells you *what the gateway sent*.
- **Real inbound auth** — file API keys, bcrypt-hashed Postgres-backed
keys, static bearer tokens, and OIDC JWT validation. Mocks let
anything through; api-test rejects bad credentials the way a real
upstream does.
- **Gateway-specific endpoint groups** — one endpoint per pagination
cursor style the gateway recognizes; one endpoint per security probe
the gateway should reject; failure modes with seeded determinism so
retry/timeout tests are reproducible.
- **In-tree OpenAPI** — every route is published at `/openapi.json`,
generated from the same metadata the portal uses, so the gateway's
`api_list_endpoints` tool sees an exact contract.

## Endpoint groups

Expand All @@ -33,12 +60,39 @@ what came back.
for retry/timeout policy testing.
- **echo** — `ANY /v1/echo`. Generic catch-all that returns the request
verbatim (with auth headers redacted).

Coming in later milestones: streaming (chunked, SSE, NDJSON), pagination
(Link, OData, cursor variants), method matrix, security probes, export
(large/long-running targets for `api_export`), the OpenAPI document,
inbound auth (bearer/api_key/OAuth2), audit log, web portal, mkdocs
site, and CI/release tooling.
- **streaming** — chunked, SSE, and NDJSON variants for stream-proxy
testing.
- **pagination** — RFC 5988 Link, OData v4, and opaque-cursor styles;
same synthetic dataset under all three so cross-style assertions are
bit-equal.
- **methods** — HTTP method matrix (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)
for verifying method pass-through.
- **security** — SSRF / path-traversal / admin-prefix probes the
gateway should reject upstream.
- **export** — long-running / large-payload targets for the gateway's
`api_export` tool.

## Web portal

A React SPA embedded into the binary (`/portal/`) gives you a browsable
audit log with filters, charts, request/response payload drawers, an
endpoint catalog, and a Try-It form that proxies through the same auth
chain as `/v1/*`. Sign in with OIDC or paste an API key.

![Portal audit list](docs/images/portal/audit-light.png)

## Prerequisites

- **Go 1.26+** for `go run` / `make build`.
- **Docker** for the full stack (Postgres + Keycloak via
`docker-compose.dev.yml`) and for the integration test suite
(testcontainers).
- **pnpm + Node 20+** only if you change the SPA (`make ui`). The
pre-built bundle ships embedded; you can run the binary without
Node installed.

Postgres is **optional**: `make dev-anon` runs the binary with no DB,
no audit, no portal — fastest loop for endpoint work.

## Quickstart

Expand All @@ -51,29 +105,41 @@ curl -s http://localhost:8080/v1/status/418
curl -s -X POST http://localhost:8080/v1/echo -H 'Content-Type: application/json' -d '{"hi":1}'
```

`make dev-anon` does the same. `make build` produces `./bin/api-test`.
- `make dev-anon` — same thing, anonymous mode, Postgres-only (no Keycloak).
- `make dev` — full stack: Postgres + Keycloak + portal. First run writes
`.env.dev` with random API-key + cookie secrets (gitignored, reused).
- `make build` — produces `./bin/api-test`.

## Tests

```bash
go test ./... # unit + in-memory tests; no Docker required
make test # alias: go test -race -count=1 ./...
make verify # CI-equivalent: fmt, vet, test, lint, security, coverage gate
make test # unit + in-memory; go test -race -count=1 ./...
make integration # //go:build integration; testcontainers Postgres (needs Docker)
make verify # CI-equivalent gate: fmt + vet + lint + test + security + coverage + codeql
```

Integration tests requiring testcontainers Postgres land in.
`make verify` is the single source of truth for "is this tree
shippable" — same commands CI runs. The pre-commit hook reads
`.claude/.last-verify-passed` (written only after a full pass).

## Layout

```
cmd/api-test # binary entry
internal/server # composition root (config + endpoints + httpsrv)
pkg/build # version metadata stamped at link time
internal/server # composition root (config + endpoints + httpsrv + portal)
internal/ui # //go:embed all:dist — SPA bundle
pkg/apikeys # Postgres-backed bcrypt API keys
pkg/audit # Event/Payload model, AsyncLogger, in-memory + Postgres stores
pkg/auth/inbound # APIKey / Bearer / OIDC authenticators + Chain
pkg/config # YAML loader + ${VAR:-default} env interpolation
pkg/endpoints # Endpoints interface + registry
pkg/endpoints/{...} # one package per group (identity, data, failure, echo)
pkg/httpsrv # HTTP mux composition + health/readiness + CORS
configs/ # *.dev.yaml, *.live.yaml, *.example.yaml
pkg/database # pgxpool wrapper + golang-migrate runner
pkg/endpoints/{...} # identity, data, failure, echo, streaming, pagination,
# methods, security, export
pkg/httpmw # RequestID, AccessLog, Identity, Audit middleware
pkg/httpsrv # mux composition + portal API + SPA serving + health
pkg/oapi # in-tree OpenAPI 3.x generator (reflection-based)
configs/ # *.dev.yaml (anon), *.live.yaml (full), *.example.yaml
ui/ # React + Vite + Tailwind portal source
```

## License
Expand Down
15 changes: 15 additions & 0 deletions docs/configuration/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ chain returns 401 immediately. This prevents accidental cross-mode
matches (a typo'd JWT shouldn't accidentally pass the static-bearer
list).

To verify the chain end-to-end, hit
[`GET /v1/whoami`](../endpoints/identity.md#whoami) — it echoes the
resolved `auth_type` and `subject`, so you can confirm the credential
the gateway is actually sending. The auth pipeline diagram and the
data-flow notes live in [Architecture › Auth chain](../reference/architecture.md#auth-chain).

## File API keys

Simplest, no DB required.
Expand Down Expand Up @@ -153,6 +159,15 @@ safe to run with anonymous + a few static keys: clients that send a
valid key get their identity, clients that send nothing get anonymous,
clients that send a bad key get 401.

!!! warning "Don't expect bad-credential demotion"
`allow_anonymous: true` is **not** "let anything in." A typo'd API
key, an expired bearer token, or a JWT signed by the wrong key all
still return 401. The anonymous fallback only fires when there is
no credential header at all. If you want to allow truly
unauthenticated callers from a script while still allowing keyed
callers, make sure the script sends no `X-API-Key` or
`Authorization` header — not a placeholder.

## Portal browser login

The portal uses a standard OIDC PKCE flow: hit `/portal/`, redirect to
Expand Down
21 changes: 18 additions & 3 deletions docs/configuration/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,24 @@ This page is the human-friendly tour.

| Key | Default | Description |
| --- | --- | --- |
| `allow_anonymous` | `false` | Falls back to anonymous identity when no inbound credential matches. |
| `require_for_api` | `false` | **Reserved** for per-surface gating; the inbound chain is currently gated by `allow_anonymous` alone. The shipped `live.yaml` opts in to `true`. |
| `require_for_portal` | `false` | **Reserved**, same shape. The shipped `live.yaml` opts in to `true`. |
| `allow_anonymous` | `false` | Falls back to anonymous identity when no inbound credential matches. The single switch that today gates **all** unauthenticated access — see note below. |
| `require_for_api` | `false` | **Loaded but not yet wired.** Intended for per-surface gating once the API and portal can require auth independently. The shipped `live.yaml` opts in to `true` so existing configs keep working when the gate lands. |
| `require_for_portal` | `false` | **Loaded but not yet wired**, same shape as above. |

!!! note "What actually gates each surface today"
- **`/v1/*`** — the [inbound auth chain](auth.md). When
`allow_anonymous: false`, every endpoint requires a credential
(API key, bearer, or OIDC); a missing credential returns 401.
When `allow_anonymous: true`, missing credentials get an
anonymous identity, but a *bad* credential still 401s.
- **`/portal/`** — `portal.enabled` mounts it; the SPA itself is
reachable without a session, but the portal API
(`/api/v1/portal/*`) requires a session cookie or an API key.
Sign in via OIDC (`oidc.enabled`) or paste an API key from the
file/DB store on the portal sign-in screen.
- **Health and well-known** (`/healthz`, `/readyz`,
`/.well-known/*`) — never gated; live outside both the auth
chain and the audit middleware.

## `api_keys`

Expand Down
114 changes: 103 additions & 11 deletions docs/endpoints/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,127 @@ HTTP method survives the proxy hop unchanged.
| --- | --- | --- |
| `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` | `/v1/method/echo` | `{ "method": "POST", "path": "/v1/method/echo", "query": {...} }` |

`HEAD` returns headers only (per RFC 7231). `OPTIONS` returns the body
plus an `Allow` header listing every supported verb.
The same handler serves every verb. The supported matrix is fixed at
seven verbs — the gateway-testing patterns these probe are mostly
about the seven common-case verbs surviving proxy traversal.

`CONNECT` and `TRACE` are not registered; Go's `http.ServeMux` answers
them with `405 Method Not Allowed` because other verbs are registered
for the same path.
## Response shape

```json
{
"method": "PATCH",
"path": "/v1/method/echo",
"query": { "foo": ["1", "2"] }
}
```

| Field | Notes |
| --- | --- |
| `method` | The verb the server observed. Should equal the verb the client sent — that's the assertion. |
| `path` | Always `/v1/method/echo`. Confirms the gateway didn't rewrite the path along with the method. |
| `query` | The parsed query string. `omitempty` — absent on requests with no query. Use this to assert the gateway preserved query params under unusual verbs (e.g., a `DELETE` with a `?reason=...` parameter). |

Two verbs have special-case behavior:

- **`HEAD`** — returns headers only; the body is suppressed at the HTTP
layer (Go's `http.ResponseWriter` automatically discards the body on
`HEAD`). The response is byte-equivalent to the `GET` headers
otherwise. Use `curl -I` or `curl -is | head -1` to inspect.
- **`OPTIONS`** — returns the body plus an `Allow` header listing every
supported verb:

```http
HTTP/1.1 200 OK
Allow: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Type: application/json
```

Useful to confirm the gateway forwards `OPTIONS` rather than
intercepting it for CORS handling.

## Unregistered verbs

`CONNECT`, `TRACE`, `LINK`, `UNLINK`, `PROPFIND`, and other less-common
verbs are not registered. Go's `http.ServeMux` answers them with
`405 Method Not Allowed` because *other* verbs are registered for the
same path. The 405 itself is informative — it tells you the gateway
forwarded the request, just to a path that doesn't accept that verb.

```bash
curl -is -X CONNECT http://localhost:8080/v1/method/echo | head -1
# HTTP/1.1 405 Method Not Allowed
```

If the gateway *blocks* `CONNECT`/`TRACE` upstream (most should), you
won't see a 405 — you'll see whatever the gateway returns for a
blocked verb. That's also a useful signal.

## Examples

```bash
# Verb preservation
curl -s -X PATCH http://localhost:8080/v1/method/echo
# {"method":"PATCH","path":"/v1/method/echo"}

# HEAD: headers only, no body
curl -is -X HEAD http://localhost:8080/v1/method/echo | head -1
# HTTP/1.1 200 OK

# OPTIONS: body + Allow header
curl -is -X OPTIONS http://localhost:8080/v1/method/echo
# HTTP/1.1 200 OK
# Allow: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
# Content-Type: application/json
# ...
# {"method":"OPTIONS","path":"/v1/method/echo"}

# Query preservation under non-GET
curl -s -X DELETE 'http://localhost:8080/v1/method/echo?reason=cleanup&id=42'
# {"method":"DELETE","path":"/v1/method/echo","query":{"id":["42"],"reason":["cleanup"]}}

curl -s -X CONNECT http://localhost:8080/v1/method/echo
# (405 Method Not Allowed)
# Unregistered verb
curl -is -X CONNECT http://localhost:8080/v1/method/echo | head -1
# HTTP/1.1 405 Method Not Allowed
```

## What to assert

For a gateway proxying api-test:

| Assertion | Means |
| --- | --- |
| Response `method` equals the client's verb | Gateway preserved the verb verbatim. |
| Response `query` matches the client's query string | Gateway didn't strip or reorder query params under this verb. |
| `OPTIONS` returns 200 with `Allow` header | Gateway didn't swallow the response inside a CORS pre-flight handler. |
| `HEAD` returns 200 with no body | Gateway didn't substitute a `GET` body on a `HEAD` response. |

## Audit-log perspective

Each verb registers as its own `EndpointMeta` (`method_get`,
`method_post`, …). The shared handler means the same Go code services
all seven, but the audit row's `route_name` carries the verb-specific
name, so you can `GROUP BY route_name` to count calls per verb:

```sql
SELECT route_name, count(*)
FROM audit_events
WHERE endpoint_group = 'methods'
AND ts > now() - interval '1 hour'
GROUP BY route_name
ORDER BY 2 DESC;
```

If you expected the client to send 50 `PATCH`es through the gateway and
the count comes back showing 50 `POST`es, the gateway is rewriting the
verb — that's exactly the kind of finding this group is built to make
visible.

## Why this exists

Gateway proxies sometimes break verbs in subtle ways: rewriting `PATCH`
to `POST` to fit a stricter client library, swallowing `OPTIONS`
pre-flight responses inside a CORS layer, or refusing `HEAD` because
the upstream handler doesn't register it explicitly. This endpoint
exposes every verb at one path so a tester can spot any of those
rewrites with a single curl loop.
pre-flight responses inside a CORS layer, refusing `HEAD` because the
upstream handler doesn't register it explicitly, or stripping query
strings on verbs that "shouldn't have a body so probably shouldn't have
query either." This endpoint exposes every verb at one path so a
tester can spot any of those rewrites with a single curl loop.
10 changes: 5 additions & 5 deletions docs/endpoints/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ call, the body it got back is bit-for-bit predictable.
| [Data](data.md) | Deterministic bodies for caching / dedup / size handling. |
| [Failure](failure.md) | Controlled error codes, latency, seeded flake. |
| [Echo](echo.md) | Generic catch-all that returns the request verbatim. |
| Streaming | Chunked, SSE, NDJSON responses. |
| Pagination | One endpoint per cursor style the gateway recognizes. |
| Methods | Method matrix on `/v1/method/echo`. |
| Security | Probe targets the gateway should refuse to forward. |
| Export | Large/long-running targets exercising `api_export`. |
| [Streaming](streaming.md) | Chunked, SSE, NDJSON responses. |
| [Pagination](pagination.md) | One endpoint per cursor style the gateway recognizes. |
| [Methods](methods.md) | Method matrix on `/v1/method/echo`. |
| [Security](security.md) | Probe targets the gateway should refuse to forward. |
| [Export](export.md) | Large/long-running targets exercising `api_export`. |

## Toggling groups

Expand Down
Binary file modified docs/endpoints/security.md
Binary file not shown.
6 changes: 3 additions & 3 deletions docs/getting-started/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ use mcp-test to validate MCP-gateway behavior.

- [Installation](installation.md) — download the binary or pull the
container image.
- [Quickstart](quickstart.md) — `make dev` runs the binary in
anonymous mode today; the full Postgres + Keycloak + portal stack
lands with.
- [Quickstart](quickstart.md) — `make dev` brings up the full
Postgres + Keycloak + portal stack; `make dev-anon` skips both for
fastest iteration.
- [Register with Plexara](register-with-plexara.md) — wire api-test in
as a connection.
Loading
Loading