This commit is contained in:
joy-void-joy 2022-07-20 15:17:03 -07:00 committed by GitHub
commit 7034e0a268
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 3077 additions and 54 deletions

3
web/.gitignore vendored
View File

@ -2,4 +2,5 @@
.next .next
node_modules node_modules
out out
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
generated

6
web/@types/graphql.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.graphql' {
import { DocumentNode } from 'graphql'
const Schema: DocumentNode
export = Schema
}

38
web/codegen.yml Normal file
View File

@ -0,0 +1,38 @@
overwrite: true
schema: lib/api/graphql/**/**.graphql
documents: null
generates:
generated/graphql_api.ts:
config:
useIndexSignature: true
contextType: web/lib/api/graphql/types#contextType
inputMaybeValue: undefined | T
maybeValue: undefined | T
strictScalars: true
mappers:
Comment: web/lib/api/graphql/types#CommentModel
MarketAnswer: web/lib/api/graphql/types#AnswerModel
Market: web/lib/api/graphql/types#MarketModel
Bet: web/lib/api/graphql/types#BetModel
scalars:
MarketID: 'string'
MarketAnswerID: 'string'
BetID: 'string'
CommentID: 'string'
UserID: 'string'
Timestamp: 'number'
plugins:
- 'typescript'
- 'typescript-resolvers'
generated/schema.graphql:
plugins:
- 'schema-ast'
hooks:
afterAllFileWrite:
- prettier --write

View File

@ -0,0 +1,108 @@
import type { Contract, BinaryContract } from 'common/contract'
import { getOutcomeProbability, getProbability } from 'common/calculate'
import { listAllBets } from 'web/lib/firebase/bets'
import {
getContractFromId,
getContractFromSlug,
listAllContracts,
} from 'web/lib/firebase/contracts'
import { listAllComments } from 'web/lib/firebase/comments'
import { getUser } from 'web/lib/firebase/users'
import { DataSource } from 'apollo-datasource'
import { InMemoryLRUCache, KeyValueCache } from 'apollo-server-caching'
/* Simple wrapper around web/lib/firebase functions */
export class FirebaseAPI extends DataSource {
cache: KeyValueCache
context?: any
constructor() {
super()
this.cache = null as any
}
initialize({
context,
cache,
}: { context?: any; cache?: KeyValueCache } = {}) {
this.context = context
this.cache = cache || new InMemoryLRUCache()
}
didEncounterError(error: any) {
throw error
}
cacheKey(id: string | undefined, type: string) {
return `firebaseapi-${type}-${id}`
}
async get<T>(
id: string | undefined,
type: string,
func: () => Promise<T>,
{ ttlInSeconds = 200 }: { ttlInSeconds?: number } = {}
): Promise<T> {
const cacheDoc = await this.cache.get(this.cacheKey(id, type))
if (cacheDoc) {
return JSON.parse(cacheDoc)
}
const doc = await func()
if (ttlInSeconds) {
this.cache.set(this.cacheKey(id, type), JSON.stringify(doc), {
ttl: ttlInSeconds,
})
}
return doc
}
async listAllBets(id: string) {
return this.get(id, 'listAllBets', () => listAllBets(id))
}
async getContractFromSlug(id: string) {
return this.get(id, 'getContractFromSlug', () => getContractFromSlug(id))
}
async getContractFromID(id: string) {
return this.get(id, 'market', () => getContractFromId(id))
}
async getOutcomeProbability(contract: Contract, answerID: string) {
return this.get(answerID, 'getOutcomeProbability', () =>
(async () => getOutcomeProbability(contract, answerID))()
)
}
async getProbability(contract: BinaryContract) {
return this.get(contract.id, 'getProbability', () =>
(async () => getProbability(contract))()
)
}
async listAllCommentAnswers(commentId: string, contractID: string) {
return this.get(commentId, 'listAllCommentAnswers', async () => {
const allComments = await this.listAllComments(contractID)
return allComments.filter((c) => c.replyToCommentId === commentId)
})
}
async listAllComments(id: string) {
return this.get(id, 'listAllComments', () => listAllComments(id))
}
async listAllContracts(limit = 1000, before?: string) {
return this.get(before, 'listAllContracts', () =>
listAllContracts(limit, before)
)
}
async getUser(id: string) {
return this.get(id, 'user', () => getUser(id))
}
}

