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
This commit is contained in:
Ben Congdon 2022-07-07 15:36:02 -07:00 committed by GitHub
parent 999c1cd8e3
commit c3bc25a4b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 269 additions and 23 deletions

View File

@ -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[]`.
- <details><summary>Example response</summary><p>
```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
}
]
```
</p>
</details>
## Changelog ## Changelog
- 2022-06-08: Add paging to markets endpoint - 2022-06-08: Add paging to markets endpoint

View File

@ -559,6 +559,28 @@
"queryScope": "COLLECTION_GROUP" "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"
}
]
} }
] ]
} }

View File

@ -4,6 +4,13 @@ import {
query, query,
where, where,
orderBy, orderBy,
QueryConstraint,
limit,
startAfter,
doc,
getDocs,
getDoc,
DocumentSnapshot,
} from 'firebase/firestore' } from 'firebase/firestore'
import { uniq } from 'lodash' import { uniq } from 'lodash'
@ -78,6 +85,43 @@ export async function getUserBets(
.catch((reason) => reason) .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<Bet>(query(querySource, ...queryParts))
}
export async function getContractsOfUserBets(userId: string) { export async function getContractsOfUserBets(userId: string) {
const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false })
const contractIds = uniq(bets.map((bet) => bet.contractId)) const contractIds = uniq(bets.map((bet) => bet.contractId))

View File

@ -55,6 +55,18 @@ export type ApiError = {
error: string 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 { export function toLiteMarket(contract: Contract): LiteMarket {
const { const {
id, id,

View File

@ -0,0 +1,17 @@
import { z } from 'zod'
import { ValidationError } from './_types'
export const validate = <T extends z.ZodTypeAny>(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<T>
}
}

66
web/pages/api/v0/bets.ts Normal file
View File

@ -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<Bet[] | ValidationError | ApiError>
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
let params: z.infer<typeof queryParams>
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)
}

View File

@ -2,38 +2,40 @@
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import { listAllContracts } from 'web/lib/firebase/contracts' import { listAllContracts } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' 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( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED) await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
let before: string | undefined
let limit: number | undefined let params: z.infer<typeof queryParams>
if (req.query.before != null) { try {
if (typeof req.query.before !== 'string') { params = validate(queryParams, req.query)
res.status(400).json({ error: 'before must be null or a market ID.' }) } catch (e) {
return if (e instanceof ValidationError) {
return res.status(400).json(e)
} }
before = req.query.before console.error(`Unknown error during validation: ${e}`)
} return res.status(500).json({ error: 'Unknown error during validation' })
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
} }
const { limit, before } = params
try { try {
const contracts = await listAllContracts(limit, before) const contracts = await listAllContracts(limit, before)
// Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching

View File

@ -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<Bet[] | ApiError>
) {
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)
}