Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
184 changes: 184 additions & 0 deletions src/__tests__/jsonapi.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
83 changes: 83 additions & 0 deletions src/__tests__/routes/transfers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -84,6 +85,7 @@ export function createApp(): express.Application {

app.use(cors());
app.use(express.json());
app.use(jsonApiMiddleware);
app.use(limiter);

// ── Accounts routes ───────────────────────────────────────────────────────────
Expand Down
Loading