View File

@ -0,0 +1,10 @@
import type { contextType } from '../types'
import { FirebaseAPI } from './firebaseAPI'
const dataSources = () =>
({
firebaseAPI: new FirebaseAPI(),
} as contextType['dataSources'])
export default dataSources

View File

@ -0,0 +1,16 @@
import { ApolloServer } from 'apollo-server-micro'
import { ApolloServerPluginLandingPageGraphQLPlayground } from 'apollo-server-core'
import dataSources from './datasources'
import resolvers from './resolvers'
import typeDefs from 'web/generated/schema.graphql'
export const apolloServer = new ApolloServer({
csrfPrevention: true,
cache: 'bounded',
plugins: [ApolloServerPluginLandingPageGraphQLPlayground()],
dataSources,
resolvers,
typeDefs,
})

View File

@ -0,0 +1,14 @@
import type { BetResolvers, Resolvers } from 'web/generated/graphql_api'
const betResolvers: BetResolvers = {
market: async (bet) => bet.contract,
user: async (bet, _, { dataSources }) =>
await dataSources.firebaseAPI.getUser(bet.userId),
}
const resolvers: Resolvers = {
Bet: betResolvers,
}
export default resolvers

View File

@ -0,0 +1,30 @@
import type { CommentResolvers, Resolvers } from 'web/generated/graphql_api'
const commentResolvers: CommentResolvers = {
market: async (comment) => comment.contract,
answers: async (comment, _, { dataSources }) => {
const result = await dataSources.firebaseAPI.listAllCommentAnswers(
comment.id,
comment.contract.id
)
return result.map((el) => ({
...el,
contract: comment.contract,
}))
},
user: async (comment) => ({
id: comment.userId,
avatarUrl: comment.userAvatarUrl,
name: comment.userName,
username: comment.userUsername,
}),
}
const resolvers: Resolvers = {
Comment: commentResolvers,
}
export default resolvers

View File

@ -0,0 +1,19 @@
import type { Resolvers } from 'web/generated/graphql_api'
import { merge } from 'lodash'
import scalarResolvers from './scalars'
import marketsResolvers from './market'
import userResolvers from './user'
import betResolvers from './bet'
import commentResolvers from './comment'
const resolvers = merge([
scalarResolvers,
marketsResolvers,
betResolvers,
userResolvers,
commentResolvers,
]) as Resolvers
export default resolvers

View File

@ -0,0 +1,19 @@
import type { Resolvers } from 'web/generated/graphql_api'
import { merge } from 'lodash'
import scalarResolvers from './scalars'
import marketsResolvers from './market'
import userResolvers from './user'
import betResolvers from './bet'
import commentResolvers from './comment'
const resolvers = merge([
scalarResolvers,
marketsResolvers,
betResolvers,
userResolvers,
commentResolvers,
]) as Resolvers
export default resolvers

View File

