From c3bc25a4b92c7ff6b285d937eca678dc0ecb6e4f Mon Sep 17 00:00:00 2001 From: Ben Congdon Date: Thu, 7 Jul 2022 15:36:02 -0700 Subject: [PATCH] Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs --- docs/docs/api.md | 58 ++++++++++++++++ firestore.indexes.json | 22 +++++++ web/lib/firebase/bets.ts | 44 +++++++++++++ web/pages/api/v0/_types.ts | 12 ++++ web/pages/api/v0/_validate.ts | 17 +++++ web/pages/api/v0/bets.ts | 66 +++++++++++++++++++ web/pages/api/v0/markets.ts | 48 +++++++------- .../api/v0/user/[username]/bets/index.ts | 25 +++++++ 8 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 web/pages/api/v0/_validate.ts create mode 100644 web/pages/api/v0/bets.ts create mode 100644 web/pages/api/v0/user/[username]/bets/index.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index a8ac18fe..1cea6027 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -567,6 +567,64 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ ]}' ``` +### `GET /v0/bets` + +Gets a list of bets, ordered by creation date descending. + +Parameters: + +- `username`: Optional. If set, the response will include only bets created by this user. +- `market`: Optional. The slug of a market. If set, the response will only include bets on this market. +- `limit`: Optional. How many bets to return. The maximum and the default is 1000. +- `before`: Optional. The ID of the bet before which the list will start. For + example, if you ask for the most recent 10 bets, and then perform a second + query for 10 more bets with `before=[the id of the 10th bet]`, you will + get bets 11 through 20. + +Requires no authorization. + +- Example request + ``` + https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa + ``` +- Response type: A `Bet[]`. + +-
Example response

+ + ```json + [ + { + "probAfter": 0.44418877319153904, + "shares": -645.8346334931828, + "outcome": "YES", + "contractId": "tgB1XmvFXZNhjr3xMNLp", + "sale": { + "betId": "RcOtarI3d1DUUTjiE0rx", + "amount": 474.9999999999998 + }, + "createdTime": 1644602886293, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.7229189477449224, + "id": "x9eNmCaqQeXW8AgJ8Zmp", + "amount": -499.9999999999998 + }, + { + "probAfter": 0.9901970375647697, + "contractId": "zdeaYVAfHlo9jKzWh57J", + "outcome": "YES", + "amount": 1, + "id": "8PqxKYwXCcLYoXy2m2Nm", + "shares": 1.0049875638533763, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.9900000000000001, + "createdTime": 1644705818872 + } + ] + ``` + +

