Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/api-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'lens': minor
---

Serve every public route under a `/v1` version segment, keeping the
unversioned paths as deprecated aliases that answer identically.

`src/api/versioning.ts` strips the segment in a Fastify `rewriteUrl` hook before
the request is routed, so `/v1/price/XLM/USDC` and `/price/XLM/USDC` resolve to
the same route definition — same handler, same auth, same x402 gating and rate
limits — rather than being mounted twice and drifting. Existing callers are not
broken: the unversioned form keeps working, and every response to it now carries
`Deprecation: true` and a `Link: </v1/...>; rel="successor-version"` header
pointing at the versioned path. The versioned response carries neither.

`openapi.yaml` (and the generated `openapi.json`) now document the `/v1` paths;
`tests/openapi.test.ts` asserts that every documented versioned path maps onto a
registered route, that the unversioned alias exists for each, and that the two
forms answer identically. Operator-only and non-HTTP routes (`/metrics`, `/ws`,
`/admin/*`, `/graphiql*`) are excluded and remain unversioned.

The warning contract and the alias-retirement window — at least two minor
releases, no more than twelve months — are written down in
[`docs/api-versioning.md`](docs/api-versioning.md).
80 changes: 44 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,42 +77,48 @@ npx @modelcontextprotocol/inspector npm run mcp:server
### REST
| Method | Path | Description |
|---|---|---|
| GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route |
| GET | `/price/:assetA/:assetB/route?amount=1000` | Best execution route for a given amount |
| GET | `/price/:assetA/:assetB/depth?amount=1000` | Simulated order-book depth and execution slippage |
| GET | `/impact/:assetA/:assetB?thresholds=1,5,10` | Adaptive slippage ladder and threshold crossing sizes |
| GET | `/book/:assetA/:assetB?depth=0.1&step=0.01&network=testnet` | Aggregated SDEX book and synthetic AMM price ladder with cumulative size and venue provenance |
| GET | `/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) |
| GET | `/price/twap/:assetA/:assetB?window=60&sampleInterval=60&method=iqr` | Manipulation-resistant time-weighted average price |
| GET | `/price/vwap/:assetA/:assetB?window=60&source=SDEX&method=iqr` | Volume-weighted average price |
| GET | `/candles/:assetA/:assetB?interval=1h&from=…&to=…` | OHLCV candles (`1m`, `5m`, `15m`, `1h`, `4h`, `1d`) |
| GET | `/prices/history?pair=XLM/USDC&from=…&to=…&interval=1m` | Historical 1-minute price snapshots, optionally aggregated (`1m`, `5m`, `1h`); honours `?network=`; `?format=csv` serves the points as `text/csv` (`ts,price,volume`); ~30-day retention |
| GET | `/pools` | Active AMM pools being watched |
| GET | `/pairs` | Watched trading pairs |
| GET | `/volumes/:asset?window=24h&network=testnet` | Cross-venue volume and trade-size concentration (`24h`, `7d`, `30d`) with per-venue breakdown |
| GET | `/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) |
| GET | `/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability |
| GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price |
| GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target |
| GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets |
| GET | `/status` | Indexer health |
| GET | `/snapshot?network=testnet\|mainnet` | Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object |
| GET | `/live` | Liveness probe — no dependency checks (see [`docs/health.md`](docs/health.md)) |
| GET | `/ready` | Readiness probe — boot complete + dependencies reachable |
| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) |
| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer <key>`) |
| GET | `/supported` | Payment kinds and extensions this facilitator supports |
| POST | `/verify` | Verify an x402 payment payload |
| POST | `/settle` | Settle an x402 payment (idempotent — retries replay the stored result) |
| POST | `/webhooks` | Subscribe to a price-threshold webhook |
| DELETE | `/webhooks/:id` | Delete a webhook subscription |
| GET | `/admin/webhook-delivery-failures?limit=25` | Recent permanent webhook failures (requires `X-Admin-Token`) |
| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) |
| GET | `/v1/price/:assetA/:assetB` | Current VWAP, 24h volume, best route |
| GET | `/v1/price/:assetA/:assetB/route?amount=1000` | Best execution route for a given amount |
| GET | `/v1/price/:assetA/:assetB/depth?amount=1000` | Simulated order-book depth and execution slippage |
| GET | `/v1/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) |
| GET | `/v1/price/twap/:assetA/:assetB?window=60&sampleInterval=60&method=iqr` | Manipulation-resistant time-weighted average price |
| GET | `/v1/price/vwap/:assetA/:assetB?window=60&source=SDEX&method=iqr` | Volume-weighted average price |
| GET | `/v1/candles/:assetA/:assetB?interval=1h&from=…&to=…` | OHLCV candles (`1m`, `5m`, `15m`, `1h`, `4h`, `1d`) |
| GET | `/v1/prices/history?pair=XLM/USDC&from=…&to=…&interval=1m` | Historical 1-minute price snapshots, optionally aggregated (`1m`, `5m`, `1h`); honours `?network=`; `?format=csv` serves the points as `text/csv` (`ts,price,volume`); ~30-day retention |
| GET | `/v1/pools` | Active AMM pools being watched |
| GET | `/v1/pairs` | Watched trading pairs |
| GET | `/v1/volumes/:asset?window=24h` | Cross-venue traded volume (`24h`, `7d`, `30d`) with a per-venue breakdown |
| GET | `/v1/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) |
| GET | `/v1/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability |
| GET | `/v1/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price |
| GET | `/v1/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target |
| GET | `/v1/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets |
| GET | `/v1/status` | Indexer health |
| GET | `/v1/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) |
| GET | `/v1/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer <key>`) |
| GET | `/v1/supported` | Payment kinds and extensions this facilitator supports |
| POST | `/v1/verify` | Verify an x402 payment payload |
| POST | `/v1/settle` | Settle an x402 payment (idempotent — retries replay the stored result) |
| POST | `/v1/webhooks` | Subscribe to a price-threshold webhook |
| DELETE | `/v1/webhooks/:id` | Delete a webhook subscription |
| GET/POST | `/v1/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`) |

### Versioning

Every public route is served under `/v1`; that is the canonical path and the one
[`openapi.yaml`](openapi.yaml) documents. The same routes also answer at their
**unversioned** paths as deprecated aliases, so callers integrated before `/v1`
keep working. An alias response carries `Deprecation: true` and a
`Link: </v1/...>; rel="successor-version"` header pointing at the versioned
path; the versioned response does not. See
[`docs/api-versioning.md`](docs/api-versioning.md) for the warning contract and
the retirement window (at least two minor releases, no more than twelve months).

Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml);
`tests/openapi.test.ts` boots the server and fails if a registered route is
missing from the spec. Operator-only and non-HTTP routes (`/admin/keys*`,
`/admin/usage*`, `/admin/webhook-delivery-failures`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via
missing from the spec, if a documented versioned path has no alias, or if the
two forms answer differently. Operator-only and non-HTTP routes (`/admin/keys*`,
`/admin/usage*`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via
the commented allow-list in [`src/openapi/coverage.ts`](src/openapi/coverage.ts).
In addition, live service responses are verified against their published schemas on a daily schedule (`05:00 UTC`) via `npm run spec:validate-live` — see [docs/live-spec-validation.md](docs/live-spec-validation.md).

Expand Down Expand Up @@ -161,15 +167,16 @@ Admin key creation accepts an optional future ISO date-time in `expiresAt`.
Omitting it creates a key without an expiry.

```bash
curl "https://api.example.com/price/XLM/USDC?network=mainnet"
curl "https://api.example.com/v1/price/XLM/USDC?network=mainnet"
```

### Data Quality Guarantees
For formal guarantees on pricing freshness (including the 300s staleness threshold), completeness (venue diversity and outlier filtering), precision (PostgreSQL Decimal(36,18) and negotiated decimal strings), venue inclusion across SDEX and AMMs, and deterministic violation semantics, see [`docs/data-quality.md`](docs/data-quality.md).

### GraphQL
Available at `/graphql` with GraphiQL IDE at `/graphiql`. Real-time price
streaming is available via the `priceUpdated` [subscription](#graphql-subscriptions-live-prices).
Available at `/v1/graphql` (the unversioned `/graphql` alias still works) with
GraphiQL IDE at `/graphiql`. Real-time price streaming is available via the
`priceUpdated` [subscription](#graphql-subscriptions-live-prices).

For complete GraphQL schema documentation, all queries with runnable `curl` examples, WebSocket `graphql-transport-ws` handshake specifications, and an explicit comparison against the REST surface, see [`docs/graphql.md`](docs/graphql.md).

Expand Down Expand Up @@ -699,6 +706,7 @@ against a local server with `npx tsx clients/typescript/src/example.ts`.
Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md).
Lens also *publishes* prices: [`docs/sep40-oracle-publisher.md`](docs/sep40-oracle-publisher.md) covers the SEP-40 contract in `oracle/contract/`, the deviation-and-interval publication policy in `src/publish/`, deployment, and key handling.
The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json.
The versioning and alias-retirement policy is in [API versioning & deprecation policy](docs/api-versioning.md).

Operators: start with the [Operations runbook](docs/RUNBOOK.md) — what runs, how each external dependency degrades, how to check ingestion health via `/status` and `/metrics`, the known failure modes with their symptoms, and deploy/rollback procedures.

Expand Down
88 changes: 88 additions & 0 deletions docs/api-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# API versioning & deprecation policy

Lens serves every public route under a version segment:

```
GET https://<host>/v1/price/XLM/USDC
```

The versioned path is the **canonical** one. It is what [`openapi.yaml`](../openapi.yaml)
documents, and what new clients should integrate against.

The same routes also answer at their **unversioned** paths:

```
GET https://<host>/price/XLM/USDC
```

These are **deprecated aliases** kept so that every caller integrated before
`/v1` existed keeps working without a change. They are not a second
implementation: the version segment is stripped before routing
([`src/api/versioning.ts`](../src/api/versioning.ts)), so an alias request
resolves to the exact same route — same handler, same auth, same x402 gating,
same rate limit — and answers identically apart from the warning headers below.

## Why the aliases exist

Response shapes are changing. Decimals are becoming strings, a `network` field
is being added, and an empty result is getting a distinct convention from "no
data". Each of those is a breaking change for anyone already parsing the
response. A URL version is the only way to make them without breaking existing
callers: `/v1` can change, the unversioned alias cannot.

## How a caller is warned

Every response sent for an **unversioned** request to a public route carries two
headers:

| Header | Value | Meaning |
| --- | --- | --- |
| `Deprecation` | `true` | RFC 8594 signal that this resource is deprecated. |
| `Link` | `</v1/price/XLM/USDC>; rel="successor-version"` | The versioned path for the exact resource requested. |

```
$ curl -sD - -o /dev/null http://localhost:3002/price/XLM/USDC
HTTP/1.1 200 OK
deprecation: true
link: </v1/price/XLM/USDC>; rel="successor-version"
...
```

The versioned request does **not** carry these headers. The headers are
advisory: the alias still answers normally, so a caller that ignores them is not
broken — it is only told where to move.

Operator-only and non-HTTP routes (`/metrics`, `/ws`, `/admin/*`, `/graphiql*`)
are excluded. They are not part of the versioned public contract and were never
intended to move; a request to them is neither versioned nor warned.

## How long an alias lives

An unversioned alias lives for **at least two minor releases** after the
versioned path ships, and no longer than **twelve months**. Concretely:

1. **Announce.** The versioned path ships and the alias starts returning the
`Deprecation` / `Link` headers. This document and the release notes record
the date.
2. **Wait.** The alias stays in place, answering identically, for the full
window. No alias is removed in a patch release.
3. **Retire.** After the window, removal is a `minor` (pre-1.0) or `major`
change with the removal called out in `CHANGELOG.md` and the alias added to
the coverage test's exclusion list in
[`src/openapi/coverage.ts`](../src/openapi/coverage.ts) in the same change.

A removal can be deferred, but never accelerated.

## Adding a new version

1. Change `API_VERSION_PREFIX` in [`src/openapi/coverage.ts`](../src/openapi/coverage.ts)
only when the *current* version's contract can no longer be extended — a
response-shape change that is not additive.
2. Prefix the canonical paths in `openapi.yaml` with the new segment and run
`npm run openapi:gen`.
3. Keep the previous version's paths as aliases (with the warning headers) for
the window above; clients migrate on their own schedule.
4. `tests/openapi.test.ts` boots the app and asserts that every documented
versioned path resolves to a registered alias, and that both answer
identically — so a route that is added but not versioned, or an alias that
drifts from its versioned path, fails CI.
Loading
Loading