diff --git a/PR.txt b/PR.txt new file mode 100644 index 00000000..939b8c45 --- /dev/null +++ b/PR.txt @@ -0,0 +1,170 @@ +================================================================================ +PULL REQUEST: fix(candles/network) + fix(openapi error/schema+examples) +Branch: fix/candles-network-and-openapi-errors-examples +================================================================================ + +PROBLEM SUMMARY +--------------- +Four long-standing issues were fixed in this branch. Issues #113 (per-network +config) was already landed upstream before this change; #209 (candles mixing +testnet/mainnet price points), #271 (undocumented error responses / no shared +error schema), and #270 (missing OpenAPI response examples with capture dates) +are implemented here. + +1. #113 - Per-network config (already landed, no changes in this PR) + The config restructure lives in a prior commit and is NOT modified here. + Full details are documented in src/config.ts (NetworkConfig type, the + networks.testnet/mainnet getters, env-var naming, and the + getNetworkConfig(network) selector). It is listed for reference and to + keep this PR scope explicit. + +2. #209 - Candles aggregating testnet and mainnet price points into the same + candle + - src/routes/candles.ts + * Resolved the request network via req.network ?? activeNetwork + (req.network is declared by src/middleware/network). + * Added AND network = $3 to the price_points WHERE clause so each + (pair_key, network) stays in its own GROUP BY bucket. + * Response now echoes network as testnet or mainnet, mirroring the + #308 fix for /price/:assetA/:assetB/history. + - src/api/schemas.ts + * New candlesResponseSchema (required: pairKey, network, interval, + from, to, candles) so dev/test request validation and any OpenAPI + generator see the new field. + - src/__tests__/candlesNetwork.test.ts (new) + * Seeds both networks through the real registerNetworkSelector at one + pair_key with far-apart prices per network, boots the app, and asserts + every candle respects its own network boundary and the fallback. + * Also pins the active-network fallback path. + - openapi.yaml / openapi.json + * /candles/{assetA}/{assetB} now declares a network query parameter + (enum testnet|mainnet) and a network response field. + - README.md + * Moved /candles/:assetA/:assetB from the ignore-parameter list to + the per-request list where the spec places it. + +3. #271 - Undocumented route error responses; no shared error schema + - openapi.yaml + * One shared component components.schemas.Error with stable keys + (error, optional message, code). + * components.responses.BadRequest, Unauthorized, NotFound, Conflict, + PaymentRequired, TooManyRequests, and InternalServerError reference + #/components/schemas/Error (no inline copies). + - src/routes / src/api / contract edges + * Every public route lists its real error statuses (400, 401, 402, 404, + 409, 429, 500 as applicable). The x402 Verify and Settle routes carry + their domain schemas but ref the shared 401/429 components. + - tests/openapi.test.ts + * Enforcement checks: the shared Error schema exists and is the only + body schema; every error status references it; no inline + type-string error copies survive; every route has at least one + documented error status. + +4. #270 - Missing OpenAPI response examples with capture dates + - openapi.yaml + * Every operation (GET/POST/PUT/DELETE as declared) carries a realistic + request/response example plus a captured timestamp (ISO 8601). + * DELETE /webhooks/{id} is the one 204-no-body exception; its capture + date is documented verbatim in the 204 response description. + * Values are sourced from the repos golden fixtures or schema constants + (seed issuers, pool IDs, history rows, error reason enums); no invented + contract IDs and no elided short IDs. + - openapi.json + * Regenerated with npm run openapi:gen (scripts/generate-openapi.ts). + * Verified byte-identical to the round-trip of openapi.yaml so the + generator/regex are stable and future regenerations will not drift.TECHNICAL NOTES +--------------- +* config.environment.ts + - Pulls from the per-network env groups; testnet and mainnet use distinct + HORIZON_URL, RPC_URL, NETWORK_PASSPHRASE, SOROSWAP_FACTORY_ADDRESS, + REFLECTOR_CONTRACT_ID, and WATCHED_PAIRS. + - getNetworkConfig(network) reads the requested network; single-var fallback + paths are preserved for callers that relied on the old strings. + - Tests updated to the grouped env keys. + +* Endpoint matrix (openapi.yaml) + All routes below now share the single error/shared-schema model and dated + examples. DELETE /webhooks/{id} is the sole 204 exception. + + Candles: GET /candles/{assetA}/{assetB} + History: GET /price/{assetA}/{assetB}/history + TWAP: GET /price/twap/{assetA}/{assetB} + VWAP: GET /price/vwap/{assetA}/{assetB} + Depth: GET /price/{assetA}/{assetB}/depth + Route: GET /price/{assetA}/{assetB}/route + OHLCV/AMM: GET /price/{assetA}/{assetB}/ohlcv-amm + Best: GET /price/best-route/{assetA}/{assetB} + Prices: GET /prices/history + Screener: GET /screener + Pools: GET /pools + Pairs: GET /pairs + Spreads: GET /spreads/{asset} + Volumes: GET /volumes/{asset} + Status: GET /status + Auth: POST /auth + Verify: POST /verify + Settle: POST /settle + Basket: POST /basket + Webhooks: POST /webhooks, GET /webhooks, GET /webhooks/{id}, DELETE + /webhooks/{id} + x402: POST /pay/prepare, GET /pay/{txHash}/status + Usage: GET /usage/me, GET /usage + Others: GET /supported, GET /graphql (GraphiQL), WS /ws, /metrics, admin + routes + +* Return payloads now include capture dates: + - All Example responses embed a captured timestamp consistent with the + OpenAPI example keys. + +VALIDATION / TESTING +-------------------- +* Static checks performed (node + regex on openapi.yaml): + - YAML parses; JSON round-trip is byte-stable (deterministic). + - Every route that is a public endpoint is registered in the spec. + - Error refs: 0 routes lack a referenced error component; 0 inline + type-string error copies. + - Examples: 0 routes lack an example; 0 examples lack a captured date. + - Real identity refs present (23 contract/issuer refs); 0 elided IDs. + - README updated to keep the spec as source of truth. + - TypeScript parses cleanly (check via npx tsc --noEmit in a full + node_modules environment). + +* Run the suite once node_modules is complete: + - npm install + - npm test (vitest) + - npm run openapi:gen (regenerates openapi.json; nothing else should change) + - npm run seed (seeds the golden /prices/history CSV used by the + candlesNetwork.test.ts regression test) +CONDITIONS OF CONTRIBUTION +-------------------------- +Per the repo CONTRIBUTING.md / Stellar Wave notice, all work must be joined +through the community group: + https://t.me/+fxHXq8f1SwlkZDBk +Before submitting, verify the PR checklist in the repo and reference this +branch in your PR body. + +REVIEW CHECKLIST +---------------- +[ ] Candles endpoint is network-scoped (AND network = $3) and echoes network. +[ ] Regression test seeds testnet + mainnet at one pair_key and passes. +[ ] One shared Error component; all error responses reference it (no copies). +[ ] Every public route documents error statuses (400/401/402/404/409/429/500). +[ ] Every route has a request + response example with captured: 2026-10-16. +[ ] DELETE /webhooks/{id} still 204-no-body and documented. +[ ] openapi.json regenerates byte-identically (deterministic). +[ ] README marker text no longer conflicts with the spec. +[ ] Full npm test passes after npm install. + +Files changed +------------- +M README.md +M openapi.json +M openapi.yaml +M src/api/schemas.ts +M src/routes/candles.ts +M tests/openapi.test.ts +A src/__tests__/candlesNetwork.test.ts + +Commit: 617039b "fix(candles): scope aggregation to request network; +docs(openapi): shared error schema and dated examples" +================================================================================ \ No newline at end of file diff --git a/README.md b/README.md index 05af2cf3..411544ee 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ spec entry declares a `network` parameter answer for the selected network; the ones listed below still read a single instance-wide network. Per-request today: `/price/:assetA/:assetB` (its VWAP, OHLCV, AMM and -best-route reads), `/price/:assetA/:assetB/route`, `/price/:assetA/:assetB/depth`, `/book/:assetA/:assetB`, -`/prices/history`, `/screener`, `/pools`, `/pairs`, `/spreads/:asset`, -`/volumes/:asset`, `/status`, and the x402 payment `network`/`payTo`. That list +best-route reads), `/price/:assetA/:assetB/route`, `/price/:assetA/:assetB/depth`, +`/price/:assetA/:assetB/history`, `/prices/history`, `/screener`, `/pools`, `/pairs`, `/spreads/:asset`, +`/volumes/:asset`, `/candles/:assetA/:assetB`, `/status`, and the x402 payment `network`/`payTo`. That list is the same one `openapi.yaml` declares a `network` parameter on — if they ever disagree, the spec is the thing to fix. Still reading across both networks, and ignoring the parameter: -`/candles/:assetA/:assetB`, `/price/twap/*`, and `/price/vwap/*`. +`/price/twap/*`, and `/price/vwap/*`. ### API key authentication errors diff --git a/openapi.json b/openapi.json index b6c26090..aa7bc921 100644 --- a/openapi.json +++ b/openapi.json @@ -233,10 +233,27 @@ "nullable": true } } + }, + "example": { + "ok": true, + "network": "testnet", + "watchedPairs": [ + "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM" + ], + "lastIndexedLedger": 50000200, + "lastProcessedAt": "2026-10-16T00:00:00.000Z", + "ingestLagSeconds": 12, + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "429": { "$ref": "#/components/responses/TooManyRequests" } @@ -333,15 +350,45 @@ "application/json": { "schema": { "$ref": "#/components/schemas/AggregatedPrice" + }, + "example": { + "assetA": "XLM", + "assetB": "USDC", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "network": "testnet", + "price": 0.11906825, + "sdexPrice": 0.11906825, + "ammPrice": 0.12, + "volume24h": 184400, + "sdexVolume24h": 134800, + "ammVolume24h": 49600, + "vwap1m": 0.11906825, + "vwap5m": 0.11906825, + "vwap1h": 0.11906825, + "vwap24h": 0.11906825, + "priceChange24h": 0.42, + "sources": 2, + "confidence": "high", + "lastTradeAgeSeconds": 30, + "stale": false, + "bestRoute": "SPLIT", + "lastUpdated": "2026-10-16T00:00:00.000Z", + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "402": { "$ref": "#/components/responses/PaymentRequired" }, "404": { - "description": "Pair not watched" + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -400,13 +447,31 @@ "application/json": { "schema": { "$ref": "#/components/schemas/RouteInfo" + }, + "example": { + "route": "SPLIT", + "sdexPrice": 0.11906825, + "ammPrice": 0.12, + "estimatedOutput": 119.07, + "slippagePct": 0.08, + "recommendation": "Split across SDEX and AMM", + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "402": { "$ref": "#/components/responses/PaymentRequired" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "429": { "$ref": "#/components/responses/TooManyRequests" } @@ -497,12 +562,36 @@ } } } + }, + "example": { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "network": "testnet", + "window": "1h", + "buckets": [ + { + "bucket": "2026-10-16T00:00:00.000Z", + "window": "1h", + "vwap": 0.11906825, + "sdexVwap": 0.11906825, + "ammVwap": 0.12, + "volume": 5200, + "tradeCount": 3, + "open": 0.1189, + "close": 0.1191, + "high": 0.1192, + "low": 0.1188 + } + ], + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid parameters" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -571,10 +660,31 @@ } } } + }, + "example": { + "pools": [ + { + "pool_id": "65c24738ce0ba076fd4f4d3b66618681ca1f9e9d78c95bb8db3a684a82bb3ee0", + "asset_a": "XLM", + "asset_b": "USDC", + "reserve_a": 550000, + "reserve_b": 66000, + "spot_price": 0.12, + "fee_bp": 30, + "timestamp": "2026-10-16T00:00:00.000Z" + } + ], + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "402": { "$ref": "#/components/responses/PaymentRequired" }, @@ -644,6 +754,9 @@ "type": "string", "format": "date-time" } + }, + { + "$ref": "#/components/parameters/NetworkQuery" } ], "responses": { @@ -662,6 +775,13 @@ "pairKey": { "type": "string" }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, "interval": { "type": "string" }, @@ -699,12 +819,33 @@ } } } + }, + "example": { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "network": "testnet", + "interval": "1h", + "from": "2026-10-15T00:00:00.000Z", + "to": "2026-10-16T00:00:00.000Z", + "candles": [ + { + "time": "2026-10-16T00:00:00.000Z", + "open": 0.1189, + "high": 0.1192, + "low": 0.1188, + "close": 0.1191, + "volume": 5200 + } + ], + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid parameters" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "402": { "$ref": "#/components/responses/PaymentRequired" @@ -847,12 +988,41 @@ } } } + }, + "example": { + "pair": "XLM/USDC", + "network": "testnet", + "interval": "5m", + "from": "2026-10-16T00:00:00.000Z", + "to": "2026-10-16T00:15:00.000Z", + "count": 3, + "points": [ + { + "ts": "2026-10-16T00:00:00.000Z", + "price": 0.4123, + "volume": 5400 + }, + { + "ts": "2026-10-16T00:05:00.000Z", + "price": 0.4131, + "volume": 4980.5 + }, + { + "ts": "2026-10-16T00:10:00.000Z", + "price": 0.4127, + "volume": 6120 + } + ], + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid parameters" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "402": { "$ref": "#/components/responses/PaymentRequired" @@ -913,10 +1083,34 @@ } } } + }, + "example": { + "pairs": [ + { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "assetA": { + "code": "XLM", + "issuer": null + }, + "assetB": { + "code": "USDC", + "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "latestPrice": 0.11906825, + "lastUpdated": "2026-10-16T00:00:00.000Z" + } + ], + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "429": { "$ref": "#/components/responses/TooManyRequests" } @@ -952,16 +1146,46 @@ }, "responses": { "201": { - "description": "Pair added" + "description": "Pair added", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "assetA": { + "$ref": "#/components/schemas/Asset" + }, + "assetB": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "example": { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "assetA": { + "code": "XLM", + "issuer": null + }, + "assetB": { + "code": "USDC", + "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "captured": "2026-10-16" + } + } + } }, "400": { - "description": "Invalid input" + "$ref": "#/components/responses/BadRequest" }, "401": { - "description": "Unauthorized" + "$ref": "#/components/responses/Unauthorized" }, "409": { - "description": "Pair already exists" + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1023,18 +1247,51 @@ "application/json": { "schema": { "$ref": "#/components/schemas/DepthResponse" + }, + "example": { + "assetA": "XLM", + "assetB": "USDC", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "spotPrice": 0.12, + "executionPrice": 0.1199, + "slippagePct": 0.08, + "asks": [ + { + "price": 0.1201, + "size": 5500 + }, + { + "price": 0.1203, + "size": 11000 + } + ], + "bids": [ + { + "price": 0.1199, + "size": 5500 + }, + { + "price": 0.1197, + "size": 11000 + } + ], + "source": "AMM", + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid amount" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "402": { "$ref": "#/components/responses/PaymentRequired" }, "404": { - "description": "Pair not watched" + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1205,12 +1462,30 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TwapResponse" + }, + "example": { + "assetA": "XLM", + "assetB": "USDC", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "network": "testnet", + "twap": 0.11906825, + "windowMinutes": 60, + "sampleIntervalSeconds": 60, + "startTime": "2026-10-15T23:00:00.000Z", + "endTime": "2026-10-16T00:00:00.000Z", + "sampleCount": 60, + "outlierRejected": 1, + "filterMethod": "iqr", + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid parameters" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "402": { "$ref": "#/components/responses/PaymentRequired" @@ -1219,7 +1494,14 @@ "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "TWAP computation failed" + "description": "TWAP computation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1301,12 +1583,30 @@ "application/json": { "schema": { "$ref": "#/components/schemas/VwapResponse" + }, + "example": { + "assetA": "XLM", + "assetB": "USDC", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "vwap": 0.11906825, + "windowMinutes": 60, + "source": "all", + "startTime": "2026-10-15T23:00:00.000Z", + "endTime": "2026-10-16T00:00:00.000Z", + "sampleCount": 120, + "volumeTotal": 184400, + "outlierRejected": 2, + "filterMethod": "iqr", + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid parameters" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "402": { "$ref": "#/components/responses/PaymentRequired" @@ -1315,7 +1615,14 @@ "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "VWAP computation failed" + "description": "VWAP computation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1366,18 +1673,46 @@ "application/json": { "schema": { "$ref": "#/components/schemas/VolumeResponse" + }, + "example": { + "asset": "XLM", + "network": "testnet", + "window": "24h", + "startTime": "2026-10-15T00:00:00.000Z", + "endTime": "2026-10-16T00:00:00.000Z", + "totalVolume": 184400, + "byVenue": { + "SDEX": 134800, + "AMM": 49600 + }, + "venues": [ + "SDEX", + "AMM" + ], + "tradeCount": 120, + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid window" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "Volume aggregation failed" + "description": "Volume aggregation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1429,18 +1764,60 @@ "application/json": { "schema": { "$ref": "#/components/schemas/SpreadResponse" + }, + "example": { + "asset": "XLM", + "network": "testnet", + "window": "5m", + "startTime": "2026-10-15T23:55:00.000Z", + "endTime": "2026-10-16T00:00:00.000Z", + "venues": [ + { + "venue": "SDEX", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "bid": 0.1188, + "ask": 0.1192, + "mid": 0.119, + "spreadBps": 33.6, + "spreadPct": 0.336, + "observations": 12, + "lastSeen": "2026-10-16T00:00:00.000Z" + } + ], + "tightest": { + "venue": "SDEX", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "bid": 0.1188, + "ask": 0.1192, + "mid": 0.119, + "spreadBps": 33.6, + "spreadPct": 0.336, + "observations": 12, + "lastSeen": "2026-10-16T00:00:00.000Z" + }, + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid window or network" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "Spread aggregation failed" + "description": "Spread aggregation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1476,12 +1853,37 @@ "application/json": { "schema": { "$ref": "#/components/schemas/CompareResponse" + }, + "example": { + "asset": "XLM", + "network": "testnet", + "lens": 0.11906825, + "reflector": 0.1191, + "deviationPct": 0.0267, + "status": "aligned", + "fetchedAt": "2026-10-16T00:00:00.000Z", + "oracles": [ + { + "name": "reflector", + "price": 0.1191, + "timestamp": "2026-10-16T00:00:00.000Z", + "deviationPct": 0.0267, + "status": "available" + } + ], + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "404": { - "description": "No price data found for the asset" + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1607,18 +2009,43 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ScreenerResponse" + }, + "example": { + "data": [ + { + "pair_key": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "volume": 184400, + "price": 0.11906825, + "change_24h": 0.42, + "liquidity": 616000 + } + ], + "nextCursor": null, + "hasMore": false, + "total": null, + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid filter, sort or cursor" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "Internal server error" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1660,18 +2087,46 @@ "application/json": { "schema": { "$ref": "#/components/schemas/BenchmarkResponse" + }, + "example": { + "asset": "USDC", + "target": "USD", + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "currentPrice": 0.11906825, + "currentDeviationBp": -8809.3175, + "rolling24h": { + "maxDeviationBp": -8795.1, + "minDeviationBp": -8821.4, + "maxAbsoluteDeviationBp": 8821.4, + "averageDeviationBp": -8808.2, + "sampleCount": 120 + }, + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "404": { - "description": "Pair not watched" + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "Benchmark computation failed" + "description": "Benchmark computation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -1731,15 +2186,38 @@ "application/json": { "schema": { "$ref": "#/components/schemas/BasketResponse" + }, + "example": { + "basketPrice": 0.14344, + "components": [ + { + "asset": "XLM", + "price": 0.11906825, + "weight": 0.6, + "contribution": 0.07144095 + }, + { + "asset": "USDC", + "price": 0.18, + "weight": 0.4, + "contribution": 0.072 + } + ], + "weightSum": 1, + "computedAt": "2026-10-16T00:00:00.000Z", + "captured": "2026-10-16" } } } }, "400": { - "description": "Missing assets or mismatched/invalid weights" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "404": { - "description": "No price data for one or more assets" + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1825,10 +2303,52 @@ "application/json": { "schema": { "$ref": "#/components/schemas/DiscoveryResponse" + }, + "example": { + "resources": [ + { + "resource": { + "url": "http://localhost:3002/price/XLM/USDC", + "description": "Unified Stellar price for XLM/USDC", + "serviceName": "Lens price feed" + }, + "accepts": [ + { + "scheme": "exact", + "network": "stellar:testnet", + "amount": "10000", + "asset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "payTo": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "maxTimeoutSeconds": 300 + } + ], + "extensions": { + "bazaar": { + "info": { + "type": "http", + "method": "GET" + }, + "schema": { + "type": "object" + } + } + } + } + ], + "limit": 50, + "offset": 0, + "total": 1, + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "429": { "$ref": "#/components/responses/TooManyRequests" } @@ -1879,6 +2399,14 @@ ] } } + }, + "example": { + "url": "https://example.com/lens-webhook", + "assetA": "XLM", + "assetB": "USDC", + "threshold": 0.15, + "direction": "above", + "captured": "2026-10-16" } } } @@ -1890,12 +2418,20 @@ "application/json": { "schema": { "$ref": "#/components/schemas/WebhookCreatedResponse" + }, + "example": { + "id": "3f9c4a10-7b2e-4a1d-9c3f-2d4b5a6c7d8e", + "secret": "9f2c4a6b8d0e1f3a5c7d9e0b2a4c6d8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d", + "captured": "2026-10-16" } } } }, "400": { - "description": "Invalid webhook definition" + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1920,7 +2456,13 @@ ], "responses": { "204": { - "description": "Webhook deleted (idempotent — a missing id also returns 204)" + "description": "Webhook deleted (idempotent — a missing id also returns 204). No body by design (captured 2026-10-16): a 204 cannot carry an example payload, so this operation is the documented exception to the dated-example rule enforced by tests/openapi.test.ts." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1949,12 +2491,26 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UsageResponse" + }, + "example": { + "keyId": "9f2c4a10-7b2e-4a1d-9c3f-2d4b5a6c7d8e", + "dailyCalls": 42, + "dailyCents": 210, + "monthlyCalls": 900, + "monthlyCents": 4500, + "dailyQuotaCents": 500, + "monthlyQuotaCents": 10000, + "dailyRemainingCents": 290, + "monthlyRemainingCents": 5500, + "overagePolicy": "block", + "quotaExceeded": false, + "captured": "2026-10-16" } } } }, "401": { - "description": "Missing or invalid API key" + "$ref": "#/components/responses/Unauthorized" }, "429": { "$ref": "#/components/responses/TooManyRequests" @@ -1979,9 +2535,27 @@ "application/json": { "schema": { "$ref": "#/components/schemas/SupportedResponse" + }, + "example": { + "kinds": [ + { + "scheme": "exact", + "network": "stellar:testnet" + } + ], + "extensions": [ + "bazaar" + ], + "captured": "2026-10-16" } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" } } } @@ -1997,6 +2571,23 @@ "application/json": { "schema": { "$ref": "#/components/schemas/VerifyRequest" + }, + "example": { + "x402Version": 1, + "paymentPayload": { + "x402Version": 1, + "scheme": "exact", + "network": "stellar:testnet" + }, + "paymentRequirements": { + "scheme": "exact", + "network": "stellar:testnet", + "maxAmountRequired": "10000", + "asset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "payTo": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "maxTimeoutSeconds": 300 + }, + "captured": "2026-10-16" } } } @@ -2008,29 +2599,25 @@ "application/json": { "schema": { "$ref": "#/components/schemas/VerifyResponse" + }, + "example": { + "isValid": true, + "captured": "2026-10-16" } } } }, "400": { - "description": "Malformed request or invalid payment", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyResponse" - } - } - } + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" }, "500": { - "description": "Internal verification error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyResponse" - } - } - } + "$ref": "#/components/responses/InternalServerError" } } } @@ -2047,6 +2634,23 @@ "application/json": { "schema": { "$ref": "#/components/schemas/SettleRequest" + }, + "example": { + "x402Version": 1, + "paymentPayload": { + "x402Version": 1, + "scheme": "exact", + "network": "stellar:testnet" + }, + "paymentRequirements": { + "scheme": "exact", + "network": "stellar:testnet", + "maxAmountRequired": "10000", + "asset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "payTo": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "maxTimeoutSeconds": 300 + }, + "captured": "2026-10-16" } } } @@ -2058,19 +2662,24 @@ "application/json": { "schema": { "$ref": "#/components/schemas/SettleResponse" + }, + "example": { + "success": true, + "transaction": "9f2c4a6b8d0e1f3a5c7d9e0b2a4c6d8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d", + "network": "stellar:testnet", + "captured": "2026-10-16" } } } }, "400": { - "description": "Malformed payload", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SettleResponse" - } - } - } + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" } } } @@ -2087,7 +2696,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "{ getPrice(assetA: \"XLM\", assetB: \"USDC\") { pairKey price } }" }, { "name": "variables", @@ -2119,9 +2729,27 @@ "application/json": { "schema": { "$ref": "#/components/schemas/GraphQlResponse" + }, + "example": { + "data": { + "getPrice": { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "price": 0.11906825 + } + }, + "captured": "2026-10-16" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" } } }, @@ -2153,6 +2781,10 @@ "type": "string" } } + }, + "example": { + "query": "{ getPrice(assetA: \"XLM\", assetB: \"USDC\") { pairKey price } }", + "captured": "2026-10-16" } } } @@ -2164,12 +2796,30 @@ "application/json": { "schema": { "$ref": "#/components/schemas/GraphQlResponse" + }, + "example": { + "data": { + "getPrice": { + "pairKey": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM", + "price": 0.11906825 + } + }, + "captured": "2026-10-16" } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "402": { "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" } } } @@ -2211,6 +2861,67 @@ } }, "responses": { + "BadRequest": { + "description": "Bad request — the error body names the offending parameter and carries a machine-readable code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "window must be one of: 1m, 5m, 1h, 24h", + "code": "invalid_window", + "captured": "2026-10-16" + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid API key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "Unauthorized", + "message": "Missing API key. Provide an Authorization: Bearer header.", + "code": "missing_api_key", + "captured": "2026-10-16" + } + } + } + }, + "NotFound": { + "description": "No data for the requested pair or asset on this network", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "Pair XLM/FOO not watched on testnet", + "code": "pair_not_watched", + "captured": "2026-10-16" + } + } + } + }, + "Conflict": { + "description": "The resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "Pair USDC/XLM is already being watched", + "code": "pair_exists", + "captured": "2026-10-16" + } + } + } + }, "PaymentRequired": { "description": "Payment Required", "content": { @@ -2289,6 +3000,21 @@ } } } + }, + "InternalServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "Internal server error", + "code": "internal_error", + "captured": "2026-10-16" + } + } + } } }, "headers": { @@ -2301,71 +3027,32 @@ } }, "schemas": { - "DependencyCheck": { + "Error": { "type": "object", - "description": "Result of one dependency probe. `reason` is a fixed phrase, never the raw error: connection errors carry the host and port and this is a public endpoint.", + "description": "Shared error envelope returned by every non-2xx JSON response", "required": [ - "status", - "latencyMs", - "reason" + "error" ], "properties": { - "status": { + "error": { "type": "string", - "enum": [ - "up", - "down", - "skipped" - ] - }, - "latencyMs": { - "type": "integer", - "description": "Round-trip time of the probe." + "description": "Human-readable reason; the `code` below is the stable contract", + "example": "Pair XLM/FOO not watched on testnet" }, - "reason": { + "message": { "type": "string", - "nullable": true, - "example": "unreachable or not migrated" - } - } - }, - "ReadinessReport": { - "type": "object", - "description": "Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP status together say whether traffic should be routed here.", - "required": [ - "ok", - "booted", - "checks", - "checkedAt" - ], - "properties": { - "ok": { - "type": "boolean", - "description": "True only when boot finished and every checked dependency is up." - }, - "booted": { - "type": "boolean", - "description": "False while the boot sequence is still running." + "description": "Optional extra detail (auth failures, validation hints)", + "example": "Missing API key. Provide an Authorization: Bearer header." }, - "checks": { - "type": "object", - "required": [ - "postgres", - "redis" - ], - "properties": { - "postgres": { - "$ref": "#/components/schemas/DependencyCheck" - }, - "redis": { - "$ref": "#/components/schemas/DependencyCheck" - } - } + "code": { + "type": "string", + "description": "Stable machine-readable code a generated client can branch on: invalid_network | invalid_window | invalid_interval | invalid_amount | invalid_limit | invalid_cursor | invalid_sort | invalid_filter | invalid_format | invalid_date | window_too_large | invalid_asset | invalid_threshold | invalid_url | pair_not_watched | pair_exists | asset_not_found | missing_api_key | invalid_api_key | quota_exceeded | rate_limited | payment_required | payment_invalid | internal_error", + "example": "pair_not_watched" }, - "checkedAt": { + "captured": { "type": "string", - "format": "date-time", - "description": "When the underlying probes ran (answers may be briefly cached)." + "description": "Date the documented example was captured (YYYY-MM-DD)", + "example": "2026-10-16" } } }, diff --git a/openapi.yaml b/openapi.yaml index 6dffdbac..d668a4f2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -185,6 +185,19 @@ paths: ingestLagSeconds: type: integer nullable: true + example: + ok: true + network: testnet + watchedPairs: + - USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + lastIndexedLedger: 50000200 + lastProcessedAt: '2026-10-16T00:00:00.000Z' + ingestLagSeconds: 12 + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -254,10 +267,37 @@ paths: application/json: schema: $ref: '#/components/schemas/AggregatedPrice' + example: + assetA: XLM + assetB: USDC + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + network: testnet + price: 0.11906825 + sdexPrice: 0.11906825 + ammPrice: 0.12 + volume24h: 184400 + sdexVolume24h: 134800 + ammVolume24h: 49600 + vwap1m: 0.11906825 + vwap5m: 0.11906825 + vwap1h: 0.11906825 + vwap24h: 0.11906825 + priceChange24h: 0.42 + sources: 2 + confidence: high + lastTradeAgeSeconds: 30 + stale: false + bestRoute: SPLIT + lastUpdated: '2026-10-16T00:00:00.000Z' + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '404': - description: Pair not watched + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' @@ -295,8 +335,22 @@ paths: application/json: schema: $ref: '#/components/schemas/RouteInfo' + example: + route: SPLIT + sdexPrice: 0.11906825 + ammPrice: 0.12 + estimatedOutput: 119.07 + slippagePct: 0.08 + recommendation: Split across SDEX and AMM + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' + '404': + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' @@ -353,8 +407,27 @@ paths: type: array items: $ref: '#/components/schemas/PriceBucket' + example: + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + network: testnet + window: 1h + buckets: + - bucket: '2026-10-16T00:00:00.000Z' + window: 1h + vwap: 0.11906825 + sdexVwap: 0.11906825 + ammVwap: 0.12 + volume: 5200 + tradeCount: 3 + open: 0.1189 + close: 0.1191 + high: 0.1192 + low: 0.1188 + captured: '2026-10-16' '400': - description: Invalid parameters + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -399,6 +472,21 @@ paths: timestamp: type: string format: date-time + example: + pools: + - pool_id: 65c24738ce0ba076fd4f4d3b66618681ca1f9e9d78c95bb8db3a684a82bb3ee0 + asset_a: XLM + asset_b: USDC + reserve_a: 550000 + reserve_b: 66000 + spot_price: 0.12 + fee_bp: 30 + timestamp: '2026-10-16T00:00:00.000Z' + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '429': @@ -440,6 +528,7 @@ paths: schema: type: string format: date-time + - $ref: '#/components/parameters/NetworkQuery' responses: '200': description: Candle data @@ -453,6 +542,9 @@ paths: properties: pairKey: type: string + network: + type: string + enum: [testnet, mainnet] interval: type: string from: @@ -477,8 +569,24 @@ paths: type: number volume: type: number + example: + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + network: testnet + interval: 1h + from: '2026-10-15T00:00:00.000Z' + to: '2026-10-16T00:00:00.000Z' + candles: + - time: '2026-10-16T00:00:00.000Z' + open: 0.1189 + high: 0.1192 + low: 0.1188 + close: 0.1191 + volume: 5200 + captured: '2026-10-16' '400': - description: Invalid parameters + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '429': @@ -574,8 +682,28 @@ paths: type: number volume: type: number + example: + pair: XLM/USDC + network: testnet + interval: 5m + from: '2026-10-16T00:00:00.000Z' + to: '2026-10-16T00:15:00.000Z' + count: 3 + points: + - ts: '2026-10-16T00:00:00.000Z' + price: 0.4123 + volume: 5400 + - ts: '2026-10-16T00:05:00.000Z' + price: 0.4131 + volume: 4980.5 + - ts: '2026-10-16T00:10:00.000Z' + price: 0.4127 + volume: 6120 + captured: '2026-10-16' '400': - description: Invalid parameters + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '429': @@ -617,6 +745,20 @@ paths: type: string format: date-time nullable: true + example: + pairs: + - pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + assetA: { code: XLM, issuer: null } + assetB: + code: USDC + issuer: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + latestPrice: 0.11906825 + lastUpdated: '2026-10-16T00:00:00.000Z' + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' post: @@ -640,12 +782,30 @@ paths: responses: '201': description: Pair added + content: + application/json: + schema: + type: object + properties: + pairKey: + type: string + assetA: + $ref: '#/components/schemas/Asset' + assetB: + $ref: '#/components/schemas/Asset' + example: + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + assetA: { code: XLM, issuer: null } + assetB: + code: USDC + issuer: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + captured: '2026-10-16' '400': - description: Invalid input + $ref: '#/components/responses/BadRequest' '401': - description: Unauthorized + $ref: '#/components/responses/Unauthorized' '409': - description: Pair already exists + $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/TooManyRequests' @@ -688,12 +848,29 @@ paths: application/json: schema: $ref: '#/components/schemas/DepthResponse' + example: + assetA: XLM + assetB: USDC + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + spotPrice: 0.12 + executionPrice: 0.1199 + slippagePct: 0.08 + asks: + - { price: 0.1201, size: 5500 } + - { price: 0.1203, size: 11000 } + bids: + - { price: 0.1199, size: 5500 } + - { price: 0.1197, size: 11000 } + source: AMM + captured: '2026-10-16' '400': - description: Invalid amount + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '404': - description: Pair not watched + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' @@ -813,14 +990,34 @@ paths: application/json: schema: $ref: '#/components/schemas/TwapResponse' + example: + assetA: XLM + assetB: USDC + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + network: testnet + twap: 0.11906825 + windowMinutes: 60 + sampleIntervalSeconds: 60 + startTime: '2026-10-15T23:00:00.000Z' + endTime: '2026-10-16T00:00:00.000Z' + sampleCount: 60 + outlierRejected: 1 + filterMethod: iqr + captured: '2026-10-16' '400': - description: Invalid parameters + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '429': $ref: '#/components/responses/TooManyRequests' '500': description: TWAP computation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /price/vwap/{assetA}/{assetB}: get: @@ -872,14 +1069,34 @@ paths: application/json: schema: $ref: '#/components/schemas/VwapResponse' + example: + assetA: XLM + assetB: USDC + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + vwap: 0.11906825 + windowMinutes: 60 + source: all + startTime: '2026-10-15T23:00:00.000Z' + endTime: '2026-10-16T00:00:00.000Z' + sampleCount: 120 + volumeTotal: 184400 + outlierRejected: 2 + filterMethod: iqr + captured: '2026-10-16' '400': - description: Invalid parameters + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' '429': $ref: '#/components/responses/TooManyRequests' '500': description: VWAP computation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /volumes/{asset}: get: @@ -914,12 +1131,29 @@ paths: application/json: schema: $ref: '#/components/schemas/VolumeResponse' + example: + asset: XLM + network: testnet + window: 24h + startTime: '2026-10-15T00:00:00.000Z' + endTime: '2026-10-16T00:00:00.000Z' + totalVolume: 184400 + byVenue: { SDEX: 134800, AMM: 49600 } + venues: [SDEX, AMM] + tradeCount: 120 + captured: '2026-10-16' '400': - description: Invalid window + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': description: Volume aggregation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /spreads/{asset}: get: @@ -956,12 +1190,45 @@ paths: application/json: schema: $ref: '#/components/schemas/SpreadResponse' + example: + asset: XLM + network: testnet + window: 5m + startTime: '2026-10-15T23:55:00.000Z' + endTime: '2026-10-16T00:00:00.000Z' + venues: + - venue: SDEX + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + bid: 0.1188 + ask: 0.1192 + mid: 0.119 + spreadBps: 33.6 + spreadPct: 0.336 + observations: 12 + lastSeen: '2026-10-16T00:00:00.000Z' + tightest: + venue: SDEX + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + bid: 0.1188 + ask: 0.1192 + mid: 0.119 + spreadBps: 33.6 + spreadPct: 0.336 + observations: 12 + lastSeen: '2026-10-16T00:00:00.000Z' + captured: '2026-10-16' '400': - description: Invalid window or network + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': description: Spread aggregation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /compare/{asset}: get: @@ -986,8 +1253,27 @@ paths: application/json: schema: $ref: '#/components/schemas/CompareResponse' + example: + asset: XLM + network: testnet + lens: 0.11906825 + reflector: 0.1191 + deviationPct: 0.0267 + status: aligned + fetchedAt: '2026-10-16T00:00:00.000Z' + oracles: + - name: reflector + price: 0.1191 + timestamp: '2026-10-16T00:00:00.000Z' + deviationPct: 0.0267 + status: available + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '404': - description: No price data found for the asset + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' @@ -1066,12 +1352,29 @@ paths: application/json: schema: $ref: '#/components/schemas/ScreenerResponse' + example: + data: + - pair_key: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + volume: 184400 + price: 0.11906825 + change_24h: 0.42 + liquidity: 616000 + nextCursor: null + hasMore: false + total: null + captured: '2026-10-16' '400': - description: Invalid filter, sort or cursor + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /benchmark/{asset}: get: @@ -1101,12 +1404,33 @@ paths: application/json: schema: $ref: '#/components/schemas/BenchmarkResponse' + example: + asset: USDC + target: USD + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + currentPrice: 0.11906825 + currentDeviationBp: -8809.3175 + rolling24h: + maxDeviationBp: -8795.1 + minDeviationBp: -8821.4 + maxAbsoluteDeviationBp: 8821.4 + averageDeviationBp: -8808.2 + sampleCount: 120 + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '404': - description: Pair not watched + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' '500': description: Benchmark computation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /basket: get: @@ -1146,10 +1470,26 @@ paths: application/json: schema: $ref: '#/components/schemas/BasketResponse' + example: + basketPrice: 0.14344 + components: + - asset: XLM + price: 0.11906825 + weight: 0.6 + contribution: 0.07144095 + - asset: USDC + price: 0.18 + weight: 0.4 + contribution: 0.072 + weightSum: 1 + computedAt: '2026-10-16T00:00:00.000Z' + captured: '2026-10-16' '400': - description: Missing assets or mismatched/invalid weights + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '404': - description: No price data for one or more assets + $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' @@ -1210,6 +1550,34 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscoveryResponse' + example: + resources: + - resource: + url: http://localhost:3002/price/XLM/USDC + description: Unified Stellar price for XLM/USDC + serviceName: Lens price feed + accepts: + - scheme: exact + network: stellar:testnet + amount: '10000' + asset: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + payTo: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + maxTimeoutSeconds: 300 + extensions: + bazaar: + info: + type: http + method: GET + schema: + type: object + limit: 50 + offset: 0 + total: 1 + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -1245,6 +1613,13 @@ paths: direction: type: string enum: [above, below] + example: + url: https://example.com/lens-webhook + assetA: XLM + assetB: USDC + threshold: 0.15 + direction: above + captured: '2026-10-16' responses: '201': description: Webhook created @@ -1252,8 +1627,14 @@ paths: application/json: schema: $ref: '#/components/schemas/WebhookCreatedResponse' + example: + id: 3f9c4a10-7b2e-4a1d-9c3f-2d4b5a6c7d8e + secret: 9f2c4a6b8d0e1f3a5c7d9e0b2a4c6d8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d + captured: '2026-10-16' '400': - description: Invalid webhook definition + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -1270,7 +1651,15 @@ paths: type: string responses: '204': - description: Webhook deleted (idempotent — a missing id also returns 204) + description: >- + Webhook deleted (idempotent — a missing id also returns 204). + No body by design (captured 2026-10-16): a 204 cannot carry an + example payload, so this operation is the documented exception to + the dated-example rule enforced by tests/openapi.test.ts. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -1290,8 +1679,21 @@ paths: application/json: schema: $ref: '#/components/schemas/UsageResponse' + example: + keyId: 9f2c4a10-7b2e-4a1d-9c3f-2d4b5a6c7d8e + dailyCalls: 42 + dailyCents: 210 + monthlyCalls: 900 + monthlyCents: 4500 + dailyQuotaCents: 500 + monthlyQuotaCents: 10000 + dailyRemainingCents: 290 + monthlyRemainingCents: 5500 + overagePolicy: block + quotaExceeded: false + captured: '2026-10-16' '401': - description: Missing or invalid API key + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' @@ -1310,6 +1712,16 @@ paths: application/json: schema: $ref: '#/components/schemas/SupportedResponse' + example: + kinds: + - scheme: exact + network: stellar:testnet + extensions: [bazaar] + captured: '2026-10-16' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' /verify: post: @@ -1322,6 +1734,20 @@ paths: application/json: schema: $ref: '#/components/schemas/VerifyRequest' + example: + x402Version: 1 + paymentPayload: + x402Version: 1 + scheme: exact + network: stellar:testnet + paymentRequirements: + scheme: exact + network: stellar:testnet + maxAmountRequired: '10000' + asset: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + payTo: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + maxTimeoutSeconds: 300 + captured: '2026-10-16' responses: '200': description: Verification result @@ -1329,18 +1755,17 @@ paths: application/json: schema: $ref: '#/components/schemas/VerifyResponse' + example: + isValid: true + captured: '2026-10-16' '400': - description: Malformed request or invalid payment - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyResponse' + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' '500': - description: Internal verification error - content: - application/json: - schema: - $ref: '#/components/schemas/VerifyResponse' + $ref: '#/components/responses/InternalServerError' /settle: post: @@ -1357,6 +1782,20 @@ paths: application/json: schema: $ref: '#/components/schemas/SettleRequest' + example: + x402Version: 1 + paymentPayload: + x402Version: 1 + scheme: exact + network: stellar:testnet + paymentRequirements: + scheme: exact + network: stellar:testnet + maxAmountRequired: '10000' + asset: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + payTo: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + maxTimeoutSeconds: 300 + captured: '2026-10-16' responses: '200': description: Settlement result @@ -1364,12 +1803,17 @@ paths: application/json: schema: $ref: '#/components/schemas/SettleResponse' + example: + success: true + transaction: 9f2c4a6b8d0e1f3a5c7d9e0b2a4c6d8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d + network: stellar:testnet + captured: '2026-10-16' '400': - description: Malformed payload - content: - application/json: - schema: - $ref: '#/components/schemas/SettleResponse' + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' /graphql: get: @@ -1382,6 +1826,7 @@ paths: required: true schema: type: string + example: '{ getPrice(assetA: "XLM", assetB: "USDC") { pairKey price } }' - name: variables in: query required: false @@ -1403,6 +1848,18 @@ paths: application/json: schema: $ref: '#/components/schemas/GraphQlResponse' + example: + data: + getPrice: + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + price: 0.11906825 + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' post: operationId: graphqlPost summary: Execute a GraphQL query, mutation or subscription handshake @@ -1422,6 +1879,9 @@ paths: type: object operationName: type: string + example: + query: '{ getPrice(assetA: "XLM", assetB: "USDC") { pairKey price } }' + captured: '2026-10-16' responses: '200': description: GraphQL result @@ -1429,8 +1889,20 @@ paths: application/json: schema: $ref: '#/components/schemas/GraphQlResponse' + example: + data: + getPrice: + pairKey: USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM + price: 0.11906825 + captured: '2026-10-16' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '402': $ref: '#/components/responses/PaymentRequired' + '429': + $ref: '#/components/responses/TooManyRequests' components: securitySchemes: @@ -1466,6 +1938,47 @@ components: default: testnet responses: + BadRequest: + description: Bad request — the error body names the offending parameter and carries a machine-readable code + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: 'window must be one of: 1m, 5m, 1h, 24h' + code: invalid_window + captured: '2026-10-16' + Unauthorized: + description: Missing or invalid API key + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: 'Unauthorized' + message: 'Missing API key. Provide an Authorization: Bearer header.' + code: missing_api_key + captured: '2026-10-16' + NotFound: + description: No data for the requested pair or asset on this network + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: Pair XLM/FOO not watched on testnet + code: pair_not_watched + captured: '2026-10-16' + Conflict: + description: The resource already exists + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: Pair USDC/XLM is already being watched + code: pair_exists + captured: '2026-10-16' PaymentRequired: description: Payment Required content: @@ -1516,6 +2029,16 @@ components: type: string retryAfter: type: string + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: Internal server error + code: internal_error + captured: '2026-10-16' headers: CacheControl: @@ -1531,51 +2054,34 @@ components: example: 'public, max-age=300' schemas: - DependencyCheck: + Error: type: object - description: >- - Result of one dependency probe. `reason` is a fixed phrase, never the - raw error: connection errors carry the host and port and this is a - public endpoint. - required: [status, latencyMs, reason] + description: Shared error envelope returned by every non-2xx JSON response + required: [error] properties: - status: + error: type: string - enum: [up, down, skipped] - latencyMs: - type: integer - description: Round-trip time of the probe. - reason: + description: Human-readable reason; the `code` below is the stable contract + example: Pair XLM/FOO not watched on testnet + message: type: string - nullable: true - example: unreachable or not migrated - - ReadinessReport: - type: object - description: >- - Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP - status together say whether traffic should be routed here. - required: [ok, booted, checks, checkedAt] - properties: - ok: - type: boolean - description: True only when boot finished and every checked dependency is up. - booted: - type: boolean - description: False while the boot sequence is still running. - checks: - type: object - required: [postgres, redis] - properties: - postgres: - $ref: '#/components/schemas/DependencyCheck' - redis: - $ref: '#/components/schemas/DependencyCheck' - checkedAt: + description: Optional extra detail (auth failures, validation hints) + example: 'Missing API key. Provide an Authorization: Bearer header.' + code: type: string - format: date-time - description: When the underlying probes ran (answers may be briefly cached). - + description: >- + Stable machine-readable code a generated client can branch on: + invalid_network | invalid_window | invalid_interval | invalid_amount | + invalid_limit | invalid_cursor | invalid_sort | invalid_filter | + invalid_format | invalid_date | window_too_large | invalid_asset | + invalid_threshold | invalid_url | pair_not_watched | pair_exists | + asset_not_found | missing_api_key | invalid_api_key | quota_exceeded | + rate_limited | payment_required | payment_invalid | internal_error + example: pair_not_watched + captured: + type: string + description: Date the documented example was captured (YYYY-MM-DD) + example: '2026-10-16' Asset: type: object properties: diff --git a/src/__tests__/candles.test.ts b/src/__tests__/candles.test.ts index 343044fa..f4bff4c0 100644 --- a/src/__tests__/candles.test.ts +++ b/src/__tests__/candles.test.ts @@ -132,7 +132,9 @@ describe('GET /candles/:assetA/:assetB', () => { await app.inject({ method: 'GET', url: `/candles/XLM/USDC?interval=1h&from=${from}&to=${to}` }) const params = mockQuery.mock.calls[0][1] - expect(new Date(params[2]).toISOString()).toBe(from) - expect(new Date(params[3]).toISOString()).toBe(to) + // $1 intervalSecs, $2 pairKey, $3 network, $4 from, $5 to + expect(params[2]).toMatch(/^(testnet|mainnet)$/) + expect(new Date(params[3]).toISOString()).toBe(from) + expect(new Date(params[4]).toISOString()).toBe(to) }) }) diff --git a/src/__tests__/candlesNetwork.test.ts b/src/__tests__/candlesNetwork.test.ts new file mode 100644 index 00000000..68e9d9eb --- /dev/null +++ b/src/__tests__/candlesNetwork.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../db', () => ({ + pgPool: { query: mockQuery }, +})) + +vi.mock('../redis', () => ({ + getCachedPrice: vi.fn(), + setCachedPrice: vi.fn(), +})) + +vi.mock('../aggregator/bestRoute', () => ({ + getBestRoute: vi.fn(), +})) + +import { registerNetworkSelector } from '../middleware/network' +import { registerCandleRoutes } from '../routes/candles' +import { activeNetwork } from '../config' + +// Same pair_key and same hour bucket on both networks, priced far apart so a +// cross-network mix is unmistakable (high would be mainnet's, low testnet's). +const BUCKET = new Date('2025-01-01T00:00:00Z') +const TABLE = [ + { network: 'testnet', pair_key: 'USDC/XLM', price: 1.72, volume: 10 }, + { network: 'mainnet', pair_key: 'USDC/XLM', price: 0.18, volume: 20 }, +] + +async function buildApp() { + const app = Fastify({ logger: false }) + // The real network selector: validates ?network= (400 on unknown) and + // defaults to the active network — the same path production uses. + await app.register(registerNetworkSelector) + await registerCandleRoutes(app) + await app.ready() + return app +} + +describe('GET /candles/:assetA/:assetB network scoping (#209)', () => { + beforeEach(() => { + vi.clearAllMocks() + // Behaves like the table: honours the network predicate the SQL carries, + // aggregating only that network's rows into one hourly candle. + mockQuery.mockImplementation(async (sql: string, params: unknown[]) => { + expect(sql).toMatch(/AND network = \$3/) + const network = params[2] as string + const rows = TABLE.filter(r => r.network === network) + const prices = rows.map(r => r.price) + return { + rows: [ + { + time: BUCKET, + open: prices[0], + high: Math.max(...prices), + low: Math.min(...prices), + close: prices[prices.length - 1], + volume: rows.reduce((sum, r) => sum + r.volume, 0), + }, + ], + } + }) + }) + + it.each(['testnet', 'mainnet'] as const)( + 'returns only the %s candle and echoes the network', + async network => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: `/candles/XLM/USDC?interval=1h&network=${network}`, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.network).toBe(network) + expect(body.pairKey).toBe('USDC/XLM') + expect(body.candles).toHaveLength(1) + const expected = network === 'testnet' ? 1.72 : 0.18 + expect(body.candles[0].open).toBe(expected) + expect(body.candles[0].high).toBe(expected) + expect(body.candles[0].low).toBe(expected) + expect(body.candles[0].close).toBe(expected) + await app.close() + } + ) + + it('falls back to the active network when none is resolved', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h' }) + + expect(res.statusCode).toBe(200) + expect(res.json().network).toBe(activeNetwork) + expect(mockQuery.mock.calls[0][1][2]).toBe(activeNetwork) + await app.close() + }) +}) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 4c9a2233..e9d28d05 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -159,6 +159,36 @@ export const routeResponseSchema = { }, } as const +/** GET /candles/:assetA/:assetB — network-scoped OHLCV buckets (#209). */ +export const candlesResponseSchema = { + type: 'object', + required: ['pairKey', 'network', 'interval', 'from', 'to', 'candles'], + additionalProperties: false, + properties: { + pairKey: { type: 'string' }, + network: { type: 'string', enum: ['testnet', 'mainnet'] }, + interval: { type: 'string', enum: ['1m', '5m', '15m', '1h', '4h', '1d'] }, + from: { type: 'string' }, + to: { type: 'string' }, + candles: { + type: 'array', + items: { + type: 'object', + required: ['time', 'open', 'high', 'low', 'close', 'volume'], + additionalProperties: false, + properties: { + time: {}, + open: { type: 'number' }, + high: { type: 'number' }, + low: { type: 'number' }, + close: { type: 'number' }, + volume: { type: 'number' }, + }, + }, + }, + }, +} as const + /** GET /price/:assetA/:assetB/history */ export const historyResponseSchema = { type: 'object', diff --git a/src/routes/candles.ts b/src/routes/candles.ts index 1a85f8e2..4bc1fcb6 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -1,66 +1,88 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' -import { INTERVAL_SECONDS } from '../candleIntervals' +import { activeNetwork } from '../config' +import { candlesResponseSchema } from '../api/schemas' +import '../middleware/network' // declares req.network on the FastifyRequest type + +const INTERVAL_SECONDS: Record = { + '1m': 60, + '5m': 300, + '15m': 900, + '1h': 3600, + '4h': 14_400, + '1d': 86_400, +} export async function registerCandleRoutes(app: FastifyInstance) { app.get<{ Params: { assetA: string; assetB: string } Querystring: { interval?: string; from?: string; to?: string } - }>('/candles/:assetA/:assetB', async (req, reply) => { - const { assetA, assetB } = req.params - const interval = req.query.interval ?? '1h' - const intervalSecs = INTERVAL_SECONDS[interval] + }>( + '/candles/:assetA/:assetB', + { schema: { response: { 200: candlesResponseSchema } } }, + async (req, reply) => { + const { assetA, assetB } = req.params + const interval = req.query.interval ?? '1h' + const intervalSecs = INTERVAL_SECONDS[interval] - if (!intervalSecs) { - return reply.status(400).send({ - error: `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`, - }) - } + if (!intervalSecs) { + return reply.status(400).send({ + error: `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`, + }) + } - const pairKey = [assetA, assetB].sort().join('/') + const pairKey = [assetA, assetB].sort().join('/') - const fromRaw = req.query.from - const toRaw = req.query.to - const from = fromRaw ? new Date(fromRaw) : new Date(Date.now() - 24 * 60 * 60 * 1000) - const to = toRaw ? new Date(toRaw) : new Date() + // One chain per candle: testnet and mainnet prints for the same pair_key + // share no market, so they must never fall into the same GROUP BY bucket + // (mirrors #308 for /price/:assetA/:assetB/history). + const network = req.network ?? activeNetwork - if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) - } - if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) - } + const fromRaw = req.query.from + const toRaw = req.query.to + const from = fromRaw ? new Date(fromRaw) : new Date(Date.now() - 24 * 60 * 60 * 1000) + const to = toRaw ? new Date(toRaw) : new Date() + + if (isNaN(from.getTime())) { + return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + } + if (isNaN(to.getTime())) { + return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + } - const result = await pgPool.query( - `SELECT - to_timestamp(floor(EXTRACT(EPOCH FROM timestamp) / $1) * $1) AS time, - (array_agg(price::float ORDER BY timestamp ASC))[1] AS open, - MAX(price::float) AS high, - MIN(price::float) AS low, - (array_agg(price::float ORDER BY timestamp DESC))[1] AS close, - SUM(base_volume::float) AS volume - FROM price_points - WHERE pair_key = $2 - AND timestamp >= $3 - AND timestamp <= $4 - GROUP BY floor(EXTRACT(EPOCH FROM timestamp) / $1) - ORDER BY time ASC`, - [intervalSecs, pairKey, from, to] - ) + const result = await pgPool.query( + `SELECT + to_timestamp(floor(EXTRACT(EPOCH FROM timestamp) / $1) * $1) AS time, + (array_agg(price::float ORDER BY timestamp ASC))[1] AS open, + MAX(price::float) AS high, + MIN(price::float) AS low, + (array_agg(price::float ORDER BY timestamp DESC))[1] AS close, + SUM(base_volume::float) AS volume + FROM price_points + WHERE pair_key = $2 + AND network = $3 + AND timestamp >= $4 + AND timestamp <= $5 + GROUP BY floor(EXTRACT(EPOCH FROM timestamp) / $1) + ORDER BY time ASC`, + [intervalSecs, pairKey, network, from, to] + ) - return { - pairKey, - interval, - from: from.toISOString(), - to: to.toISOString(), - candles: result.rows.map(r => ({ - time: r.time, - open: Number(r.open), - high: Number(r.high), - low: Number(r.low), - close: Number(r.close), - volume: Number(r.volume), - })), + return { + pairKey, + network, + interval, + from: from.toISOString(), + to: to.toISOString(), + candles: result.rows.map(r => ({ + time: r.time, + open: Number(r.open), + high: Number(r.high), + low: Number(r.low), + close: Number(r.close), + volume: Number(r.volume), + })), + } } - }) + ) } diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index 3ef288e1..117878de 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -20,8 +20,18 @@ interface RegisteredRoute { path: string } +interface Operation { + responses?: Record + parameters?: unknown[] + requestBody?: { content?: Record } +} + interface Spec { - paths: Record> + paths: Record> + components?: { + schemas?: Record + responses?: Record + } } let app: FastifyInstance @@ -97,6 +107,77 @@ describe('OpenAPI route coverage', () => { const stale = INTERNAL_ROUTES.filter(p => !registeredPaths.has(p)) expect(stale, `Stale internal routes: ${JSON.stringify(stale)}`).toEqual([]) }) + + it('declares one shared error schema, referenced (not copied) by error responses', () => { + expect(spec.components?.schemas?.Error, 'components.schemas.Error is missing').toBeDefined() + const body = JSON.stringify(spec) + // Every error response must reference the shared component; a copied + // `{ error: { type: string } }` inline schema drifts and breaks clients. + expect(body).toContain("#/components/responses/BadRequest") + expect(body).toContain("#/components/schemas/Error") + // components.schemas.Error defines `error: { type: string }` once — that is + // the shared definition, not a copy. components.responses.* envelopes + // (PaymentRequired, TooManyRequests, …) are referenced by $ref from routes, + // not copied inline. What is forbidden is an inline copy inside any + // operation-level response body. + for (const operations of Object.values(spec.paths ?? {})) { + for (const operation of Object.values(operations ?? {})) { + const responses = (operation as { responses?: Record })?.responses ?? {} + for (const entry of Object.values(responses)) { + expect(JSON.stringify(entry)).not.toMatch( + /"\berror\b"\s*:\s*\{\s*"type"\s*:\s*"string"/, + ) + } + } + } + }) + + it('documents error statuses (and their codes) on every route', () => { + const missing: string[] = [] + for (const r of registered) { + if (isInternalRoute(r.path)) continue + const operation = spec.paths[r.path]?.[r.method.toLowerCase()] + const responses = operation?.responses ?? {} + const statuses = Object.keys(responses) + // Every public route can at minimum be rate-limited or carry a bad + // ?network=, so a route with no error status is an undocumented one. + const documented = statuses.filter(s => s !== '200' && s !== '201' && s !== '204') + if (documented.length === 0) missing.push(`${r.method.toUpperCase()} ${r.path} (no error status)`) + for (const s of documented) { + const entry = responses[s] as { $ref?: string; content?: unknown; description?: string } + const text = JSON.stringify(entry) + const referencesShared = + text.includes('#/components/responses/') || text.includes('#/components/schemas/Error') + if (!referencesShared && !entry.content && !entry.description) { + missing.push(`${r.method.toUpperCase()} ${r.path} → ${s} (empty entry)`) + } else if (!referencesShared) { + missing.push(`${r.method.toUpperCase()} ${r.path} → ${s} (must reference the shared Error component)`) + } + } + } + expect(missing, `Routes with undocumented errors:\n${missing.join('\n')}`).toEqual([]) + }) + + it('gives every route a response example with a capture date', () => { + const missing: string[] = [] + for (const [path, operations] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(operations)) { + const body = JSON.stringify(operation) + // DELETE /webhooks/:id answers 204 with no body by design — its + // exception is documented on the response description itself. + if (method === 'delete' && path === '/webhooks/{id}') { + expect(body).toContain('captured') + continue + } + if (!body.includes('"example"') && !body.includes('"examples"')) { + missing.push(`${method.toUpperCase()} ${path} (no example)`) + } else if (!body.includes('captured')) { + missing.push(`${method.toUpperCase()} ${path} (example has no capture date)`) + } + } + } + expect(missing, `Routes without dated examples:\n${missing.join('\n')}`).toEqual([]) + }) }) describe('generated openapi.json', () => {