Extend the brX GraphQL Service schema - Bloomreach Experience - The Headless Digital Experience Platform Built for Commerce

Extend the brX GraphQL Service schema

This Bloomreach Experience Manager feature requires a standard or premium license. Please contact Bloomreach for more information.

Introduction

The brX GraphQL Service provides a single data graph modeling online shopping. Considering the complexity of eCommerce nowadays, it is challenging to represent several facets with a single graph.

Luckily the GraphQL eco-system - driven by the GraphQL specs - offers different tools enabling schema extension and/or aggregation: as a result, it is very easy to introduce additional operation types, or additional directives to an existing schema and even new relations between separate schemas.

Schema extensions/aggregations can be implemented in different ways. Below you can find some common examples:

  • Schema Merging: consolidates the type definitions and resolvers from many local schema instances into a single executable schema. As an example, in the case of the brX GraphQL Service, schema merging may be useful when additional commerce functionalities - not provided OOTB, like Commerce Backend API custom extension - need to be added.
  • Apollo Federation: creates a single data graph from multiple underlying sub-graphs. As an example, federation is useful when retailers are interested in combinining product data (commerce) with content data (CMS).

The next paragraphs provide very basic implementations show-casing the examples above.  

Prerequisites

Make sure that the brX GraphQL Service is up and running: you may want to read the How to install the GraphQL Service documentation page first.

Schema Merging

Schema merging gives developers full controll over the unified schema definition. More specifically, in this example we are going to experiment the merging with Executor functions. This enables Schema Delegation for all the functionalities provided by the brX GraphQL Service. At the same time, schema extensions are resolved based on the new types/resolvers definition added.  

Setup

We recommend to apply the following changes on top of the GraphQL Service sample project. Open a shell/terminal session on the same folder and install all the additional NPM depedencies:

npm install graphql apollo-server node-jose @graphql-tools/wrap @graphql-tools/merge @graphql-tools/schema node-fetch 

GraphQL Service Executor

As a first step, the GraphQL Service executor is defined. Executors are functions capable of retrieving GraphQL results. In this specific case the idea is to forward (or delegate) GraphQL operations from the merged schema to the bX GraphQL Service sub-schema. The snippet below assumes that the brX GraphQL Service is running locally on port 4000.

const gsExecutor = async ({ document, variables, context }) => {
  const query = print(document);
  let headers =  {
    'Content-Type': 'application/json',
    'connector': 'brsm',
  };
  if (context) {
    if (context.connector) {
      headers = {
        ...headers,
        'connector': context.connector,
      }
    }
    if (context.authorization) {
      headers = {
        ...headers,
        'authorization': context.authorization,
      }
    }
  }
  const fetchResult = await fetch('http://localhost:4000/graphql', {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
  });

  return fetchResult.json();
};

Schema Extension Types/Resolvers definition

As a next step, new types and resolvers are defined. In this particular example the CustomPayment type is introduced: this willl enable client applications to interact with any Payment extension module provided by the Commerce Backend. In addition, two simple GraphQL operations are provided: the payments query and the makePayment mutation. The resolver definition is responsible for translating those requests to the Commerce Backend and map the response according to the CustomPayment type definition.

const createServer = async (request) => {
  const typeDefs = `
    type CustomPayment {
      id: String!
    }

    type Query {
      payments: [CustomPayment]
    }

    input PaymentInput {
      moneyAmount: Float
    }

    type Mutation {
      makePayment(paymentInput: PaymentInput): CustomPayment
    }
  `;

  const resolvers = {
    Query: {
      payments: async (parent, args, context) => {
        const accessData = await getAccessData(context.authorization);
        const headers =  {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessData.accessToken.access_token}`,
        };
        const fetchResult = await fetch('<COMMERCE_BACKEND_PAYMENT_API_URL>', {
          method: 'GET',
          headers,
        });
        const { results } = await fetchResult.json();
        return results;
      }
    },

    Mutation: {
      makePayment: async (parent, args, context) => {
        const accessData = await getAccessData(context.authorization);
        const headers =  {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessData.accessToken.access_token}`,
        };
        const fetchResult = await fetch('<COMMERCE_BACKEND_PAYMENT_API_URL>', {
          method: 'POST',
          headers,
          body: JSON.stringify({
            "amountPlanned": {
              "currencyCode": ...,
              "centAmount": ...
            }
          }),
        });
        return fetchResult.json();
      }
    },

    CustomPayment: {
      id: payment => payment.id,
    },
  };

 ...
}

