SPB Git

spb/rareindex

Public
8.6 KB · 141 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/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
Files · b5d6968a47f5d2546937866bdabc967739349047
Menu

Theme