SPB Git

spb/rareindex

Public
8.0 KB · 130 lines typescript
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/history', async (req, reply) => {59    const p = parse(Range.extend({ 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 rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to });64    reply.header('cache-control', 'public, max-age=300');65    return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>);66  });6768  app.get('/v1/categories', async (req, reply) => {69    const rows = await q.listCategories();70    reply.header('cache-control', 'public, max-age=3600');71    return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);72  });7374  app.get('/v1/indices', async (req, reply) => {75    const rows = await q.listIndices();76    const shaped = rows.map((r) => {77      const v = r.value as number | null;78      const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null);79      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 };80    });81    reply.header('cache-control', 'public, max-age=300');82    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>);83  });8485  app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => {86    const p = parse(Range, req.query);87    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);88    const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data);89    reply.header('cache-control', 'public, max-age=300');90    return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>);91  });9293  app.get('/v1/markets', async (req, reply) => {94    const rows = await q.listMarkets();95    reply.header('cache-control', 'public, max-age=300');96    return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);97  });9899  app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => {100    const m = await q.getMarket(req.params.slug);101    if (!m) return problem(reply, 404, 'Market not found');102    reply.header('cache-control', 'public, max-age=300');103    return envelope(m);104  });105106  app.get('/v1/trending', async (req, reply) => {107    const p = parse(Paging.extend({ category: z.string().optional() }), req.query);108    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);109    const offset = decodeCursor(p.data.cursor);110    const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset });111    const page = rows.slice(0, p.data.limit);112    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>);113  });114115  app.get('/v1/sales/latest', async (req, reply) => {116    const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query);117    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);118    const offset = decodeCursor(p.data.cursor);119    const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset });120    const page = rows.slice(0, p.data.limit);121    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>);122  });123124  app.get('/v1/records', async (req, reply) => {125    const rows = await q.recordSales();126    reply.header('cache-control', 'public, max-age=600');127    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>);128  });129}130
Files · 34af8bd9fd569ed935d38a4b3002479131c6e95c
Menu

Theme