Resolvers make use of the getAccessData helper. We will see the details in a next paragraph.

Schema Merge

Once the new schema types/resolvers have been defined, we are almost ready for the schema merging: we are only missing the brX GraphQL Service schema definition. The existing schema definition is retrieved using the introspectSchema functionality provided by the graphql-tools library. In order to delegate operations to the GraphQL Service, we need to install the wrapSchema utility. Once the two schemas are succesfully merged, a new instance of Apollo Server can be created. 

const createServer = async (request) => {
  ...

  const remoteSchema = wrapSchema({
    schema: await introspectSchema(gsExecutor),
    executor: gsExecutor,
  });

  const paymentSchema = makeExecutableSchema({ typeDefs, resolvers });

  const mergedSchema = mergeSchemas({
    schemas: [remoteSchema, paymentSchema]
  });

  return new ApolloServer({
    schema: mergedSchema,
    context: ({ req }) => ({
      authorization: req.headers.authorization,
      connector: req.headers.connector
    }) 
  });
}

While interacting with the Apollo Server instance, both the authorization and connector request header values are stored as part of the Apollo context. Those can be used by the executor while delegating the requests to the underlying ervice.

Additional Changes

As discussed in the previous paragraph, the getAccessData helper is defined. This functionality is responsible for decrypting the authorization header and extracting acces data (e.g. access token).
As described in the Access Management section, the brX GraphQL Service stores, as part of the authorization header, the access data required for accessing the commerce backend API. In case the schema extension needs to interact with the same commerce backend, the same access token can be re-used for those requests: there is no need to re-authenticate while resolving the new GraphQL requests.

const getAccessData = async(authorization) => {
  const encryptedAccessData = authorization?.substring('Bearer '.length);
  let accessData;
  if (encryptedAccessData) {
    try {
      const jwk = await jose.JWK.asKeyStore(JSON.parse(process.env.JWK_KEYSTORE));
      const { payload } = await jose.JWE.createDecrypt(jwk).decrypt(encryptedAccessData);
      return JSON.parse(payload.toString());
    } catch (e) {
      throw new Error(`Invalid authorization: ${e}`);
    }
  }
}

async function startApolloServer() {
  const server = await createServer();
  // The `listen` method launches a web server.
  server.listen({ port: 4100 }).then(({ url }) => {
    console.log(`�  Server ready at ${url}`);
  });
}

startApolloServer();

Moreover, you can also find the startApolloServer function: this will ensure that the Apollo Server is running on port 4100.

Run

If everything goes well, you should have an index.js file like the following (please also note the require statements this time):

const graphqlService = require("@bloomreach/graphql-commerce-connector-service");
const { print } = require('graphql');
const { ApolloServer } = require('apollo-server');
const jose = require('node-jose');
const { introspectSchema, wrapSchema } = require('@graphql-tools/wrap');
const { mergeSchemas } = require('@graphql-tools/merge');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const fetch = require("node-fetch");

const gsExecutor = async ({ document, variables, context }) => {
  const query = print(document);
  let headers =  {
    'Content-Type': 'application/json',
    'connector': 'brsm',
  };
  if (context) {
    if (context.connector) {
      headers = {
        ...headers,
        'connector': context.connector,
      }
    }
    if (context.authorization) {
      headers = {
        ...headers,
        'authorization': context.authorization,
      }
    }
  }
  const fetchResult = await fetch('http://localhost:4000/graphql', {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
  });

  return fetchResult.json();
};