+
+ ## Changelog - 2022-06-08: Add paging to markets endpoint diff --git a/firestore.indexes.json b/firestore.indexes.json index e0cee632..0a8b14bd 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -559,6 +559,28 @@ "queryScope": "COLLECTION_GROUP" } ] + }, + { + "collectionGroup": "bets", + "fieldPath": "id", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] } ] } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index c442ff73..6fc29d24 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -4,6 +4,13 @@ import { query, where, orderBy, + QueryConstraint, + limit, + startAfter, + doc, + getDocs, + getDoc, + DocumentSnapshot, } from 'firebase/firestore' import { uniq } from 'lodash' @@ -78,6 +85,43 @@ export async function getUserBets( .catch((reason) => reason) } +export async function getBets(options: { + userId?: string + contractId?: string + before?: string + limit: number +}) { + const { userId, contractId, before } = options + + const queryParts: QueryConstraint[] = [ + orderBy('createdTime', 'desc'), + limit(options.limit), + ] + if (userId) { + queryParts.push(where('userId', '==', userId)) + } + if (before) { + let beforeSnap: DocumentSnapshot + if (contractId) { + beforeSnap = await getDoc( + doc(db, 'contracts', contractId, 'bets', before) + ) + } else { + beforeSnap = ( + await getDocs( + query(collectionGroup(db, 'bets'), where('id', '==', before)) + ) + ).docs[0] + } + queryParts.push(startAfter(beforeSnap)) + } + + const querySource = contractId + ? collection(db, 'contracts', contractId, 'bets') + : collectionGroup(db, 'bets') + return await getValues(query(querySource, ...queryParts)) +} + export async function getContractsOfUserBets(userId: string) { const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) const contractIds = uniq(bets.map((bet) => bet.contractId)) diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index e0012c2b..7f52077d 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -55,6 +55,18 @@ export type ApiError = { error: string } +type ValidationErrorDetail = { + field: string | null + error: string +} +export class ValidationError { + details: ValidationErrorDetail[] + + constructor(details: ValidationErrorDetail[]) { + this.details = details + } +} + export function toLiteMarket(contract: Contract): LiteMarket { const { id, diff --git a/web/pages/api/v0/_validate.ts b/web/pages/api/v0/_validate.ts new file mode 100644 index 00000000..25f5af4e --- /dev/null +++ b/web/pages/api/v0/_validate.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { ValidationError } from './_types' + +export const validate = (schema: T, val: unknown) => { + const result = schema.safeParse(val) + if (!result.success) { + const issues = result.error.issues.map((i) => { + return { + field: i.path.join('.') || null, + error: i.message, + } + }) + throw new ValidationError(issues) + } else { + return result.data as z.infer + } +} diff --git a/web/pages/api/v0/bets.ts b/web/pages/api/v0/bets.ts new file mode 100644 index 00000000..c3de3e97 --- /dev/null +++ b/web/pages/api/v0/bets.ts @@ -0,0 +1,66 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { Bet, getBets } from 'web/lib/firebase/bets' +import { getContractFromSlug } from 'web/lib/firebase/contracts' +import { getUserByUsername } from 'web/lib/firebase/users' +import { ApiError, ValidationError } from './_types' +import { z } from 'zod' +import { validate } from './_validate' + +const queryParams = z + .object({ + username: z.string().optional(), + market: z.string().optional(), + limit: z + .number() + .default(1000) + .or(z.string().regex(/\d+/).transform(Number)) + .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), + before: z.string().optional(), + }) + .strict() + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + + let params: z.infer + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) + } + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) + } + + const { username, market, limit, before } = params + + let userId: string | undefined + if (username) { + const user = await getUserByUsername(username) + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + userId = user.id + } + + let contractId: string | undefined + if (market) { + const contract = await getContractFromSlug(market) + if (!contract) { + res.status(404).json({ error: 'Contract not found' }) + return + } + contractId = contract.id + } + + const bets = await getBets({ userId, contractId, limit, before }) + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(bets) +} diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index fec3cc30..56ecc594 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -2,38 +2,40 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { listAllContracts } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' -import { toLiteMarket } from './_types' +import { toLiteMarket, ValidationError } from './_types' +import { z } from 'zod' +import { validate } from './_validate' + +const queryParams = z + .object({ + limit: z + .number() + .default(1000) + .or(z.string().regex(/\d+/).transform(Number)) + .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), + before: z.string().optional(), + }) + .strict() export default async function handler( req: NextApiRequest, res: NextApiResponse ) { await applyCorsHeaders(req, res, CORS_UNRESTRICTED) - let before: string | undefined - let limit: number | undefined - if (req.query.before != null) { - if (typeof req.query.before !== 'string') { - res.status(400).json({ error: 'before must be null or a market ID.' }) - return + + let params: z.infer + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) } - before = req.query.before - } - if (req.query.limit != null) { - if (typeof req.query.limit !== 'string') { - res - .status(400) - .json({ error: 'limit must be null or a number of markets to return.' }) - return - } - limit = parseInt(req.query.limit) - } else { - limit = 1000 - } - if (limit < 1 || limit > 1000) { - res.status(400).json({ error: 'limit must be between 1 and 1000.' }) - return + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) } + const { limit, before } = params + try { const contracts = await listAllContracts(limit, before) // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching diff --git a/web/pages/api/v0/user/[username]/bets/index.ts b/web/pages/api/v0/user/[username]/bets/index.ts new file mode 100644 index 00000000..464af52c --- /dev/null +++ b/web/pages/api/v0/user/[username]/bets/index.ts @@ -0,0 +1,25 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { Bet, getUserBets } from 'web/lib/firebase/bets' +import { getUserByUsername } from 'web/lib/firebase/users' +import { ApiError } from '../../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { username } = req.query + + const user = await getUserByUsername(username as string) + + if (!user) { + res.status(404).json({ error: 'User not found' }) + return + } + + const bets = await getUserBets(user.id, { includeRedemptions: false }) + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(bets) +}