diff --git a/.changeset/api-versioning.md b/.changeset/api-versioning.md new file mode 100644 index 00000000..63ae9efb --- /dev/null +++ b/.changeset/api-versioning.md @@ -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: ; 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). diff --git a/README.md b/README.md index 24302db6..cd93aca8 100644 --- a/README.md +++ b/README.md @@ -91,52 +91,48 @@ npx @modelcontextprotocol/inspector npm run mcp:server | Method | Path | Description | |---|---|---| -| GET | `/venues/registry` | List registered venues and their network liveness | -| GET | `/live` | Liveness probe — no dependency checks (see [`docs/health.md`](docs/health.md)) | -| GET | `/tvl` | Total Value Locked across Stellar DeFi venues | -| GET | `/ready` | Readiness probe — boot complete + dependencies reachable | -| GET | `/status` | Indexer health | -| GET | `/snapshot?network=testnet` | Whole-network snapshot: venues, TVL, 24h volume, top pairs, peg readings and data freshness in one cached, timestamped object | -| 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/:asset/fiat` | Convert asset price to fiat currency with NGN support (#290) | -| GET | `/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) | -| GET | `/pools` | Active AMM pools being watched | -| 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 | `/pairs` | Watched trading pairs | -| POST | `/pairs` | Add a new trading pair (Admin only) | -| GET | `/price/:assetA/:assetB/depth?amount=1000` | Simulated order-book depth and execution slippage | -| 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 | `/impact/:assetA/:assetB?thresholds=1,5,10` | Adaptive slippage ladder and threshold crossing sizes | -| 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 | `/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 | `/rates/:asset` | Get supply and borrow rates for an asset across lending venues | -| 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 | `/movers` | Get biggest gainers, biggest losers, and volume movers | -| 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 | `/correlation` | Compute price correlation matrix across a basket of assets over a window | -| 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 | `/discovery/search` | Natural language search with ranking and quality evaluation for discoverable resources | -| POST | `/webhooks` | Subscribe to a price-threshold webhook | -| DELETE | `/webhooks/:id` | Delete a webhook subscription | -| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | -| GET | `/usage` | Get usage and quota for the calling API key (alias for /usage/me) | -| 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) | -| GET | `/rates` | Query latest lending and borrow rates across pools | -| 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 `) | +| 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: ; 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). @@ -187,15 +183,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). @@ -725,6 +722,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. diff --git a/docs/api-versioning.md b/docs/api-versioning.md new file mode 100644 index 00000000..fd6a7421 --- /dev/null +++ b/docs/api-versioning.md @@ -0,0 +1,88 @@ +# API versioning & deprecation policy + +Lens serves every public route under a version segment: + +``` +GET https:///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:///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` | `; 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: ; 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. diff --git a/openapi.json b/openapi.json index 0c401f5e..2041ffce 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Lens API", - "description": "Unified Stellar price API aggregating SDEX + AMM prices.\nCache policy: every route sends a deliberate `Cache-Control` response header. Live prices (`/price/*`) are `public, max-age=30`; aggregates (`/price/*/history`, `/price/twap/*`, `/price/vwap/*`, `/prices/history`, `/compare/*`, `/spreads/*`) are `public, max-age=60`; slower-moving views (`/volumes/*`, `/screener`, `/basket`) are `public, max-age=120`; reference data that changes rarely (`/pairs`, `/pools`, `/benchmark/*`) is `public, max-age=300`; the discovery document is `public, max-age=3600`. `/candles` for a closed historical window (its `to` at least one full interval in the past) is *probably* immutable — backfill can rewrite history — so it is `public, max-age=3600, stale-while-revalidate=86400`; the still-forming window is `public, max-age=60`. Every `public` response also sends `Vary: x-network`, because the Stellar network can come from the `x-network` header (not part of the URL cache key). Routes behind the x402 payment gate use `private` instead of `public` when x402 is configured, so a shared cache never serves paid content for free. Per-caller responses (`/usage/me`), operator routes (`/admin/*`), mutations and error responses are `no-store` and must never sit in a shared cache.\nData Quality Guarantees: see `docs/data-quality.md` for explicit SLAs and guarantees on freshness (e.g. 300s staleness bound), completeness, precision (PostgreSQL Decimal(36,18) and negotiated string serialization), and multi-venue coverage across SDEX, Stellar AMM, Soroswap, and Aquarius.", + "description": "Unified Stellar price API aggregating SDEX + AMM prices.\nEvery route is served under the `/v1` version segment; the versioned path is the canonical one documented here. The same routes also answer at their unversioned paths as deprecated aliases so pre-`/v1` integrations keep working — those responses carry `Deprecation` and `Link` headers pointing at the successor. See `docs/api-versioning.md` for the retirement policy.", "version": "0.1.0", "license": { "name": "MIT", @@ -16,246 +16,7 @@ } ], "paths": { - "/venues/registry": { - "get": { - "operationId": "getVenueRegistry", - "summary": "List registered venues and their network liveness", - "description": "Returns each registered venue adapter for the selected network. Liveness is based on recent price writes where available; on-demand adapters without persisted price points report unknown.", - "security": [], - "parameters": [ - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "Registered venues and their current liveness", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "network", - "venues" - ], - "properties": { - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, - "venues": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "kind", - "network", - "enabled", - "liveness", - "lastSeenAt" - ], - "properties": { - "id": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": [ - "orderbook", - "amm", - "oracle" - ] - }, - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, - "enabled": { - "type": "boolean" - }, - "liveness": { - "type": "string", - "enum": [ - "live", - "stale", - "disabled", - "unknown" - ] - }, - "lastSeenAt": { - "type": "string", - "format": "date-time", - "nullable": true - } - } - } - } - } - } - } - } - } - } - } - }, - "/live": { - "get": { - "operationId": "getLive", - "summary": "Liveness probe", - "description": "Reports whether this process is wedged and needs to be restarted. It checks no dependencies by design: a process that cannot reach Postgres is still healthy and should keep running, so failing liveness during a database outage would restart every instance at once. Use `/ready` to decide whether to send traffic.", - "security": [], - "responses": { - "200": { - "description": "Process is alive." - } - } - } - }, - "/tvl": { - "get": { - "operationId": "getTvl", - "summary": "Total Value Locked across Stellar DeFi venues", - "description": "Aggregates Total Value Locked (TVL) in USD across Stellar Classic AMM, Soroswap, and Aquarius. Reports totals, breakdown by venue, breakdown by asset with valuation sources named, and marks pools with unpriceable assets as unpriced rather than zero. Querying `?history=true` returns historical hourly TVL samples.", - "security": [], - "parameters": [ - { - "$ref": "#/components/parameters/NetworkQuery" - }, - { - "name": "history", - "in": "query", - "required": false, - "description": "Return historical time series of TVL when true", - "schema": { - "type": "boolean", - "default": false - } - }, - { - "name": "window", - "in": "query", - "required": false, - "description": "Historical lookback window in hours (default 24)", - "schema": { - "type": "string", - "default": 24 - } - } - ], - "responses": { - "200": { - "description": "Total Value Locked breakdown or historical series", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControl" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ok", - "uptimeSeconds" - ], - "properties": { - "ok": { - "type": "boolean", - "example": true - }, - "uptimeSeconds": { - "type": "integer", - "description": "Seconds since the process started.", - "example": 3600 - } - } - } - } - } - } - } - } - }, - "/ready": { - "get": { - "operationId": "getReady", - "summary": "Readiness probe", - "description": "Reports whether this process should receive traffic yet. Unhealthy until the boot sequence finishes — the server starts listening before the ingesters and background workers are running — and while Postgres is unreachable or unmigrated, or Redis is unreachable when Redis is part of readiness (`READINESS_REDIS_CHECK`, default true).\nA probe timeout is reported as `down`, and answers are cached briefly, so this is cheap to poll every few seconds. `/live` stays healthy throughout: a starting instance should be held out of rotation, not killed.", - "security": [], - "responses": { - "200": { - "description": "Ready to receive traffic.", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControl" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadinessReport" - } - } - } - }, - "400": { - "description": "Invalid parameters" - }, - "503": { - "description": "Not ready. `ok` is false and `checks` says which dependency (or the boot sequence) is responsible. `/live` remains healthy.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadinessReport", - "properties": { - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, - "totalTvlUsd": { - "type": "number" - }, - "unpricedPoolsCount": { - "type": "integer" - }, - "pricedPoolsCount": { - "type": "integer" - }, - "venues": { - "type": "object" - }, - "byAsset": { - "type": "object" - }, - "pools": { - "type": "array", - "items": { - "type": "object" - } - }, - "computedAt": { - "type": "string", - "format": "date-time" - } - } - } - } - } - } - } - } - }, - "/status": { + "/v1/status": { "get": { "operationId": "getStatus", "summary": "Get API status and indexer state", @@ -403,63 +164,7 @@ } } }, - "/snapshot": { - "get": { - "operationId": "getSnapshot", - "summary": "Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness)", - "description": "A single timestamped summary of one network: every venue's liveness, AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin peg readings and how fresh the underlying data is. All figures are read in one consistent database statement, so `asOf` is a real instant and every age is measured from it. Missing data is reported as `null` with a reason (or an empty list), never invented — a cross-pair volume total is only returned when the pairs share one unit. Responses are cached briefly per network; `cache.ageSeconds` states the age of a served copy.", - "security": [], - "parameters": [ - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "Network snapshot", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - }, - "description": "Freshness/brief-caching policy for the snapshot." - }, - "X-Cache": { - "schema": { - "type": "string", - "enum": [ - "HIT", - "MISS" - ] - }, - "description": "Whether this response came from the short-lived cache." - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SnapshotResponse" - } - } - } - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "description": "Snapshot aggregation failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InternalErrorResponse" - } - } - } - } - } - } - }, - "/price/{assetA}/{assetB}": { + "/v1/price/{assetA}/{assetB}": { "get": { "operationId": "getPrice", "summary": "Get aggregated price for a pair", @@ -522,7 +227,7 @@ } } }, - "/price/{assetA}/{assetB}/route": { + "/v1/price/{assetA}/{assetB}/route": { "get": { "operationId": "getBestRoute", "summary": "Get best swap route for a pair and amount", @@ -589,54 +294,7 @@ } } }, - "/price/{asset}/fiat": { - "get": { - "operationId": "getFiatPrice", - "summary": "Convert asset price to fiat currency with NGN support (#290)", - "security": [], - "parameters": [ - { - "name": "asset", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "currency", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "USD" - } - }, - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "Price converted into target fiat currency", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "400": { - "description": "Invalid parameters" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, - "/price/{assetA}/{assetB}/history": { + "/v1/price/{assetA}/{assetB}/history": { "get": { "operationId": "getPriceHistory", "summary": "Get price history buckets", @@ -736,7 +394,7 @@ } } }, - "/pools": { + "/v1/pools": { "get": { "operationId": "getPools", "summary": "Get AMM liquidity pool reserves and spot prices", @@ -834,7 +492,7 @@ } } }, - "/candles/{assetA}/{assetB}": { + "/v1/candles/{assetA}/{assetB}": { "get": { "operationId": "getCandles", "summary": "Get OHLCV candle data", @@ -967,7 +625,7 @@ } } }, - "/prices/history": { + "/v1/prices/history": { "get": { "operationId": "getPriceSnapshots", "summary": "Get historical 1-minute price snapshots", @@ -1116,281 +774,118 @@ } } } - }, - "/pairs": { - "get": { - "operationId": "listPairs", - "summary": "List all active trading pairs", - "security": [], - "parameters": [ - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "List of pairs", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControl" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "pairs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "pairKey": { - "type": "string" - }, - "assetA": { - "$ref": "#/components/schemas/Asset" - }, - "assetB": { - "$ref": "#/components/schemas/Asset" - }, - "latestPrice": { - "type": "number", - "nullable": true - }, - "lastUpdated": { - "type": "string", - "format": "date-time", - "nullable": true - } - } - } - } - } - } - } - } - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - }, - "post": { - "operationId": "addPair", - "summary": "Add a new trading pair (Admin only)", - "security": [ - { - "adminAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "assetA": { - "type": "string", - "example": "XLM" - }, - "assetB": { - "type": "string", - "example": "USDC:GBBD47..." - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Pair added" - }, - "400": { - "description": "Invalid input" - }, - "401": { - "description": "Unauthorized" - }, - "409": { - "description": "Pair already exists" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, - "/price/{assetA}/{assetB}/depth": { - "get": { - "operationId": "getDepth", - "summary": "Get order-book depth and execution slippage for a pair", - "description": "Simulates an order of `amount` units of assetA and returns the price ladder plus the execution price and slippage versus the spot price.", - "security": [ - { - "x402": [] - } - ], - "parameters": [ - { - "name": "assetA", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "example": "XLM" - }, - { - "name": "assetB", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "example": "USDC" - }, - { - "name": "amount", - "in": "query", - "required": false, - "schema": { - "type": "number", - "default": 1000 - } - }, - { - "$ref": "#/components/parameters/NetworkQuery" - }, - { - "$ref": "#/components/parameters/DecimalFormatHeader" - } - ], - "responses": { - "200": { - "description": "Depth ladder", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControl" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DepthResponse" - } - } - } - }, - "400": { - "description": "Invalid amount" - }, - "402": { - "$ref": "#/components/responses/PaymentRequired" - }, - "404": { - "description": "Pair not watched" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, - "/book/{assetA}/{assetB}": { - "get": { - "operationId": "getBook", - "summary": "Get the aggregated SDEX and AMM price ladder for a pair", - "description": "Returns asks and bids as cumulative assetA size at each price. Live SDEX levels are reported as observed; AMM levels are synthetic, fee-adjusted reserve steps. `depth` is a fractional price distance around spot (default 0.1), and `step` is the fraction of each AMM reserve used per synthetic level (default 0.01). Per-venue sample counts, observation times, ages and availability are included.", - "parameters": [ - { - "name": "assetA", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Base asset; use XLM for native Stellar or CODE:ISSUER for a token.", - "example": "XLM" - }, - { - "name": "assetB", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Quote asset; token issuers must be supplied in full.", - "example": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - { - "name": "depth", - "in": "query", - "required": false, - "description": "Maximum fractional distance from spot; valid range 0 to 0.5.", - "schema": { - "type": "number", - "minimum": 0, - "maximum": 0.5, - "default": 0.1 - } - }, - { - "name": "step", - "in": "query", - "required": false, - "description": "AMM input-reserve fraction per synthetic rung; valid range 0.001 to 0.1.", - "schema": { - "type": "number", - "minimum": 0.001, - "maximum": 0.1, - "default": 0.01 - } - }, + }, + "/v1/pairs": { + "get": { + "operationId": "listPairs", + "summary": "List all active trading pairs", + "security": [], + "parameters": [ { "$ref": "#/components/parameters/NetworkQuery" - }, - { - "$ref": "#/components/parameters/DecimalFormatHeader" } ], "responses": { "200": { - "description": "Book levels with per-venue provenance; unavailable venues are identified in the response.", + "description": "List of pairs", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControl" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BookResponse" + "type": "object", + "properties": { + "pairs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "assetA": { + "$ref": "#/components/schemas/Asset" + }, + "assetB": { + "$ref": "#/components/schemas/Asset" + }, + "latestPrice": { + "type": "number", + "nullable": true + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } } } } }, - "400": { - "description": "Invalid asset, depth, or step parameter" - }, "429": { "$ref": "#/components/responses/TooManyRequests" - }, - "503": { - "description": "No source could be read; response provenance identifies failed venues.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookResponse" + } + } + }, + "post": { + "operationId": "addPair", + "summary": "Add a new trading pair (Admin only)", + "security": [ + { + "adminAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "assetA": { + "type": "string", + "example": "XLM" + }, + "assetB": { + "type": "string", + "example": "USDC:GBBD47..." + } } } } } + }, + "responses": { + "201": { + "description": "Pair added" + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Unauthorized" + }, + "409": { + "description": "Pair already exists" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } } } }, - "/impact/{assetA}/{assetB}": { + "/v1/price/{assetA}/{assetB}/depth": { "get": { - "operationId": "getImpact", - "summary": "Get an adaptive slippage impact curve for a pair", - "description": "Returns slippage at standard trade sizes, an additional liquidity-sized point, and the size at which configured slippage thresholds are crossed. A thin pair returns `insufficient_liquidity` instead of a fabricated 100% slippage result.", + "operationId": "getDepth", + "summary": "Get order-book depth and execution slippage for a pair", + "description": "Simulates an order of `amount` units of assetA and returns the price ladder plus the execution price and slippage versus the spot price.", "security": [ { "x402": [] @@ -1416,48 +911,45 @@ "example": "USDC" }, { - "name": "thresholds", - "in": "query", - "required": false, - "description": "Comma-separated positive slippage percentages. Defaults to 1,5,10.", - "schema": { - "type": "string", - "default": "1,5,10" - } - }, - { - "name": "liquidityFraction", + "name": "amount", "in": "query", "required": false, - "description": "Usable fraction of the A-side reserve, between 0 and 1.", "schema": { "type": "number", - "default": 0.5 + "default": 1000 } }, { "$ref": "#/components/parameters/NetworkQuery" + }, + { + "$ref": "#/components/parameters/DecimalFormatHeader" } ], "responses": { "200": { - "description": "Adaptive impact curve", + "description": "Depth ladder", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControl" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImpactResponse" + "$ref": "#/components/schemas/DepthResponse" } } } }, "400": { - "description": "Invalid threshold or liquidity settings" + "description": "Invalid amount" }, "402": { "$ref": "#/components/responses/PaymentRequired" }, "404": { - "description": "Pair or AMM liquidity not found" + "description": "Pair not watched" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1465,7 +957,7 @@ } } }, - "/price/twap/{assetA}/{assetB}": { + "/v1/price/twap/{assetA}/{assetB}": { "get": { "operationId": "getTwap", "summary": "Get a manipulation-resistant time-weighted average price", @@ -1566,7 +1058,7 @@ } } }, - "/price/vwap/{assetA}/{assetB}": { + "/v1/price/vwap/{assetA}/{assetB}": { "get": { "operationId": "getVwap", "summary": "Get a volume-weighted average price", @@ -1668,7 +1160,7 @@ } } }, - "/volumes/{asset}": { + "/v1/volumes/{asset}": { "get": { "operationId": "getVolumes", "summary": "Get cross-venue traded volume for an asset", @@ -1737,7 +1229,7 @@ } } }, - "/spreads/{asset}": { + "/v1/spreads/{asset}": { "get": { "operationId": "getSpreads", "summary": "Get the tightest per-venue bid/ask spread for an asset", @@ -1807,50 +1299,7 @@ } } }, - "/rates/{asset}": { - "get": { - "operationId": "getLendingRates", - "summary": "Get supply and borrow rates for an asset across lending venues", - "description": "Answers what `asset` earns when supplied and costs to borrow at every lending venue Lens ingests, one entry per venue sorted best-first (highest supply rate first). Each entry names the venue, the source it was read from and the specific pool/market it came from, so a rate is always attributable and venues are never silently averaged.\nAn asset with no lending market returns an empty `venues` list with a 200, not a 404.", - "security": [], - "parameters": [ - { - "name": "asset", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "example": "USDC" - }, - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "Per-venue lending rates, best-first (empty when no market)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RatesResponse" - } - } - } - }, - "400": { - "description": "Invalid network" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "description": "Rate lookup failed" - } - } - } - }, - "/compare/{asset}": { + "/v1/compare/{asset}": { "get": { "operationId": "compareAsset", "summary": "Compare Lens prices with every configured external oracle", @@ -1892,7 +1341,7 @@ } } }, - "/screener": { + "/v1/screener": { "get": { "operationId": "screenPairs", "summary": "Screen pairs by volume, change, liquidity and price", @@ -2027,55 +1476,7 @@ } } }, - "/movers": { - "get": { - "operationId": "getMovers", - "summary": "Get biggest gainers, biggest losers, and volume movers", - "description": "Returns the top gainers, losers, and volume movers for the specified lookback window, annotated with venue provenance, sample counts, and single-venue move labelling.", - "security": [], - "parameters": [ - { - "name": "window", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "1h", - "24h", - "7d" - ], - "default": "24h" - } - }, - { - "$ref": "#/components/parameters/NetworkQuery" - } - ], - "responses": { - "200": { - "description": "Market movers", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MoversResponse" - } - } - } - }, - "400": { - "description": "Invalid window or network" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/benchmark/{asset}": { + "/v1/benchmark/{asset}": { "get": { "operationId": "getBenchmark", "summary": "Get peg-deviation statistics for an asset against a target", @@ -2135,7 +1536,7 @@ } } }, - "/basket": { + "/v1/basket": { "get": { "operationId": "getBasket", "summary": "Compute the weighted price of a basket of assets", @@ -2275,7 +1676,7 @@ } } }, - "/discovery/resources": { + "/v1/discovery/resources": { "get": { "operationId": "listDiscoveryResources", "summary": "Browse the x402 Bazaar catalog of discoverable resources", @@ -2363,55 +1764,7 @@ } } }, - "/discovery/search": { - "get": { - "operationId": "searchDiscoveryResources", - "summary": "Natural language search with ranking and quality evaluation for discoverable resources", - "parameters": [ - { - "name": "q", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 20 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Search results with relevance ranking and metadata quality", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - } - } - }, - "/webhooks": { + "/v1/webhooks": { "post": { "operationId": "createWebhook", "summary": "Subscribe to a price-threshold webhook", @@ -2479,7 +1832,7 @@ } } }, - "/webhooks/{id}": { + "/v1/webhooks/{id}": { "delete": { "operationId": "deleteWebhook", "summary": "Delete a webhook subscription", @@ -2504,7 +1857,7 @@ } } }, - "/usage/me": { + "/v1/usage/me": { "get": { "operationId": "getMyUsage", "summary": "Get usage and quota for the calling API key", @@ -2538,7 +1891,7 @@ } } }, - "/usage": { + "/v1/supported": { "get": { "operationId": "getCallerUsage", "summary": "Get usage and quota for the calling API key (alias for /usage/me)", @@ -2572,31 +1925,7 @@ } } }, - "/supported": { - "get": { - "operationId": "getSupported", - "summary": "List the payment kinds and extensions this facilitator supports", - "security": [], - "responses": { - "200": { - "description": "Supported payment kinds and extensions", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControl" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SupportedResponse" - } - } - } - } - } - } - }, - "/verify": { + "/v1/verify": { "post": { "operationId": "verifyPayment", "summary": "Verify an x402 payment payload", @@ -2645,7 +1974,7 @@ } } }, - "/settle": { + "/v1/settle": { "post": { "operationId": "settlePayment", "summary": "Settle an x402 payment (idempotent)", @@ -2685,7 +2014,7 @@ } } }, - "/graphql": { + "/v1/graphql": { "get": { "operationId": "graphqlGet", "summary": "Execute a GraphQL query over HTTP GET", diff --git a/openapi.yaml b/openapi.yaml index 72d5778b..b05ca652 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,30 +4,11 @@ info: description: >- Unified Stellar price API aggregating SDEX + AMM prices. - Cache policy: every route sends a deliberate `Cache-Control` response - header. Live prices (`/price/*`) are `public, max-age=30`; aggregates - (`/price/*/history`, `/price/twap/*`, `/price/vwap/*`, `/prices/history`, - `/compare/*`, `/spreads/*`) are `public, max-age=60`; slower-moving views - (`/volumes/*`, `/screener`, `/basket`) are `public, max-age=120`; - reference data that changes rarely (`/pairs`, `/pools`, `/benchmark/*`) - is `public, max-age=300`; the discovery document is - `public, max-age=3600`. `/candles` for a closed historical window (its - `to` at least one full interval in the past) is *probably* immutable — - backfill can rewrite history — so it is - `public, max-age=3600, stale-while-revalidate=86400`; the still-forming - window is `public, max-age=60`. Every `public` response also sends - `Vary: x-network`, because the Stellar network can come from the - `x-network` header (not part of the URL cache key). Routes behind the - x402 payment gate use `private` instead of `public` when x402 is - configured, so a shared cache never serves paid content for free. - Per-caller responses (`/usage/me`), operator routes - (`/admin/*`), mutations and error responses are `no-store` and must never - sit in a shared cache. - - Data Quality Guarantees: see `docs/data-quality.md` for explicit SLAs - and guarantees on freshness (e.g. 300s staleness bound), completeness, - precision (PostgreSQL Decimal(36,18) and negotiated string serialization), - and multi-venue coverage across SDEX, Stellar AMM, Soroswap, and Aquarius. + Every route is served under the `/v1` version segment; the versioned path is + the canonical one documented here. The same routes also answer at their + unversioned paths as deprecated aliases so pre-`/v1` integrations keep + working — those responses carry `Deprecation` and `Link` headers pointing at + the successor. See `docs/api-versioning.md` for the retirement policy. version: 0.1.0 license: name: MIT @@ -38,170 +19,7 @@ servers: description: Local development server paths: - /venues/registry: - get: - operationId: getVenueRegistry - summary: List registered venues and their network liveness - description: Returns each registered venue adapter for the selected network. Liveness is based on recent price writes where available; on-demand adapters without persisted price points report unknown. - security: [] - parameters: - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Registered venues and their current liveness - content: - application/json: - schema: - type: object - required: [network, venues] - properties: - network: - type: string - enum: [testnet, mainnet] - venues: - type: array - items: - type: object - required: [id, kind, network, enabled, liveness, lastSeenAt] - properties: - id: - type: string - kind: - type: string - enum: [orderbook, amm, oracle] - network: - type: string - enum: [testnet, mainnet] - enabled: - type: boolean - liveness: - type: string - enum: [live, stale, disabled, unknown] - lastSeenAt: - type: string - format: date-time - nullable: true - - /live: - get: - operationId: getLive - summary: Liveness probe - description: >- - Reports whether this process is wedged and needs to be restarted. It - checks no dependencies by design: a process that cannot reach Postgres - is still healthy and should keep running, so failing liveness during a - database outage would restart every instance at once. Use `/ready` to - decide whether to send traffic. - security: [] - responses: - '200': - description: Process is alive. - /tvl: - get: - operationId: getTvl - summary: Total Value Locked across Stellar DeFi venues - description: >- - Aggregates Total Value Locked (TVL) in USD across Stellar Classic AMM, - Soroswap, and Aquarius. Reports totals, breakdown by venue, breakdown - by asset with valuation sources named, and marks pools with unpriceable - assets as unpriced rather than zero. Querying `?history=true` returns - historical hourly TVL samples. - security: [] - parameters: - - $ref: '#/components/parameters/NetworkQuery' - - name: history - in: query - required: false - description: Return historical time series of TVL when true - schema: - type: boolean - default: false - - name: window - in: query - required: false - description: Historical lookback window in hours (default 24) - schema: - type: string - default: 24 - responses: - '200': - description: Total Value Locked breakdown or historical series - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - type: object - required: [ok, uptimeSeconds] - properties: - ok: - type: boolean - example: true - uptimeSeconds: - type: integer - description: Seconds since the process started. - example: 3600 - - /ready: - get: - operationId: getReady - summary: Readiness probe - description: >- - Reports whether this process should receive traffic yet. Unhealthy - until the boot sequence finishes — the server starts listening before - the ingesters and background workers are running — and while Postgres - is unreachable or unmigrated, or Redis is unreachable when Redis is - part of readiness (`READINESS_REDIS_CHECK`, default true). - - A probe timeout is reported as `down`, and answers are cached briefly, - so this is cheap to poll every few seconds. `/live` stays healthy - throughout: a starting instance should be held out of rotation, not - killed. - security: [] - responses: - '200': - description: Ready to receive traffic. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - $ref: '#/components/schemas/ReadinessReport' - '503': - description: >- - Not ready. `ok` is false and `checks` says which dependency (or the - boot sequence) is responsible. `/live` remains healthy. - content: - application/json: - schema: - $ref: '#/components/schemas/ReadinessReport' - properties: - network: - type: string - enum: [testnet, mainnet] - totalTvlUsd: - type: number - unpricedPoolsCount: - type: integer - pricedPoolsCount: - type: integer - venues: - type: object - byAsset: - type: object - pools: - type: array - items: - type: object - computedAt: - type: string - format: date-time - '400': - description: Invalid parameters - - /status: + /v1/status: get: operationId: getStatus summary: Get API status and indexer state @@ -306,49 +124,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /snapshot: - get: - operationId: getSnapshot - summary: Whole-network snapshot (venues, TVL, 24h volume, top pairs, pegs, freshness) - description: >- - A single timestamped summary of one network: every venue's liveness, - AMM pool reserves (TVL), 24h traded volume, the busiest pairs, stablecoin - peg readings and how fresh the underlying data is. All figures are read - in one consistent database statement, so `asOf` is a real instant and - every age is measured from it. Missing data is reported as `null` with a - reason (or an empty list), never invented — a cross-pair volume total is - only returned when the pairs share one unit. Responses are cached briefly - per network; `cache.ageSeconds` states the age of a served copy. - security: [] - parameters: - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Network snapshot - headers: - Cache-Control: - schema: - type: string - description: Freshness/brief-caching policy for the snapshot. - X-Cache: - schema: - type: string - enum: [HIT, MISS] - description: Whether this response came from the short-lived cache. - content: - application/json: - schema: - $ref: '#/components/schemas/SnapshotResponse' - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - description: Snapshot aggregation failed - content: - application/json: - schema: - $ref: '#/components/schemas/InternalErrorResponse' - - /price/{assetA}/{assetB}: + /v1/price/{assetA}/{assetB}: get: operationId: getPrice summary: Get aggregated price for a pair @@ -386,7 +162,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/{assetA}/{assetB}/route: + /v1/price/{assetA}/{assetB}/route: get: operationId: getBestRoute summary: Get best swap route for a pair and amount @@ -426,37 +202,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/{asset}/fiat: - get: - operationId: getFiatPrice - summary: Convert asset price to fiat currency with NGN support (#290) - security: [] - parameters: - - name: asset - in: path - required: true - schema: - type: string - - name: currency - in: query - required: false - schema: - type: string - default: USD - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Price converted into target fiat currency - content: - application/json: - schema: - type: object - '400': - description: Invalid parameters - '429': - $ref: '#/components/responses/TooManyRequests' - - /price/{assetA}/{assetB}/history: + /v1/price/{assetA}/{assetB}/history: get: operationId: getPriceHistory summary: Get price history buckets @@ -515,7 +261,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /pools: + /v1/pools: get: operationId: getPools summary: Get AMM liquidity pool reserves and spot prices @@ -568,7 +314,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /candles/{assetA}/{assetB}: + /v1/candles/{assetA}/{assetB}: get: operationId: getCandles summary: Get OHLCV candle data @@ -662,7 +408,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /prices/history: + /v1/prices/history: get: operationId: getPriceSnapshots summary: Get historical 1-minute price snapshots @@ -773,7 +519,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /pairs: + /v1/pairs: get: operationId: listPairs summary: List all active trading pairs @@ -841,7 +587,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/{assetA}/{assetB}/depth: + /v1/price/{assetA}/{assetB}/depth: get: operationId: getDepth summary: Get order-book depth and execution slippage for a pair @@ -890,126 +636,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /book/{assetA}/{assetB}: - get: - operationId: getBook - summary: Get the aggregated SDEX and AMM price ladder for a pair - description: >- - Returns asks and bids as cumulative assetA size at each price. Live - SDEX levels are reported as observed; AMM levels are synthetic, - fee-adjusted reserve steps. `depth` is a fractional price distance - around spot (default 0.1), and `step` is the fraction of each AMM - reserve used per synthetic level (default 0.01). Per-venue sample - counts, observation times, ages and availability are included. - parameters: - - name: assetA - in: path - required: true - schema: - type: string - description: Base asset; use XLM for native Stellar or CODE:ISSUER for a token. - example: XLM - - name: assetB - in: path - required: true - schema: - type: string - description: Quote asset; token issuers must be supplied in full. - example: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 - - name: depth - in: query - required: false - description: Maximum fractional distance from spot; valid range 0 to 0.5. - schema: - type: number - minimum: 0 - maximum: 0.5 - default: 0.1 - - name: step - in: query - required: false - description: AMM input-reserve fraction per synthetic rung; valid range 0.001 to 0.1. - schema: - type: number - minimum: 0.001 - maximum: 0.1 - default: 0.01 - - $ref: '#/components/parameters/NetworkQuery' - - $ref: '#/components/parameters/DecimalFormatHeader' - responses: - '200': - description: Book levels with per-venue provenance; unavailable venues are identified in the response. - content: - application/json: - schema: - $ref: '#/components/schemas/BookResponse' - '400': - description: Invalid asset, depth, or step parameter - '503': - description: No source could be read; response provenance identifies failed venues. - content: - application/json: - schema: - $ref: '#/components/schemas/BookResponse' - '429': - $ref: '#/components/responses/TooManyRequests' - - /impact/{assetA}/{assetB}: - get: - operationId: getImpact - summary: Get an adaptive slippage impact curve for a pair - description: >- - Returns slippage at standard trade sizes, an additional liquidity-sized - point, and the size at which configured slippage thresholds are crossed. - A thin pair returns `insufficient_liquidity` instead of a fabricated - 100% slippage result. - security: - - x402: [] - parameters: - - name: assetA - in: path - required: true - schema: - type: string - example: XLM - - name: assetB - in: path - required: true - schema: - type: string - example: USDC - - name: thresholds - in: query - required: false - description: Comma-separated positive slippage percentages. Defaults to 1,5,10. - schema: - type: string - default: 1,5,10 - - name: liquidityFraction - in: query - required: false - description: Usable fraction of the A-side reserve, between 0 and 1. - schema: - type: number - default: 0.5 - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Adaptive impact curve - content: - application/json: - schema: - $ref: '#/components/schemas/ImpactResponse' - '400': - description: Invalid threshold or liquidity settings - '402': - $ref: '#/components/responses/PaymentRequired' - '404': - description: Pair or AMM liquidity not found - '429': - $ref: '#/components/responses/TooManyRequests' - - /price/twap/{assetA}/{assetB}: + /v1/price/twap/{assetA}/{assetB}: get: operationId: getTwap summary: Get a manipulation-resistant time-weighted average price @@ -1072,7 +699,7 @@ paths: '500': description: TWAP computation failed - /price/vwap/{assetA}/{assetB}: + /v1/price/vwap/{assetA}/{assetB}: get: operationId: getVwap summary: Get a volume-weighted average price @@ -1133,7 +760,7 @@ paths: '500': description: VWAP computation failed - /volumes/{asset}: + /v1/volumes/{asset}: get: operationId: getVolumes summary: Get cross-venue traded volume for an asset @@ -1190,7 +817,7 @@ paths: schema: $ref: '#/components/schemas/InternalErrorResponse' - /spreads/{asset}: + /v1/spreads/{asset}: get: operationId: getSpreads summary: Get the tightest per-venue bid/ask spread for an asset @@ -1236,43 +863,7 @@ paths: schema: $ref: '#/components/schemas/InternalErrorResponse' - /rates/{asset}: - get: - operationId: getLendingRates - summary: Get supply and borrow rates for an asset across lending venues - description: >- - Answers what `asset` earns when supplied and costs to borrow at every - lending venue Lens ingests, one entry per venue sorted best-first - (highest supply rate first). Each entry names the venue, the source it - was read from and the specific pool/market it came from, so a rate is - always attributable and venues are never silently averaged. - - An asset with no lending market returns an empty `venues` list with a - 200, not a 404. - security: [] - parameters: - - name: asset - in: path - required: true - schema: - type: string - example: USDC - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Per-venue lending rates, best-first (empty when no market) - content: - application/json: - schema: - $ref: '#/components/schemas/RatesResponse' - '400': - description: Invalid network - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - description: Rate lookup failed - - /compare/{asset}: + /v1/compare/{asset}: get: operationId: compareAsset summary: Compare Lens prices with every configured external oracle @@ -1304,7 +895,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /screener: + /v1/screener: get: operationId: screenPairs summary: Screen pairs by volume, change, liquidity and price @@ -1391,38 +982,7 @@ paths: '500': description: Internal server error - /movers: - get: - operationId: getMovers - summary: Get biggest gainers, biggest losers, and volume movers - description: >- - Returns the top gainers, losers, and volume movers for the specified lookback window, - annotated with venue provenance, sample counts, and single-venue move labelling. - security: [] - parameters: - - name: window - in: query - required: false - schema: - type: string - enum: [1h, 24h, 7d] - default: 24h - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Market movers - content: - application/json: - schema: - $ref: '#/components/schemas/MoversResponse' - '400': - description: Invalid window or network - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - description: Internal server error - - /benchmark/{asset}: + /v1/benchmark/{asset}: get: operationId: getBenchmark summary: Get peg-deviation statistics for an asset against a target @@ -1461,7 +1021,7 @@ paths: schema: $ref: '#/components/schemas/InternalErrorResponse' - /basket: + /v1/basket: get: operationId: getBasket summary: Compute the weighted price of a basket of assets @@ -1509,47 +1069,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /correlation: - get: - operationId: getCorrelation - summary: Compute price correlation matrix across a basket of assets over a window - security: [] - parameters: - - name: assets - in: query - required: true - description: Comma-separated or repeated asset codes (at least two) - schema: - oneOf: - - type: string - - type: array - items: - type: string - example: XLM,USDC - - name: window - in: query - required: false - description: Lookback window (5m, 15m, 1h, 24h, 7d, 30d). Defaults to 24h. - schema: - type: string - enum: [5m, 15m, 1h, 24h, 7d, 30d] - default: 24h - - $ref: '#/components/parameters/NetworkQuery' - responses: - '200': - description: Correlation matrix and pair provenance - content: - application/json: - schema: - $ref: '#/components/schemas/CorrelationResponse' - '400': - description: Missing or invalid parameters - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - description: Correlation computation failed - - /discovery/resources: + /v1/discovery/resources: get: operationId: listDiscoveryResources summary: Browse the x402 Bazaar catalog of discoverable resources @@ -1609,38 +1129,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /discovery/search: - get: - operationId: searchDiscoveryResources - summary: Natural language search with ranking and quality evaluation for discoverable resources - parameters: - - name: q - in: query - required: true - schema: - type: string - - name: limit - in: query - required: false - schema: - type: integer - default: 20 - - name: cursor - in: query - required: false - schema: - type: string - responses: - '200': - description: Search results with relevance ranking and metadata quality - content: - application/json: - schema: - type: object - '429': - $ref: '#/components/responses/TooManyRequests' - - /webhooks: + /v1/webhooks: post: operationId: createWebhook summary: Subscribe to a price-threshold webhook @@ -1684,7 +1173,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /webhooks/{id}: + /v1/webhooks/{id}: delete: operationId: deleteWebhook summary: Delete a webhook subscription @@ -1701,7 +1190,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /usage/me: + /v1/usage/me: get: operationId: getMyUsage summary: Get usage and quota for the calling API key @@ -1722,28 +1211,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /usage: - get: - operationId: getCallerUsage - summary: Get usage and quota for the calling API key (alias for /usage/me) - security: - - apiKeyAuth: [] - responses: - '200': - description: Usage summary for the authenticated key - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - $ref: '#/components/schemas/UsageResponse' - '401': - description: Missing or invalid API key - '429': - $ref: '#/components/responses/TooManyRequests' - - /supported: + /v1/supported: get: operationId: getSupported summary: List the payment kinds and extensions this facilitator supports @@ -1759,7 +1227,7 @@ paths: schema: $ref: '#/components/schemas/SupportedResponse' - /verify: + /v1/verify: post: operationId: verifyPayment summary: Verify an x402 payment payload @@ -1790,7 +1258,7 @@ paths: schema: $ref: '#/components/schemas/VerifyResponse' - /settle: + /v1/settle: post: operationId: settlePayment summary: Settle an x402 payment (idempotent) @@ -1819,7 +1287,7 @@ paths: schema: $ref: '#/components/schemas/SettleResponse' - /graphql: + /v1/graphql: get: operationId: graphqlGet summary: Execute a GraphQL query over HTTP GET diff --git a/src/api/versioning.ts b/src/api/versioning.ts new file mode 100644 index 00000000..230a3df7 --- /dev/null +++ b/src/api/versioning.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from 'fastify' +import { + isInternalRoute, + isVersionedPath, + toBarePath, + toOpenApiPath, + toVersionedPath, +} from '../openapi/coverage' + +/** + * API surfacing policy, in code. + * + * Lens mounts every route once, at its bare path, and serves it in two places: + * + * - `/v1/` — the canonical, versioned path. `openapi.yaml` documents + * this form and new clients should use it. + * - `/` — an unversioned alias kept so callers integrated before `/v1` + * existed keep working. It is deprecated and every response carries the + * warning headers described in `docs/api-versioning.md`. + * + * Rather than registering each route twice (which would double the surface and, + * more importantly, let the two copies drift in middleware behaviour — x402 + * gating, auth and rate limits all key off the *routed* path), the version + * segment is stripped before Fastify routes the request. Both URLs therefore + * resolve to the exact same route definition and answer identically by + * construction. + * + * See `docs/api-versioning.md` for the deprecation window and warning contract. + */ + +/** Splits a URL into its path and its query string (everything from the first `?`). */ +function splitPath(url: string): { path: string; query: string } { + const questionMark = url.indexOf('?') + if (questionMark === -1) return { path: url, query: '' } + return { path: url.slice(0, questionMark), query: url.slice(questionMark) } +} + +/** + * Fastify `rewriteUrl`: resolves the versioned and unversioned forms onto the + * single bare route that is actually registered. + * + * `/v1/price/XLM/USDC?network=mainnet` → `/price/XLM/USDC?network=mainnet` + * + * A request that already used the bare path is returned unchanged; the caller + * who used the deprecated alias is identified later from the original URL that + * Fastify records (`req.raw.originalUrl`) before calling this function. + */ +export function rewriteVersionedApiUrl(req: { url?: string }): string { + const url = req.url ?? '/' + const { path, query } = splitPath(url) + if (!isVersionedPath(path)) return url + return `${toBarePath(path)}${query}` +} + +/** + * Registers the deprecation warning for the unversioned aliases. + * + * Every request that arrived without the `/v1` prefix and resolved to a public + * (non-internal) route gets: + * + * - `Deprecation: true` — RFC 8594 signal that this resource is deprecated. + * - `Link: ; rel="successor-version"` — the versioned path to use + * instead, for the exact resource the caller asked for. + * + * The headers are advisory: the alias keeps answering normally, so a caller + * that ignores them is not broken. 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. + */ +export function registerApiVersioning(app: FastifyInstance): void { + app.addHook('onRequest', async (req, reply) => { + // Fastify records the caller's original URL on the raw request before the + // rewriteUrl hook (fastify.js `preRouting`), so this is the un-rewritten + // path even though `req.url` has had the `/v1` segment stripped. + const rawUrl = (req.raw as { originalUrl?: string } | undefined)?.originalUrl + const original = rawUrl ?? req.url + const { path } = splitPath(original) + if (isVersionedPath(path)) return + + const routedPath = req.routeOptions?.url + if (!routedPath || isInternalRoute(toOpenApiPath(routedPath))) return + + reply.header('Deprecation', 'true') + reply.header('Link', `<${toVersionedPath(path)}>; rel="successor-version"`) + }) +} diff --git a/src/app.ts b/src/app.ts index bb11014b..a6c587a7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -34,12 +34,8 @@ import { registerDiscoveryRoutes } from './routes/discovery' import { registerSnapshotRoutes } from './routes/snapshot' import { registerVenueRoutes } from './routes/venues' import { registerSettleRoute } from './routes/facilitator' -import { registerHealthRoutes } from './routes/health' -import { registerTvlRoutes } from './routes/tvl' -import { registerCachePolicy } from './middleware/cachePolicy' -import { registerFiatRoutes } from './routes/fiat' -import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit' -import { getMetrics, rate_limit_rejections_total } from './metrics' +import { registerApiVersioning, rewriteVersionedApiUrl } from './api/versioning' +import { getMetrics } from './metrics' import { loggerOptions, createDestination } from './logger' import { isPoolExhaustionError, recordPoolExhaustion } from './db' import { createCorsDelegator, type CorsConfig } from './cors' @@ -88,19 +84,12 @@ export const RATE_LIMITS = { } as const export async function buildApp(options: BuildAppOptions = {}): Promise { - // Share the structured logger configuration (level, redaction, dev pretty - // printing) with Fastify so `req.log` is scrubbed the same way. const app = Fastify({ - logger: { ...loggerOptions(), stream: createDestination() }, - genReqId: () => randomUUID(), - }) - - app.setErrorHandler((error: any, req, reply) => { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 500 - if (statusCode < 500) { - return reply.status(statusCode).send(error) - } - return sendInternalError(reply, req, error, 'Unhandled server error') + logger: { level: 'warn' }, + // Resolves `/v1/` onto the single bare route that is registered, so + // every route answers at both `/v1/...` and its unversioned alias without + // being mounted twice. See src/api/versioning.ts. + rewriteUrl: rewriteVersionedApiUrl, }) // Capture route registrations first, so it observes every route below @@ -109,12 +98,20 @@ export async function buildApp(options: BuildAppOptions = {}): Promise