diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 0000000..6e8b914 --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,45 @@ +Metaforecast website is implemented on top of GraphQL API. + +Tech stack: + +- [Pothos](https://pothos-graphql.dev/) on the backend for implementing our graphql server +- [urql](https://formidable.com/open-source/urql/) on the frontend +- [GraphQL Code Generator](https://www.graphql-code-generator.com/) for schema generation, queries generation and schema introspection + +Rationale for this stack can be found on [#32](https://github.com/quantified-uncertainty/metaforecast/issues/32) and [#21](https://github.com/quantified-uncertainty/metaforecast/issues/32) in comments. + +# Code layout + +List of all files used for graphql: + +- [schema.graphql](../schema.graphql), GraphQL schema generated by graphql-code-generator. +- [codegen.yml](../codegen.yml), graphql-code-generator [config](https://www.graphql-code-generator.com/docs/config-reference/codegen-config) +- [graphql.config.yaml](../graphql.config.yaml), [GraphQL Config](https://www.graphql-config.com/) for better VS Code integration ([VS Code GraphQL extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql) type-checks graphql files thanks to it) +- [src/pages/api/graphql.ts](../src/pages/api/graphql.ts) implements the GraphQL HTTP endpoint and GraphQL playground +- [src/web/urql.ts](../src/web/urql.ts) contains some helper functions. + +[src/graphql/](../src/graphql) dir contains GraphQL server code. + +`*.graphql` files in `src/web/**` contain GraphQL fragments, queries and mutations which are used on the frontend. + +`graphql-code-generator` converts those into `*.generated.ts` files which can be imported from the React components. + +# Recipes + +**I want to check out what Metaforecast's GraphQL API is capable of** + +Go to [/api/graphql](https://metaforecast.org/api/graphql) and do some queries by hand. Note the "Docs" link in the top right corner. + +**I want to add a new query/mutation to our schema** + +Read the [Pothos](https://pothos-graphql.dev/) docs to learn how to implement new objects and fields. Add the new code somewhere in `src/graphql/schema/`. + +**I want to display a new piece of data on the metaforecast website** + +- add a query in a nearest `queries.graphql` file (if there isn't one, create it) +- run `gql-gen` (or keep it running with `gql-gen -w`) to get a `graphql.generated.ts` file +- use [useQuery](https://formidable.com/open-source/urql/docs/basics/react-preact/#queries) with your new `MyQueryDocument` which you can import from the `graphql.generated.ts` file + +If you need SSR, you'll also need to do **the same** query with **the same variables** from your page's `getServerSideProps` function. Check out any existing page which calls `ssrUrql`. + +(You might not need to use `useQuery` in your components if you're doing SSR and the page content is not dynamic, just pass the data from `getServerSideProps` in `props` and avoid GraphQL on the client side). diff --git a/src/graphql/schema/dashboards.ts b/src/graphql/schema/dashboards.ts index f216850..f9837a6 100644 --- a/src/graphql/schema/dashboards.ts +++ b/src/graphql/schema/dashboards.ts @@ -8,11 +8,18 @@ import { QuestionObj } from "./questions"; const DashboardObj = builder.objectRef("Dashboard").implement({ fields: (t) => ({ id: t.exposeID("id"), - title: t.exposeString("title"), - description: t.exposeString("description"), - creator: t.exposeString("creator"), + title: t.exposeString("title", { + description: "The title of the dashboard", + }), + description: t.exposeString("description", { + description: "The longer description of the dashboard", + }), + creator: t.exposeString("creator", { + description: 'The creator of the dashboard, e.g. "Peter Parker"', + }), questions: t.field({ type: [QuestionObj], + description: "The list of questions on the dashboard", resolve: async (parent) => { return await prisma.question.findMany({ where: { @@ -29,6 +36,7 @@ const DashboardObj = builder.objectRef("Dashboard").implement({ builder.queryField("dashboard", (t) => t.field({ type: DashboardObj, + description: "Look up a single dashboard by its id", args: { id: t.arg({ type: "ID", required: true }), }, @@ -55,16 +63,25 @@ const CreateDashboardResult = builder const CreateDashboardInput = builder.inputType("CreateDashboardInput", { fields: (t) => ({ - title: t.string({ required: true }), - description: t.string(), - creator: t.string(), - ids: t.idList({ required: true }), + title: t.string({ + required: true, + description: "The title of the dashboard", + }), + description: t.string({ + description: "The longer description of the dashboard", + }), + creator: t.string({ + description: 'The creator of the dashboard, e.g. "Peter Parker"', + }), + ids: t.idList({ required: true, description: "List of question ids" }), }), }); builder.mutationField("createDashboard", (t) => t.field({ type: CreateDashboardResult, + description: + "Create a new dashboard; if the dashboard with given ids already exists then it will be returned instead.", args: { input: t.arg({ type: CreateDashboardInput, required: true }), }, diff --git a/src/graphql/schema/frontpage.ts b/src/graphql/schema/frontpage.ts index 1b64648..46d8464 100644 --- a/src/graphql/schema/frontpage.ts +++ b/src/graphql/schema/frontpage.ts @@ -8,6 +8,7 @@ import { QuestionObj } from "./questions"; builder.queryField("frontpage", (t) => t.field({ type: [QuestionObj], + description: "Get a list of questions that are currently on the frontpage", resolve: async () => { const legacyQuestions = await getFrontpage(); const ids = legacyQuestions.map((q) => q.id); diff --git a/src/graphql/schema/questions.ts b/src/graphql/schema/questions.ts index 15690ed..69a4ef2 100644 --- a/src/graphql/schema/questions.ts +++ b/src/graphql/schema/questions.ts @@ -3,9 +3,15 @@ import { platforms, QualityIndicators } from "../../backend/platforms"; import { builder } from "../builder"; const PlatformObj = builder.objectRef("Platform").implement({ - description: "Platform supported by metaforecast", + description: "Forecasting platform supported by Metaforecast", fields: (t) => ({ + id: t.id({ + description: 'Short unique platform name, e.g. "xrisk"', + resolve: (x) => x, + }), label: t.string({ + description: + 'Platform name for displaying on frontend etc., e.g. "X-risk estimates"', resolve: (platformName) => { if (platformName === "metaforecast") { return "Metaforecast"; @@ -21,9 +27,6 @@ const PlatformObj = builder.objectRef("Platform").implement({ return platform.label; }, }), - id: t.id({ - resolve: (x) => x, - }), }), }); @@ -32,7 +35,9 @@ export const QualityIndicatorsObj = builder .implement({ description: "Various indicators of the question's quality", fields: (t) => ({ - stars: t.exposeInt("stars"), + stars: t.exposeInt("stars", { + description: "0 to 5", + }), numForecasts: t.int({ nullable: true, resolve: (parent) => @@ -48,19 +53,28 @@ export const ProbabilityOptionObj = builder .implement({ fields: (t) => ({ name: t.exposeString("name", { nullable: true }), - probability: t.exposeFloat("probability", { nullable: true }), // number, 0 to 1 + probability: t.exposeFloat("probability", { + description: "0 to 1", + nullable: true, + }), }), }); export const QuestionObj = builder.prismaObject("Question", { findUnique: (question) => ({ id: question.id }), fields: (t) => ({ - id: t.exposeID("id"), + id: t.exposeID("id", { + description: "Unique string which identifies the question", + }), title: t.exposeString("title"), description: t.exposeString("description"), - url: t.exposeString("url"), + url: t.exposeString("url", { + description: + "Non-unique, a very small number of platforms have a page for more than one prediction", + }), timestamp: t.field({ type: "Date", + description: "Timestamp at which metaforecast fetched the question", resolve: (parent) => parent.timestamp, }), platform: t.field({ @@ -81,7 +95,7 @@ export const QuestionObj = builder.prismaObject("Question", { }, }), visualization: t.string({ - resolve: (parent) => (parent.extra as any)?.visualization, + resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts nullable: true, }), }), diff --git a/src/graphql/schema/search.ts b/src/graphql/schema/search.ts index 2eff5dc..ca6a41a 100644 --- a/src/graphql/schema/search.ts +++ b/src/graphql/schema/search.ts @@ -7,9 +7,15 @@ import { QuestionObj } from "./questions"; const SearchInput = builder.inputType("SearchInput", { fields: (t) => ({ query: t.string({ required: true }), - starsThreshold: t.int(), - forecastsThreshold: t.int(), - forecastingPlatforms: t.stringList(), + starsThreshold: t.int({ + description: "Minimum number of stars on a question", + }), + forecastsThreshold: t.int({ + description: "Minimum number of forecasts on a question", + }), + forecastingPlatforms: t.stringList({ + description: "List of platform ids to filter by", + }), limit: t.int(), }), }); @@ -17,13 +23,15 @@ const SearchInput = builder.inputType("SearchInput", { builder.queryField("searchQuestions", (t) => t.field({ type: [QuestionObj], + description: + "Search for questions; uses Algolia instead of the primary metaforecast database", args: { input: t.arg({ type: SearchInput, required: true }), }, resolve: async (parent, { input }) => { // defs const query = input.query === undefined ? "" : input.query; - if (query == "") return []; + if (query === "") return []; const forecastsThreshold = input.forecastsThreshold; const starsThreshold = input.starsThreshold; const platformsIncludeGuesstimate =