@ -0,0 +1,122 @@
import type {
MarketAnswerResolvers,
MarketResolvers,
QueryResolvers,
Resolvers,
ResolversTypes,
} from 'web/generated/graphql_api'
import type { MarketModel } from '../types'
import { UserInputError } from 'apollo-server-micro'
function contractVerify(contract?: MarketModel) {
if (!contract) {
throw new UserInputError('Contract not found')
}
return contract
}
function augmentContract<T>(contract: MarketModel, l: T[]) {
return l.map((el) => ({
...el,
contract,
}))
}
const answerResolvers: MarketAnswerResolvers = {
creator: (answer) => ({
id: answer.userId,
name: answer.name,
username: answer.username,
avatarUrl: answer.avatarUrl,
}),
probability: (answer, _, { dataSources }) =>
dataSources.firebaseAPI.getOutcomeProbability(answer.contract, answer.id),
market: async (answer) => answer.contract,
}
const marketResolvers: MarketResolvers = {
url: async (contract) =>
`https://manifold.markets/${contract.creatorUsername}/${contract.slug}`,
creator: async (contract) => ({
id: contract.creatorId,
username: contract.creatorUsername,
name: contract.creatorName,
avatarUrl: contract.creatorAvatarUrl,
}),
outcome: async (contract: MarketModel, _, { dataSources }) => {
switch (contract.outcomeType) {
case 'BINARY':
return {
__typename: 'MarketOutcomeBinary',
probability: await dataSources.firebaseAPI.getProbability(contract),
} as ResolversTypes['MarketOutcomeBinary']
case 'FREE_RESPONSE':
return {
__typename: 'MarketOutcomeFreeResponse',
answers: augmentContract(contract, contract.answers),
} as ResolversTypes['MarketOutcomeFreeResponse']
case 'PSEUDO_NUMERIC':
case 'NUMERIC':
return {
__typename: 'MarketOutcomeNumeric',
min: contract.min,
max: contract.max,
} as ResolversTypes['MarketOutcomeNumeric']
}
},
pool: async (contract) => contract.pool.YES + contract.pool.NO || undefined,
closeTime: async (contract) =>
contract.resolutionTime && contract.closeTime
? Math.min(contract.resolutionTime, contract.closeTime)
: contract.closeTime,
bets: async (contract, _, { dataSources }) =>
augmentContract(
contract,
await dataSources.firebaseAPI.listAllBets(contract.id)
),
comments: async (contract, _, { dataSources }) =>
augmentContract(
contract,
await dataSources.firebaseAPI.listAllComments(contract.id)
),
}
const queryResolvers: QueryResolvers = {
markets: async (_, { before, limit }, { dataSources }) => {
if (limit < 1 || limit > 1000) {
throw new UserInputError('limit must be between 1 and 1000')
}
try {
return await dataSources.firebaseAPI.listAllContracts(limit, before)
} catch (e) {
throw new UserInputError(
'Failed to fetch markets (did you pass an invalid ID as the before parameter?)'
)
}
},
market: async (_, { id }, { dataSources }) =>
contractVerify(await dataSources.firebaseAPI.getContractFromID(id)),
slug: async (_, { url }, { dataSources }) =>
contractVerify(await dataSources.firebaseAPI.getContractFromSlug(url)),
}
const resolver: Resolvers = {
Query: queryResolvers,
Market: marketResolvers,
MarketAnswer: answerResolvers,
}
export default resolver

View File

@ -0,0 +1,5 @@
import { TimestampResolver } from 'graphql-scalars'
const resolvers = { Timestamp: TimestampResolver }
export default resolvers

View File

@ -0,0 +1,11 @@
import type { Resolvers, UserResolvers } from 'web/generated/graphql_api'
const userResolvers: UserResolvers = {
id: (user) => user.id,
}
const resolvers: Resolvers = {
User: userResolvers,
}
export default resolvers

View File

@ -0,0 +1,51 @@
scalar BetID
type Sale {
# amount user makes from sale
amount: Int
}
type Bet {
id: BetID!
user: User
market: Market!
# bet size; negative if SELL bet
amount: Int!
outcome: String!
# dynamic parimutuel pool weight; negative if SELL bet
shares: Float!
probBefore: Float!
probAfter: Float!
sale: Sale
# true if this BUY bet has been sold
isSold: Boolean
isAnte: Boolean
createdTime: Timestamp
}
type Mutation {
"""
Places a new bet on behalf of the authorized user.
"""
placeBet(
"""
The amount to bet, in M$, before fees.
"""
amount: Int!
"""
The ID of the contract (market) to bet on.
"""
contractId: MarketID!
"""
The outcome to bet on. For binary markets, this is YES or NO. For free response markets, this is the ID of the free response answer. For numeric markets, this is a string representing the target bucket, and an additional value parameter is required which is a number representing the target value. (Bet on numeric markets at your own peril.)
"""
outcome: String!
): Bet
}

