1import type { FastifyInstance } from 'fastify';2import { z } from 'zod';3import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js';4import * as q from '../lib/queries.js';5import { openapiDocument } from '../openapi.js';67const Paging = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), cursor: z.string().optional(), format: z.enum(['json', 'csv']).optional() });8const Range = z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), format: z.enum(['json', 'csv']).optional() });910function parse<T>(schema: z.ZodType<T>, input: unknown): { ok: true; data: T } | { ok: false; error: string } {11 const r = schema.safeParse(input);12 return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') };13}1415export async function v1Routes(app: FastifyInstance) {16 app.get('/v1/openapi.json', async () => openapiDocument());1718 app.get('/v1/stats', async () => envelope(await q.platformStats()));1920 app.get('/v1/assets/search', async (req, reply) => {21 const p = parse(Paging.extend({ q: z.string().max(200).optional(), category: z.string().max(80).optional() }), req.query);22 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);23 const offset = decodeCursor(p.data.cursor);24 const rows = await q.searchAssets({ q: p.data.q, category: p.data.category, limit: p.data.limit + 1, offset });25 const page = rows.slice(0, p.data.limit);26 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, query: p.data.q ?? null }, req.query as Record<string, unknown>);27 });2829 app.get<{ Params: { id: string } }>('/v1/assets/:id', async (req, reply) => {30 const asset = await q.getAsset(req.params.id);31 if (!asset) return problem(reply, 404, 'Asset not found');32 reply.header('cache-control', 'public, max-age=60, s-maxage=300');33 return envelope(asset);34 });3536 app.get<{ Params: { id: string } }>('/v1/assets/:id/sales', async (req, reply) => {37 const p = parse(Paging.extend({ variant: z.string().optional(), include_flagged: z.coerce.boolean().optional() }), req.query);38 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);39 const asset = await q.getAsset(req.params.id);40 if (!asset) return problem(reply, 404, 'Asset not found');41 const offset = decodeCursor(p.data.cursor);42 const rows = await q.assetSales(asset.id as string, { limit: p.data.limit + 1, offset, variantId: p.data.variant, includeFlagged: p.data.include_flagged });43 const page = rows.slice(0, p.data.limit);44 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id }, req.query as Record<string, unknown>);45 });4647 app.get<{ Params: { id: string } }>('/v1/assets/:id/listings', async (req, reply) => {48 const p = parse(Paging.extend({ availability: z.enum(['available', 'sold', 'ended', 'removed']).optional() }), req.query);49 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);50 const asset = await q.getAsset(req.params.id);51 if (!asset) return problem(reply, 404, 'Asset not found');52 const offset = decodeCursor(p.data.cursor);53 const rows = await q.assetListings(asset.id as string, { limit: p.data.limit + 1, offset, availability: p.data.availability });54 const page = rows.slice(0, p.data.limit);55 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'Listing prices are asks, not confirmed transactions.' }, req.query as Record<string, unknown>);56 });5758 app.get<{ Params: { id: string } }>('/v1/assets/:id/depth', async (req, reply) => {59 const p = parse(z.object({ variant: z.string().optional() }), req.query);60 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);61 const asset = await q.getAsset(req.params.id);62 if (!asset) return problem(reply, 404, 'Asset not found');63 const data = await q.assetDepth(asset.id as string, p.data.variant);64 if (!data) return problem(reply, 404, 'Asset not found');65 reply.header('cache-control', 'public, max-age=120, s-maxage=300');66 return reply.send(envelope(data, { asset_id: asset.id }));67 });6869 app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {70 const p = parse(Range.extend({ variant: z.string().optional() }), req.query);71 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);72 const asset = await q.getAsset(req.params.id);73 if (!asset) return problem(reply, 404, 'Asset not found');74 const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to });75 reply.header('cache-control', 'public, max-age=300');76 return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>);77 });7879 app.get('/v1/categories', async (req, reply) => {80 const rows = await q.listCategories();81 reply.header('cache-control', 'public, max-age=3600');82 return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);83 });8485 app.get('/v1/indices', async (req, reply) => {86 const rows = await q.listIndices();87 const shaped = rows.map((r) => {88 const v = r.value as number | null;89 const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null);90 return { ...r, change_1d: pct(r.value_1d), change_7d: pct(r.value_7d), change_30d: pct(r.value_30d), change_1y: pct(r.value_1y), change_ytd: pct(r.value_ytd), published: v !== null };91 });92 reply.header('cache-control', 'public, max-age=300');93 return sendList(reply, shaped, { count: shaped.length, note: 'An index is published only once it has enough priced constituents; unpublished indices report value=null.' }, req.query as Record<string, unknown>);94 });9596 app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => {97 const p = parse(Range, req.query);98 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);99 const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data);100 reply.header('cache-control', 'public, max-age=300');101 return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>);102 });103104 app.get('/v1/markets', async (req, reply) => {105 const rows = await q.listMarkets();106 reply.header('cache-control', 'public, max-age=300');107 return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);108 });109110 app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => {111 const m = await q.getMarket(req.params.slug);112 if (!m) return problem(reply, 404, 'Market not found');113 reply.header('cache-control', 'public, max-age=300');114 return envelope(m);115 });116117 app.get('/v1/trending', async (req, reply) => {118 const p = parse(Paging.extend({ category: z.string().optional() }), req.query);119 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);120 const offset = decodeCursor(p.data.cursor);121 const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset });122 const page = rows.slice(0, p.data.limit);123 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>);124 });125126 app.get('/v1/sales/latest', async (req, reply) => {127 const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query);128 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);129 const offset = decodeCursor(p.data.cursor);130 const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset });131 const page = rows.slice(0, p.data.limit);132 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>);133 });134135 app.get('/v1/records', async (req, reply) => {136 const rows = await q.recordSales();137 reply.header('cache-control', 'public, max-age=600');138 return sendList(reply, rows, { count: rows.length, note: 'Highest verified transaction per family; bundles and low-confidence records excluded.' }, req.query as Record<string, unknown>);139 });140}141