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
|
## Changelog
|
||||||
|
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 2022-06-08: Add paging to markets endpoint
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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 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
|
||||||
|
|
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