const createServer = async (request) => {
  const typeDefs = `
    type CustomPayment {
      id: String!
    }

    type Query {
      payments: [CustomPayment]
    }

    input PaymentInput {
      moneyAmount: Float
    }

    type Mutation {
      makePayment(paymentInput: PaymentInput): CustomPayment
    }
  `;

  const resolvers = {
    Query: {
      payments: async (parent, args, context) => {
        const accessData = await getAccessData(context.authorization);
        const headers =  {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessData.accessToken.access_token}`,
        };
        const fetchResult = await fetch('<COMMERCE_BACKEND_PAYMENT_API_URL>', {
          method: 'GET',
          headers,
        });
        const { results } = await fetchResult.json();
        return results;
      }
    },

    Mutation: {
      makePayment: async (parent, args, context) => {
        const accessData = await getAccessData(context.authorization);
        const headers =  {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessData.accessToken.access_token}`,
        };
        const fetchResult = await fetch('<COMMERCE_BACKEND_PAYMENT_API_URL>', {
          method: 'POST',
          headers,
          body: JSON.stringify({
            "amountPlanned": {
              "currencyCode": ...,
              "centAmount": ...
            }
          }),
        });
        return fetchResult.json();
      }
    },

    CustomPayment: {
      id: payment => payment.id,
    },
  };

  const remoteSchema = wrapSchema({
    schema: await introspectSchema(gsExecutor),
    executor: gsExecutor,
  });

  const paymentSchema = makeExecutableSchema({ typeDefs, resolvers });

  const mergedSchema = mergeSchemas({
    schemas: [remoteSchema, paymentSchema]
  });

  return new ApolloServer({
    schema: mergedSchema,
    context: ({ req }) => ({
      authorization: req.headers.authorization,
      connector: req.headers.connector
    }) 
  });
}

const getAccessData = async(authorization) => {
  const encryptedAccessData = authorization?.substring('Bearer '.length);
  let accessData;
  if (encryptedAccessData) {
    try {
      const jwk = await jose.JWK.asKeyStore(JSON.parse(process.env.JWE_KEYSTORE));
      const { payload } = await jose.JWE.createDecrypt(jwk).decrypt(encryptedAccessData);
      return JSON.parse(payload.toString());
    } catch (e) {
      throw new Error(`Invalid authorization: ${e}`);
    }
  }
}

async function startApolloServer() {
  const server = await createServer();
  // The `listen` method launches a web server.
  server.listen({ port: 4100 }).then(({ url }) => {
    console.log(`�  Server ready at ${url}`);
  });
}

startApolloServer();

Please do not forget to replace the dummy values: for example, the <COMMERCE_BACKEND_PAYMENT_API_URL> needs to be properly replaced .

Once the code adjustments are over, please run the project locally like the following:

node --require dotenv/config index.js

Trigger a sign-in request and save locally  the authorization token returned:

curl --location --request POST 'http://localhost:4000/signin' \--header 'connector: <commerce_backend>' \--header 'Content-Type: application/json' \--data-raw '{}'

First of all, let's trigger a findItemByKeywords request and check if the response is successful:

curl --location --request POST 'http://localhost:4100/' \
--header 'Content-Type: application/json'  --header 'connector: brsm' --header 'authorization: Bearer $TOKEN' \
--data-raw '{"query":"query{\n  findItemsByKeyword(\n    text:\"\", limit: 1, offset: 0, \n  ) {\n    items{\n      displayName\n      itemId{\n        id,\n        code\n      }\n      description\n      customAttrs {\n        name, values\n      }\n    }\n  }\n}\n","variables":{}}'

If every looks good, please trigger the makePayment mutation:

curl --location --request POST 'http://localhost:4100/' \
--header 'Content-Type: application/json'  --header 'connector: <commerce_backend>' --header 'authorization: Bearer $TOKEN' \
--data-raw '{"query":"mutation { makePayment(paymentInput: { moneyAmount: 12 }) { id } }","variables":{}}' 

If everything went well, a new payment entry is added to the commerce backend.

As a final step, please retrieve the newly created payment:

curl --location --request POST 'http://localhost:4100/' \
--header 'Content-Type: application/json'  --header 'connector: <commerce_backend>' --header 'authorization: Bearer $TOKEN' \
--data-raw '{"query":"query { payments { id } }","variables":{}}'

