SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.0 KB · 168 lines typescript
Raw Blame History
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/auctions', async (req, reply) => {70    const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).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 offset = decodeCursor(p.data.cursor);75    const rows = await q.auctionLots({ assetId: asset.id as string, status: p.data.status, limit: p.data.limit + 1, offset });76    const page = rows.slice(0, p.data.limit);77    return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. Bids are not confirmed transactions.' }, req.query as Record<string, unknown>);78  });7980  app.get('/v1/auctions/lots', async (req, reply) => {81    const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional(), ending_within_hours: z.coerce.number().int().min(1).max(24 * 90).optional(), category: z.string().max(80).optional(), house: z.string().max(120).optional(), below_riv: z.coerce.boolean().optional(), asset: z.string().max(80).optional(), sort: z.enum(['ending', 'discount']).optional() }), req.query);82    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);83    const offset = decodeCursor(p.data.cursor);84    let assetId: string | undefined;85    if (p.data.asset) {86      const asset = await q.getAsset(p.data.asset);87      if (!asset) return problem(reply, 404, 'Asset not found');88      assetId = asset.id as string;89    }90    const rows = await q.auctionLots({ status: p.data.status, endingWithinHours: p.data.ending_within_hours, category: p.data.category, house: p.data.house, belowRiv: p.data.below_riv, assetId, sort: p.data.sort, limit: p.data.limit + 1, offset });91    const page = rows.slice(0, p.data.limit);92    reply.header('cache-control', 'public, max-age=60, s-maxage=120');93    return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. bid_vs_riv = (all-in bid − RIV) / RIV, null when the comparison did not pass the gates. Analytical data, not advice.' }, req.query as Record<string, unknown>);94  });9596  app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {97    const p = parse(Range.extend({ variant: z.string().optional() }), req.query);98    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);99    const asset = await q.getAsset(req.params.id);100    if (!asset) return problem(reply, 404, 'Asset not found');101    const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to });102    reply.header('cache-control', 'public, max-age=300');103    return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>);104  });105106  app.get('/v1/categories', async (req, reply) => {107    const rows = await q.listCategories();108    reply.header('cache-control', 'public, max-age=3600');109    return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);110  });111112  app.get('/v1/indices', async (req, reply) => {113    const rows = await q.listIndices();114    const shaped = rows.map((r) => {115      const v = r.value as number | null;116      const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null);117      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 };118    });119    reply.header('cache-control', 'public, max-age=300');120    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>);121  });122123  app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => {124    const p = parse(Range, req.query);125    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);126    const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data);127    reply.header('cache-control', 'public, max-age=300');128    return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>);129  });130131  app.get('/v1/markets', async (req, reply) => {132    const rows = await q.listMarkets();133    reply.header('cache-control', 'public, max-age=300');134    return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);135  });136137  app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => {138    const m = await q.getMarket(req.params.slug);139    if (!m) return problem(reply, 404, 'Market not found');140    reply.header('cache-control', 'public, max-age=300');141    return envelope(m);142  });143144  app.get('/v1/trending', async (req, reply) => {145    const p = parse(Paging.extend({ category: z.string().optional() }), req.query);146    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);147    const offset = decodeCursor(p.data.cursor);148    const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset });149    const page = rows.slice(0, p.data.limit);150    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>);151  });152153  app.get('/v1/sales/latest', async (req, reply) => {154    const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query);155    if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);156    const offset = decodeCursor(p.data.cursor);157    const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset });158    const page = rows.slice(0, p.data.limit);159    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>);160  });161162  app.get('/v1/records', async (req, reply) => {163    const rows = await q.recordSales();164    reply.header('cache-control', 'public, max-age=600');165    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>);166  });167}168