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:
parent
999c1cd8e3
commit
c3bc25a4b9
|
@ -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
|
||||
|
||||
- 2022-06-08: Add paging to markets endpoint
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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<Bet>(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))
|
||||
|
|
|
@ -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,
|
||||
|
|
17
web/pages/api/v0/_validate.ts
Normal file
17
web/pages/api/v0/_validate.ts
Normal 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
66
web/pages/api/v0/bets.ts
Normal 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)
|
||||
}
|
|
@ -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<typeof queryParams>
|
||||
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
|
||||
|
|
25
web/pages/api/v0/user/[username]/bets/index.ts
Normal file
25
web/pages/api/v0/user/[username]/bets/index.ts
Normal 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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user