Schema Stitching

Last updated 5 minutes ago

Learn how to combine the campaign service and the headless server to one schema.

With the setup done in Getting Started, you are now able to query assignments from the CoreMedia Campaign Service and to query content data from the CoreMedia Headless Server.

Get content ids and then content properties

Getting IDs

To check for your content IDs, open https://api.campaigns.coremedia.io/ and drop a test query:

Campaign Query
query CampaignContent($site: String!, $channelType: String!) {
  campaignContent(site: $site, channelType: $channelType) {
    slots {
      name
      assignment {
        refinements
        items {
          id
        }
      }
    }
  }
}
Campaign Parameters
{
  "site": "<your-site-id>",
  "channelType": "<category-page|product-page|content-page>",
}
Campaign Headers
{
  "authorization": "<your-token>"
}

You should receive something similar to:

Campaign Result
{
  "data": {
    "campaignContent": {
      "slots": [
        {
          "name": "hero",
          "assignment": {
            "refinements": [
              "clothing",
              "summer"
            ],
            "items": [
              {
                "id": "827ae48b-9000-422c-b731-16b651aa853a"
              }
            ]
          }
        },
        {
          "name": "main",
          "assignment": {
            "refinements": [
              "clothing",
              "summer"
            ],
            "items": [
              {
                "id": "f9b44fad-b68b-4e1a-86c1-20055187d6f8"
              }
            ]
          }
        }
      ]
    }
  }
}

Getting the properties

Now that you have got the content in context with the assignments you probably want to access content properties behind the content IDs. This can be done via a separate query in a different GraphiQL against the CoreMedia Headless Server. In order to check, open https://headless-server-preview-<your-name>.cloud.coremedia.io/graphql and drop a test query:

Content Query
query Query( $contentId: String!) {
  content {
    content(id: $contentId){
      __typename
      repositoryPath
    }
  }
}

Put the UUID of your previously gotten content, that is "f9b44fad-b68b-4e1a-86c1-20055187d6f8"

Content Parameters
{
  "contentId": "<content-id>"
}

You should receive something similar to:

Content Result
{
  "data": {
    "content": {
      "content": {
        "__typename": "CMArticleImpl",
        "repositoryPath": "/Sites/Calista/United States/English/Editorial/Content/Blog/Fashion Trends"
      }
    }
  }
}

Conclusion: separate requests

So you can do separate queries for two reasons:

  • Read the content assignments managed in campaigns from the campaign service

  • Resolve the IDs of the assignments afterwards via queries against the content services.

But this is not very convenient for frontend developers, so you want to provide a single schema and endpoint to provide both campaign assignments and content data.

This is where this schema stitching tutorial kicks in. Go ahead to install a schema stitching server on you local development system.

Please also note, that there are numerous other ways to implement a backend for frontend, that merges multiple sub-graphs to a single super-graph. For example Apollo Federation, Hasura or Stepzen.

In the following section you will focus on schema stitching in the way it is integrated in our Spark demo app right now.

Setup Apollo Stitching Server Project

Prerequisites

To follow this tutorial, you need:

  • Node.js version 16

  • pnpm version 7

  • A CoreMedia Content Cloud account with Campaign Service

Initialize a Project

mkdir stitching-example
cd stitching-example
pnpm init
pnpm add @graphql-tools/delegate @graphql-tools/load @graphql-tools/schema @graphql-tools/stitch @graphql-tools/url-loader @graphql-tools/utils @graphql-tools/wrap @tsconfig/node16 @types/node apollo-server-express cors cross-undici-fetch dataloader dotenv express express-graphql graphql nodemon ts-node typescript

Create tsconfig.json

tsconfig.json
{
  "extends": "@tsconfig/node16",
  "compilerOptions": {
    "strict": false
  }
}

Modify package.json and add a start script

package.json
{
...
"scripts": {
  "start": "nodemon --ext ts --exec 'ts-node' index.ts"
},
...
}

Create .env file and configure the service coordinates

CAMPAIGN_SERVICE_ENDPOINT=https://api.campaigns.coremedia.io/
CAMPAIGN_SERVICE_AUTHORIZATION_ID=<your authorization id for the campaign service>
CMS_HEADLESS_SERVER_ENDPOINT=<the CoreMedia headless server endpoint of your CMCC instance>
CM_CLOUD_ACCESS_TOKEN=<your CMCC access token, leave blank if you have an on premise or local installation >

Create index.ts and implement the main application logic

In the index.ts file the following main steps are executed:

  1. Read Campaign and Content Headless Server Coordinates from .env file

  2. Fetch remote schema from Campaign and Content Headless Endpoints

  3. Merge both schemas and compile Gateway Schema

  4. Start the Server

import {stitchSchemas} from '@graphql-tools/stitch';
import {loadSchema} from "@graphql-tools/load";
import {UrlLoader} from "@graphql-tools/url-loader";
import {GraphQLSchema} from "graphql";
import {wrapSchema} from "@graphql-tools/wrap";
import {resolvers} from "./resolvers";
import {campaignExecutor, cmExecutor} from "./executors";
import http, {Server} from "http";
import cors from "cors";
import express from "express";
import {ApolloServer} from "apollo-server-express";
import "dotenv/config";

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

