diff --git a/web/codegen.yml b/web/codegen.yml index 1786624c..720c54d4 100644 --- a/web/codegen.yml +++ b/web/codegen.yml @@ -6,13 +6,24 @@ 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' diff --git a/web/lib/api/graphql/resolvers/bet.ts b/web/lib/api/graphql/resolvers/bet.ts new file mode 100644 index 00000000..8b62b220 --- /dev/null +++ b/web/lib/api/graphql/resolvers/bet.ts @@ -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 diff --git a/web/lib/api/graphql/resolvers/comment.ts b/web/lib/api/graphql/resolvers/comment.ts new file mode 100644 index 00000000..ac10063f --- /dev/null +++ b/web/lib/api/graphql/resolvers/comment.ts @@ -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 diff --git a/web/lib/api/graphql/resolvers/index.ts b/web/lib/api/graphql/resolvers/index.ts index 0c6ca84a..f5e6513e 100644 --- a/web/lib/api/graphql/resolvers/index.ts +++ b/web/lib/api/graphql/resolvers/index.ts @@ -2,8 +2,18 @@ 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([marketsResolvers]) as Resolvers +const resolvers = merge([ + scalarResolvers, + marketsResolvers, + betResolvers, + userResolvers, + commentResolvers, +]) as Resolvers export default resolvers diff --git a/web/lib/api/graphql/resolvers/index.ts.bak b/web/lib/api/graphql/resolvers/index.ts.bak new file mode 100644 index 00000000..f5e6513e --- /dev/null +++ b/web/lib/api/graphql/resolvers/index.ts.bak @@ -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 diff --git a/web/lib/api/graphql/resolvers/market.ts b/web/lib/api/graphql/resolvers/market.ts index d30aee3e..9c33d01b 100644 --- a/web/lib/api/graphql/resolvers/market.ts +++ b/web/lib/api/graphql/resolvers/market.ts @@ -1,11 +1,122 @@ -import type { QueryResolvers, Resolvers } from 'web/generated/graphql_api' +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(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 () => [], + 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 diff --git a/web/lib/api/graphql/resolvers/scalars.ts b/web/lib/api/graphql/resolvers/scalars.ts new file mode 100644 index 00000000..30dc1eed --- /dev/null +++ b/web/lib/api/graphql/resolvers/scalars.ts @@ -0,0 +1,5 @@ +import { TimestampResolver } from 'graphql-scalars' + +const resolvers = { Timestamp: TimestampResolver } + +export default resolvers diff --git a/web/lib/api/graphql/resolvers/user.ts b/web/lib/api/graphql/resolvers/user.ts new file mode 100644 index 00000000..548ea266 --- /dev/null +++ b/web/lib/api/graphql/resolvers/user.ts @@ -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 diff --git a/web/lib/api/graphql/typedefs/bet.graphql b/web/lib/api/graphql/typedefs/bet.graphql new file mode 100644 index 00000000..7b7a5d9a --- /dev/null +++ b/web/lib/api/graphql/typedefs/bet.graphql @@ -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 +} diff --git a/web/lib/api/graphql/typedefs/comments.graphql b/web/lib/api/graphql/typedefs/comments.graphql new file mode 100644 index 00000000..a44b3326 --- /dev/null +++ b/web/lib/api/graphql/typedefs/comments.graphql @@ -0,0 +1,12 @@ +scalar CommentID + +type Comment { + id: CommentID! + market: Market! + answerOutcome: Int + text: String! + createdTime: Timestamp! + replyTo: Comment + answers: [Comment!]! + user: User! +} diff --git a/web/lib/api/graphql/typedefs/market.graphql b/web/lib/api/graphql/typedefs/market.graphql index d5a678f0..4dded058 100644 --- a/web/lib/api/graphql/typedefs/market.graphql +++ b/web/lib/api/graphql/typedefs/market.graphql @@ -1,9 +1,155 @@ scalar MarketID type Market { - id: MarketID + 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 Query { - markets(before: MarketID, limit: Int): [Market] +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 } diff --git a/web/lib/api/graphql/typedefs/scalars.graphql b/web/lib/api/graphql/typedefs/scalars.graphql new file mode 100644 index 00000000..4f50a78e --- /dev/null +++ b/web/lib/api/graphql/typedefs/scalars.graphql @@ -0,0 +1 @@ +scalar Timestamp diff --git a/web/lib/api/graphql/typedefs/user.graphql b/web/lib/api/graphql/typedefs/user.graphql new file mode 100644 index 00000000..7462f58f --- /dev/null +++ b/web/lib/api/graphql/typedefs/user.graphql @@ -0,0 +1,8 @@ +scalar UserID + +type User { + id: UserID! + username: String! + name: String! + avatarUrl: String +} diff --git a/web/lib/api/graphql/types.ts b/web/lib/api/graphql/types.ts index 0ef7ef15..b552b7bb 100644 --- a/web/lib/api/graphql/types.ts +++ b/web/lib/api/graphql/types.ts @@ -1,4 +1,14 @@ 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 = { diff --git a/web/package.json b/web/package.json index 7a591a35..3e9e9aad 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "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,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 \"", + "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: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", @@ -44,6 +44,7 @@ "dayjs": "1.10.7", "firebase": "9.6.0", "graphql": "16.5.0", + "graphql-scalars": "1.17.0", "gridjs": "5.0.2", "gridjs-react": "5.0.2", "lodash": "4.17.21", diff --git a/yarn.lock b/yarn.lock index 785a8215..faa9370e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8111,6 +8111,13 @@ graphql-request@^4.0.0: extract-files "^9.0.0" form-data "^3.0.0" +graphql-scalars@1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/graphql-scalars/-/graphql-scalars-1.17.0.tgz#10e4f0fe44472d475dd72f14412c065fd1b7aff8" + integrity sha512-y1WtSu6jiW5QdDjK3RWMRTdK+xAAtSIq3IxmtnhxzH7bCkHV/z8VZa8fsSG4BcWbjQQtCQYQvMnvbQ+TBCyJRQ== + dependencies: + tslib "~2.3.0" + graphql-tag@^2.11.0: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"