query CampaignContent($site: String!, $channelType: String!) {
campaignContent(site: $site, channelType: $channelType) {
slots {
name
assignment {
refinements
items {
id
}
}
}
}
}
Schema Stitching
Learn how to combine the campaign service and the headless server to one schema.
- Get Content IDs and Content Properties
- Setup Apollo Stitching Server Project
- Prerequisites
- Initialize a Project
- Create tsconfig.json
- Modify package.json and add a start script
- Create .env file and configure the service coordinates
- Create index.ts and implement the main application logic
- Create resolvers.ts and implement content reference resolution
- Create executors.ts and provide additional header information
- Fetching the Data
- Conclusion
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 Content Properties
Getting IDs
To check for your content IDs test the following query:
{
"site": "<your-site-id>",
"channelType": "<category-page|product-page|content-page>",
}
{
"authorization": "<your-token>"
}
You should receive something similar to:
{
"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/graphiql
and drop a test 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"
{
"contentId": "<content-id>"
}
You should receive something similar to:
{
"data": {
"content": {
"content": {
"__typename": "CMArticleImpl",
"repositoryPath": "/Sites/Calista/United States/English/Editorial/Content/Blog/Fashion Trends"
}
}
}
}
Conclusion: Separate Requests
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 your 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 18 -
pnpm
version 8 -
A CoreMedia Content Cloud account with CoreMedia Campaigns enabled
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/node18 @types/node apollo-server-express cors cross-undici-fetch dataloader dotenv express express-graphql graphql nodemon ts-node typescript
Create tsconfig.json
{
"extends": "@tsconfig/node18",
"compilerOptions": {
"strict": false
}
}
Modify package.json and add a start script
{
...
"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:
-
Read Campaign and Content Headless Server Coordinates from .env file
-
Fetch remote schema from Campaign and Content Headless Endpoints
-
Merge both schemas and compile Gateway Schema
-
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).
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.
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
-
Open http://localhost:4000/graphql and query your 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.