Extending the GraphQL API

@moneypot/hub is built on top of Postgraphile v5 which turns a postgres schema into a GraphQL API.

You can write plugins to extend the GraphQL API with your own queries and mutations.

Just pass your additional plugins to the @moneypot/hub server's startAndListen function:

import { defaultPlugins, ServerOptions, startAndListen } from "@moneypot/hub";
import { MyPlugin } from "./MyPlugin";

const options: ServerOptions = {
  extraPgSchemas: ["app"],
  plugins: [...defaultPlugins, MyPlugin],
  // ...
};

startAndListen(options, ({ port }) => {
  console.log(`Server is listening on ${port}...`);
});

Note: defaultPlugins are the core @moneypot/hub plugins that create the base API.

Toy plugin example

Here's a simple resolver-style plugin that should help you get started:

import { makeExtendSchemaPlugin, gql } from "@moneypot/hub/graphile";

type Fortune = {
  id: number;
  text: string;
};

const database = {
  nextId: 10,
  fortunes: [
    { id: 0, text: "You will find a new friend soon." },
    { id: 1, text: "A pleasant surprise is waiting for you." },
    { id: 2, text: "Your hard work will pay off in the near future." },
    { id: 3, text: "An exciting opportunity will present itself to you." },
    { id: 4, text: "You will overcome a significant challenge." },
    { id: 5, text: "A long-lost acquaintance will reenter your life." },
    { id: 6, text: "Your creativity will lead you to success." },
    { id: 7, text: "A journey of a thousand miles begins with a single step." },
    { id: 8, text: "Your kindness will be repaid tenfold." },
    { id: 9, text: "Good fortune will be yours in the coming months." },
  ],
  insertFortune: async (text: string): Promise<Fortune> => {
    const fortune: Fortune = { id: database.nextId++, text };
    database.fortunes.push(todo);
    return fortune;
  },
  getFortuneById: async (id: number): Promise<Fortune | null> => {
    return database.fortunes[id] || null;
  },
};

export const MyPlugin = makeExtendSchemaPlugin(() => {
  return {
    typeDefs: gql`
      type Fortune {
        id: Int!
        text: String!
      }

      extend type Query {
        randomFortune: Fortune
      }

      extend type Mutation {
        addFortune(text: String!): Fortune
      }
    `,
    resolvers: {
      Mutation: {
        addFortune: async (_, args) => {
          const { text } = args;
          const fortune = await database.insertFortune(text);
          return fortune;
        },
      },
      Query: {
        randomFortune: async () => {
          const id = Math.floor(Math.random() * database.fortunes.length);
          const fortune = await database.getFortuneById(id);
          return fortune;
        },
      },
    },
  };
});

Let's add our new plugin to hub:

import { defaultPlugins, ServerOptions, startAndListen } from "@moneypot/hub";
import { MyPlugin } from "./MyPlugin";

const options: ServerOptions = {
  extraPgSchemas: ["app"],
  plugins: [...defaultPlugins, MyPlugin],
  // ...
};

startAndListen(options, ({ port }) => {
  console.log(`Server is listening on ${port}...`);
});

Now when you launch the server, you should be able to visit it in the browser (GraphiQL interface) and see our new query and mutation.

For those familiar with postgraphile, you can also provide postgraphile v5 plan-based plugins, though resolver-based plugins are much simpler.

Real-world resolver plugin example (simple)

Here's a more realistic example of a resolver-style plugin that adds a makeCoinflip mutation to the API:

// ./plugins/coinflip-resolver.ts
import type { PluginContext } from "@moneypot/hub";
import { superuserPool, withPgPoolTransaction } from "@moneypot/hub/db";
import { gql, makeExtendSchemaPlugin } from "@moneypot/hub/graphile";
import { GraphQLError } from "@moneypot/hub/graphql";
import crypto from "crypto";

