Extending the GraphQL API
@moneypot/hub
is built on top of Postgraphile v5 which automatically turns a Postgres schema into a GraphQL API.
The hub GraphQL API is designed to be extensible. You can write plugins to extend the GraphQL API with your own queries and mutations.
Adding new fields to the Bet
type
The Bet
type is the root type for all bets.
The controller template already has an example plugin in
src/plugins/make-coinflip-bet.ts
that implements a graphql mutationmakeCoinflipBet(wager, 'HEADS' | 'TAILS')
But this section demonstrates an even simpler plugin.
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).then(({ 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(fortune);
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).then(({ 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 canonicalCoinflip
entity (a type that postgraphile generated from ourapp.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 CoinflipPlanPlugin = 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 $coinflipId = 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: $coinflipId });
},
},
},
};
});