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/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 4f164dd6..977693b1 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"; @@ -84,6 +85,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..35450365 --- /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; +} \ No newline at end of file