//read environment variables from .env file
const campaignServiceEndpoint = process.env.CAMPAIGN_SERVICE_ENDPOINT;
const campaignServiceAuthorizationId = process.env.CAMPAIGN_SERVICE_AUTHORIZATION_ID;
const coreMediaHeadlessServerEndpoint = process.env.CMS_HEADLESS_SERVER_ENDPOINT;
const coreMediaCloudAccessToken = process.env.CM_CLOUD_ACCESS_TOKEN ?? "";

//fetch remote schema from CoreMedia Campaign Service
const fetchCampaignSchema = async (): Promise<GraphQLSchema> => {
  console.log(`Loading schema from ${campaignServiceEndpoint} (Campaign Service).`);
  const campaignSchema = await loadSchema(campaignServiceEndpoint, {
    loaders: [new UrlLoader()],
    headers: {
      Authorization: campaignServiceAuthorizationId,
    },
  });
  console.log(`Successfully loaded schema from campaignServiceEndpoint.`);

  return campaignSchema;
}

//fetch remote schema from CMCC instance of the CoreMedia Headless Server
const fetchContentSchema = async (): Promise<GraphQLSchema> => {
  console.log(`Loading schema from ${coreMediaHeadlessServerEndpoint} (Headless Server).`);
  const coreMediaSchema = await loadSchema(coreMediaHeadlessServerEndpoint, {
    loaders: [new UrlLoader()],
    headers: {
      "CoreMedia-AccessToken": coreMediaCloudAccessToken,
    },
  });
  console.log(`Successfully loaded schema from coreMediaHeadlessServerEndpoint.`);

  return coreMediaSchema;
}

//merge both schemas
const makeGatewaySchema = async () => {
  const contentSchema = await fetchContentSchema();
  const campaignSchema = await fetchCampaignSchema();

  //in the merged schema: properties of content can be accessed directly below contentRef -> content
  const linkSchemaDefs = `
        type ContentRef @extends {
            content: Content_
        }
      `;

  return stitchSchemas({
    subschemas: [
      /*
        The method wrapSchema() creates a new modified schema that proxies the original schmema.
        One does that to be able to do a bunch of transforms on the new schmema and modifying it
        in that way (a remote schmema as we retrieved cannot be modified).

        executors are needed to forward the header to the underlying services
      */
      wrapSchema({schema: contentSchema, executor: cmExecutor}),
      wrapSchema({schema: campaignSchema, executor: campaignExecutor}),
    ],
    //resolvers delegate entity resolution to the correspondig subschema
    resolvers: resolvers(
      wrapSchema({schema: contentSchema, executor: cmExecutor}),
    ),
    typeDefs: linkSchemaDefs
  });
}

//create Apollo Server
const createServer = async (schema) => {
  const app = express().disable("x-powered-by").use(cors());
  // add apollo server
  const httpServer = http.createServer(app);
  const server = new ApolloServer({
    schema: schema,
    introspection: true,
    context: ({req}) => ({
      request: req,
    }),
  });
  await server.start();
  server.applyMiddleware({app});

  return httpServer;
}

//local Apollo Server
const startServer = (app: Server, port = 4000, host = "localhost") => {
  app.listen(port, () => console.log(`Stitching server started on: http://${host}:${port}/graphql`));
};

// finally start the server after retrieving the schemas and stitching them
makeGatewaySchema().then((schema) => {
  createServer(schema).then((app) => {
    startServer(app);
  });
});

When you dropped the code into your IDE, you will notice, that the app is not executable yet. The missing code references will be addressed in the following sections.

Create resolvers.ts and implement content reference resolution

You start with the resolvers, which define how entity references between the sub graphs are resolved. In our case the campaign schema contains "ContentRef" entities. "ContentRef" entities only provide an ID but no access to other properties of the underlying content. The following code resolves a "ContentRef.id" and provides access to the attribute "ContentRef.content" and all its underlying attributes of an entity of type "Content_".

Whenever the property "content" of a "ContentRef" entity is accessed, the corresponding resolver code kicks in and internally triggers a content query towards the CoreMedia Content Headless Server (see delegateToSchema).

resolvers.ts
import {delegateToSchema} from "@graphql-tools/delegate";
import {GraphQLResolveInfo, GraphQLSchema, Kind, OperationTypeNode} from "graphql";
import {WrapQuery} from "@graphql-tools/wrap";