You should be able to see the payment created with the previous step. If that's the case, you have successfully implemented a brX GraphQL Schema extension using the merging technique.

Apollo Federation

The brX GraphQL Service is ready to be extended according to the Apollo Federation principles. The next paragraphs describe a very basic example of graph federation in action, mostly related to the use-case provided above. The example is based on the Apollo platform.

Setup

Create a new separate folder (e.g. graphql-service-federation), open a shell/terminal session on that folder and initialize an empty NPM project:

npm init -y 

A new package.json file is created with the default configurations. Let's install the required dependencies:

npm install @apollo/gateway apollo-server apollo-server-express express graphql

The implementing services

This basic example is composed of two implementing services:

  1. The brX GraphQL Service, exposing commerce functionalities. It should be already up and running (e.g. http://localhost:4000/graphql);
  2. The Article Service, providing extensive information (e.g. how-to, guides) regarding products.

For the sake of simplicity, let's assume that the Article Service is a GraphQL Server responsible for resolving very basic articles, containing just one description field. You may want to increase the complexity of this service later on, maybe connecting to an external REST API. 

Create a new index.js file and paste the following snippet:

const { ApolloServer, gql } = require('apollo-server');
const { buildFederatedSchema } = require('@apollo/federation');

const typeDefs = gql`
  type Article {
    description: String!
  }

  type ItemId {
    id: String!
    code: String
  }

  extend type Item @key(fields: "itemId") {
    "The ItemId of a product item"
    itemId: ItemId! @external
    "Related article"
    article: Article
  }
`;

const resolvers = {
    Article: {
        description() {
            return 'Hey, this is an article description';
        }
    },
    Item: {
        article() { 
            return {};
        }
    }
}

const articleService = new ApolloServer({
    schema: buildFederatedSchema([{ typeDefs, resolvers }]),
});

articleService.listen({ port: 4001}, () => { 
   console.log(`Article Service ready at http://localhost:4001`);
});

The Article Service graph definition extends the Item type, already defined in the brX GraphQL Service. This new definition links the Item to the related Article: it would be possible to "navigate" from a product to the related article description, using the same GraphQL request.

The gateway

Once the implementing services are ready to be federated, you can setup the gateway. This layer is responsible for composing the individual schema in a single - federated - graph. Once the gateway is up and running, client applications can execute GraphQL operations against this new instance: it would be possible to execute GraphQL requests on the composed schema.

For simplicity, you can - properly - append the following snippet to the existing index.js file: 

const { ApolloGateway, RemoteGraphQLDataSource } = require('@apollo/gateway');

class CommerceConnectorDataSource extends RemoteGraphQLDataSource {
  willSendRequest({ request, context }) {
    if (context.connector) {
        request.http.headers.set('connector', context.connector);
    }
    if (context.authorization) {
        request.http.headers.set('authorization', context.authorization);
    }
  }
}

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'graphql-service', url: 'http://localhost:4000/graphql' },
    { name: 'article-service', url: 'http://localhost:4001' },
  ],
  buildService({ name, url }) {
    return new CommerceConnectorDataSource({ url });
  },
});

The gateway instantiation is straightforward, except for the buildService option. By default, an Apollo Gateway instance does not forward incoming HTTP headers to the implementing services. This behaviour could be problematic for the brX GraphQL Service, since it may require the authorization and connector headers.

The Apollo Gateway provides the buildService function: if specified, it is invoked once for each implementing service. In the example above, the CommerceConnectorDataSource class forwards both the authorization and connector headers: it reads the values from the context and, if those exist, set them accordingly as part of the outgoing HTTP request. 

Additional changes

The brX GraphQL Service is a full-fledged application: next to the Apollo Server, the service provides an Access Management layer enabling client authentication/authorization. Considering that the underlying service only accepts authorized requests, it may be an option to re-expose the sign-in and sign-out endpoints at gateway level. You can - properly - append the following snippet to the existing index.js file: 

