Integrating External APIs
Integrating a Graphql API
Working with multiple GraphQL APIs is a breeze through our schema stitching integration. In this example we will go through how you can add an integration with a 3rd party external API, such as a CMS.
Example integration with DatoCMS
The schema extension is done in server.js
by extending the config imported from shop.config.js
. The reason for this is to not include any of the server extension logic in the client bundle.
We provide a simple helper utility that helps you to do the most common API integrations in just a few lines:
// server.js
import React from "react";
import { createApp, createExtension } from "@jetshop/core/server";
import Shop from "./components/Shop";
import sharedConfig from "./shop.config";
const datoCmsToken = ""; // insert your DatoCMS API token here
const config = {
...sharedConfig,
server: {
graphql: {
extensions: [
createExtension({
endpoint: "https://graphql.datocms.com",
getHeaders: () => {
return {
authorization: `Bearer ${datoCmsToken}`,
};
},
prefix: "dato",
}),
],
},
},
};
export default createApp(<Shop />, config);
We also need to tell Apollo to use the local graphql proxy server by updating shop.config.js
:
// shop.config.js
const config = {
apolloConfig: {
useGraphQLProxyServer: true,
// rest of Apollo config
},
// rest of config
};
export default config;
Now start the shop using yarn start
and you should be able to query DatoCMS!
createExtension
createExtension
is a helper utility for the most common schema extensions. It is configurable by some basic options.
endpoint: string
The url to the GraphQL endpoint you want to integrate.
getHeaders: (req: Request) => Headers
A function that returns any request headers you want to send along to the server. This is where you pass authentication token and other headers that affect which data is loaded. This function gets the express request as an argument, so you can pass on any headers you want. For example, in the storeapiExtension
utility we forward channelid, culture, fallbackculture, currency, country, preview, authorization and storeid.
prefix: string
A string that is used to prefix the types and root fields of the integrated endpoint. It's possible to leave this empty, but that usually results in some naming conflicts, so we recommend that you supply a short name here.
transforms: Transform[]
(advanced)
An array of transforms to apply. This is used if you don't supply a prefix, and can be used to customize the behaviour of the schema transformation. See Built in transformations on graphql-tools for more information.
storeapiExtension
If you want to work against two different shops on the StoreAPI (e.g. fetching data from the main shop in a satellite shop) there's a helper function specifically targeting the StoreAPI.
By default, this helper just passes all headers through to the API, meaning that if the user has selected a channel, the same value will be passed to the extension as well. This can cause some problems if the channel doesn't exist on the extension API. To override this behaviour, you can pass a function called getHeaders
which receives the request as a parameter, and anything you return from this function will be merged into the headers for the request.
const config = {
graphql: {
extensions: [
storeapiExtension({
token: "359fd7c1-8e72-4270-b899-2bda9ae6ef57",
shopid: "demostore",
prefix: "main",
getHeaders: () => ({ channelid: 1 }), // optional
}),
],
},
};
When using this helper, you should always provide a prefix.
Integrating a REST API
It is also possible to integrate REST APIs in the Flight framework, by defining a custom GraphQL schema and how to resolve the fields. This takes some more work than with a regular GraphQL API. Here's an example for how to implement Yotpo Product Reviews.
Define your schema
The first step is to define your GraphQL schema. This is a pretty straightforward process if you have access to good API documentation. For our example, we're interested in the Retrieve reviews for a product feature of the Yotpo API.
First we define our response types based on the structure of the JSON response from Yotpo:
const typeDefs = `
type YotpoProductReviews {
bottomline: YotpoBottomline
pagination: YotpoPagination!
reviews: [YotpoReview]
}
type YotpoBottomline {
averageScore: Float
totalReviews: Int
}
type YotpoPagination {
page: Int
perPage: Int
total: Int
}
type YotpoReview {
id: Int
score: Int
upVotes: Int
downVotes: Int
content: String
title: String
createdAt: String
verifiedBuyer: Boolean
deleted: Boolean
user: YotpoUser
}
type YotpoUser {
userId: Int
displayName: String
}
`;
Next, we also define our query and its inputs:
const typeDefs = `
${/* continuing after code from previous example */}
input YotpoPaginationInput {
perPage: Int
page: Int
star: Int
sort: String
direction: String
}
type Query {
yotpoProductReviews(productId: String!, pagination: YotpoPaginationInput): YotpoProductReviews
}
`;
Note that it's good practice to prefix all types and queries to avoid conflicts with already existing types in the StoreAPI.
Fetch data from Yotpo
Next, let's define a function to fetch data from Yotpo. Here we're using a regular fetch request, passing the app key as a part of the url. For some services you might need to set authentication headers, or supply a token in a different way.
import fetch from "node-fetch"; // required if we want to use fetch on the server side
async function yotpoFetch(appKey, endpoint) {
const response = await fetch(
`https://api.yotpo.com/v1/widget/${appKey}/${endpoint}`
);
if (response.ok) {
try {
const content = await response.text();
return JSON.parse(content);
} catch (e) {
throw new Error("Failed to parse Yotpo data");
}
} else {
throw new Error("Failed to fetch data from Yotpo");
}
}
This is includes some very basic error handling, to make sure we provide understandable errors back to the user should the Yotpo API respond with a bad response.
Create your resolver
We also need to specify how and where to get the data when someone queries for "yotpoProductReviews". To do this, we create an object with our resolvers:
const resolvers = {
Query: {
yotpoProductReviews: async (_, { productId, pagination }) => {
// Read pagination input (if it exists) and pass along to the Yotpo API using query string
let qs = new URLSearchParams();
if (pagination) {
if (pagination.perPage) qs.set("per_page", pagination.perPage);
if (pagination.page) qs.set("page", pagination.page);
if (pagination.star) qs.set("star", pagination.star);
if (pagination.sort) qs.set("sort", pagination.sort);
if (pagination.direction) qs.set("direction", pagination.direction);
}
// Get the actual data from the Yotpo API
const data = await yotpoFetch(
appKey,
`products/${productId}/reviews.json?${qs.toString()}`
);
// Map the data to the corresponding field in the Graph. Make sure to add null checks and default values when needed.
if (data.response) {
return {
bottomline: {
averageScore: data.response.bottomline?.average_score || 0,
totalReviews: data.response.bottomline?.total_review || 0,
},
pagination: {
page: data.response.pagination?.page || 0,
perPage: data.response.pagination?.per_page || 0,
total: data.response.pagination?.total || 0,
},
reviews: data.response.reviews.map((review) => ({
id: review.id,
score: review.score,
upVotes: review.votes_up,
downVotes: review.votes_down,
content: review.content,
title: review.title,
createdAt: review.created_at,
verifiedBuyer: review.verified_buyer,
deleted: review.deleted,
user: review.user
? {
userId: review.user.user_id,
displayName: review.user.display_name,
}
: null,
})),
};
} else {
return null;
}
},
},
};
Connect all the pieces together
Finally, we need to combine our typeDefs and resolvers using makeExecutableSchema
from @graphql-tools/schema
. If you don't already have it installed, add it as a dependency:
yarn add @graphql-tools/schema
Then import the function and combine your definition and resolvers:
import { makeExecutableSchema } from "@graphql-tools/schema";
export const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
Now you can extend your config in server.js
and add your extension:
// server.js
import sharedConfig from "./shop.config";
import { schema } from "./yotpo";
const config = {
...sharedConfig,
server: {
graphql: {
extensions: [{ schema }],
},
},
};
Note that the extensions array expects a series of objects containing schemas. There's a lot more information about how to extend a GraphQL schema on the GraphQL Tools documentation.
To verify that your schema extension works as intended, you can start your dev server and go to http://localhost:3000/graphql
and explore your combined GraphQL API!
Full source for Yotpo extension
// yotpoExtension.js
import fetch from "node-fetch";
import { makeExecutableSchema } from "@graphql-tools/schema";
const typeDefs = `
type YotpoProductReviews {
bottomline: YotpoBottomline
pagination: YotpoPagination!
reviews: [YotpoReview]
}
type YotpoBottomline {
averageScore: Float
totalReviews: Int
}
type YotpoPagination {
page: Int
perPage: Int
total: Int
}
type YotpoReview {
id: Int
score: Int
upVotes: Int
downVotes: Int
content: String
title: String
createdAt: String
verifiedBuyer: Boolean
deleted: Boolean
user: YotpoUser
}
type YotpoUser {
userId: Int
displayName: String
}
input YotpoPaginationInput {
perPage: Int
page: Int
star: Int
sort: String
direction: String
}
type Query {
yotpoProductReviews(productId: String!, pagination: YotpoPaginationInput): YotpoProductReviews
}
`;
async function yotpoFetch(appKey, endpoint) {
const response = await fetch(
`https://api.yotpo.com/v1/widget/${appKey}/${endpoint}`
);
if (response.ok) {
try {
const content = await response.text();
return JSON.parse(content);
} catch (e) {
throw new Error("Failed to parse Yotpo data");
}
} else {
throw new Error("Failed to fetch data from Yotpo");
}
}
export function yotpoExtension(appKey) {
const resolvers = {
Query: {
yotpoProductReviews: async (_, { productId, pagination }) => {
let qs = new URLSearchParams();
if (pagination) {
if (pagination.perPage) qs.set("per_page", pagination.perPage);
if (pagination.page) qs.set("page", pagination.page);
if (pagination.star) qs.set("star", pagination.star);
if (pagination.sort) qs.set("sort", pagination.sort);
if (pagination.direction) qs.set("direction", pagination.direction);
}
const data = await yotpoFetch(
appKey,
`products/${productId}/reviews.json?${qs.toString()}`
);
if (data.response) {
return {
bottomline: {
averageScore: data.response.bottomline?.average_score || 0,
totalReviews: data.response.bottomline?.total_review || 0,
},
pagination: {
page: data.response.pagination?.page || 0,
perPage: data.response.pagination?.per_page || 0,
total: data.response.pagination?.total || 0,
},
reviews: data.response.reviews.map((review) => ({
id: review.id,
score: review.score,
upVotes: review.votes_up,
downVotes: review.votes_down,
content: review.content,
title: review.title,
createdAt: review.created_at,
verifiedBuyer: review.verified_buyer,
deleted: review.deleted,
user: review.user
? {
userId: review.user.user_id,
displayName: review.user.display_name,
}
: null,
})),
};
} else {
return null;
}
},
},
};
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
return {
schema,
};
}
To use this in your shop:
// server.js
import React from "react";
import { createApp } from "@jetshop/core/boot/server";
import Shop from "./components/Shop";
import sharedConfig from "./shop.config";
import { yotpoExtension } from "./yotpoExtension";
const config = {
...sharedConfig,
server: {
graphql: {
extensions: [yotpoExtension("your-yotpo-app-key")],
},
},
};
export default createApp(<Shop />, config);