Creating a Server-side GraphQL plugin
For our Homepage we want to have some dynamic content that can be changed by non-developers. Specifically we want to feature a subset of products on the home page, which we will call "Featured Products". These products will be designated by just adding a particular tag with the name "featured-products". What we want to create is a new GraphQL query that will return a cursor of products that are tagged with this tag.
To do this we need to create a new plugin in the
Reaction project. We are going to call this plugin
featured-products and you can find the finished code in the
server folder in the tutorial repository. You need to place this new directory in
imports/plugins/custom from the root of your Reaction project
What we want to accomplish is to create a new Query (
featuredProductsByShop). Let's start by creating a file named
register.js at the root our new plugin. This registers all the schemas/queries/resolvers/mutations, etc and makes our plugin visible to the rest of the system.
For now, while we are moving server code off Meteor, you'll actually need two
register.js files. The first is recognized by Meteor loading and should look like this:
import Reaction from "/imports/plugins/core/core/server/Reaction"; import register from "./server/no-meteor/register"; Reaction.whenAppInstanceReady(register);
The second
register.js file goes in the
server/no-meteor folder. Let's just add the skeleton of it for now and we will add more to it as we complete them:
export default async function register(app) { await app.registerPlugin({ label: "Featured Products", name: "featured-products" }); }
Now the first thing we are going to create is our GraphQL schema files. You need to create a certain directory hierarchy, in this case it's
server/no-meteor. All of the files we create in this tutorial will be in the
server/no-meteor directory.
In this directory now create a
schemas directory, and in that directory create a
schema.graphql file. I find it easier to think of this file as creating the external API and defining the "shape" of it (as the word schema would imply). So the first thing we do is add our Query. This query will take a
shopId and return a cursor of featured products. So it looks like this:
extend type Query { featuredProductsByShop(shopId: ID!): CatalogItemConnection }
We defined the name of the query (featuredProductsByShop), the argument it takes (the shopId, of the type ID) and what it returns which is a
CatalogItemConnection (more on this later). We don't need to define
CatalogItemConnection here because it is defined elsewhere and registering the schema makes all parts of the schema available globally (since they are all published to the client-side). To make the system aware of our new schema we need to go back and edit our
register.js. But first let's add in
index.js file there that imports our schema as exports it as the only entry to an array:
import schema from "./schema.graphql" export default [schema]
Now let's edit our
register.js
We will add a new
graphQL key which will contain a
schemas entry. We'll import our schema file and add it there.
import schemas from "./schemas"; export default async function register(app) { await app.registerPlugin({ label: "Featured Products", name: "featured-products", graphQL: { schemas } }); }
That's fine but our query doesn't actually "do" anything (often a requirement!). To make it do something we will need to add a resolver and a query. Let's start with the resolver. At the same level as we created
schemas let's create a
resolvers directory, add a
Query directory (the capital letter there is intentional) and in that
Query directory add a file called
featuredProductsByShop. The name of the file needs to be the same as our query in order for the GraphQL server to find it. While we're creating files let's also create an
index.js file there that imports the default and exports it back out as a key in an object
import featuredProductsByShop from "./featuredProductsByShop" export default { featuredProductsByShop }
What's happening here?
There is a global object where the server will look to resolve any particular query that comes in. It checks in the
resolvers names space and if it is a Query, it looks in the
Query namespace for something with the same name. This keeps the namespace from becoming too large and minimizes conflicts. So this pathing and naming is critical to making sure your code gets called properly.
Ok, now that that's all set up let's get to the meat of our resolver.
import { getPaginatedResponse } from "@reactioncommerce/reaction-graphql-utils"; import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; export default async function featuredProductsByShop(_, args, context) { const { shopId: opaqueShopId, ...connectionArgs } = args; const shopId = decodeShopOpaqueId(opaqueShopId); const results = await context.queries.featuredProductsByShop(context, { shopId }); return getPaginatedResponse(results, connectionArgs); }
Here we are basically taking the data that comes in (here it's
args, and
context), doing any transforms that we need to and then returning the results of running our actual query.
One additional step is that we need to decode our shopId as it is coming in as encoded from the GraphQL side. We import the utility to do that, decode the ID and then passing that on to our query. When we return the query we are using
getPaginatedResponse to do most of the heavy lifting of passing on a cursor so that the client-side can do pagination, etc. Instead of importing and executing it directly we are calling our query from within the context (again namespaced). This is a mongo query so it returns a promise so we need to make this an
async function and
await the actual results.
Now at the same level as
resolvers and
schemas let's create a directory called
queries where we will place the file that does the actual db query. (Note that while we do a Mongo query here, this file could do just about anything, and your resolver could be combining the results of multiple data-sources or even making a call to an external API. But for now writing Mongo queries will probably be the simplest and most common thing you will do)
So inside our
queries directory we create another file called
featuredProductsByShop (this could theoretically could be called something else since we are calling it directly from our resolver, but we will call it the same for simplicity sake for now). Let's also create an
index.js and import our default and export it as an object.
import featuredProductsByShop from "./featuredProductsByShop"; export default { featuredProductsByShop };
Now let's create our query file:
import ReactionError from "@reactioncommerce/reaction-error"; /** * @name featuredProductsByShop * @method * @summary Query the Catalog collection for products that have the `featured-product` tag * @param {Object} context - an object containing the per-request state * @param {String} params.shopId - Shop ID for the shop * @return {Promise<Object>|undefined} - An array of Catalog objects */ export default async function featuredProductsByShop(context, { shopId } = {}) { const { collections } = context; const { Catalog, Tags } = collections; if (!shopId) { throw new ReactionError("invalid-param", "You must provide shopId arguments"); } const featuredTag = await Tags.findOne({ name: "featured-product"}); // If a feature-product doesn't exist, let's create one let newTag; if (!featuredTag) { newTag = await Tags.insertOne({ name: "featured-product" }); } const featuredItems = Catalog.find({ "product.tagIds": { $in: [featuredTag._id] }, shopId: shopId }); return featuredItems; }
Nothing really special or fancy here except that if you are used to Meteor development you will notice that we are not importing our
Catalog collection but getting it from the context, but other than that this is just a standard Mongo query with the addition that we add our
featured-product tag if it doesn't exist. (The Meteor Mongo API and the one provided by the raw node driver are so similar that they are often identical as they are here)
The only things extra to point out are that we need to
await on every database action, the query to
Tags and inserting the
Tag.
We now have all our pieces in place, we just need to let the system know about them through entries to our
register.js. So we just need to add new entries for
resolvers and
queries. So our updated
register.js should look something like this:
import queries from "./queries"; import resolvers from "./resolvers"; import schemas from "./schemas"; export default async function register(app) { await app.registerPlugin({ label: "Featured Products", name: "featured-products", graphQL: { resolvers, schemas }, queries }); }
If you visit GraphQL Playground and hit the "schema" tab you should now see your query available to the client. If you want to execute the schema you will need to get your encoded shopId. Your shopId should be available in the
Shops collection and to get the encoded version of it you can execute this command from the command line:
echo -n reaction/shop:<your_shop_id> | base64
Then just do:
{ featuredProductsByShop(shopId: "cmVhY3Rpb24vc2hvcDpKOEJocTN1VHRkZ3daeDNyeg==") { nodes { _id ...on CatalogItemProduct { product { title description slug } } } } }
And substitute your own encoded shopID for the long string above.
Your server plugin should be complete. Now let's create our client components.