Added api builder

This commit is contained in:
Luis Alvarez 2020-10-03 16:06:41 -05:00
parent 808ad87413
commit c9f540cbd0
9 changed files with 261 additions and 19 deletions

View File

@ -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)

View File

@ -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<T>(endpoint: string, options?: RequestInit): Promise<T>
}
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<BigcommerceConfig> = {}) {
const { images: configImages, ...config } = this.config
const images = { ...configImages, ...userConfig.images }
return Object.assign(config, userConfig, {
images,
imageVariables: this.getImageVariables(images),
})
}
setConfig(newConfig: Partial<BigcommerceConfig>) {
@ -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<BigcommerceConfig>) {
return config.getConfig(userConfig)
}
export function setConfig(newConfig: Partial<BigcommerceConfig>) {

View File

@ -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<void>
export default function createApiHandler(handler: BigcommerceApiHandler) {
return function getApiHandler({
config,
}: { config?: BigcommerceConfig } = {}): NextApiHandler {
return function apiHandler(req, res) {
return handler(req, res, getConfig(config))
}
}
}

View File

@ -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'
}
}

View File

@ -0,0 +1,67 @@
import { getConfig } from '..'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
export default async function fetchStoreApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
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
}
}

View File

@ -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
}

View File

@ -1,6 +1,7 @@
export interface CommerceAPIConfig {
commerceUrl: string
apiToken: string
cartCookie: string
fetch<Q, V = any>(
query: string,
queryData?: CommerceAPIFetchOptions<V>

View File

@ -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",

View File

@ -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"