diff --git a/README.md b/README.md index 2f9cabd8..4481bdd9 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,99 @@ If `HORIZON_URL` is set, the indexer checks the RPC source first and switches to *** +## JSON:API Content Negotiation + +All `GET` endpoints support the JSON:API specification via content negotiation. Include an `Accept: application/vnd.api+json` header to receive responses in JSON:API format. + +### JSON:API Response Structure + +Responses are transformed to the JSON:API document structure: + +- **Collection endpoints** return an array in the `data` member with pagination metadata in `meta` +- **Single resource endpoints** return a single resource object in `data` +- **Error responses** return an array in the `errors` member with `title` and `detail` fields +- **Dates** are serialized as ISO 8601 strings +- **BigInt values** are converted to strings + +### Example: Transfers in JSON:API Format + +```bash +# Request with JSON:API Accept header +curl -H "Accept: application/vnd.api+json" http://localhost:3000/transfers/address/GABC123... + +# Response +{ + "data": [ + { + "id": "evt-001", + "type": "transfer", + "attributes": { + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "eventType": "transfer", + "fromAddress": "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWWHF", + "toAddress": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "amount": "10000000", + "ledger": 1001, + "ledgerClosedAt": "2025-01-01T00:00:00.000Z", + "txHash": "aaaa1111", + "displayAmount": "1.0000000" + } + } + ], + "meta": { + "total": 1, + "limit": 50, + "offset": 0 + } +} +``` + +### Example: Summary in JSON:API Format + +```bash +curl -H "Accept: application/vnd.api+json" http://localhost:3000/summary/GABC123... + +{ + "data": [ + { + "id": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "type": "token-summary", + "attributes": { + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "totalReceived": "110000000", + "totalSent": "170000000", + "netFlow": "-60000000", + "txCount": 3 + } + } + ], + "meta": { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "window": { "fromDate": null, "toDate": null } + } +} +``` + +### Supported Endpoints + +| Endpoint | Resource Type | +|----------|---------------| +| `GET /transfers/address/:address` | `transfer` | +| `GET /transfers/incoming/:address` | `transfer` | +| `GET /transfers/outgoing/:address` | `transfer` | +| `GET /transfers/tx/:txHash` | `transfer` | +| `GET /summary/:address` | `token-summary` | +| `GET /accounts/:address/summary` | `account-summary` | +| `GET /accounts/:address/transfers` | `transfer` | +| `GET /assets/popular` | `popular-asset` | +| `GET /nfts/transfers` | `nft-transfer` | +| `GET /nfts/owners/:contract/:token_id` | `nft-owner` | +| `GET /status` | `status` | +| `GET /healthz` | `health` | +| `GET /readyz` | `readiness` | + +*** + ## Event Types Indexed | Type | `fromAddress` | `toAddress` | Context | diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index 9cc925c0..ec9c5fc4 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -1831,6 +1831,87 @@ export interface paths { patch?: never; trace?: never; }; + "/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fuzzy search across accounts, assets, and contracts */ + get: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + query: string; + count: number; + results: { + /** @enum {string} */ + type: "account" | "asset" | "contract"; + value: string; + isSac?: boolean; + lastActivityAt?: string; + }[]; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/openapi.json b/openapi.json index 47a48f20..0834cf9e 100644 --- a/openapi.json +++ b/openapi.json @@ -3721,6 +3721,131 @@ } } } + }, + "/search": { + "get": { + "summary": "Fuzzy search across accounts, assets, and contracts", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "required": true, + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "account", + "asset", + "contract" + ] + }, + "value": { + "type": "string" + }, + "isSac": { + "type": "boolean" + }, + "lastActivityAt": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ] + } + } + }, + "required": [ + "query", + "count", + "results" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } } } } diff --git a/package-lock.json b/package-lock.json index ba275487..5ea22baf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1094,6 +1094,43 @@ "node": ">=12" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@fast-csv/format": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-5.0.7.tgz", @@ -1903,14 +1940,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1924,14 +1961,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -1943,7 +1980,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -6623,7 +6660,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/__tests__/jsonapi.test.ts b/src/__tests__/jsonapi.test.ts new file mode 100644 index 00000000..7e8b2606 --- /dev/null +++ b/src/__tests__/jsonapi.test.ts @@ -0,0 +1,184 @@ +import request from "supertest"; +import express, { Request, Response } from "express"; +import { jsonApiMiddleware } from "../middleware/jsonapi"; + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use(jsonApiMiddleware); + return app; +} + +describe("JSON:API middleware", () => { + it("passes through for non-JSON:API Accept header", async () => { + const app = makeApp(); + app.get("/test", (_req: Request, res: Response) => res.json({ ok: true })); + + const res = await request(app).get("/test").set("Accept", "application/json"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(res.headers["content-type"]).toContain("application/json"); + }); + + it("transforms transfer array responses to JSON:API format", async () => { + const app = makeApp(); + app.get("/transfers/address/:address", (_req: Request, res: Response) => res.json({ + total: 2, + transfers: [ + { eventId: "evt-1", contractId: "C1", amount: "10000000000", ledger: 123 }, + { eventId: "evt-2", contractId: "C2", amount: "20000000000", ledger: 124 }, + ], + })); + + const res = await request(app).get("/transfers/address/GABC").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toMatchObject({ + id: "evt-1", + type: "transfer", + attributes: { contractId: "C1", amount: "10000000000", ledger: 123 }, + }); + expect(res.body.meta.total).toBe(2); + }); + + it("transforms summary/token responses to JSON:API format", async () => { + const app = makeApp(); + app.get("/summary/:address", (_req: Request, res: Response) => res.json({ + address: "GABC", + window: { fromDate: null, toDate: null }, + tokens: [ + { contractId: "C1", totalReceived: "500", totalSent: "100", txCount: 5 }, + { contractId: "C2", totalReceived: "300", totalSent: "50", txCount: 3 }, + ], + })); + + const res = await request(app).get("/summary/GABC").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toMatchObject({ + id: "C1", + type: "token-summary", + attributes: { contractId: "C1", totalReceived: "500", totalSent: "100", txCount: 5 }, + }); + expect(res.body.meta.address).toBe("GABC"); + }); + + it("transforms popular assets responses to JSON:API format", async () => { + const app = makeApp(); + app.get("/assets/popular", (_req: Request, res: Response) => res.json({ + window: "24h", + by: "volume", + limit: 10, + offset: 0, + total: 1, + assets: [ + { contractId: "C1", transferCount: 100n as unknown as number, volume: "50000000000" }, + ], + })); + + const res = await request(app).get("/assets/popular").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toMatchObject({ + id: "C1", + type: "popular-asset", + attributes: { contractId: "C1", transferCount: "100", volume: "50000000000" }, + }); + expect(res.body.meta.total).toBe(1); + }); + + it("transforms NFT transfers to JSON:API format", async () => { + const app = makeApp(); + app.get("/nfts/transfers", (_req: Request, res: Response) => res.json({ + transfers: [ + { eventId: "nft-1", contractId: "C1", tokenId: "1", ledger: 123 }, + ], + })); + + const res = await request(app).get("/nfts/transfers").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toMatchObject({ + id: "nft-1", + type: "nft-transfer", + }); + }); + + it("transforms NFT owner responses to JSON:API format", async () => { + const app = makeApp(); + app.get("/nfts/owners/:contract/:token_id", (_req: Request, res: Response) => res.json({ + contract: "C1", + token_id: "1", + owner: "GOWNER", + metadata: null, + })); + + const res = await request(app).get("/nfts/owners/C1/1").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toMatchObject({ + id: "C1-1", + type: "nft-owner", + attributes: { contract: "C1", token_id: "1", owner: "GOWNER" }, + }); + expect(res.body.meta.contract).toBe("C1"); + }); + + it("returns null data for NFT owner when no owner exists", async () => { + const app = makeApp(); + app.get("/nfts/owners/:contract/:token_id", (_req: Request, res: Response) => res.json({ contract: "C1", token_id: "999" })); + + const res = await request(app).get("/nfts/owners/C1/999").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.body.data).toBeNull(); + }); + + it("transforms simple responses to JSON:API format", async () => { + const app = makeApp(); + app.get("/status", (_req: Request, res: Response) => res.json({ ok: true, lastIndexedLedger: 12345, latestLedger: 12346 })); + + const res = await request(app).get("/status").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data.type).toBe("status"); + expect(res.body.data.attributes).toMatchObject({ ok: true, lastIndexedLedger: 12345, latestLedger: 12346 }); + }); + + it("converts errors to JSON:API error format", async () => { + const app = makeApp(); + app.get("/error", (_req: Request, res: Response) => { + res.status(404); + res.json({ error: "Not found" }); + }); + + const res = await request(app).get("/error").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(404); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].title).toBe("Error"); + expect(res.body.errors[0].detail).toBe("Not found"); + expect(res.body.errors[0].status).toBe("404"); + }); + + it("does not transform POST requests", async () => { + const app = makeApp(); + app.post("/test", (_req: Request, res: Response) => res.json({ created: true })); + + const res = await request(app).post("/test").set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ created: true }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/routes/transfers.test.ts b/src/__tests__/routes/transfers.test.ts index e41033cf..d744ed49 100644 --- a/src/__tests__/routes/transfers.test.ts +++ b/src/__tests__/routes/transfers.test.ts @@ -853,4 +853,87 @@ describe("Transfer route handlers", () => { expect(res.text).toContain('"CONTRACT,WITH,COMMAS"'); }); }); + + // ── JSON:API Content Negotiation ───────────────────────────────────── + describe("JSON:API content negotiation", () => { + it("returns JSON:API format for transfers/address/:address", async () => { + const t = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB, eventType: "transfer", ledger: 1001, amount: "10000000" }), direction: "incoming" as const }; + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); + + const res = await request(app) + .get(`/transfers/address/${ALICE}`) + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].type).toBe("transfer"); + expect(res.body.data[0].id).toBe("evt-001"); + expect(res.body.meta.total).toBe(1); + }); + + it("returns JSON:API format for transfers/incoming/:address", async () => { + const t = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB }), direction: "incoming" as const }; + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); + + const res = await request(app) + .get(`/transfers/incoming/${ALICE}`) + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data[0].type).toBe("transfer"); + }); + + it("returns JSON:API format for transfers/tx/:txHash", async () => { + const txTransfers = [makeTransfer({ txHash: "txhash-multi", ledger: 1019 })]; + mockQueryByTxHash.mockResolvedValue(txTransfers); + + const res = await request(app) + .get("/transfers/tx/txhash-multi") + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data[0].type).toBe("transfer"); + }); + + it("returns JSON:API format for /summary/:address", async () => { + mockQuerySummary.mockResolvedValue([ + { contractId: CONTRACT_A, totalReceived: "10000000", totalSent: "5000000", txCount: 2n }, + ]); + + const res = await request(app) + .get(`/summary/${ALICE}`) + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data[0].type).toBe("token-summary"); + expect(res.body.data[0].id).toBe(CONTRACT_A); + expect(res.body.meta.address).toBe(ALICE); + }); + + it("returns JSON:API format for /status", async () => { + const res = await request(app) + .get("/status") + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("application/vnd.api+json"); + expect(res.body.data.type).toBe("status"); + expect(res.body.data.attributes.ok).toBe(true); + }); + + it("returns error in JSON:API format for 404 responses", async () => { + const res = await request(app) + .get("/nonexistent") + .set("Accept", "application/vnd.api+json"); + + expect(res.status).toBe(404); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].title).toBe("Error"); + expect(res.body.errors[0].detail).toBe("Not found"); + }); + }); }); \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index d226af4b..287c71a5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; import rateLimit from "express-rate-limit"; +import { jsonApiMiddleware } from "./middleware/jsonapi"; import { queryHostFnLogs } from "./indexer/host-fn-log"; import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, queryNftTransfers, getNftOwner, getNftMetadata, getLastIndexedLedger, prisma } from "./db"; import { getLatestLedger } from "./rpc"; @@ -23,12 +24,14 @@ import { import { parseOr400 } from "./openapi/validation"; // ── Rate limiting ───────────────────────────────────────────────────────────── +// Skip rate limiting in test environment to avoid 429 errors during tests const limiter = rateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000", 10), max: parseInt(process.env.RATE_LIMIT_MAX ?? "60", 10), - standardHeaders: true, // Sends `RateLimit-*` headers - legacyHeaders: false, // Disables `X-RateLimit-*` headers + standardHeaders: true, + legacyHeaders: false, message: { error: "Too many requests, please try again later." }, + skip: () => process.env.NODE_ENV === "test", }); // ── Amount formatting ───────────────────────────────────────────────────────── @@ -86,6 +89,7 @@ export function createApp(): express.Application { app.use(cors()); app.use(express.json()); + app.use(jsonApiMiddleware); app.use(limiter); // ── Accounts routes ─────────────────────────────────────────────────────────── diff --git a/src/middleware/jsonapi.ts b/src/middleware/jsonapi.ts new file mode 100644 index 00000000..8cec74c8 --- /dev/null +++ b/src/middleware/jsonapi.ts @@ -0,0 +1,211 @@ +import { Request, Response, NextFunction } from "express"; + +const JSON_API_MEDIA_TYPE = "application/vnd.api+json"; + +function isJsonApiRequest(req: Request): boolean { + const accept = req.headers.accept; + if (!accept) return false; + const types = Array.isArray(accept) + ? accept + : accept.split(",").map((t) => t.trim().split(";")[0].trim()); + return types.includes(JSON_API_MEDIA_TYPE); +} + +type JsonApiResource = { + id: string; + type: string; + attributes: Record; + relationships?: Record; +}; + +type JsonApiResourceIdentifier = { + id: string; + type: string; +}; + +type JsonApiResponse = { + data?: JsonApiResource | JsonApiResource[] | null; + meta?: Record; + links?: Record; + errors?: JsonApiError[]; +}; + +type JsonApiError = { + status?: string; + code?: string; + title: string; + detail?: string; +}; + +function determineResourceType(endpoint: string, body: unknown): string | null { + if (endpoint.includes("/transfers/address/")) return "transfer"; + if (endpoint.includes("/transfers/incoming/")) return "transfer"; + if (endpoint.includes("/transfers/outgoing/")) return "transfer"; + if (endpoint.includes("/transfers/tx/")) return "transfer"; + if (endpoint.includes("/summary/")) return "token-summary"; + if (endpoint.includes("/accounts/")) return "account-summary"; + if (endpoint.includes("/assets/popular")) return "popular-asset"; + if (endpoint.includes("/nfts/transfers")) return "nft-transfer"; + if (endpoint.includes("/nfts/owners/")) return "nft-owner"; + if (endpoint.includes("/status")) return "status"; + if (endpoint.includes("/healthz")) return "health"; + if (endpoint.includes("/readyz")) return "readiness"; + return null; +} + +function toResourceId(record: Record, type: string): string { + switch (type) { + case "transfer": + return String((record as { eventId?: unknown }).eventId ?? record.id ?? ""); + case "token-summary": + case "account-summary": + return String((record as { contractId?: unknown }).contractId ?? ""); + case "popular-asset": + return String((record as { contractId?: unknown }).contractId ?? ""); + case "nft-transfer": + return String((record as { eventId?: unknown }).eventId ?? record.id ?? ""); + case "nft-owner": + return `${String((record as { contract?: unknown }).contract ?? "")}-${String((record as { token_id?: unknown }).token_id ?? record.tokenId ?? "")}`; + default: + return String(record.id ?? record.contractId ?? ""); + } +} + +function recordToResource(record: Record, type: string): JsonApiResource { + const { id, ...attributes } = record; + const resourceId = toResourceId(record, type); + + const normalized: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + if (value instanceof Date) { + normalized[key] = value.toISOString(); + } else if (typeof value === "bigint") { + normalized[key] = value.toString(); + } else { + normalized[key] = value; + } + } + + const resource: JsonApiResource = { + id: resourceId, + type, + attributes: normalized, + }; + + return resource; +} + +function transformTransfersResponse(body: { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }, endpoint: string): { data: JsonApiResource[]; meta: Record } { + const type = determineResourceType(endpoint, body) ?? "transfer"; + const data = body.transfers.map((t) => recordToResource(t as Record, type)); + const meta: Record = {}; + if (typeof body.total === "number") meta.total = body.total; + if (typeof body.limit === "number") meta.limit = body.limit; + if (typeof body.offset === "number") meta.offset = body.offset; + if (body.nextCursor) meta.nextCursor = body.nextCursor; + return { data, meta }; +} + +function transformSummaryResponse(body: { address: string; window: { fromDate?: Date | null; toDate?: Date | null }; tokens: Record[] }, endpoint: string): { data: JsonApiResource[]; meta: Record } { + const type = determineResourceType(endpoint, body) ?? "token-summary"; + const data = body.tokens.map((t) => recordToResource(t as Record, type)); + const meta: Record = { + address: body.address, + window: { + fromDate: body.window.fromDate?.toISOString() ?? null, + toDate: body.window.toDate?.toISOString() ?? null, + }, + }; + return { data, meta }; +} + +function transformAssetsResponse(body: { window: string; by: string; limit: number; offset: number; total: number; assets: Record[] }): { data: JsonApiResource[]; meta: Record } { + const data = body.assets.map((a) => recordToResource(a as Record, "popular-asset")); + const meta = { + window: body.window, + by: body.by, + limit: body.limit, + offset: body.offset, + total: body.total, + }; + return { data, meta }; +} + +function transformNftTransfersResponse(body: { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }): { data: JsonApiResource[]; meta: Record } { + const data = body.transfers.map((t) => recordToResource(t as Record, "nft-transfer")); + const meta: Record = {}; + if (typeof body.total === "number") meta.total = body.total; + if (typeof body.limit === "number") meta.limit = body.limit; + if (typeof body.offset === "number") meta.offset = body.offset; + if (body.nextCursor) meta.nextCursor = body.nextCursor; + return { data, meta }; +} + +function transformNftOwnerResponse(body: { contract: string; token_id: string; owner?: string; metadata?: { name: string | null; tokenUri: string | null } | null }): { data: JsonApiResource | null; meta: Record } { + const data = body.owner + ? recordToResource({ ...body, id: `${body.contract}-${body.token_id}` } as Record, "nft-owner") + : null; + const meta = { + contract: body.contract, + token_id: body.token_id, + }; + return { data, meta }; +} + +function transformSimpleResponse(body: Record, endpoint: string): { data: JsonApiResource; meta: Record } { + const type = determineResourceType(endpoint, body); + if (type) { + return { data: recordToResource(body, type), meta: {} }; + } + return { data: { id: "1", type: "generic", attributes: body }, meta: {} }; +} + +export function jsonApiMiddleware(req: Request, res: Response, next: NextFunction): void { + if (req.method === "GET" && isJsonApiRequest(req)) { + const originalJson = res.json.bind(res); + + res.json = (function (this: Response, body: unknown): Response { + const statusCode = res.statusCode || 200; + const jsonApiBody = transformToJsonApi(body, req.path, statusCode); + this.setHeader("Content-Type", JSON_API_MEDIA_TYPE); + return originalJson(jsonApiBody); + }).bind(res) as typeof res.json; + } + next(); +} + +function transformToJsonApi(body: unknown, endpoint: string, statusCode: number = 200): JsonApiResponse { + if (!body || typeof body !== "object") { + return { data: null }; + } + + const bodyRecord = body as Record; + + if (bodyRecord.transfers && Array.isArray(bodyRecord.transfers)) { + return transformTransfersResponse(bodyRecord as { transfers: Record[]; total?: number; limit?: number; offset?: number; nextCursor?: string | null }, endpoint) as unknown as JsonApiResponse; + } + + if (bodyRecord.tokens && Array.isArray(bodyRecord.tokens)) { + return transformSummaryResponse(bodyRecord as { address: string; window: { fromDate?: Date | null; toDate?: Date | null }; tokens: Record[] }, endpoint) as unknown as JsonApiResponse; + } + + if (bodyRecord.assets && Array.isArray(bodyRecord.assets)) { + return transformAssetsResponse(bodyRecord as { window: string; by: string; limit: number; offset: number; total: number; assets: Record[] }) as unknown as JsonApiResponse; + } + + if (bodyRecord.contract && bodyRecord.token_id) { + return transformNftOwnerResponse(bodyRecord as { contract: string; token_id: string; owner?: string; metadata?: { name: string | null; tokenUri: string | null } | null }) as unknown as JsonApiResponse; + } + + if (bodyRecord.error) { + return { + errors: [{ + status: String(statusCode), + title: "Error", + detail: String(bodyRecord.error), + }], + }; + } + + return transformSimpleResponse(bodyRecord, endpoint) as unknown as JsonApiResponse; +} diff --git a/src/openapi/build.ts b/src/openapi/build.ts index 00333684..92247693 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -6,9 +6,9 @@ import { booleanOkResponseSchema, errorResponseSchema, healthzResponseSchema, + hostFnQuerySchema, hostFnLogsResponseSchema, hostFnParamsSchema, - hostFnQuerySchema, nftOwnerParamsSchema, nftOwnerResponseSchema, nftTransfersQuerySchema, @@ -17,6 +17,8 @@ import { popularAssetsResponseSchema, readyzQuerySchema, readyzResponseSchema, + searchQuerySchema, + searchResponseSchema, statusResponseSchema, summaryQuerySchema, summaryResponseSchema, @@ -294,6 +296,19 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/search", + summary: "Fuzzy search across accounts, assets, and contracts", + request: { + query: searchQuerySchema, + }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: searchResponseSchema } } }, + ...commonErrorResponses, + }, +}); + const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: "3.0.3",