From 61b01ee1a352406c03efbcd1268eb24d8accac4c Mon Sep 17 00:00:00 2001 From: teetyff Date: Sun, 28 Jun 2026 16:16:43 +0100 Subject: [PATCH 1/4] feat: add JSON:API content negotiation support for REST endpoints - Create src/middleware/jsonapi.ts middleware for Accept header transformation - Transform responses for transfers, summary, assets, and NFT endpoints - Add comprehensive unit and integration tests - Document JSON:API format in README with examples --- README.md | 93 +++++++++++ src/__tests__/jsonapi.test.ts | 184 +++++++++++++++++++++ src/__tests__/routes/transfers.test.ts | 83 ++++++++++ src/api.ts | 2 + src/middleware/jsonapi.ts | 211 +++++++++++++++++++++++++ 5 files changed, 573 insertions(+) create mode 100644 src/__tests__/jsonapi.test.ts create mode 100644 src/middleware/jsonapi.ts 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 From e8ef58bb5be4fb9387e542716a07d42c6753629e Mon Sep 17 00:00:00 2001 From: teetyff Date: Tue, 30 Jun 2026 04:30:27 +0100 Subject: [PATCH 2/4] fix: make data optional in JsonApiResponse type to match JSON:API spec Per JSON:API spec, data and errors are mutually exclusive. The error branch returns { errors: [...] } with no data property, so data must be optional in the type definition. --- src/middleware/jsonapi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middleware/jsonapi.ts b/src/middleware/jsonapi.ts index 35450365..8cec74c8 100644 --- a/src/middleware/jsonapi.ts +++ b/src/middleware/jsonapi.ts @@ -24,7 +24,7 @@ type JsonApiResourceIdentifier = { }; type JsonApiResponse = { - data: JsonApiResource | JsonApiResource[] | null; + data?: JsonApiResource | JsonApiResource[] | null; meta?: Record; links?: Record; errors?: JsonApiError[]; @@ -208,4 +208,4 @@ function transformToJsonApi(body: unknown, endpoint: string, statusCode: number } return transformSimpleResponse(bodyRecord, endpoint) as unknown as JsonApiResponse; -} \ No newline at end of file +} From 005bea13cbd1abd45ba1812fab502593c45e2219 Mon Sep 17 00:00:00 2001 From: teetyff Date: Tue, 30 Jun 2026 04:58:07 +0100 Subject: [PATCH 3/4] fix: register /search endpoint in OpenAPI spec and add @fast-csv/format dependency - Added searchQuerySchema and searchResponseSchema to OpenAPI build - Added /search endpoint registration for fuzzy search across accounts, assets, contracts - Added @fast-csv/format dependency for CSV export endpoint (already in package.json from recent merge) --- clients/react-query/src/schema.d.ts | 81 ++++++++++++++++++ openapi.json | 125 ++++++++++++++++++++++++++++ package-lock.json | 49 +++++++++-- src/openapi/build.ts | 17 +++- 4 files changed, 265 insertions(+), 7 deletions(-) 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/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", From b2733c6f525bc4c1ce0d74dc283955b5a57a0091 Mon Sep 17 00:00:00 2001 From: teetyff Date: Tue, 30 Jun 2026 05:19:58 +0100 Subject: [PATCH 4/4] fix: skip rate limiting in test environment to avoid 429 errors The rate limiter was blocking tests running in rapid succession during the same test run. Added skip callback that returns true when NODE_ENV=test. --- src/api.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 4076176a..287c71a5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -24,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 ─────────────────────────────────────────────────────────