View File

@ -0,0 +1,12 @@
scalar CommentID
type Comment {
id: CommentID!
market: Market!
answerOutcome: Int
text: String!
createdTime: Timestamp!
replyTo: Comment
answers: [Comment!]!
user: User!
}

View File

@ -0,0 +1,155 @@
scalar MarketID
type Market {
id: MarketID!
creator: User!
bets: [Bet!]!
comments: [Comment!]!
outcome: MarketOutcome!
createdTime: Timestamp!
# Min of creator's chosen date, and resolutionTime
closeTime: Timestamp
question: String!
description: String!
tags: [String!]
url: String!
pool: Float
volume: Float!
volume7Days: Float!
volume24Hours: Float!
isResolved: Boolean!
resolutionTime: Int
resolution: String
p: Float
totalLiquidity: Float
# Method of market making (cpmm, dpm, etc)
# ... TODO: This should be a enum
mechanism: String!
}
type MarketOutcomeBinary {
probability: Float!
}
input MarketOutcomeBinaryInput {
probability: Float!
}
scalar MarketAnswerID
type MarketAnswer {
id: MarketAnswerID!
text: String!
market: Market
creator: User!
createdTime: Timestamp!
number: Int!
probability: Float!
}
type MarketOutcomeFreeResponse {
answers: [MarketAnswer!]!
}
type MarketOutcomeNumeric {
min: Float!
max: Float!
}
input MarketOutcomeNumericInput {
min: Float!
max: Float!
}
enum MarketType {
BINARY
FREE_RESPONSE
NUMERIC
}
union MarketOutcome =
MarketOutcomeFreeResponse
| MarketOutcomeNumeric
| MarketOutcomeBinary
type Query {
"""
Lists all markets, ordered by creation date descending.
"""
markets(
"""
The ID of the market before which the list will start.
For example, if you ask for the most recent 10 markets, and then perform a second query for 10 more markets with before=[the id of the 10th market], you will get markets 11 through 20.
"""
before: MarketID
"""
How many markets to return. The maximum and the default is 1000.
"""
limit: Int = 1000
): [Market!]!
"""
Gets information about a single market by ID.
Example request: https://manifold.markets/api/v0/market/3zspH9sSzMlbFQLn9GKR
"""
market(id: MarketID!): Market!
"""
Gets information about a single market by slug (the portion of the URL path after the username).
"""
slug(url: String!): Market!
}
input MarketInput {
"""
The headline question for the market.
"""
question: String!
"""
A long description describing the rules for the market.
"""
description: String!
"""
The time at which the market will close, represented as milliseconds since the epoch.
"""
closeTime: Timestamp
"""
An array of string tags for the market.
"""
tags: [String!]
}
type Mutation {
"""
Creates a new binary market on behalf of the authorized user.
"""
createMarketBinary(
"""
Condition of market at creation
"""
initial: MarketOutcomeBinaryInput
input: MarketInput
): Market
"""
Creates a new free response market on behalf of the authorized user.
"""
createMarketFreeResponse(input: MarketInput): Market
"""
Creates a new numeric market on behalf of the authorized user.
"""
createMarketNumeric(
"""
Condition of market at creation
"""
initial: MarketOutcomeNumericInput
input: MarketInput
): Market
}

View File

@ -0,0 +1 @@
scalar Timestamp

View File

@ -0,0 +1,8 @@
scalar UserID
type User {
id: UserID!
username: String!
name: String!
avatarUrl: String
}

View File

@ -0,0 +1,18 @@
import type { FirebaseAPI } from './datasources/firebaseAPI'
import type { Answer } from 'common/answer'
import type { Bet } from 'common/bet'
import type { Comment } from 'common/comment'
import type { Contract } from 'common/contract'
/* Model types, used internally by resolvers */
export type MarketModel = Contract
export type AnswerModel = Answer & { contract: MarketModel }
export type CommentModel = Comment & { contract: MarketModel }
export type BetModel = Bet & { contract: MarketModel }
/* Context type */
export type contextType = {
dataSources: {
firebaseAPI: FirebaseAPI
}
}