const express = require('express');
const { ApolloServer: ApolloServerExpress } = require('apollo-server-express');

const server = new ApolloServerExpress({
  gateway,
  // Disable subscriptions (not currently supported with ApolloGateway)
  subscriptions: false,
  context: ({ req }) => {
    return { 
      connector: req.headers.connector,
      authorization: req.headers.authorization,
    };
  },
});

const app = express();

app.post('/signin', function(req, res) {
  res.redirect(307, 'http://localhost:4000/signin');
});
app.post('/signout', function(req, res) {
  res.redirect(307, 'http://localhost:4000/signout');
});

server.applyMiddleware({ app });

app.listen({ port: 4999 }, () => { 
  console.log(`🚀 Gateway ready at http://localhost:4999`);
});

If a client triggers a sign-in request against the gateway, it is simply redirected to the related brX GraphQL Service endpoint.
Additionally, as discussed in the previous paragraph, the server application is responsible for initializing the context with the expected properties: in the example above both the authorizion and connector headers are added.

Run

If everything goes well, you should have an index.js file like the following:

const { ApolloServer, gql } = require('apollo-server');
const { buildFederatedSchema } = require('@apollo/federation');
const { ApolloGateway, RemoteGraphQLDataSource } = require('@apollo/gateway');
const express = require('express');
const { ApolloServer: ApolloServerExpress } = require('apollo-server-express');

const typeDefs = gql`
  type Article {
    description: String!
  }

  type ItemId {
    id: String!
    code: String
  }

  extend type Item @key(fields: "itemId") {
    "The ItemId of a product item"
    itemId: ItemId! @external
    "Related article"
    article: Article
  }
`;

const resolvers = {
    Article: {
        description() {
            return 'Hey, this is an article description';
        }
    },
    Item: {
        article() {
            return {};
        }
    }
}

const articleService = new ApolloServer({
    schema: buildFederatedSchema([{ typeDefs, resolvers }]),
});

articleService.listen({ port: 4001}, () => {
   console.log(`Article Service ready at http://localhost:4001`);
});

class CommerceConnectorDataSource extends RemoteGraphQLDataSource {
  willSendRequest({ request, context }) {
    if (context.connector) {
        request.http.headers.set('connector', context.connector);
    }
    if (context.authorization) {
        request.http.headers.set('authorization', context.authorization);
    }
  }
}

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'graphql-service', url: 'http://localhost:4000/graphql' },
    { name: 'article-service', url: 'http://localhost:4001' },
  ],
  buildService({ name, url }) {
    return new CommerceConnectorDataSource({ url });
  },
});

const server = new ApolloServerExpress({
  gateway,
  // Disable subscriptions (not currently supported with ApolloGateway)
  subscriptions: false,
  context: ({ req }) => {
    return {
      connector: req.headers.connector,
      authorization: req.headers.authorization,
    };
  },
});

const app = express();

app.post('/signin', function(req, res) {
  res.redirect(307, 'http://localhost:4000/signin');
});
app.post('/signout', function(req, res) {
  res.redirect(307, 'http://localhost:4000/signout');
});

server.applyMiddleware({ app });

app.listen({ port: 4999 }, () => {
  console.log(`🚀 Gateway ready at http://localhost:4999`);
});

Execute the application above with the following command:

node index.js 

Once the application has started, you should see the following logs:

Article Service ready at http://localhost:4001
🚀 Gateway ready at http://localhost:4999 

The GraphQL playground should be available at http://localhost:4999/graphql.

Query example

Now you are able to execute a GraphQL request against the federated schema:

query findItemsByKeyword {
  findItemsByKeyword(text: "", offset: 0, limit: 5) {
    items {
      itemId {
        id
        code
      }
      displayName
      article {
        description
      }
    }
    total
  }
}

Do not forget to include the following HTTP headers:

​{"connector": "<CONNECTOR_ID>", "authorization": "Bearer <AUTHORIZATION_TOKEN>"}
Did you find this page helpful?
How could this documentation serve you better?
On this page
    Did you find this page helpful?
    How could this documentation serve you better?