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>