View File

@ -2,6 +2,17 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
module.exports = { module.exports = {
webpack: (config) => {
config.module.rules.push({
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader',
})
return config
},
webpackDevMiddleware: (config) => {
return config
},
staticPageGenerationTimeout: 600, // e.g. stats page staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,

View File

@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"", "dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"",
"devdev": "cross-env NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"cross-env FIREBASE_ENV=DEV next dev -p 3000\" \"cross-env FIREBASE_ENV=DEV yarn ts --watch\"", "devdev": "cross-env NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS,GRAPHQL -c magenta,cyan,yellow \"cross-env FIREBASE_ENV=DEV next dev -p 3000\" \"cross-env FIREBASE_ENV=DEV yarn ts --watch\" \"yarn generate --watch lib/api/graphql/**/**.graphql --watch lib/api/graphql/types.ts \"",
"dev:dev": "yarn devdev", "dev:dev": "yarn devdev",
"dev:the": "cross-env NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"cross-env FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"cross-env FIREBASE_ENV=THEOREMONE yarn ts --watch\"", "dev:the": "cross-env NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"cross-env FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"cross-env FIREBASE_ENV=THEOREMONE yarn ts --watch\"",
"dev:emulate": "cross-env NEXT_PUBLIC_FIREBASE_EMULATE=TRUE yarn devdev", "dev:emulate": "cross-env NEXT_PUBLIC_FIREBASE_EMULATE=TRUE yarn devdev",
@ -15,7 +15,12 @@
"format": "npx prettier --write .", "format": "npx prettier --write .",
"postbuild": "next-sitemap", "postbuild": "next-sitemap",
"verify": "(cd .. && yarn verify)", "verify": "(cd .. && yarn verify)",
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit" "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit",
"generate": "graphql-codegen --config codegen.yml",
"predev": "yarn generate",
"prebuild": "yarn generate",
"prets": "yarn generate",
"prelint": "yarn generate"
}, },
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "0.4.1", "@amplitude/analytics-browser": "0.4.1",
@ -31,14 +36,19 @@
"@tiptap/react": "2.0.0-beta.114", "@tiptap/react": "2.0.0-beta.114",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.190",
"algoliasearch": "4.13.0", "algoliasearch": "4.13.0",
"apollo-server-caching": "3.3.0",
"apollo-server-micro": "3.10.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"cors": "2.8.5", "cors": "2.8.5",
"daisyui": "1.16.4", "daisyui": "1.16.4",
"dayjs": "1.10.7", "dayjs": "1.10.7",
"firebase": "9.6.0", "firebase": "9.6.0",
"graphql": "16.5.0",
"graphql-scalars": "1.17.0",
"gridjs": "5.0.2", "gridjs": "5.0.2",
"gridjs-react": "5.0.2", "gridjs-react": "5.0.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"micro": "9.4.0",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"next": "12.1.2", "next": "12.1.2",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
@ -52,6 +62,9 @@
"string-similarity": "^4.0.4" "string-similarity": "^4.0.4"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "2.6.2",
"@graphql-codegen/typescript": "2.5.1",
"@graphql-codegen/typescript-resolvers": "2.6.6",
"@tailwindcss/forms": "0.4.0", "@tailwindcss/forms": "0.4.0",
"@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/line-clamp": "^0.3.1",
"@tailwindcss/typography": "^0.5.1", "@tailwindcss/typography": "^0.5.1",

View File

@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { apolloServer } from 'web/lib/api/graphql'
const startServer = apolloServer.start()
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await startServer
await apolloServer.createHandler({
path: '/api/graphql',
})(req, res)
}

2436
yarn.lock

File diff suppressed because it is too large Load Diff