From c8e96b312bcbdaf4fbc90a2ccfb007a79691b4cc Mon Sep 17 00:00:00 2001 From: DevScoopee Date: Sat, 17 Oct 2026 07:26:34 -0400 Subject: [PATCH] This change versions the Lens API by serving every public route under /v1 while keeping the existing unversioned paths as deprecated, behavior-identical aliases. Versioning is handled centrally with a Fastify rewriteUrl hook instead of mounting routes twice, so both forms resolve to the same handler and cannot drift. The routes previously had no version segment, which made the in-flight response-shape changes impossible to ship without breaking existing callers. To fix this, the /v1 segment is stripped before routing so /v1/price/... and /price/... hit one identical route definition, and requests using the deprecated alias get Deprecation: true and a Link: ; rel="successor-version" header pointing at the versioned path. I added path helpers to src/openapi/coverage.ts, wired the rewrite and warning hook in src/app.ts, moved all public paths in openapi.yaml under /v1 (regenerating openapi.json), and extended tests/openapi.test.ts to assert that every documented versioned path maps to a registered alias, that the alias always exists, and that both forms answer identically. I also wrote the warning contract and retirement window in docs/api-versioning.md, updated the README endpoint table, and added a minor changeset. No callers break because the unversioned paths still answer identically Closes #264 --- .changeset/api-versioning.md | 24 ++++++++++ README.md | 72 +++++++++++++++++------------ docs/api-versioning.md | 88 ++++++++++++++++++++++++++++++++++++ openapi.json | 52 ++++++++++----------- openapi.yaml | 59 +++++++++++++----------- src/api/versioning.ts | 86 +++++++++++++++++++++++++++++++++++ src/app.ts | 22 ++++++++- src/openapi/coverage.ts | 39 ++++++++++++++++ tests/openapi.test.ts | 67 +++++++++++++++++++++++---- 9 files changed, 417 insertions(+), 92 deletions(-) create mode 100644 .changeset/api-versioning.md create mode 100644 docs/api-versioning.md create mode 100644 src/api/versioning.ts 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 92d0ada6..49640f36 100644 --- a/README.md +++ b/README.md @@ -14,35 +14,47 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity ### 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 | `/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` | Cross-venue traded volume (`24h`, `7d`, `30d`) with a 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 | `/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 `) | -| 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/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`) | +| 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*`, +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). @@ -63,12 +75,13 @@ Still reading across both networks, and ignoring the parameter: `/candles/:assetA/:assetB`, `/price/twap/*`, and `/price/vwap/*`. ```bash -curl "https://api.example.com/price/XLM/USDC?network=mainnet" +curl "https://api.example.com/v1/price/XLM/USDC?network=mainnet" ``` ### 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). ```graphql query { @@ -322,6 +335,7 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). 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). ## Examples 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 be8c1005..fd938f2e 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.", + "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,7 +16,7 @@ } ], "paths": { - "/status": { + "/v1/status": { "get": { "operationId": "getStatus", "summary": "Get API status and indexer state", @@ -74,7 +74,7 @@ } } }, - "/price/{assetA}/{assetB}": { + "/v1/price/{assetA}/{assetB}": { "get": { "operationId": "getPrice", "summary": "Get aggregated price for a pair", @@ -129,7 +129,7 @@ } } }, - "/price/{assetA}/{assetB}/route": { + "/v1/price/{assetA}/{assetB}/route": { "get": { "operationId": "getBestRoute", "summary": "Get best swap route for a pair and amount", @@ -188,7 +188,7 @@ } } }, - "/price/{assetA}/{assetB}/history": { + "/v1/price/{assetA}/{assetB}/history": { "get": { "operationId": "getPriceHistory", "summary": "Get price history buckets", @@ -280,7 +280,7 @@ } } }, - "/pools": { + "/v1/pools": { "get": { "operationId": "getPools", "summary": "Get AMM liquidity pool reserves and spot prices", @@ -349,7 +349,7 @@ } } }, - "/candles/{assetA}/{assetB}": { + "/v1/candles/{assetA}/{assetB}": { "get": { "operationId": "getCandles", "summary": "Get OHLCV candle data", @@ -475,7 +475,7 @@ } } }, - "/prices/history": { + "/v1/prices/history": { "get": { "operationId": "getPriceSnapshots", "summary": "Get historical 1-minute price snapshots", @@ -618,7 +618,7 @@ } } }, - "/pairs": { + "/v1/pairs": { "get": { "operationId": "listPairs", "summary": "List all active trading pairs", @@ -719,7 +719,7 @@ } } }, - "/price/{assetA}/{assetB}/depth": { + "/v1/price/{assetA}/{assetB}/depth": { "get": { "operationId": "getDepth", "summary": "Get order-book depth and execution slippage for a pair", @@ -787,7 +787,7 @@ } } }, - "/price/twap/{assetA}/{assetB}": { + "/v1/price/twap/{assetA}/{assetB}": { "get": { "operationId": "getTwap", "summary": "Get a manipulation-resistant time-weighted average price", @@ -880,7 +880,7 @@ } } }, - "/price/vwap/{assetA}/{assetB}": { + "/v1/price/vwap/{assetA}/{assetB}": { "get": { "operationId": "getVwap", "summary": "Get a volume-weighted average price", @@ -971,7 +971,7 @@ } } }, - "/volumes/{asset}": { + "/v1/volumes/{asset}": { "get": { "operationId": "getVolumes", "summary": "Get cross-venue traded volume for an asset", @@ -1028,7 +1028,7 @@ } } }, - "/spreads/{asset}": { + "/v1/spreads/{asset}": { "get": { "operationId": "getSpreads", "summary": "Get the tightest per-venue bid/ask spread for an asset", @@ -1086,7 +1086,7 @@ } } }, - "/compare/{asset}": { + "/v1/compare/{asset}": { "get": { "operationId": "compareAsset", "summary": "Compare Lens prices with every configured external oracle", @@ -1125,7 +1125,7 @@ } } }, - "/screener": { + "/v1/screener": { "get": { "operationId": "screenPairs", "summary": "Screen pairs by volume, change, liquidity and price", @@ -1254,7 +1254,7 @@ } } }, - "/benchmark/{asset}": { + "/v1/benchmark/{asset}": { "get": { "operationId": "getBenchmark", "summary": "Get peg-deviation statistics for an asset against a target", @@ -1302,7 +1302,7 @@ } } }, - "/basket": { + "/v1/basket": { "get": { "operationId": "getBasket", "summary": "Compute the weighted price of a basket of assets", @@ -1368,7 +1368,7 @@ } } }, - "/discovery/resources": { + "/v1/discovery/resources": { "get": { "operationId": "listDiscoveryResources", "summary": "Browse the x402 Bazaar catalog of discoverable resources", @@ -1451,7 +1451,7 @@ } } }, - "/webhooks": { + "/v1/webhooks": { "post": { "operationId": "createWebhook", "summary": "Subscribe to a price-threshold webhook", @@ -1519,7 +1519,7 @@ } } }, - "/webhooks/{id}": { + "/v1/webhooks/{id}": { "delete": { "operationId": "deleteWebhook", "summary": "Delete a webhook subscription", @@ -1544,7 +1544,7 @@ } } }, - "/usage/me": { + "/v1/usage/me": { "get": { "operationId": "getMyUsage", "summary": "Get usage and quota for the calling API key", @@ -1573,7 +1573,7 @@ } } }, - "/supported": { + "/v1/supported": { "get": { "operationId": "getSupported", "summary": "List the payment kinds and extensions this facilitator supports", @@ -1592,7 +1592,7 @@ } } }, - "/verify": { + "/v1/verify": { "post": { "operationId": "verifyPayment", "summary": "Verify an x402 payment payload", @@ -1641,7 +1641,7 @@ } } }, - "/settle": { + "/v1/settle": { "post": { "operationId": "settlePayment", "summary": "Settle an x402 payment (idempotent)", @@ -1681,7 +1681,7 @@ } } }, - "/graphql": { + "/v1/graphql": { "get": { "operationId": "graphqlGet", "summary": "Execute a GraphQL query over HTTP GET", diff --git a/openapi.yaml b/openapi.yaml index 685c29ff..f7aabf88 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,14 @@ openapi: 3.0.0 info: title: Lens API - description: Unified Stellar price API aggregating SDEX + AMM prices. + description: >- + Unified Stellar price API aggregating SDEX + AMM prices. + + 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 @@ -12,7 +19,7 @@ servers: description: Local development server paths: - /status: + /v1/status: get: operationId: getStatus summary: Get API status and indexer state @@ -49,7 +56,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/{assetA}/{assetB}: + /v1/price/{assetA}/{assetB}: get: operationId: getPrice summary: Get aggregated price for a pair @@ -83,7 +90,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 @@ -119,7 +126,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /price/{assetA}/{assetB}/history: + /v1/price/{assetA}/{assetB}/history: get: operationId: getPriceHistory summary: Get price history buckets @@ -174,7 +181,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /pools: + /v1/pools: get: operationId: getPools summary: Get AMM liquidity pool reserves and spot prices @@ -217,7 +224,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /candles/{assetA}/{assetB}: + /v1/candles/{assetA}/{assetB}: get: operationId: getCandles summary: Get OHLCV candle data @@ -294,7 +301,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /prices/history: + /v1/prices/history: get: operationId: getPriceSnapshots summary: Get historical 1-minute price snapshots @@ -388,7 +395,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /pairs: + /v1/pairs: get: operationId: listPairs summary: List all active trading pairs @@ -453,7 +460,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 @@ -498,7 +505,7 @@ paths: '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 @@ -557,7 +564,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 @@ -613,7 +620,7 @@ paths: '500': description: VWAP computation failed - /volumes/{asset}: + /v1/volumes/{asset}: get: operationId: getVolumes summary: Get cross-venue traded volume for an asset @@ -650,7 +657,7 @@ paths: '500': description: Volume aggregation failed - /spreads/{asset}: + /v1/spreads/{asset}: get: operationId: getSpreads summary: Get the tightest per-venue bid/ask spread for an asset @@ -689,7 +696,7 @@ paths: '500': description: Spread aggregation failed - /compare/{asset}: + /v1/compare/{asset}: get: operationId: compareAsset summary: Compare Lens prices with every configured external oracle @@ -714,7 +721,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /screener: + /v1/screener: get: operationId: screenPairs summary: Screen pairs by volume, change, liquidity and price @@ -793,7 +800,7 @@ paths: '500': description: Internal server error - /benchmark/{asset}: + /v1/benchmark/{asset}: get: operationId: getBenchmark summary: Get peg-deviation statistics for an asset against a target @@ -825,7 +832,7 @@ paths: '500': description: Benchmark computation failed - /basket: + /v1/basket: get: operationId: getBasket summary: Compute the weighted price of a basket of assets @@ -867,7 +874,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /discovery/resources: + /v1/discovery/resources: get: operationId: listDiscoveryResources summary: Browse the x402 Bazaar catalog of discoverable resources @@ -924,7 +931,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /webhooks: + /v1/webhooks: post: operationId: createWebhook summary: Subscribe to a price-threshold webhook @@ -968,7 +975,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /webhooks/{id}: + /v1/webhooks/{id}: delete: operationId: deleteWebhook summary: Delete a webhook subscription @@ -985,7 +992,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 @@ -1003,7 +1010,7 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' - /supported: + /v1/supported: get: operationId: getSupported summary: List the payment kinds and extensions this facilitator supports @@ -1016,7 +1023,7 @@ paths: schema: $ref: '#/components/schemas/SupportedResponse' - /verify: + /v1/verify: post: operationId: verifyPayment summary: Verify an x402 payment payload @@ -1047,7 +1054,7 @@ paths: schema: $ref: '#/components/schemas/VerifyResponse' - /settle: + /v1/settle: post: operationId: settlePayment summary: Settle an x402 payment (idempotent) @@ -1076,7 +1083,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 da11e200..ef2daa8c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' import { registerSettleRoute } from './routes/facilitator' +import { registerApiVersioning, rewriteVersionedApiUrl } from './api/versioning' import { getMetrics } from './metrics' export interface BuildAppOptions { @@ -50,7 +51,13 @@ export interface BuildAppOptions { * The returned app is NOT listening; the caller calls `app.listen(...)`. */ export async function buildApp(options: BuildAppOptions = {}): Promise { - const app = Fastify({ logger: { level: 'warn' } }) + const app = Fastify({ + 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 // (including the ones registered by encapsulated plugins such as Mercurius). @@ -58,9 +65,20 @@ export async function buildApp(options: BuildAppOptions = {}): Promise