export const resolvers = (
  coreMediaSchema: GraphQLSchema
) => {
  const resolvers = {
    ContentRef: {
      content: {
        selectionSet: `{ id }`,
        resolve(contentRef, args: Record<string, string>, context: Record<string, string>, info: GraphQLResolveInfo) {
          const contentId = contentRef.id;
          console.log("ContentRef#content " + contentId);
          return delegateToSchema({
            schema: coreMediaSchema,
            operation: OperationTypeNode.QUERY,
            fieldName: "content",
            context,
            info,
            transforms: [
              new WrapQuery(
                ["content"],
                (subtree) => ({
                  kind: Kind.SELECTION_SET,
                  selections: [
                    {
                      kind: Kind.FIELD,
                      name: {
                        kind: Kind.NAME,
                        value: "content",
                      },
                      arguments: [
                        {
                          kind: Kind.ARGUMENT,
                          name: {kind: Kind.NAME, value: "id"},
                          value: {kind: Kind.STRING, value: contentId},
                        },
                      ],
                      selectionSet: subtree,
                    },
                  ],
                }),
                (result) => {
                  return result && result.content;
                }
              ),
            ],
          });
        },
      },
    }
  };
  return resolvers;
};

But there is still something missing since the CoreMedia Campaign Service and the CoreMedia CMS Headless Server require a token in order to authenticate incoming client requests.

Create executors.ts and provide additional header information

With every GraphQL request the client passes these tokens as headers to the Stitching Server. The Stitching Server forwards these tokens internally to the underlying service endpoints.

executors.ts
import {Executor} from "@graphql-tools/utils";
import {print} from "graphql";
import {fetch} from "cross-undici-fetch";

/**
 * Executor to query subschema endpoint. Forwards headers from the context
 * For some reason when running graphiql, the headers hide within
 * context.headers, for all other calls within context.request.headers
 *
 * @param document
 * @param variables
 * @param context
 */
export const cmExecutor: Executor = async ({document, variables, context}) => {
  const query = print(document);
  let newHeaders = {};
  let method = "POST";
  if (context.request) {
    newHeaders = {...context.request.headers};
    method = context.request.method;
  } else if (context.headers) {
    newHeaders = {...context.headers};
    method = context.method;
  }
  // remove host and connection header to prevent issues with subschema service accepting it.
  delete newHeaders["host"];
  delete newHeaders["connection"];

  if (process.env.COREMEDIA_CLOUD_ACCESS_TOKEN) {
    newHeaders["CoreMedia-AccessToken"] = process.env.CM_CLOUD_ACCESS_TOKEN;
  }

  // set correct content length for changed request.
  newHeaders["content-length"] = JSON.stringify({query, variables}).length;
  let requestInit = undefined;
  if (method !== "GET" && method !== "HEAD") {
    requestInit = {
      method: method,
      headers: {
        ...newHeaders,
      },
      body: JSON.stringify({query, variables}),
    };
  }
  const fetchResult = await fetch(process.env.CMS_HEADLESS_SERVER_ENDPOINT, requestInit);
  return fetchResult.json();
};

export const campaignExecutor: Executor = async ({document, variables, context}) => {
  const query = print(document);
  let newHeaders = {};
  let method = "POST";
  if (context.request) {
    newHeaders = {...context.request.headers};
    method = context.request.method;
  } else if (context.headers) {
    newHeaders = {...context.headers};
    method = context.method;
  }
  // remove host and connection header to prevent issues with subschema service accepting it.
  delete newHeaders["host"];
  delete newHeaders["connection"];

  // add campaign tenant information
  newHeaders["authorization"] = process.env.CAMPAIGN_SERVICE_AUTHORIZATION_ID;

  // set correct content length for changed request.
  newHeaders["content-length"] = JSON.stringify({query, variables}).length;
  console.log(newHeaders);
  let requestInit = undefined;
  if (method !== "GET" && method !== "HEAD") {
    requestInit = {
      method: method,
      headers: {
        ...newHeaders,
      },
      body: JSON.stringify({query, variables}),
    };
  }
  const fetchResult = await fetch(process.env.CAMPAIGN_SERVICE_ENDPOINT, requestInit);
  return fetchResult.json();
};

Now all missing code references should be resolved, and you can start the Stitching Server.

Fetching the Data

Start the Stitching Server

pnpm run start

Test the Stitching Server

query CampaignContent($site: String!, $channelType: String!, $refinements: [String!]) {
  campaignContent(site: $site, channelType: $channelType, refinements: $refinements) {
    slots {
      id
      name
      assignment {
        refinements
        items {
          id
          content {
            repositoryPath
          }
        }
      }
    }
  }
}

with parameters:

{
  "site": "<your-site-id>",
  "channelType": "<category-page|product-page|cms-page>",
 }

If you have some running campaigns in your system which match your filter criteria, you should see something like:

{
  "data": {
    "campaignContent": {
      "slots": [
        {
          "name": "hero",
          "assignment": {
            "items": [
              {
                "id": "3352",
                "content": {
                  "repositoryPath": "/Sites/Calista/Germany/German/Pictures/Product Pictures/Women/Dresses"
                }
              }
            ]
          }
        },
        {
          "name": "main",
          "assignment": {
            "items": [
              {
                "id": "3426",
                "content": {
                  "repositoryPath": "/Sites/Calista/Germany/German/Pictures/Blog"
                }
              }
            ]
          }
        }
      ]
    }
  }
}

Conclusion

Congratulations, you are now able to access content attributes defined in the content schema directly from items below a campaign query result.

Copyright © 2023 CoreMedia GmbH, CoreMedia Corporation. All Rights Reserved.