Skip to content

Commit ccfdd30

Browse files
committed
feat(indexer): expose piece metadata endpoint
Add a read-only /index/v0 endpoint that returns the indexed piece metadata field with bounded ID validation and safe error responses. Signed-off-by: Jakub Sztandera <oss@kubuxu.com>
1 parent 6931a6d commit ccfdd30

5 files changed

Lines changed: 128 additions & 6 deletions

File tree

apps/ponder/ponder.schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { tableNames } from '@filoz/repair-db'
1+
import { type JsonRecord, tableNames } from '@filoz/repair-db'
22
import { index, onchainTable, primaryKey } from 'ponder'
33

44
export const providers = onchainTable(
@@ -27,7 +27,7 @@ export const dataSets = onchainTable(
2727
providerId: t.int8({ mode: 'bigint' }).notNull(),
2828
payer: t.text().notNull(),
2929
source: t.text(),
30-
metadata: t.jsonb(),
30+
metadata: t.jsonb().$type<JsonRecord | null>(),
3131
withCdn: t.boolean().notNull(),
3232
withIpfsIndexing: t.boolean().notNull(),
3333
pdpEndEpoch: t.int8({ mode: 'bigint' }),
@@ -47,7 +47,7 @@ export const pieces = onchainTable(
4747
pieceId: t.int8({ mode: 'bigint' }).notNull(),
4848
cid: t.text().notNull(),
4949
rawSize: t.int8({ mode: 'bigint' }).notNull(),
50-
metadata: t.jsonb(),
50+
metadata: t.jsonb().$type<JsonRecord | null>(),
5151
removed: t.boolean().notNull(),
5252
addedAtBlock: t.int8({ mode: 'bigint' }).notNull(),
5353
removedAtBlock: t.int8({ mode: 'bigint' }),

apps/ponder/src/api/index-api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Hono } from 'hono'
2+
3+
export type FindPieceMetadata = (
4+
dataSetId: bigint,
5+
pieceId: bigint
6+
) => Promise<Record<string, string> | null | undefined>
7+
8+
const unsignedInt8 = /^(0|[1-9]\d{0,18})$/
9+
const maxPostgresBigint = (1n << 63n) - 1n
10+
11+
function parseId(value: string): bigint | undefined {
12+
if (!unsignedInt8.test(value)) return undefined
13+
14+
const id = BigInt(value)
15+
return id <= maxPostgresBigint ? id : undefined
16+
}
17+
18+
export function createIndexApi(findPieceMetadata: FindPieceMetadata) {
19+
const app = new Hono()
20+
21+
app.onError((error, c) => {
22+
console.error('Failed to serve piece metadata', error)
23+
return c.json({ error: 'Internal server error' }, 500)
24+
})
25+
26+
app.get('/index/v0/datasets/:dataSetId/pieces/:pieceId/metadata', async (c) => {
27+
const dataSetId = parseId(c.req.param('dataSetId'))
28+
const pieceId = parseId(c.req.param('pieceId'))
29+
30+
if (dataSetId === undefined || pieceId === undefined) {
31+
return c.json({ error: 'dataSetId and pieceId must be unsigned int8 values' }, 400)
32+
}
33+
34+
const metadata = await findPieceMetadata(dataSetId, pieceId)
35+
if (metadata === undefined) {
36+
return c.json({ error: 'Piece not found' }, 404)
37+
}
38+
39+
return c.json(metadata)
40+
})
41+
42+
return app
43+
}

apps/ponder/src/api/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { Hono } from 'hono'
1+
import { db } from 'ponder:api'
2+
import { pieces } from 'ponder:schema'
3+
import { and, eq } from 'ponder'
4+
import { createIndexApi } from './index-api.ts'
25

3-
const app = new Hono()
6+
const app = createIndexApi(async (dataSetId, pieceId) => {
7+
const [piece] = await db
8+
.select({ metadata: pieces.metadata })
9+
.from(pieces)
10+
.where(and(eq(pieces.dataSetId, dataSetId), eq(pieces.pieceId, pieceId)))
11+
.limit(1)
12+
13+
return piece?.metadata
14+
})
415

516
export default app

apps/ponder/src/provider-status.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Context } from 'ponder:registry'
22
import { ponder } from 'ponder:registry'
33
import { providers } from 'ponder:schema'
4-
import { and, eq, inArray, notInArray } from 'drizzle-orm'
4+
import { and, eq, inArray, notInArray } from 'ponder'
55
import { ProviderIdSetAbi } from './abis.ts'
66
import { NETWORKS } from './networks.ts'
77

apps/ponder/test/index-api.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from 'node:assert/strict'
2+
import { test } from 'node:test'
3+
import { createIndexApi } from '../src/api/index-api.ts'
4+
5+
test('metadata endpoint returns the metadata field as the response body', async () => {
6+
const app = createIndexApi(async (dataSetId, pieceId) => {
7+
assert.equal(dataSetId, 123n)
8+
assert.equal(pieceId, 42n)
9+
return { name: 'example', type: 'application/octet-stream' }
10+
})
11+
12+
const response = await app.request('/index/v0/datasets/123/pieces/42/metadata')
13+
14+
assert.equal(response.status, 200)
15+
assert.deepEqual(await response.json(), { name: 'example', type: 'application/octet-stream' })
16+
})
17+
18+
test('metadata endpoint distinguishes absent metadata from an unknown piece', async () => {
19+
const withoutMetadata = createIndexApi(async () => null)
20+
const missingPiece = createIndexApi(async () => undefined)
21+
22+
const withoutMetadataResponse = await withoutMetadata.request('/index/v0/datasets/123/pieces/42/metadata')
23+
const missingPieceResponse = await missingPiece.request('/index/v0/datasets/123/pieces/42/metadata')
24+
25+
assert.equal(withoutMetadataResponse.status, 200)
26+
assert.equal(await withoutMetadataResponse.json(), null)
27+
assert.equal(missingPieceResponse.status, 404)
28+
assert.deepEqual(await missingPieceResponse.json(), { error: 'Piece not found' })
29+
})
30+
31+
test('metadata endpoint rejects non-canonical unsigned IDs before querying', async () => {
32+
let queryCount = 0
33+
const app = createIndexApi(async () => {
34+
queryCount++
35+
return null
36+
})
37+
38+
for (const path of [
39+
'/index/v0/datasets/-1/pieces/42/metadata',
40+
'/index/v0/datasets/01/pieces/42/metadata',
41+
'/index/v0/datasets/123/pieces/not-a-number/metadata',
42+
'/index/v0/datasets/9223372036854775808/pieces/42/metadata',
43+
`/index/v0/datasets/${'9'.repeat(1_000)}/pieces/42/metadata`,
44+
]) {
45+
const response = await app.request(path)
46+
assert.equal(response.status, 400)
47+
assert.deepEqual(await response.json(), { error: 'dataSetId and pieceId must be unsigned int8 values' })
48+
}
49+
50+
assert.equal(queryCount, 0)
51+
})
52+
53+
test('metadata endpoint does not disclose query errors', async () => {
54+
const app = createIndexApi(async () => {
55+
throw new Error('database connection string leaked')
56+
})
57+
const consoleError = console.error
58+
console.error = () => undefined
59+
60+
try {
61+
const response = await app.request('/index/v0/datasets/123/pieces/42/metadata')
62+
63+
assert.equal(response.status, 500)
64+
assert.deepEqual(await response.json(), { error: 'Internal server error' })
65+
} finally {
66+
console.error = consoleError
67+
}
68+
})

0 commit comments

Comments
 (0)