From c9f540cbd053b51678248571b97a6dc1c57241c9 Mon Sep 17 00:00:00 2001 From: Luis Alvarez Date: Sat, 3 Oct 2020 16:06:41 -0500 Subject: [PATCH] Added api builder --- lib/bigcommerce/api/cart.ts | 57 ++++++++++++++++ lib/bigcommerce/api/index.ts | 65 ++++++++++++------ .../api/utils/create-api-handler.ts | 18 +++++ lib/bigcommerce/api/utils/errors.ts | 32 +++++++++ lib/bigcommerce/api/utils/fetch-store-api.ts | 67 +++++++++++++++++++ .../api/utils/is-allowed-method.ts | 28 ++++++++ lib/commerce/api/index.ts | 1 + package.json | 2 + yarn.lock | 10 +++ 9 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 lib/bigcommerce/api/cart.ts create mode 100644 lib/bigcommerce/api/utils/create-api-handler.ts create mode 100644 lib/bigcommerce/api/utils/errors.ts create mode 100644 lib/bigcommerce/api/utils/fetch-store-api.ts create mode 100644 lib/bigcommerce/api/utils/is-allowed-method.ts diff --git a/lib/bigcommerce/api/cart.ts b/lib/bigcommerce/api/cart.ts new file mode 100644 index 000000000..5b0ac4751 --- /dev/null +++ b/lib/bigcommerce/api/cart.ts @@ -0,0 +1,57 @@ +import { serialize, CookieSerializeOptions } from 'cookie' +import isAllowedMethod from './utils/is-allowed-method' +import createApiHandler, { + BigcommerceApiHandler, +} from './utils/create-api-handler' +import { BigcommerceApiError } from './utils/errors' + +type Cart = any + +const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] + +const cartApi: BigcommerceApiHandler = async (req, res, config) => { + if (!isAllowedMethod(req, res, METHODS)) return + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + // Return current cart info + if (req.method === 'GET') { + let result: { data?: Cart } = {} + + try { + result = await config.storeApiFetch( + `/v3/carts/${cartId}?include=redirect_urls` + ) + } catch (error) { + if (error instanceof BigcommerceApiError && error.status === 404) { + // The cookie exists but the cart wasn't found, so, remove the cookie + res.setHeader('Set-Cookie', getCartCookie(name)) + } else { + throw error + } + } + + return res.status(200).json({ cart: result.data ?? null }) + } +} + +const ONE_DAY = 60 * 60 * 24 +const MAX_AGE = ONE_DAY * 30 + +function getCartCookie(name: string, cartId?: string) { + const options: CookieSerializeOptions = cartId + ? { + maxAge: MAX_AGE, + expires: new Date(Date.now() + MAX_AGE * 1000), + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + } + : { maxAge: -1, path: '/' } // Removes the cookie + + return serialize(name, cartId || '', options) +} + +export default createApiHandler(cartApi) diff --git a/lib/bigcommerce/api/index.ts b/lib/bigcommerce/api/index.ts index 44d09d9a9..73769f947 100644 --- a/lib/bigcommerce/api/index.ts +++ b/lib/bigcommerce/api/index.ts @@ -1,6 +1,7 @@ import { CommerceAPIConfig } from 'lib/commerce/api' import { GetAllProductsQueryVariables } from '../schema' import fetchAPI from './utils/fetch-api' +import fetchStoreApi from './utils/fetch-store-api' export interface Images { small?: ImageOptions @@ -28,6 +29,10 @@ export type ProductImageVariables = Pick< export interface BigcommerceConfigOptions extends CommerceAPIConfig { images?: Images + storeApiUrl: string + storeApiToken: string + storeApiClientId: string + storeApiFetch(endpoint: string, options?: RequestInit): Promise } export interface BigcommerceConfig extends BigcommerceConfigOptions { @@ -36,6 +41,9 @@ export interface BigcommerceConfig extends BigcommerceConfigOptions { const API_URL = process.env.BIGCOMMERCE_STOREFRONT_API_URL const API_TOKEN = process.env.BIGCOMMERCE_STOREFRONT_API_TOKEN +const STORE_API_URL = process.env.BIGCOMMERCE_STORE_API_URL +const STORE_API_TOKEN = process.env.BIGCOMMERCE_STORE_API_TOKEN +const STORE_API_CLIENT_ID = process.env.BIGCOMMERCE_STORE_API_CLIENT_ID if (!API_URL) { throw new Error( @@ -49,32 +57,45 @@ if (!API_TOKEN) { ) } +if (!(STORE_API_URL && STORE_API_TOKEN && STORE_API_CLIENT_ID)) { + throw new Error( + `The environment variables BIGCOMMERCE_STORE_API_URL, BIGCOMMERCE_STORE_API_TOKEN, BIGCOMMERCE_STORE_API_CLIENT_ID have to be set in order to access the REST API of your store` + ) +} + export class Config { private config: BigcommerceConfig constructor(config: BigcommerceConfigOptions) { this.config = { ...config, - get imageVariables() { - const { images } = this - return images - ? { - imgSmallWidth: images.small?.width, - imgSmallHeight: images.small?.height, - imgMediumWidth: images.medium?.height, - imgMediumHeight: images.medium?.height, - imgLargeWidth: images.large?.height, - imgLargeHeight: images.large?.height, - imgXLWidth: images.xl?.height, - imgXLHeight: images.xl?.height, - } - : undefined - }, + imageVariables: this.getImageVariables(config.images), } } - getConfig() { - return this.config + getImageVariables(images?: Images) { + return images + ? { + imgSmallWidth: images.small?.width, + imgSmallHeight: images.small?.height, + imgMediumWidth: images.medium?.height, + imgMediumHeight: images.medium?.height, + imgLargeWidth: images.large?.height, + imgLargeHeight: images.large?.height, + imgXLWidth: images.xl?.height, + imgXLHeight: images.xl?.height, + } + : undefined + } + + getConfig(userConfig: Partial = {}) { + const { images: configImages, ...config } = this.config + const images = { ...configImages, ...userConfig.images } + + return Object.assign(config, userConfig, { + images, + imageVariables: this.getImageVariables(images), + }) } setConfig(newConfig: Partial) { @@ -85,11 +106,17 @@ export class Config { const config = new Config({ commerceUrl: API_URL, apiToken: API_TOKEN, + cartCookie: process.env.BIGCOMMERCE_CART_COOKIE ?? 'bc_cartId', fetch: fetchAPI, + // REST API only + storeApiUrl: STORE_API_URL, + storeApiToken: STORE_API_TOKEN, + storeApiClientId: STORE_API_CLIENT_ID, + storeApiFetch: fetchStoreApi, }) -export function getConfig() { - return config.getConfig() +export function getConfig(userConfig?: Partial) { + return config.getConfig(userConfig) } export function setConfig(newConfig: Partial) { diff --git a/lib/bigcommerce/api/utils/create-api-handler.ts b/lib/bigcommerce/api/utils/create-api-handler.ts new file mode 100644 index 000000000..144f13068 --- /dev/null +++ b/lib/bigcommerce/api/utils/create-api-handler.ts @@ -0,0 +1,18 @@ +import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next' +import { BigcommerceConfig, getConfig } from '..' + +export type BigcommerceApiHandler = ( + req: NextApiRequest, + res: NextApiResponse, + config: BigcommerceConfig +) => void | Promise + +export default function createApiHandler(handler: BigcommerceApiHandler) { + return function getApiHandler({ + config, + }: { config?: BigcommerceConfig } = {}): NextApiHandler { + return function apiHandler(req, res) { + return handler(req, res, getConfig(config)) + } + } +} diff --git a/lib/bigcommerce/api/utils/errors.ts b/lib/bigcommerce/api/utils/errors.ts new file mode 100644 index 000000000..70f97953e --- /dev/null +++ b/lib/bigcommerce/api/utils/errors.ts @@ -0,0 +1,32 @@ +// Used for GraphQL errors +export class BigcommerceError extends Error { + status?: number + + constructor(msg: string, res?: Response) { + super(msg) + this.name = 'BigcommerceError' + + if (res) { + this.status = res.status + } + } +} + +export class BigcommerceApiError extends Error { + status: number + res: Response + + constructor(msg: string, res: Response) { + super(msg) + this.name = 'BigcommerceApiError' + this.status = res.status + this.res = res + } +} + +export class BigcommerceNetworkError extends Error { + constructor(msg: string) { + super(msg) + this.name = 'BigcommerceNetworkError' + } +} diff --git a/lib/bigcommerce/api/utils/fetch-store-api.ts b/lib/bigcommerce/api/utils/fetch-store-api.ts new file mode 100644 index 000000000..a3dae1e34 --- /dev/null +++ b/lib/bigcommerce/api/utils/fetch-store-api.ts @@ -0,0 +1,67 @@ +import { getConfig } from '..' +import { BigcommerceApiError, BigcommerceNetworkError } from './errors' + +export default async function fetchStoreApi( + endpoint: string, + options?: RequestInit +): Promise { + const config = getConfig() + let res: Response + + try { + res = await fetch(config.storeApiUrl + endpoint, { + ...options, + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + 'X-Auth-Token': config.storeApiToken, + 'X-Auth-Client': config.storeApiClientId, + }, + }) + } catch (error) { + throw new BigcommerceNetworkError( + `Fetch to Bigcommerce failed: ${error.message}` + ) + } + + if (!res.ok) { + throw new BigcommerceApiError(await getErrorText(res), res) + } + + const contentType = res.headers.get('Content-Type') + + if (contentType?.includes('application/json')) { + throw new BigcommerceApiError( + `Fetch to Bigcommerce API failed, expected JSON content but found: ${contentType}`, + res + ) + } + + const data = await res.json() + + return data +} + +async function getErrorText(res: Response) { + return `Big Commerce API error (${res.status}) \n${JSON.stringify( + getRawHeaders(res) + )}\n ${await getTextOrNull(res)}` +} + +function getRawHeaders(res: Response) { + const headers: { [key: string]: string } = {} + + res.headers.forEach((value, key) => { + headers[key] = value + }) + + return headers +} + +function getTextOrNull(res: Response) { + try { + return res.text() + } catch (err) { + return null + } +} diff --git a/lib/bigcommerce/api/utils/is-allowed-method.ts b/lib/bigcommerce/api/utils/is-allowed-method.ts new file mode 100644 index 000000000..78bbba568 --- /dev/null +++ b/lib/bigcommerce/api/utils/is-allowed-method.ts @@ -0,0 +1,28 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export default function isAllowedMethod( + req: NextApiRequest, + res: NextApiResponse, + allowedMethods: string[] +) { + const methods = allowedMethods.includes('OPTIONS') + ? allowedMethods + : [...allowedMethods, 'OPTIONS'] + + if (!req.method || !methods.includes(req.method)) { + res.status(405) + res.setHeader('Allow', methods.join(', ')) + res.end() + return false + } + + if (req.method === 'OPTIONS') { + res.status(200) + res.setHeader('Allow', methods.join(', ')) + res.setHeader('Content-Length', '0') + res.end() + return false + } + + return true +} diff --git a/lib/commerce/api/index.ts b/lib/commerce/api/index.ts index 49df4ccd0..dcf95498f 100644 --- a/lib/commerce/api/index.ts +++ b/lib/commerce/api/index.ts @@ -1,6 +1,7 @@ export interface CommerceAPIConfig { commerceUrl: string apiToken: string + cartCookie: string fetch( query: string, queryData?: CommerceAPIFetchOptions diff --git a/package.json b/package.json index 2fb6e1aa1..49e51fe5b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@tailwindcss/ui": "^0.6.2", "@types/classnames": "^2.2.10", "classnames": "^2.2.6", + "cookie": "^0.4.1", "lodash": "^4.17.20", "next": "^9.5.4-canary.23", "postcss-nested": "^5.0.1", @@ -34,6 +35,7 @@ "@graphql-codegen/schema-ast": "^1.17.8", "@graphql-codegen/typescript": "^1.17.10", "@graphql-codegen/typescript-operations": "^1.17.8", + "@types/cookie": "^0.4.0", "@types/node": "^14.11.2", "@types/react": "^16.9.49", "graphql": "^15.3.0", diff --git a/yarn.lock b/yarn.lock index 858e9c246..58b4345ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1507,6 +1507,11 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/cookie@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" + integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== + "@types/eslint-scope@^3.7.0": version "3.7.0" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86" @@ -2704,6 +2709,11 @@ convert-source-map@^0.3.3: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-0.3.5.tgz#f1d802950af7dd2631a1febe0596550c86ab3190" integrity sha1-8dgClQr33SYxof6+BZZVDIarMZA= +cookie@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"