export const MakeCoinflipResolverPlugin = makeExtendSchemaPlugin(() => {
  return {
    typeDefs: gql`
      input MakeCoinflipInput {
        currency: String!
        wager: Float!
      }

      type CoinflipOut {
        id: UUID!
        net: Float!
      }

      extend type Mutation {
        makeCoinflip(input: MakeCoinflipInput!): CoinflipOut
      }
    `,
    resolvers: {
      Mutation: {
        makeCoinflip: async (_, { input }, context: PluginContext) => {
          if (context.identity?.kind !== "user") {
            // Not logged in as a user
            return null;
          }

          const { currency, wager } = input;
          const { user_id, casino_id, experience_id } =
            context.identity.session;

          // TODO: Validate input

          if (wager < 1) {
            throw new GraphQLError("Wager must be at least 1");
          }

          return withPgPoolTransaction(superuserPool, async (pgClient) => {
            // TODO: Ensure user can afford wager and bankroll can afford payout.

            const playerWon = crypto.randomInt(0, 2) === 0;
            const net = playerWon ? wager * 0.99 : -wager;

            // TODO: Update user's balance and casino's bankroll

            const coinflip = await pgClient
              .query(
                `
                    insert into app.coinflip (id, wager, net, currency_key, user_id, casino_id, experience_id)
                    values (hub_hidden.uuid_generate_v7(), $1, $2, $3, $4, $5, $6)
                    returning *
                  `,
                [wager, net, currency, user_id, casino_id, experience_id]
              )
              .then((result) => result.rows[0]);

            return coinflip;
          });
        },
      },
    },
  };
});

Notice how we can't return a first-class Coinflip graphql entity from our resolver plugin; we can only return explicit data. That's a limitation of resolver plugins.

But the following plan plugin will let us return a Coinflip graphql entity.

Real-world plan plugin example (advanced)

Postgraphile plan plugins are more powerful than resolver plugins, but they require more Postgraphile-specific knowledge.

Plan plugins are more advanced because they use [Grafast][grafast] step plans which allow certain optimizations but require more understanding of the underlaying grafast library.

Some benefits of using plans over resolvers:

  • Your makeCoinflip() plan can return a canonical Coinflip entity (a type that postgraphile generated from our app.coinflip table) unlike the resolver plan.
  • Not applicable nor in-scope here, but grafast plans let us do batch optimization.

Here's the resolver plugin converted into a grafast plan plugin. Notice how it returns a Coinflip instead of a type that the resolver plugin created just for its own return value.

// ./plugins/coinflip-plan.ts
import type { PluginContext } from "@moneypot/hub";
import { superuserPool, withPgPoolTransaction } from "@moneypot/hub/db";
import { context, sideEffect } from "@moneypot/hub/grafast";
import { gql, makeExtendSchemaPlugin } from "@moneypot/hub/graphile";
import { GraphQLError } from "@moneypot/hub/graphql";
import crypto from "crypto";

export const ConflipPlanPlugin = makeExtendSchemaPlugin((build) => {
  // This line is key since it will let us return a plan that looks up a coinflip record by id.
  const coinflipTable = build.input.pgRegistry.pgResources.coinflip;
  return {
    typeDefs: gql`
      input MakeCoinflipInput {
        currency: String!
        wager: Float!
      }

      extend type Mutation {
        makeCoinflip(input: MakeCoinflipInput!): Coinflip
      }
    `,
    plans: {
      Mutation: {
        makeCoinflip: (_, { $input }) => {
          const $context = context<PluginContext>();
          const $conflipId = sideEffect(
            [$input, $context],
            ([input, context]) => {
              if (context.identity?.kind !== "user") {
                // Not logged in as user
                return null;
              }

              // You could use tsafe or zod to enforce the MakeCoinflipInput type here
              // which will be guaranteed by postgraphile.
              // But for this demo, we'll just use `any`.
              const { currency, wager } = input as any;

              const { user_id, casino_id, experience_id } =
                context.identity.session;

              // TODO: Validate input

              if (wager < 1) {
                throw new GraphQLError("Wager must be at least 1");
              }

              return withPgPoolTransaction(superuserPool, async (pgClient) => {
                // TODO: Ensure user can afford wager and bankroll can afford payout.

                const playerWon = crypto.randomInt(0, 2) === 0;
                const net = playerWon ? wager * 0.99 : -wager;

                // TODO: Update user's balance and casino's bankroll

                const coinflip = await pgClient
                  .query(
                    `
                    insert into app.coinflip (id, wager, net, currency_key, user_id, casino_id, experience_id)
                    values (hub_hidden.uuid_generate_v7(), $1, $2, $3, $4, $5, $6)
                    returning id
                  `,
                    [wager, net, currency, user_id, casino_id, experience_id]
                  )
                  .then((result) => result.rows[0]);

                return coinflip.id;
              });
            }
          );

          return coinflipTable.get({ id: $conflipId });
        },
      },
    },
  };
});

Futher reading