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:

Copy
Copied
// 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:

Copy
Copied
// 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.

Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
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.

Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
yarn add @graphql-tools/schema

Then import the function and combine your definition and resolvers:

Copy
Copied
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:

Copy
Copied
// 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

Copy
Copied
// 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:

Copy
Copied
// 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);
Copyright © Norce 2023. All right reserved.