4
0
forked from crowetic/commerce

Dynamic API routes (#836)

* Add dynamic API endpoints

* Add missing dependency

* Update api handlers

* Updates

* Fix build errors

* Update package.json

* Add checkout endpoint parser & update errors

* Update tsconfig.json

* Update cart.ts

* Update parser

* Update errors.ts

* Update errors.ts

* Move to Edge runtime

* Revert to local

* Fix switchable runtimes

* Make nodejs default runtime

* Update pnpm-lock.yaml

* Update handlers

* Fix build errors

* Change headers
This commit is contained in:
Catalin Pinte 2022-10-30 20:41:21 +02:00 committed by GitHub
parent a5b367a747
commit c75b0fc001
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
316 changed files with 2482 additions and 2176 deletions

View File

@ -47,14 +47,15 @@
}
},
"dependencies": {
"@cfworker/uuid": "^1.12.4",
"@tsndr/cloudflare-worker-jwt": "^2.1.0",
"@vercel/commerce": "workspace:*",
"@vercel/fetch": "^6.2.0",
"cookie": "^0.4.1",
"immutability-helper": "^3.1.1",
"js-cookie": "^3.0.1",
"jsonwebtoken": "^8.5.1",
"lodash.debounce": "^4.0.8",
"uuidv4": "^6.2.12",
"node-fetch": "^2.6.7"
"uuidv4": "^6.2.13"
},
"peerDependencies": {
"next": "^12",
@ -69,8 +70,8 @@
"@types/jsonwebtoken": "^8.5.7",
"@types/lodash.debounce": "^4.0.6",
"@types/node": "^17.0.8",
"@types/react": "^18.0.14",
"@types/node-fetch": "^2.6.2",
"@types/react": "^18.0.14",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"prettier": "^2.5.1",

View File

@ -1,22 +1,14 @@
// @ts-nocheck
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const addItem: CartEndpoint['handlers']['addItem'] = async ({
res,
body: { cartId, item },
config,
}) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
if (!item.quantity) item.quantity = 1
const options = {
method: 'POST',
body: JSON.stringify({
@ -26,22 +18,27 @@ const addItem: CartEndpoint['handlers']['addItem'] = async ({
: {}),
}),
}
const { data } = cartId
? await config.storeApiFetch(
? await config.storeApiFetch<{ data: BigcommerceCart }>(
`/v3/carts/${cartId}/items?include=line_items.physical_items.options,line_items.digital_items.options`,
options
)
: await config.storeApiFetch(
: await config.storeApiFetch<{ data: BigcommerceCart }>(
'/v3/carts?include=line_items.physical_items.options,line_items.digital_items.options',
options
)
// Create or update the cart cookie
res.setHeader(
'Set-Cookie',
getCartCookie(config.cartCookie, data.id, config.cartCookieMaxAge)
)
res.status(200).json({ data: normalizeCart(data) })
return {
data: normalizeCart(data),
headers: {
'Set-Cookie': getCartCookie(
config.cartCookie,
data.id,
config.cartCookieMaxAge
),
},
}
}
export default addItem

View File

@ -1,36 +1,41 @@
// @ts-nocheck
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import getCartCookie from '../../utils/get-cart-cookie'
import { normalizeCart } from '../../../lib/normalize'
import { BigcommerceApiError } from '../../utils/errors'
import getCartCookie from '../../utils/get-cart-cookie'
import type { BigcommerceCart } from '../../../types'
import type { CartEndpoint } from '.'
// Return current cart info
const getCart: CartEndpoint['handlers']['getCart'] = async ({
res,
body: { cartId },
config,
}) => {
let result: { data?: BigcommerceCart } = {}
if (cartId) {
try {
result = await config.storeApiFetch(
const result = await config.storeApiFetch<{
data?: BigcommerceCart
} | null>(
`/v3/carts/${cartId}?include=line_items.physical_items.options,line_items.digital_items.options`
)
return {
data: result?.data ? normalizeCart(result.data) : null,
}
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 404) {
// Remove the cookie if it exists but the cart wasn't found
res.setHeader('Set-Cookie', getCartCookie(config.cartCookie))
return {
headers: { 'Set-Cookie': getCartCookie(config.cartCookie) },
}
} else {
throw error
}
}
}
res.status(200).json({
data: result.data ? normalizeCart(result.data) : null,
})
return {
data: null,
}
}
export default getCart

View File

@ -1,4 +1,4 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import { type GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
import type { CartSchema } from '@vercel/commerce/types/cart'
import type { BigcommerceAPI } from '../..'

View File

@ -1,34 +1,26 @@
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
import { normalizeCart } from '../../../lib/normalize'
import getCartCookie from '../../utils/get-cart-cookie'
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
res,
body: { cartId, itemId },
config,
}) => {
if (!cartId || !itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const result = await config.storeApiFetch<{ data: any } | null>(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{ method: 'DELETE' }
)
const data = result?.data ?? null
res.setHeader(
'Set-Cookie',
data
? // Update the cart cookie
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
: // Remove the cart cookie if the cart was removed (empty items)
getCartCookie(config.cartCookie)
)
res.status(200).json({ data: data && normalizeCart(data) })
return {
data: result?.data ? normalizeCart(result.data) : null,
headers: {
'Set-Cookie': result?.data
? // Update the cart cookie
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
: // Remove the cart cookie if the cart was removed (empty items)
getCartCookie(config.cartCookie),
},
}
}
export default removeItem

View File

@ -1,21 +1,15 @@
import type { CartEndpoint } from '.'
import type { BigcommerceCart } from '../../../types'
import { normalizeCart } from '../../../lib/normalize'
import { parseCartItem } from '../../utils/parse-item'
import getCartCookie from '../../utils/get-cart-cookie'
import type { CartEndpoint } from '.'
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
res,
body: { cartId, itemId, item },
config,
}) => {
if (!cartId || !itemId || !item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const { data } = await config.storeApiFetch(
const { data } = await config.storeApiFetch<{ data: BigcommerceCart }>(
`/v3/carts/${cartId}/items/${itemId}?include=line_items.physical_items.options`,
{
method: 'PUT',
@ -25,12 +19,16 @@ const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
}
)
// Update the cart cookie
res.setHeader(
'Set-Cookie',
getCartCookie(config.cartCookie, cartId, config.cartCookieMaxAge)
)
res.status(200).json({ data: normalizeCart(data) })
return {
data: normalizeCart(data),
headers: {
'Set-Cookie': getCartCookie(
config.cartCookie,
cartId,
config.cartCookieMaxAge
),
},
}
}
export default updateItem

View File

@ -11,7 +11,6 @@ const LIMIT = 12
// Return current cart info
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, categoryId, brandId, sort },
config,
commerce,
@ -73,7 +72,7 @@ const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
if (product) products.push(product)
})
res.status(200).json({ data: { products, found } })
return { data: { products, found } }
}
export default getProducts

View File

@ -1,38 +1,47 @@
import type { CheckoutEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id'
import jwt from 'jsonwebtoken'
import { uuid } from 'uuidv4'
const fullCheckout = true
const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
req,
res,
config,
}) => {
const { cookies } = req
const cartId = cookies[config.cartCookie]
const customerToken = cookies[config.customerCookie]
const cartId = cookies.get(config.cartCookie)
const customerToken = cookies.get(config.customerCookie)
if (!cartId) {
res.redirect('/cart')
return
return { redirectTo: '/cart' }
}
const { data } = await config.storeApiFetch(
const { data } = await config.storeApiFetch<any>(
`/v3/carts/${cartId}/redirect_urls`,
{
method: 'POST',
}
)
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
//if there is a customer create a jwt token
if (!customerId) {
if (fullCheckout) {
res.redirect(data.checkout_url)
return
return { redirectTo: data.checkout_url }
}
} else {
// Dynamically import uuid & jsonwebtoken based on the runtime
const { uuid } =
process.env.NEXT_RUNTIME === 'edge'
? await import('@cfworker/uuid')
: await import('uuidv4')
const jwt =
process.env.NEXT_RUNTIME === 'edge'
? await import('@tsndr/cloudflare-worker-jwt')
: await import('jsonwebtoken')
const dateCreated = Math.round(new Date().getTime() / 1000)
const payload = {
iss: config.storeApiClientId,
@ -42,49 +51,51 @@ const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({
store_hash: config.storeHash,
customer_id: customerId,
channel_id: config.storeChannelId,
redirect_to: data.checkout_url.replace(config.storeUrl, ""),
redirect_to: data.checkout_url.replace(config.storeUrl, ''),
}
let token = jwt.sign(payload, config.storeApiClientSecret!, {
algorithm: 'HS256',
})
let checkouturl = `${config.storeUrl}/login/token/${token}`
console.log('checkouturl', checkouturl)
if (fullCheckout) {
res.redirect(checkouturl)
return
return { redirectTo: checkouturl }
}
}
// TODO: make the embedded checkout work too!
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout</title>
<script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
<script>
window.onload = function() {
checkoutKitLoader.load('checkout-sdk').then(function (service) {
service.embedCheckout({
containerId: 'checkout',
url: '${data.embedded_checkout_url}'
});
});
}
</script>
</head>
<body>
<div id="checkout"></div>
</body>
</html>
`
// const html = `
// <!DOCTYPE html>
// <html lang="en">
// <head>
// <meta charset="UTF-8">
// <meta name="viewport" content="width=device-width, initial-scale=1.0">
// <title>Checkout</title>
// <script src="https://checkout-sdk.bigcommerce.com/v1/loader.js"></script>
// <script>
// window.onload = function() {
// checkoutKitLoader.load('checkout-sdk').then(function (service) {
// service.embedCheckout({
// containerId: 'checkout',
// url: '${data.embedded_checkout_url}'
// });
// });
// }
// </script>
// </head>
// <body>
// <div id="checkout"></div>
// </body>
// </html>
// `
res.status(200)
res.setHeader('Content-Type', 'text/html')
res.write(html)
res.end()
// return new Response(html, {
// headers: {
// 'Content-Type': 'text/html',
// },
// })
return { data: null }
}
export default getCheckout

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1,5 +1,6 @@
import type { GetLoggedInCustomerQuery } from '../../../../schema'
import type { CustomerEndpoint } from '.'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
export const getLoggedInCustomerQuery = /* GraphQL */ `
query getLoggedInCustomer {
@ -25,29 +26,26 @@ export const getLoggedInCustomerQuery = /* GraphQL */ `
export type Customer = NonNullable<GetLoggedInCustomerQuery['customer']>
const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
async ({ req, res, config }) => {
const token = req.cookies[config.customerCookie]
async ({ req, config }) => {
const token = req.cookies.get(config.customerCookie)
if (token) {
const { data } = await config.fetch<GetLoggedInCustomerQuery>(
getLoggedInCustomerQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${token}`,
},
'Set-Cookie': `${config.customerCookie}=${token}`,
}
)
const { customer } = data
if (!customer) {
return res.status(400).json({
data: null,
errors: [{ message: 'Customer not found', code: 'not_found' }],
throw new CommerceAPIError('Customer not found', {
status: 404,
})
}
return res.status(200).json({
return {
data: {
customer: {
id: String(customer.entityId),
@ -59,10 +57,12 @@ const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] =
notes: customer.notes,
},
},
})
}
}
res.status(200).json({ data: null })
return {
data: null,
}
}
export default getLoggedInCustomer

View File

@ -0,0 +1,27 @@
import type { BigcommerceAPI, Provider } from '..'
import createEndpoints from '@vercel/commerce/api/endpoints'
import cart from './cart'
import login from './login'
import logout from './logout'
import signup from './signup'
import checkout from './checkout'
import customer from './customer'
import wishlist from './wishlist'
import products from './catalog/products'
const endpoints = {
cart,
login,
logout,
signup,
checkout,
wishlist,
customer,
'catalog/products': products,
}
export default function bigcommerceAPI(commerce: BigcommerceAPI) {
return createEndpoints<Provider>(commerce, endpoints)
}

View File

@ -1,49 +1,35 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { LoginEndpoint } from '.'
import { FetcherError } from '@vercel/commerce/utils/errors'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
const invalidCredentials = /invalid credentials/i
const login: LoginEndpoint['handlers']['login'] = async ({
res,
body: { email, password },
config,
commerce,
}) => {
// TODO: Add proper validations with something like Ajv
if (!(email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
const res = new Response()
await commerce.login({ variables: { email, password }, config, res })
return {
status: res.status,
headers: res.headers,
}
} catch (error) {
// Check if the email and password didn't match an existing account
if (
error instanceof FetcherError &&
invalidCredentials.test(error.message)
) {
return res.status(401).json({
data: null,
errors: [
{
message:
'Cannot find an account that matches the provided credentials',
code: 'invalid_credentials',
},
],
})
if (error instanceof FetcherError) {
throw new CommerceAPIError(
invalidCredentials.test(error.message)
? 'Cannot find an account that matches the provided credentials'
: error.message,
{ status: error.status || 401 }
)
} else {
throw error
}
throw error
}
res.status(200).json({ data: null })
}
export default login

View File

@ -2,22 +2,24 @@ import { serialize } from 'cookie'
import type { LogoutEndpoint } from '.'
const logout: LogoutEndpoint['handlers']['logout'] = async ({
res,
body: { redirectTo },
config,
}) => {
// Remove the cookie
res.setHeader(
'Set-Cookie',
serialize(config.customerCookie, '', { maxAge: -1, path: '/' })
)
// Only allow redirects to a relative URL
if (redirectTo?.startsWith('/')) {
res.redirect(redirectTo)
} else {
res.status(200).json({ data: null })
const headers = {
'Set-Cookie': serialize(config.customerCookie, '', {
maxAge: -1,
path: '/',
}),
}
return redirectTo
? {
redirectTo,
headers,
}
: {
headers,
}
}
export default logout

View File

@ -1,23 +1,13 @@
import { BigcommerceApiError } from '../../utils/errors'
import type { SignupEndpoint } from '.'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
import { BigcommerceApiError } from '../../utils/errors'
const signup: SignupEndpoint['handlers']['signup'] = async ({
res,
body: { firstName, lastName, email, password },
config,
commerce,
}) => {
// TODO: Add proper validations with something like Ajv
if (!(firstName && lastName && email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
// TODO: validate the password and email
// Passwords must be at least 7 characters and contain both alphabetic
// and numeric characters.
try {
await config.storeApiFetch('/v3/customers', {
method: 'POST',
@ -35,28 +25,26 @@ const signup: SignupEndpoint['handlers']['signup'] = async ({
} catch (error) {
if (error instanceof BigcommerceApiError && error.status === 422) {
const hasEmailError = '0.email' in error.data?.errors
// If there's an error with the email, it most likely means it's duplicated
if (hasEmailError) {
return res.status(400).json({
data: null,
errors: [
{
message: 'The email is already in use',
code: 'duplicated_email',
},
],
throw new CommerceAPIError('Email already in use', {
status: 400,
code: 'duplicated_email',
})
}
} else {
throw error
}
throw error
}
const res = new Response()
// Login the customer right after creating it
await commerce.login({ variables: { email, password }, res, config })
res.status(200).json({ data: null })
return {
headers: res.headers,
}
}
export default signup

View File

@ -1,67 +1,53 @@
import getCustomerWishlist from '../../operations/get-customer-wishlist'
import { parseWishlistItem } from '../../utils/parse-item'
import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.'
const addItem: WishlistEndpoint['handlers']['addItem'] = async ({
res,
body: { customerToken, item },
config,
commerce,
}) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
if (!customerId) {
throw new Error('Invalid request. No CustomerId')
}
try {
const customerId =
customerToken && (await getCustomerId({ customerToken, config }))
let { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
config,
})
if (!customerId) {
throw new Error('Invalid request. No CustomerId')
}
let { wishlist } = await commerce.getCustomerWishlist({
variables: { customerId },
config,
if (!wishlist) {
// If user has no wishlist, then let's create one with new item
const { data } = await config.storeApiFetch<any>('/v3/wishlists', {
method: 'POST',
body: JSON.stringify({
name: 'Next.js Commerce Wishlist',
is_public: false,
customer_id: Number(customerId),
items: [parseWishlistItem(item)],
}),
})
if (!wishlist) {
// If user has no wishlist, then let's create one with new item
const { data } = await config.storeApiFetch('/v3/wishlists', {
method: 'POST',
body: JSON.stringify({
name: 'Next.js Commerce Wishlist',
is_public: false,
customer_id: Number(customerId),
items: [parseWishlistItem(item)],
}),
})
return res.status(200).json(data)
return {
data,
}
// Existing Wishlist, let's add Item to Wishlist
const { data } = await config.storeApiFetch(
`/v3/wishlists/${wishlist.id}/items`,
{
method: 'POST',
body: JSON.stringify({
items: [parseWishlistItem(item)],
}),
}
)
// Returns Wishlist
return res.status(200).json(data)
} catch (err: any) {
res.status(500).json({
data: null,
errors: [{ message: err.message }],
})
}
// Existing Wishlist, let's add Item to Wishlist
const { data } = await config.storeApiFetch<any>(
`/v3/wishlists/${wishlist.id}/items`,
{
method: 'POST',
body: JSON.stringify({
items: [parseWishlistItem(item)],
}),
}
)
// Returns Wishlist
return { data }
}
export default addItem

View File

@ -1,10 +1,10 @@
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
import type { Wishlist } from '@vercel/commerce/types/wishlist'
import type { WishlistEndpoint } from '.'
import getCustomerId from '../../utils/get-customer-id'
// Return wishlist info
const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
res,
body: { customerToken, includeProducts },
config,
commerce,
@ -16,11 +16,7 @@ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
customerToken && (await getCustomerId({ customerToken, config }))
if (!customerId) {
// If the customerToken is invalid, then this request is too
return res.status(404).json({
data: null,
errors: [{ message: 'Wishlist not found' }],
})
throw new CommerceAPIError('Wishlist not found', { status: 404 })
}
const { wishlist } = await commerce.getCustomerWishlist({
@ -32,7 +28,7 @@ const getWishlist: WishlistEndpoint['handlers']['getWishlist'] = async ({
result = { data: wishlist }
}
res.status(200).json({ data: result.data ?? null })
return { data: result.data ?? null }
}
export default getWishlist

View File

@ -1,10 +1,10 @@
import type { Wishlist } from '@vercel/commerce/types/wishlist'
import getCustomerId from '../../utils/get-customer-id'
import type { WishlistEndpoint } from '.'
import { CommerceAPIError } from '@vercel/commerce/api/utils/errors'
// Return wishlist info
const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
res,
body: { customerToken, itemId },
config,
commerce,
@ -20,10 +20,7 @@ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
{}
if (!wishlist || !itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
throw new CommerceAPIError('Wishlist not found', { status: 400 })
}
const result = await config.storeApiFetch<{ data: Wishlist } | null>(
@ -32,7 +29,7 @@ const removeItem: WishlistEndpoint['handlers']['removeItem'] = async ({
)
const data = result?.data ?? null
res.status(200).json({ data })
return { data }
}
export default removeItem

View File

@ -1,4 +1,3 @@
import type { RequestInit } from '@vercel/fetch'
import {
CommerceAPI,
CommerceAPIConfig,
@ -35,7 +34,14 @@ export interface BigcommerceConfig extends CommerceAPIConfig {
storeUrl?: string
storeApiClientSecret?: string
storeHash?: string
storeApiFetch<T>(endpoint: string, options?: RequestInit): Promise<T>
storeApiFetch<T>(
endpoint: string,
options?: {
method?: string
body?: any
headers?: HeadersInit
}
): Promise<T>
}
const API_URL = process.env.BIGCOMMERCE_STOREFRONT_API_URL // GraphAPI

View File

@ -1,4 +1,3 @@
import type { ServerResponse } from 'http'
import type {
OperationContext,
OperationOptions,
@ -23,14 +22,14 @@ export default function loginOperation({
async function login<T extends LoginOperation>(opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
res: Response
}): Promise<T['data']>
async function login<T extends LoginOperation>(
opts: {
variables: T['variables']
config?: BigcommerceConfig
res: ServerResponse
res: Response
} & OperationOptions
): Promise<T['data']>
@ -42,7 +41,7 @@ export default function loginOperation({
}: {
query?: string
variables: T['variables']
res: ServerResponse
res: Response
config?: BigcommerceConfig
}): Promise<T['data']> {
config = commerce.getConfig(config)
@ -64,10 +63,15 @@ export default function loginOperation({
cookie = cookie.replace(/; SameSite=none/gi, '; SameSite=lax')
}
response.setHeader(
'Set-Cookie',
concatHeader(response.getHeader('Set-Cookie'), cookie)!
)
const prevCookie = response.headers.get('Set-Cookie')
const newCookie = concatHeader(prevCookie, cookie)
if (newCookie) {
res.headers.set(
'Set-Cookie',
String(Array.isArray(newCookie) ? newCookie.join(',') : newCookie)
)
}
}
return {

View File

@ -1,4 +1,4 @@
type Header = string | number | string[] | undefined
type Header = string | number | string[] | undefined | null
export default function concatHeader(prev: Header, val: Header) {
if (!val) return prev

View File

@ -1,5 +1,3 @@
import type { Response } from '@vercel/fetch'
// Used for GraphQL errors
export class BigcommerceGraphQLError extends Error {}

View File

@ -1,19 +1,22 @@
import { FetcherError } from '@vercel/commerce/utils/errors'
import type { GraphQLFetcher } from '@vercel/commerce/api'
import type { BigcommerceConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: (getConfig: () => BigcommerceConfig) => GraphQLFetcher =
(getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => {
async (
query: string,
{ variables, preview } = {},
options: { headers?: HeadersInit } = {}
): Promise<any> => {
// log.warn(query)
const config = getConfig()
const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), {
...fetchOptions,
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiToken}`,
...fetchOptions?.headers,
...options.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({

View File

@ -1,11 +1,16 @@
import type { FetchOptions, Response } from '@vercel/fetch'
import type { BigcommerceConfig } from '../index'
import { BigcommerceApiError, BigcommerceNetworkError } from './errors'
import fetch from './fetch'
const fetchStoreApi =
<T>(getConfig: () => BigcommerceConfig) =>
async (endpoint: string, options?: FetchOptions): Promise<T> => {
async (
endpoint: string,
options?: {
method?: string
body?: any
headers?: HeadersInit
}
): Promise<T> => {
const config = getConfig()
let res: Response

View File

@ -20,9 +20,7 @@ async function getCustomerId({
getCustomerIdQuery,
undefined,
{
headers: {
cookie: `${config.customerCookie}=${customerToken}`,
},
'Set-Cookie': `${config.customerCookie}=${customerToken}`,
}
)

View File

@ -9,7 +9,7 @@ export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
url: '/api/login',
url: '/api/commerce/login',
method: 'POST',
},
async fetcher({ input: { email, password }, options, fetch }) {

View File

@ -8,7 +8,7 @@ export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
fetchOptions: {
url: '/api/logout',
url: '/api/commerce/logout',
method: 'GET',
},
useHook:

View File

@ -9,7 +9,7 @@ export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
fetchOptions: {
url: '/api/signup',
url: '/api/commerce/signup',
method: 'POST',
},
async fetcher({

View File

@ -9,7 +9,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
@ -33,7 +33,6 @@ export const handler: MutationHook<AddItemHook> = {
({ fetch }) =>
() => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })

View File

@ -7,7 +7,7 @@ export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'GET',
},
useHook:

View File

@ -26,7 +26,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'DELETE',
},
async fetcher({

View File

@ -5,7 +5,9 @@ import type {
HookFetcherContext,
} from '@vercel/commerce/utils/types'
import { ValidationError } from '@vercel/commerce/utils/errors'
import useUpdateItem, { UseUpdateItem } from '@vercel/commerce/cart/use-update-item'
import useUpdateItem, {
UseUpdateItem,
} from '@vercel/commerce/cart/use-update-item'
import type { LineItem, UpdateItemHook } from '@vercel/commerce/types/cart'
import { handler as removeItemHandler } from './use-remove-item'
import useCart from './use-cart'
@ -18,7 +20,7 @@ export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'PUT',
},
async fetcher({

View File

@ -8,7 +8,7 @@ export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
url: '/api/customer',
url: '/api/commerce/customer',
method: 'GET',
},
async fetcher({ options, fetch }) {

View File

@ -1,4 +1,7 @@
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce'
import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider }

View File

@ -3,9 +3,9 @@ import type { Product } from '@vercel/commerce/types/product'
import type { Cart, LineItem } from '@vercel/commerce/types/cart'
import type { Category, Brand } from '@vercel/commerce/types/site'
import type { BigcommerceCart, BCCategory, BCBrand } from '../types'
import type { ProductNode } from '../api/operations/get-all-products'
import type { definitions } from '../api/definitions/store-content'
import { definitions } from '../api/definitions/store-content'
import update from './immutability'
import getSlug from './get-slug'
function normalizeProductOption(productOption: any) {
@ -20,55 +20,44 @@ function normalizeProductOption(productOption: any) {
}
}
export function normalizeProduct(productNode: any): Product {
export function normalizeProduct(productNode: ProductNode): Product {
const {
entityId: id,
productOptions,
prices,
path,
id: _,
options: _0,
images,
variants,
} = productNode
return update(productNode, {
id: { $set: String(id) },
images: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
url: urlOriginal,
alt: altText,
...rest,
})),
},
variants: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({
return {
id: String(id),
name: productNode.name,
description: productNode.description,
images:
images.edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
url: urlOriginal,
alt: altText,
...rest,
})) || [],
path: `/${getSlug(path)}`,
variants:
variants.edges?.map(
({ node: { entityId, productOptions, ...rest } }: any) => ({
id: String(entityId),
options: productOptions?.edges
? productOptions.edges.map(normalizeProductOption)
: [],
...rest,
})),
},
options: {
$set: productOptions.edges
? productOptions?.edges.map(normalizeProductOption)
: [],
},
brand: {
$apply: (brand: any) => (brand?.id ? brand.id : null),
},
slug: {
$set: path?.replace(/^\/+|\/+$/g, ''),
},
})
) || [],
options: productOptions?.edges?.map(normalizeProductOption) || [],
slug: path?.replace(/^\/+|\/+$/g, ''),
price: {
$set: {
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
},
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
},
$unset: ['entityId'],
})
}
}
export function normalizePage(page: definitions['page_Full']): Page {
@ -122,7 +111,7 @@ function normalizeLineItem(item: any): LineItem {
listPrice: item.list_price,
},
options: item.options,
path: item.url.split('/')[3],
path: `/${item.url.split('/')[3]}`,
discounts: item.discounts.map((discount: any) => ({
value: discount.discounted_amount,
})),

View File

@ -14,7 +14,7 @@ export type SearchProductsInput = {
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
url: '/api/catalog/products',
url: '/api/commerce/catalog/products',
method: 'GET',
},
fetcher({ input: { search, categoryId, brandId, sort }, options, fetch }) {

View File

@ -12,7 +12,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/wishlist',
url: '/api/commerce/wishlist',
method: 'POST',
},
useHook:

View File

@ -12,7 +12,7 @@ export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler: MutationHook<RemoveItemHook> = {
fetchOptions: {
url: '/api/wishlist',
url: '/api/commerce/wishlist',
method: 'DELETE',
},
useHook:

View File

@ -10,7 +10,7 @@ import type { GetWishlistHook } from '@vercel/commerce/types/wishlist'
export default useWishlist as UseWishlist<typeof handler>
export const handler: SWRHook<GetWishlistHook> = {
fetchOptions: {
url: '/api/wishlist',
url: '/api/commerce/wishlist',
method: 'GET',
},
async fetcher({ input: { customerId, includeProducts }, options, fetch }) {

View File

@ -69,7 +69,10 @@ Then, open [/site/.env.template](/site/.env.template) and add the provider name
Using BigCommerce as an example. The first thing to do is export a `CommerceProvider` component that includes a `provider` object with all the handlers that can be used for hooks:
```tsx
import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce'
import {
getCommerceProvider,
useCommerce as useCoreCommerce,
} from '@vercel/commerce'
import { bigcommerceProvider, BigcommerceProvider } from './provider'
export { bigcommerceProvider }
@ -135,7 +138,7 @@ export default useCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'GET',
},
useHook:
@ -175,7 +178,7 @@ export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
fetchOptions: {
url: '/api/cart',
url: '/api/commerce/cart',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {
@ -213,25 +216,26 @@ export const handler: MutationHook<AddItemHook> = {
```
## Showing progress and features
When creating a PR for a new provider, include this list in the PR description and mark the progress as you push so we can organize the code review. Not all points are required (but advised) so make sure to keep the list up to date.
**Status**
* [ ] CommerceProvider
* [ ] Schema & TS types
* [ ] API Operations - Get all collections
* [ ] API Operations - Get all pages
* [ ] API Operations - Get all products
* [ ] API Operations - Get page
* [ ] API Operations - Get product
* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
* [ ] Hook - Add Item
* [ ] Hook - Remove Item
* [ ] Hook - Update Item
* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working)
* [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
* [ ] Customer information
* [ ] Product attributes - Size, Colors
* [ ] Custom checkout
* [ ] Typing (in progress)
* [ ] Tests
- [ ] CommerceProvider
- [ ] Schema & TS types
- [ ] API Operations - Get all collections
- [ ] API Operations - Get all pages
- [ ] API Operations - Get all products
- [ ] API Operations - Get page
- [ ] API Operations - Get product
- [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction)
- [ ] Hook - Add Item
- [ ] Hook - Remove Item
- [ ] Hook - Update Item
- [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working)
- [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens)
- [ ] Customer information
- [ ] Product attributes - Size, Colors
- [ ] Custom checkout
- [ ] Typing (in progress)
- [ ] Tests

View File

@ -47,13 +47,12 @@
}
},
"dependencies": {
"@vercel/fetch": "^6.2.0",
"@vercel/edge": "^0.0.4",
"deepmerge": "^4.2.2",
"import-cwd": "^3.0.0",
"js-cookie": "^3.0.1",
"swr": "^1.3.0",
"node-fetch": "^2.6.7",
"zod": "^3.19.0"
"zod": "^3.19.1"
},
"peerDependencies": {
"next": "^12",
@ -66,8 +65,8 @@
"@taskr/watch": "^1.1.0",
"@types/js-cookie": "^3.0.1",
"@types/node": "^17.0.8",
"@types/react": "^18.0.14",
"@types/node-fetch": "2.6.2",
"@types/react": "^18.0.14",
"lint-staged": "^12.1.7",
"next": "^12.0.8",
"prettier": "^2.5.1",

View File

@ -1,62 +1,64 @@
import type { CartSchema } from '../../types/cart'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
import type { CartSchema } from '../../types/cart'
import { parse, getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
import {
getCartBodySchema,
addItemBodySchema,
updateItemBodySchema,
removeItemBodySchema,
cartSchema,
} from '../../schemas/cart'
const cartEndpoint: GetAPISchema<
any,
CartSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const { req, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getCart'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getCart'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
const input = await getInput(req)
let output
const { cookies } = req
const cartId = cookies[config.cartCookie]
const cartId = cookies.get(config.cartCookie)
try {
// Return current cart info
if (req.method === 'GET') {
const body = { cartId }
return await handlers['getCart']({ ...ctx, body })
}
// Create or add an item to the cart
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in cart
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from the cart
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
// Return current cart info
if (req.method === 'GET') {
const body = getCartBodySchema.parse({ cartId })
output = await handlers['getCart']({ ...ctx, body })
}
// Create or add an item to the cart
if (req.method === 'POST') {
const body = addItemBodySchema.parse({ ...input, cartId })
if (!body.item.quantity) {
body.item.quantity = 1
}
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in cart
if (req.method === 'PUT') {
const body = updateItemBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from the cart
if (req.method === 'DELETE') {
const body = removeItemBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, cartSchema.nullish()) : { status: 405 }
}
export default cartEndpoint

View File

@ -1,31 +1,37 @@
import type { ProductsSchema } from '../../../types/product'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import type { GetAPISchema } from '../..'
import type { ProductsSchema } from '../../../types/product'
import validateHandlers from '../../utils/validate-handlers'
import {
searchProductBodySchema,
searchProductsSchema,
} from '../../../schemas/product'
import { parse } from '../../utils'
const productsEndpoint: GetAPISchema<
any,
ProductsSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
const { req, handlers } = ctx
if (!isAllowedOperation(req, res, { GET: handlers['getProducts'] })) {
return
validateHandlers(req, { GET: handlers['getProducts'] })
const { searchParams } = new URL(req.url)
const body = searchProductBodySchema.parse({
search: searchParams.get('search') ?? undefined,
categoryId: searchParams.get('categoryId') ?? undefined,
brandId: searchParams.get('brandId') ?? undefined,
sort: searchParams.get('sort') ?? undefined,
})
const res = await handlers['getProducts']({ ...ctx, body })
res.headers = {
'Cache-Control': 'max-age=0, s-maxage=3600, stale-while-revalidate, public',
...res.headers,
}
try {
const body = req.query
return await handlers['getProducts']({ ...ctx, body })
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
return parse(res, searchProductsSchema)
}
export default productsEndpoint

View File

@ -1,49 +1,45 @@
import type { CheckoutSchema } from '../../types/checkout'
import type { GetAPISchema } from '..'
import type { CheckoutSchema } from '../../types/checkout'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import {
checkoutSchema,
getCheckoutBodySchema,
submitCheckoutBodySchema,
} from '../../schemas/checkout'
import { parse, getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
const checkoutEndpoint: GetAPISchema<
any,
CheckoutSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const { req, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getCheckout'],
POST: handlers['submitCheckout'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getCheckout'],
POST: handlers['submitCheckout'],
})
const { cookies } = req
const cartId = cookies[config.cartCookie]
const cartId = cookies.get(config.cartCookie)!
const input = await getInput(req)
try {
// Create checkout
if (req.method === 'GET') {
const body = { ...req.body, cartId }
return await handlers['getCheckout']({ ...ctx, body })
}
// Create checkout
if (req.method === 'POST' && handlers['submitCheckout']) {
const body = { ...req.body, cartId }
return await handlers['submitCheckout']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
// Get checkout
if (req.method === 'GET') {
const body = getCheckoutBodySchema.parse({ ...input, cartId })
const res = await handlers['getCheckout']({ ...ctx, body })
return parse(res, checkoutSchema.optional())
}
// Create checkout
if (req.method === 'POST' && handlers['submitCheckout']) {
const body = submitCheckoutBodySchema.parse({ ...input, cartId })
const res = await handlers['submitCheckout']({ ...ctx, body })
return parse(res, checkoutSchema.optional())
}
return { status: 405 }
}
export default checkoutEndpoint

View File

@ -1,65 +1,68 @@
import type { CustomerAddressSchema } from '../../../types/customer/address'
import type { GetAPISchema } from '../..'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import validateHandlers from '../../utils/validate-handlers'
import {
addAddressBodySchema,
addressSchema,
deleteAddressBodySchema,
updateAddressBodySchema,
} from '../../../schemas/customer'
import { parse, getInput } from '../../utils'
import { getCartBodySchema } from '../../../schemas/cart'
// create a function that returns a function
const customerShippingEndpoint: GetAPISchema<
any,
CustomerAddressSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const { req, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getAddresses'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getAddresses'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
let output
const input = await getInput(req)
const { cookies } = req
// Cart id might be usefull for anonymous shopping
const cartId = cookies[config.cartCookie]
const cartId = cookies.get(config.cartCookie)
try {
// Return customer addresses
if (req.method === 'GET') {
const body = { cartId }
return await handlers['getAddresses']({ ...ctx, body })
}
// Create or add an item to customer addresses list
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in customer addresses list
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer addresses list
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
// Return customer addresses
if (req.method === 'GET') {
const body = getCartBodySchema.parse({ cartId })
return parse(
await handlers['getAddresses']({ ...ctx, body }),
addressSchema
)
}
// Create or add an item to customer addresses list
if (req.method === 'POST') {
const body = addAddressBodySchema.parse({ ...input, cartId })
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in customer addresses list
if (req.method === 'PUT') {
const body = updateAddressBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer addresses list
if (req.method === 'DELETE') {
const body = deleteAddressBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, addressSchema) : { status: 405 }
}
export default customerShippingEndpoint

View File

@ -1,65 +1,67 @@
import type { CustomerCardSchema } from '../../../types/customer/card'
import type { GetAPISchema } from '../..'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import { z } from 'zod'
import {
cardSchema,
addCardBodySchema,
deleteCardBodySchema,
updateCardBodySchema,
} from '../../../schemas/customer'
import { parse, getInput } from '../../utils'
import validateHandlers from '../../utils/validate-handlers'
const customerCardEndpoint: GetAPISchema<
any,
CustomerCardSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const { req, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getCards'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getCards'],
POST: handlers['addItem'],
PUT: handlers['updateItem'],
DELETE: handlers['removeItem'],
})
let output
const input = await getInput(req)
const { cookies } = req
// Cart id might be usefull for anonymous shopping
const cartId = cookies[config.cartCookie]
const cartId = cookies.get(config.cartCookie)
try {
// Create or add a card
if (req.method === 'GET') {
const body = { ...req.body }
return await handlers['getCards']({ ...ctx, body })
}
// Create or add an item to customer cards
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ ...ctx, body })
}
// Update item in customer cards
if (req.method === 'PUT') {
const body = { ...req.body, cartId }
return await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer cards
if (req.method === 'DELETE') {
const body = { ...req.body, cartId }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
// Create or add a card
if (req.method === 'GET') {
const body = { ...input }
return parse(
await handlers['getCards']({ ...ctx, body }),
z.array(cardSchema).optional()
)
}
// Create or add an item to customer cards
if (req.method === 'POST') {
const body = addCardBodySchema.parse({ ...input, cartId })
output = await handlers['addItem']({ ...ctx, body })
}
// Update item in customer cards
if (req.method === 'PUT') {
const body = updateCardBodySchema.parse({ ...input, cartId })
output = await handlers['updateItem']({ ...ctx, body })
}
// Remove an item from customer cards
if (req.method === 'DELETE') {
const body = deleteCardBodySchema.parse({ ...input, cartId })
return await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, cardSchema.nullish()) : { status: 405 }
}
export default customerCardEndpoint

View File

@ -1,36 +1,25 @@
import type { CustomerSchema } from '../../../types/customer'
import type { GetAPISchema } from '../..'
import { CommerceAPIError } from '../../utils/errors'
import isAllowedOperation from '../../utils/is-allowed-operation'
import { parse } from '../../utils'
import validateHandlers from '../../utils/validate-handlers'
import { customerSchema } from '../../../schemas/customer'
const customerEndpoint: GetAPISchema<
any,
CustomerSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
const { req, handlers } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getLoggedInCustomer'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getLoggedInCustomer'],
})
try {
const body = null
return await handlers['getLoggedInCustomer']({ ...ctx, body })
} catch (error) {
console.error(error)
const body = null
const output = await handlers['getLoggedInCustomer']({ ...ctx, body })
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
return output ? parse(output, customerSchema) : { status: 204 }
}
export default customerEndpoint

View File

@ -0,0 +1,10 @@
import edgeHandler from '../utils/edge-handler'
import nodeHandler from '../utils/node-handler'
/**
* Next.js Commerce API endpoints handler. Based on the path, it will call the corresponding endpoint handler,
* exported from the `endpoints` folder of the provider.
* @param {CommerceAPI} commerce The Commerce API instance.
* @param endpoints An object containing the handlers for each endpoint.
*/
export default process.env.NEXT_RUNTIME === 'edge' ? edgeHandler : nodeHandler

View File

@ -1,36 +1,25 @@
import type { LoginSchema } from '../../types/login'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
import type { LoginSchema } from '../../types/login'
import { getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
import { loginBodySchema } from '../../schemas/auth'
const loginEndpoint: GetAPISchema<
any,
LoginSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
const { req, handlers } = ctx
if (
!isAllowedOperation(req, res, {
POST: handlers['login'],
GET: handlers['login'],
})
) {
return
}
validateHandlers(req, {
POST: handlers['login'],
GET: handlers['login'],
})
try {
const body = req.body ?? {}
return await handlers['login']({ ...ctx, body })
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
const input = await getInput(req)
const body = loginBodySchema.parse(input)
return handlers['login']({ ...ctx, body })
}
export default loginEndpoint

View File

@ -1,35 +1,26 @@
import type { LogoutSchema } from '../../types/logout'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
import type { LogoutSchema } from '../../types/logout'
const logoutEndpoint: GetAPISchema<any, LogoutSchema>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers } = ctx
import { logoutBodySchema } from '../../schemas/auth'
import validateHandlers from '../utils/validate-handlers'
if (
!isAllowedOperation(req, res, {
GET: handlers['logout'],
})
) {
return
}
const logoutEndpoint: GetAPISchema<
any,
LogoutSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, handlers } = ctx
try {
const redirectTo = req.query.redirect_to
const body = typeof redirectTo === 'string' ? { redirectTo } : {}
validateHandlers(req, {
GET: handlers['logout'],
})
return await handlers['logout']({ ...ctx, body })
} catch (error) {
console.error(error)
const redirectTo = new URL(req.url).searchParams.get('redirectTo')
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
const body = logoutBodySchema.parse(
typeof redirectTo === 'string' ? { redirectTo } : {}
)
res.status(500).json({ data: null, errors: [{ message }] })
}
}
return handlers['logout']({ ...ctx, body })
}
export default logoutEndpoint

View File

@ -1,36 +1,27 @@
import type { SignupSchema } from '../../types/signup'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
import type { SignupSchema } from '../../types/signup'
const signupEndpoint: GetAPISchema<any, SignupSchema>['endpoint']['handler'] =
async (ctx) => {
const { req, res, handlers, config } = ctx
import { getInput } from '../utils'
import validateHandlers from '../utils/validate-handlers'
if (
!isAllowedOperation(req, res, {
POST: handlers['signup'],
})
) {
return
}
import { signupBodySchema } from '../../schemas/auth'
const { cookies } = req
const cartId = cookies[config.cartCookie]
const signupEndpoint: GetAPISchema<
any,
SignupSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, handlers, config } = ctx
try {
const body = { ...req.body, cartId }
return await handlers['signup']({ ...ctx, body })
} catch (error) {
console.error(error)
validateHandlers(req, {
POST: handlers['signup'],
})
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
const input = await getInput(req)
const { cookies } = req
const cartId = cookies.get(config.cartCookie)
res.status(500).json({ data: null, errors: [{ message }] })
}
}
const body = signupBodySchema.parse({ ...input, cartId })
return handlers['signup']({ ...ctx, body })
}
export default signupEndpoint

View File

@ -1,58 +1,58 @@
import type { WishlistSchema } from '../../types/wishlist'
import { CommerceAPIError } from '../utils/errors'
import isAllowedOperation from '../utils/is-allowed-operation'
import type { GetAPISchema } from '..'
import type { WishlistSchema } from '../../types/wishlist'
import { parse, getInput } from '../utils'
import {
wishlistSchema,
addItemBodySchema,
removeItemBodySchema,
getWishlistBodySchema,
} from '../../schemas/whishlist'
import validateHandlers from '../utils/validate-handlers'
const wishlistEndpoint: GetAPISchema<
any,
WishlistSchema
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers, config } = ctx
const { req, handlers, config } = ctx
if (
!isAllowedOperation(req, res, {
GET: handlers['getWishlist'],
POST: handlers['addItem'],
DELETE: handlers['removeItem'],
})
) {
return
}
validateHandlers(req, {
GET: handlers['getWishlist'],
POST: handlers['addItem'],
DELETE: handlers['removeItem'],
})
let output
const { cookies } = req
const customerToken = cookies[config.customerCookie]
const input = await getInput(req)
try {
// Return current wishlist info
if (req.method === 'GET') {
const body = {
customerToken,
includeProducts: req.query.products === '1',
}
return await handlers['getWishlist']({ ...ctx, body })
}
const customerToken = cookies.get(config.customerCookie)
const products = new URL(req.url).searchParams.get('products')
// Add an item to the wishlist
if (req.method === 'POST') {
const body = { ...req.body, customerToken }
return await handlers['addItem']({ ...ctx, body })
}
// Remove an item from the wishlist
if (req.method === 'DELETE') {
const body = { ...req.body, customerToken }
return await handlers['removeItem']({ ...ctx, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
// Return current wishlist info
if (req.method === 'GET') {
const body = getWishlistBodySchema.parse({
customerToken,
includeProducts: !!products,
})
output = await handlers['getWishlist']({ ...ctx, body })
}
// Add an item to the wishlist
if (req.method === 'POST') {
const body = addItemBodySchema.parse({ ...input, customerToken })
output = await handlers['addItem']({ ...ctx, body })
}
// Remove an item from the wishlist
if (req.method === 'DELETE') {
const body = removeItemBodySchema.parse({ ...input, customerToken })
output = await handlers['removeItem']({ ...ctx, body })
}
return output ? parse(output, wishlistSchema.optional()) : { status: 405 }
}
export default wishlistEndpoint

View File

@ -1,6 +1,5 @@
import type { NextApiHandler } from 'next'
import type { FetchOptions, Response } from '@vercel/fetch'
import type { APIEndpoint, APIHandler } from './utils/types'
import type { NextRequest } from 'next/server'
import type { APIEndpoint, APIHandler, APIResponse } from './utils/types'
import type { CartSchema } from '../types/cart'
import type { CustomerSchema } from '../types/customer'
import type { LoginSchema } from '../types/login'
@ -119,6 +118,8 @@ export function getCommerceApi<P extends APIProvider>(
return commerce
}
export type EndpointHandler = (req: NextRequest) => Promise<APIResponse>
export function getEndpoint<
P extends APIProvider,
T extends GetAPISchema<any, any>
@ -128,13 +129,11 @@ export function getEndpoint<
config?: P['config']
options?: T['schema']['endpoint']['options']
}
): NextApiHandler {
): EndpointHandler {
const cfg = commerce.getConfig(context.config)
return function apiHandler(req, res) {
return function apiHandler(req) {
return context.handler({
req,
res,
commerce,
config: cfg,
handlers: context.handlers,
@ -151,7 +150,7 @@ export const createEndpoint =
config?: P['config']
options?: API['schema']['endpoint']['options']
}
): NextApiHandler => {
): EndpointHandler => {
return getEndpoint(commerce, { ...endpoint, ...context })
}
@ -166,7 +165,7 @@ export interface CommerceAPIConfig {
fetch<Data = any, Variables = any>(
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: FetchOptions
headers?: HeadersInit
): Promise<GraphQLFetcherResult<Data>>
}
@ -175,8 +174,7 @@ export type GraphQLFetcher<
Variables = any
> = (
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: FetchOptions
queryData?: CommerceAPIFetchOptions<Variables>
) => Promise<Data>
export interface GraphQLFetcherResult<Data = any> {

View File

@ -1,4 +1,3 @@
import type { ServerResponse } from 'http'
import type { LoginOperation } from '../types/login'
import type { GetAllPagesOperation, GetPageOperation } from '../types/page'
import type { GetSiteInfoOperation } from '../types/site'
@ -44,14 +43,14 @@ export type Operations<P extends APIProvider> = {
<T extends LoginOperation>(opts: {
variables: T['variables']
config?: P['config']
res: ServerResponse
res: Response
}): Promise<T['data']>
<T extends LoginOperation>(
opts: {
variables: T['variables']
config?: P['config']
res: ServerResponse
res: Response
} & OperationOptions
): Promise<T['data']>
}

View File

@ -0,0 +1,81 @@
import type { APIProvider, CommerceAPI, EndpointHandler } from '..'
import type { NextRequest } from 'next/server'
import { normalizeApiError } from './errors'
import { transformHeaders } from '.'
export default function edgeHandler<P extends APIProvider>(
commerce: CommerceAPI<P>,
endpoints: Record<string, (commerce: CommerceAPI<P>) => EndpointHandler>
) {
const endpointsKeys = Object.keys(endpoints)
const handlers = endpointsKeys.reduce<Record<string, EndpointHandler>>(
(acc, endpoint) =>
Object.assign(acc, {
[endpoint]: endpoints[endpoint](commerce),
}),
{}
)
return async (req: NextRequest) => {
try {
const { pathname } = new URL(req.url)
/**
* Get the current endpoint by removing the leading and trailing slash & base path.
* Csovers: /api/commerce/cart & /checkout
*/
const endpoint = pathname
.replace('/api/commerce/', '')
.replace(/^\/|\/$/g, '')
// Check if the handler for this path exists and return a 404 if it doesn't
if (!endpointsKeys.includes(endpoint)) {
throw new Error(
`Endpoint "${endpoint}" not implemented. Please use one of the available api endpoints: ${endpointsKeys.join(
', '
)}`
)
}
/**
* Executes the handler for this endpoint, provided by the provider,
* parses the input body and returns the parsed output
*/
const output = await handlers[endpoint](req)
// If the output is a Response, return it directly (E.g. checkout page & validateMethod util)
if (output instanceof Response) {
return output
}
const headers = transformHeaders(output.headers)
// If the output contains a redirectTo property, return a Response with the redirect
if (output.redirectTo) {
headers.append('Location', output.redirectTo)
return new Response(null, {
status: 302,
headers,
})
}
// Otherwise, return a JSON response with the output data or errors returned by the handler
const { data = null, errors, status } = output
return new Response(JSON.stringify({ data, errors }), {
status,
headers,
})
} catch (error) {
const output = normalizeApiError(error)
if (output instanceof Response) {
return output
}
const { status = 500, ...rest } = output
return output instanceof Response
? output
: new Response(JSON.stringify(rest), { status })
}
}
}

View File

@ -1,9 +1,9 @@
import type { NextRequest } from 'next/server'
import { CommerceError } from '../../utils/errors'
import { ZodError } from 'zod'
import type { Response } from '@vercel/fetch'
import { CommerceError } from '../../utils/errors'
export class CommerceAPIError extends Error {
export class CommerceAPIResponseError extends Error {
status: number
res: Response
data: any
@ -17,6 +17,23 @@ export class CommerceAPIError extends Error {
}
}
export class CommerceAPIError extends Error {
status: number
code: string
constructor(
msg: string,
options?: {
status?: number
code?: string
}
) {
super(msg)
this.name = 'CommerceApiError'
this.status = options?.status || 500
this.code = options?.code || 'api_error'
}
}
export class CommerceNetworkError extends Error {
constructor(msg: string) {
super(msg)
@ -24,24 +41,59 @@ export class CommerceNetworkError extends Error {
}
}
export const normalizeZodIssues = (issues: ZodError['issues']) =>
issues.map(({ path, message }) => `${message} at "${path.join('.')}" field`)
export const getOperationError = (operation: string, error: unknown) => {
if (error instanceof ZodError) {
return new CommerceError({
code: 'SCHEMA_VALIDATION_ERROR',
message:
`The ${operation} operation returned invalid data and has ${
error.issues.length
} parse ${error.issues.length === 1 ? 'error' : 'errors'}: \n` +
error.issues
.map(
(e, index) =>
`Error #${index + 1} ${
e.path.length > 0 ? `Path: ${e.path.join('.')}, ` : ''
}Code: ${e.code}, Message: ${e.message}`
)
.join('\n'),
`Validation ${
error.issues.length === 1 ? 'error' : 'errors'
} at "${operation}" operation: \n` +
normalizeZodIssues(error.issues).join('\n'),
})
}
return error
}
export const normalizeApiError = (error: unknown, req?: NextRequest) => {
if (error instanceof CommerceAPIResponseError && error.res) {
return error.res
}
req?.url && console.log(req.url)
if (error instanceof ZodError) {
const message = 'Validation error, please check the input data!'
const errors = normalizeZodIssues(error.issues).map((message) => ({
message,
}))
console.error(`${message}\n${errors.map((e) => e.message).join('\n')}`)
return {
status: 400,
data: null,
errors,
}
}
console.error(error)
if (error instanceof CommerceAPIError) {
return {
errors: [
{
message: error.message,
code: error.code,
},
],
status: error.status,
}
}
return {
data: null,
errors: [{ message: 'An unexpected error ocurred' }],
}
}

View File

@ -0,0 +1,88 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import type { ZodSchema } from 'zod'
import type { APIResponse } from './types'
import { NextRequest } from 'next/server'
/**
* Parses the output data of the API handler and returns a valid APIResponse
* or throws an error if the data is invalid.
* @param res APIResponse
* @param parser ZodSchema
*/
export const parse = <T>(res: APIResponse<T>, parser: ZodSchema) => {
if (res.data) {
res.data = parser.parse(res.data)
}
return res
}
/**
* Returns the body of the request as a JSON object.
* @param req NextRequest
*/
export const getInput = (req: NextRequest) => req.json().catch(() => ({}))
/**
* Convert NextApiRequest to NextRequest
* @param req NextApiRequest
* @param path string
*/
export const transformRequest = (req: NextApiRequest, path: string) => {
const headers = new Headers()
let body
for (let i = 0; i < req.rawHeaders.length; i += 2) {
headers.append(req.rawHeaders[i], req.rawHeaders[i + 1])
}
if (
req.method === 'POST' ||
req.method === 'PUT' ||
req.method === 'DELETE'
) {
body = JSON.stringify(req.body)
}
return new NextRequest(`https://${req.headers.host}/api/commerce/${path}`, {
headers,
method: req.method,
body,
})
}
/**
* Sets the custom headers received in the APIResponse in the
* @param headers Record<string, string|string[]> | Headers | undefined
* @returns Headers
*/
export const transformHeaders = (
headers: Record<string, string | number | string[]> | Headers = {}
) => {
if (headers instanceof Headers) {
return headers
}
const newHeaders = new Headers()
Object.entries(headers).forEach(([key, value]) => {
newHeaders.append(key, value as string)
})
return newHeaders
}
export const setHeaders = (
res: NextApiResponse,
headers: Record<string, string | number | string[]> | Headers = {}
) => {
if (headers instanceof Headers) {
headers.forEach((value, key) => {
res.setHeader(key, value)
})
} else {
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value)
})
}
}

View File

@ -1,30 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE'
export default function isAllowedMethod(
req: NextApiRequest,
res: NextApiResponse,
allowedMethods: HTTP_METHODS[]
) {
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,19 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import isAllowedMethod, { HTTP_METHODS } from './is-allowed-method'
import { APIHandler } from './types'
export default function isAllowedOperation(
req: NextApiRequest,
res: NextApiResponse,
allowedOperations: { [k in HTTP_METHODS]?: APIHandler<any, any> }
) {
const methods = Object.keys(allowedOperations) as HTTP_METHODS[]
const allowedMethods = methods.reduce<HTTP_METHODS[]>((arr, method) => {
if (allowedOperations[method]) {
arr.push(method)
}
return arr
}, [])
return isAllowedMethod(req, res, allowedMethods)
}

View File

@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import type { APIProvider, CommerceAPI, EndpointHandler } from '..'
import { normalizeApiError } from './errors'
import { transformRequest, setHeaders } from '.'
export default function nodeHandler<P extends APIProvider>(
commerce: CommerceAPI<P>,
endpoints: {
[key: string]: (commerce: CommerceAPI<P>) => EndpointHandler
}
) {
const paths = Object.keys(endpoints)
const handlers = paths.reduce<Record<string, EndpointHandler>>(
(acc, path) =>
Object.assign(acc, {
[path]: endpoints[path](commerce),
}),
{}
)
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
if (!req.query.commerce) {
throw new Error(
'Invalid configuration. Please make sure that the /pages/api/commerce/[[...commerce]].ts route is configured correctly, and it passes the commerce instance.'
)
}
/**
* Get the url path
*/
const path = Array.isArray(req.query.commerce)
? req.query.commerce.join('/')
: req.query.commerce
// Check if the handler for this path exists and return a 404 if it doesn't
if (!paths.includes(path)) {
throw new Error(
`Endpoint handler not implemented. Please use one of the available api endpoints: ${paths.join(
', '
)}`
)
}
const output = await handlers[path](transformRequest(req, path))
const { status, errors, data, redirectTo, headers } = output
setHeaders(res, headers)
if (output instanceof Response) {
return res.end(output.body)
}
if (redirectTo) {
return res.redirect(redirectTo)
}
res.status(status || 200).json({
data,
errors,
})
} catch (error) {
const output = normalizeApiError(error)
if (output instanceof Response) {
return res.end(output.body)
}
const { status = 500, ...rest } = output
res.status(status).json(rest)
}
}
}

View File

@ -1,14 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import type { NextRequest } from 'next/server'
import type { CommerceAPI } from '..'
export type ErrorData = { message: string; code?: string }
export type APIResponse<Data = any> =
| { data: Data; errors?: ErrorData[] }
// If `data` doesn't include `null`, then `null` is only allowed on errors
| (Data extends null
? { data: null; errors?: ErrorData[] }
: { data: null; errors: ErrorData[] })
export type APIResponse<Data = any> = {
data?: Data
errors?: ErrorData[]
status?: number
headers?: Record<string, number | string | string[]> | Headers
/**
* @type {string}
* @example redirectTo: '/cart'
*/
redirectTo?: string
}
export type APIHandlerContext<
C extends CommerceAPI,
@ -16,14 +21,10 @@ export type APIHandlerContext<
Data = any,
Options extends {} = {}
> = {
req: NextApiRequest
res: NextApiResponse<APIResponse<Data>>
req: NextRequest
commerce: C
config: C['provider']['config']
handlers: H
/**
* Custom configs that may be used by a particular handler
*/
options: Options
}
@ -35,7 +36,7 @@ export type APIHandler<
Options extends {} = {}
> = (
context: APIHandlerContext<C, H, Data, Options> & { body: Body }
) => void | Promise<void>
) => Promise<APIResponse<Data>>
export type APIHandlers<C extends CommerceAPI> = {
[k: string]: APIHandler<C, any, any, any, any>
@ -46,4 +47,6 @@ export type APIEndpoint<
H extends APIHandlers<C> = {},
Data = any,
Options extends {} = {}
> = (context: APIHandlerContext<C, H, Data, Options>) => void | Promise<void>
> = (
context: APIHandlerContext<C, H, Data, Options>
) => Promise<APIResponse<Data>>

View File

@ -0,0 +1,24 @@
import type { NextRequest } from 'next/server'
import type { APIHandler } from './types'
import validateMethod, { HTTP_METHODS } from './validate-method'
/**
* Validates the request method and throws an error if it's not allowed, or if the handler is not implemented.
* and stops the execution of the handler.
* @param req The request object.
* @param allowedOperations An object containing the handlers for each method.
* @throws Error when the method is not allowed or the handler is not implemented.
*/
export default function validateHandlers(
req: NextRequest,
allowedOperations: { [k in HTTP_METHODS]?: APIHandler<any, any> }
) {
const methods = Object.keys(allowedOperations) as HTTP_METHODS[]
const allowedMethods = methods.reduce<HTTP_METHODS[]>((arr, method) => {
if (allowedOperations[method]) {
arr.push(method)
}
return arr
}, [])
return validateMethod(req, allowedMethods)
}

View File

@ -0,0 +1,48 @@
import type { NextRequest } from 'next/server'
import { CommerceAPIResponseError } from './errors'
export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE'
export default function validateMethod(
req: NextRequest,
allowedMethods: HTTP_METHODS[]
) {
const methods = allowedMethods.includes('OPTIONS')
? allowedMethods
: [...allowedMethods, 'OPTIONS']
if (!req.method || !methods.includes(req.method)) {
throw new CommerceAPIResponseError(
`The HTTP ${req.method} method is not supported at this route.`,
new Response(
JSON.stringify({
errors: [
{
code: 'invalid_method',
message: `The HTTP ${req.method} method is not supported at this route.`,
},
],
}),
{
status: 405,
headers: {
Allow: methods.join(', '),
},
}
)
)
}
if (req.method === 'OPTIONS') {
throw new CommerceAPIResponseError(
'This is a CORS preflight request.',
new Response(null, {
status: 204,
headers: {
Allow: methods.join(', '),
'Content-Length': '0',
},
})
)
}
}

View File

@ -0,0 +1,18 @@
import { z } from 'zod'
export const loginBodySchema = z.object({
redirectTo: z.string().optional(),
email: z.string().email(),
password: z.string().min(7),
})
export const logoutBodySchema = z.object({
redirectTo: z.string().optional(),
})
export const signupBodySchema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
password: z.string().min(7),
})

View File

@ -0,0 +1,102 @@
import { z } from 'zod'
export const getCartBodySchema = z.object({
cartId: z.string().optional(),
})
export const cartItemBodySchema = z.object({
variantId: z.string(),
productId: z.string().optional(),
quantity: z.number().min(1).optional(),
})
export const addItemBodySchema = z.object({
cartId: z.string().optional(),
item: cartItemBodySchema,
})
export const updateItemBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
item: cartItemBodySchema,
})
export const removeItemBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
})
export const discountSchema = z.object({
value: z.number(),
})
export const optionsSchema = z.object({
id: z.string().optional(),
name: z.string(),
value: z.string(),
})
export const cartProductVariantSchema = z.object({
id: z.string(),
sku: z.string().optional(),
name: z.string(),
price: z.number(),
listPrice: z.number(),
availableForSale: z.boolean().optional(),
requiresShipping: z.boolean().optional(),
image: z.object({
url: z.string(),
altText: z.string().optional(),
}),
weight: z
.object({
value: z.number(),
unit: z.string(),
})
.optional(),
height: z
.object({
value: z.number(),
unit: z.string(),
})
.optional(),
width: z
.object({
value: z.number(),
unit: z.string(),
})
.optional(),
depth: z
.object({
value: z.number(),
unit: z.string(),
})
.optional(),
})
export const cartLineItemSchema = z.object({
id: z.string(),
variantId: z.string(),
productId: z.string(),
name: z.string(),
quantity: z.number().min(1),
discounts: z.array(discountSchema).optional(),
path: z.string().startsWith('/').optional(),
variant: cartProductVariantSchema,
options: z.array(optionsSchema).optional(),
})
export const cartSchema = z.object({
id: z.string(),
customerId: z.string().optional(),
url: z.string().optional(),
email: z.string().optional(),
createdAt: z.string(),
currency: z.object({ code: z.string() }),
taxesIncluded: z.boolean(),
lineItems: z.array(cartLineItemSchema),
lineItemsSubtotalPrice: z.number(),
subtotalPrice: z.number(),
totalPrice: z.number(),
discounts: z.array(discountSchema).optional(),
})

View File

@ -0,0 +1,25 @@
import { z } from 'zod'
import { cartLineItemSchema } from './cart'
import { addressFieldsSchema, cardFieldsSchema } from './customer'
export const getCheckoutBodySchema = z.object({
cartId: z.string().optional(),
})
export const submitCheckoutBodySchema = z.object({
cartId: z.string(),
item: z.object({
cartId: z.string().optional(),
card: cardFieldsSchema,
address: addressFieldsSchema,
}),
})
export const checkoutSchema = z.object({
hasPayment: z.boolean(),
hasShipping: z.boolean(),
addressId: z.string(),
payments: z.array(cardFieldsSchema).optional(),
cardId: z.string().optional(),
lineItems: z.array(cartLineItemSchema).optional(),
})

View File

@ -0,0 +1,84 @@
import { z } from 'zod'
export const getCustomerAddressBodySchema = z.object({
cartId: z.string(),
})
export const customerSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string().optional(),
phone: z.string().optional(),
company: z.string().optional(),
notes: z.string().optional(),
acceptsMarketing: z.boolean().optional(),
})
export const addressSchema = z.object({
id: z.string(),
mask: z.string(),
})
export const addressFieldsSchema = z.object({
type: z.string(),
firstName: z.string(),
lastName: z.string(),
company: z.string(),
streetNumber: z.string(),
apartments: z.string(),
zipCode: z.string(),
city: z.string(),
country: z.string(),
})
export const addAddressBodySchema = z.object({
cartId: z.string(),
item: addressFieldsSchema,
})
export const updateAddressBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
item: addressFieldsSchema,
})
export const deleteAddressBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
})
export const cardFieldsSchema = z.object({
cardHolder: z.string(),
cardNumber: z.string(),
cardExpireDate: z.string(),
cardCvc: z.string(),
firstName: z.string(),
lastName: z.string(),
company: z.string(),
streetNumber: z.string(),
zipCode: z.string(),
city: z.string(),
country: z.string(),
})
export const cardSchema = z.object({
id: z.string(),
mask: z.string(),
})
export const addCardBodySchema = z.object({
cartId: z.string(),
item: cardFieldsSchema,
})
export const updateCardBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
item: cardFieldsSchema,
})
export const deleteCardBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
})

View File

@ -1,4 +1,4 @@
import { z } from 'zod'
import { boolean, z } from 'zod'
export const productPriceSchema = z.object({
value: z.number(),
@ -52,9 +52,14 @@ export const productsPathsSchema = z.array(
)
export const searchProductBodySchema = z.object({
search: z.string(),
categoryId: z.string(),
search: z.string().optional(),
categoryId: z.string().optional(),
brandId: z.string().optional(),
sort: z.string().optional(),
locale: z.string().optional(),
})
export const searchProductsSchema = z.object({
products: z.array(productSchema),
found: z.boolean(),
})

View File

@ -0,0 +1,41 @@
import { z } from 'zod'
import { productSchema } from './product'
export const wishlistSchemaItem = z.object({
id: z.string(),
productId: z.string(),
variantId: z.string(),
product: productSchema,
})
export const wishlistSchema = z.object({
id: z.string(),
items: z.array(wishlistSchemaItem),
token: z.string().optional(),
})
export const getWishlistBodySchema = z.object({
customerAccessToken: z.string(),
includeProducts: z.boolean(),
})
export const wishlistItemBodySchema = z.object({
productId: z.string(),
variantId: z.string(),
})
export const addItemBodySchema = z.object({
cartId: z.string().optional(),
item: wishlistItemBodySchema,
})
export const updateItemBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
item: wishlistItemBodySchema,
})
export const removeItemBodySchema = z.object({
cartId: z.string(),
itemId: z.string(),
})

View File

@ -199,14 +199,14 @@ export type CartHooks = {
}
export type GetCartHook = {
data: Cart | null
data: Cart | null | undefined
input: {}
fetcherInput: { cartId?: string }
swrState: { isEmpty: boolean }
}
export type AddItemHook = {
data: Cart
data: Cart | null | undefined
input?: CartItemBody
fetcherInput: CartItemBody
body: { item: CartItemBody }
@ -251,7 +251,8 @@ export type GetCartHandler = GetCartHook & {
}
export type AddItemHandler = AddItemHook & {
body: { cartId: string }
data: Cart | null | undefined
body: { cartId?: string }
}
export type UpdateItemHandler = UpdateItemHook & {

View File

@ -48,7 +48,7 @@ export interface CheckoutBody {
}
export type SubmitCheckoutHook = {
data: Checkout
data: Checkout | null
input?: CheckoutBody
fetcherInput: CheckoutBody
body: { item: CheckoutBody }
@ -69,7 +69,7 @@ export type CheckoutHooks = {
}
export type GetCheckoutHandler = GetCheckoutHook & {
body: { cartId: string }
body: { cartId?: string }
}
export type SubmitCheckoutHandler = SubmitCheckoutHook & {

View File

@ -61,7 +61,7 @@ export type GetAddressesHook = {
}
export type AddItemHook = {
data: Address
data: Address | null
input?: AddressFields
fetcherInput: AddressFields
body: { item: AddressFields }
@ -77,7 +77,7 @@ export type UpdateItemHook = {
}
export type RemoveItemHook = {
data: Address | null | undefined
data: Address | null
input: { item?: Address }
fetcherInput: { itemId: string }
body: { itemId: string }
@ -100,7 +100,6 @@ export type AddItemHandler = AddItemHook & {
}
export type UpdateItemHandler = UpdateItemHook & {
data: Address
body: { cartId: string }
}

View File

@ -78,7 +78,7 @@ export type GetCardsHook = {
}
export type AddItemHook = {
data: Card
data: Card | null
input?: CardFields
fetcherInput: CardFields
body: { item: CardFields }
@ -86,7 +86,7 @@ export type AddItemHook = {
}
export type UpdateItemHook = {
data: Card | null | undefined
data: Card | null
input: { item?: CardFields; wait?: number }
fetcherInput: { itemId: string; item: CardFields }
body: { itemId: string; item: CardFields }
@ -94,7 +94,7 @@ export type UpdateItemHook = {
}
export type RemoveItemHook = {
data: Card | null | undefined
data: Card | null
input: { item?: Card }
fetcherInput: { itemId: string }
body: { itemId: string }
@ -116,7 +116,6 @@ export type AddItemHandler = AddItemHook & {
}
export type UpdateItemHandler = UpdateItemHook & {
data: Card
body: { cartId: string }
}

View File

@ -55,7 +55,7 @@ export type HookFetcherOptions = { method?: string } & (
| { query?: string; url: string }
)
export type HookInputValue = string | number | boolean | undefined
export type HookInputValue = string | number | boolean | null | undefined
export type HookSWRInput = [string, HookInputValue][]

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +1,3 @@
export default function noopApi(...args: any[]): void {}
export default function getCheckout(..._args: any[]) {
return Promise.resolve({ data: null })
}

View File

@ -3,16 +3,15 @@ import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout'
import type { CheckoutSchema } from '@vercel/commerce/types/checkout'
import type { CommercejsAPI } from '../..'
import submitCheckout from './submit-checkout'
import getCheckout from './get-checkout'
import submitCheckout from './submit-checkout'
export type CheckoutAPI = GetAPISchema<CommercejsAPI, CheckoutSchema>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
export const handlers: CheckoutEndpoint['handlers'] = {
submitCheckout,
getCheckout,
submitCheckout,
}
const checkoutApi = createEndpoint<CheckoutAPI>({

View File

@ -5,7 +5,6 @@ import sdkFetcherFunction from '../../utils/sdk-fetch'
import { normalizeTestCheckout } from '../../../utils/normalize-checkout'
const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({
res,
body: { item, cartId },
config: { sdkFetch },
}) => {
@ -38,7 +37,7 @@ const submitCheckout: CheckoutEndpoint['handlers']['submitCheckout'] = async ({
// Capture the order
await sdkFetcher('checkout', 'capture', checkoutToken, checkoutData)
res.status(200).json({ data: null, errors: [] })
return { data: null }
}
export default submitCheckout

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -0,0 +1,15 @@
import type { CommercejsAPI } from '..'
import createEndpoints from '@vercel/commerce/api/endpoints'
import login from './login'
import checkout from './checkout'
const endpoints = {
login,
checkout,
}
export default function commercejsAPI(commerce: CommercejsAPI) {
return createEndpoints(commerce, endpoints)
}

View File

@ -1,32 +1,33 @@
import type { LoginEndpoint } from '.'
import { serialize } from 'cookie'
import sdkFetcherFunction from '../../utils/sdk-fetch'
import { getDeploymentUrl } from '../../../utils/get-deployment-url'
import type { LoginEndpoint } from '.'
const login: LoginEndpoint['handlers']['login'] = async ({
req,
res,
config: { sdkFetch, customerCookie },
}) => {
const sdkFetcher: typeof sdkFetcherFunction = sdkFetch
const redirectUrl = getDeploymentUrl()
try {
const loginToken = req.query?.token as string
if (!loginToken) {
res.redirect(redirectUrl)
}
const { jwt } = await sdkFetcher('customer', 'getToken', loginToken, false)
res.setHeader(
'Set-Cookie',
serialize(customerCookie, jwt, {
const { searchParams } = new URL(req.url)
const loginToken = searchParams.get('token')
if (!loginToken) {
return { redirectTo: redirectUrl }
}
const { jwt } = await sdkFetcher('customer', 'getToken', loginToken, false)
return {
headers: {
'Set-Cookie': serialize(customerCookie, jwt, {
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24,
path: '/',
})
)
res.redirect(redirectUrl)
} catch {
res.redirect(redirectUrl)
}),
},
}
}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -11,7 +11,7 @@ export default useSubmitCheckout as UseSubmitCheckout<typeof handler>
export const handler: MutationHook<SubmitCheckoutHook> = {
fetchOptions: {
url: '/api/checkout',
url: '/api/commerce/checkout',
method: 'POST',
},
async fetcher({ input: item, options, fetch }) {

View File

@ -1,11 +1,16 @@
import type { SWRHook } from '@vercel/commerce/utils/types'
import type { CustomerHook } from '@vercel/commerce/types/customer'
import Cookies from 'js-cookie'
import { decode } from 'jsonwebtoken'
import { SWRHook } from '@vercel/commerce/utils/types'
import { decode, type JwtPayload } from 'jsonwebtoken'
import useCustomer, {
UseCustomer,
type UseCustomer,
} from '@vercel/commerce/customer/use-customer'
import { CUSTOMER_COOKIE, API_URL } from '../constants'
import type { CustomerHook } from '@vercel/commerce/types/customer'
type JwtData = JwtPayload & {
cid: string
}
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
@ -20,7 +25,8 @@ export const handler: SWRHook<CustomerHook> = {
return null
}
const decodedToken = decode(token) as { cid: string }
const decodedToken = decode(token) as JwtData
const customer = await fetch<any>({
query: options.query,
method: options.method,

View File

@ -4,8 +4,8 @@ const getFilterVariables = ({
search,
categoryId,
}: {
search?: string
categoryId?: string | number
search?: string | null
categoryId?: string | number | null
}) => {
let filterVariables: { [key: string]: any } = {}
if (search) {
@ -17,7 +17,7 @@ const getFilterVariables = ({
return filterVariables
}
const getSortVariables = ({ sort }: { sort?: string }) => {
const getSortVariables = ({ sort }: { sort?: string | null }) => {
let sortVariables: { [key: string]: any } = {}
switch (sort) {
case 'trending-desc':

View File

@ -50,9 +50,7 @@
},
"dependencies": {
"@vercel/commerce": "workspace:*",
"@vercel/fetch": "^6.2.0",
"lodash.debounce": "^4.0.8",
"node-fetch": "^2.6.7"
"lodash.debounce": "^4.0.8"
},
"peerDependencies": {
"next": "^12",

View File

@ -53,28 +53,19 @@ const buildAddToCartVariables = ({
const addItem: CartEndpoint['handlers']['addItem'] = async ({
req,
res,
body: { cartId, item },
body: { item },
config,
}) => {
if (!item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Missing item' }],
})
}
if (!item.quantity) item.quantity = 1
const productResponse = await config.fetch(getProductQuery, {
variables: { productCode: item?.productId },
})
const cookieHandler = new CookieHandler(config, req, res)
const cookieHandler = new CookieHandler(config, req)
let accessToken = null
if (!cookieHandler.getAccessToken()) {
let anonymousShopperTokenResponse = await cookieHandler.getAnonymousToken()
accessToken = anonymousShopperTokenResponse.accessToken;
accessToken = anonymousShopperTokenResponse.accessToken
} else {
accessToken = cookieHandler.getAccessToken()
}
@ -95,7 +86,8 @@ const addItem: CartEndpoint['handlers']['addItem'] = async ({
)
currentCart = result?.data?.currentCart
}
res.status(200).json({ data: normalizeCart(currentCart) })
return { data: normalizeCart(currentCart) }
}
export default addItem

View File

@ -6,17 +6,17 @@ import { getCartQuery } from '../../queries/get-cart-query'
const getCart: CartEndpoint['handlers']['getCart'] = async ({
req,
res,
body: { cartId },
config,
}) => {
let currentCart: Cart = {}
let headers
try {
const cookieHandler = new CookieHandler(config, req, res)
const cookieHandler = new CookieHandler(config, req)
let accessToken = null
if (!cookieHandler.getAccessToken()) {
let anonymousShopperTokenResponse = await cookieHandler.getAnonymousToken()
let anonymousShopperTokenResponse =
await cookieHandler.getAnonymousToken()
const response = anonymousShopperTokenResponse.response
accessToken = anonymousShopperTokenResponse.accessToken
cookieHandler.setAnonymousShopperCookie(response)
@ -30,12 +30,14 @@ const getCart: CartEndpoint['handlers']['getCart'] = async ({
{ headers: { 'x-vol-user-claims': accessToken } }
)
currentCart = result?.data?.currentCart
headers = cookieHandler.headers
} catch (error) {
throw error
}
res.status(200).json({
return {
data: currentCart ? normalizeCart(currentCart) : null,
})
}
}
export default getCart

View File

@ -1,8 +1,8 @@
import { GetAPISchema, createEndpoint } from '@vercel/commerce/api'
import cartEndpoint from '@vercel/commerce/api/endpoints/cart'
import type { KiboCommerceAPI } from '../..'
import getCart from './get-cart';
import addItem from './add-item';
import getCart from './get-cart'
import addItem from './add-item'
import updateItem from './update-item'
import removeItem from './remove-item'

View File

@ -5,17 +5,10 @@ import { getCartQuery } from '../../../api/queries/get-cart-query'
const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
req,
res,
body: { cartId, itemId },
body: { itemId },
config,
}) => {
if (!itemId) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const encodedToken = req.cookies[config.customerCookie]
const encodedToken = req.cookies.get(config.customerCookie)
const token = encodedToken
? Buffer.from(encodedToken, 'base64').toString('ascii')
: null
@ -39,7 +32,10 @@ const removeItem: CartEndpoint['handlers']['removeItem'] = async ({
)
currentCart = result?.data?.currentCart
}
res.status(200).json({ data: normalizeCart(currentCart) })
return {
data: normalizeCart(currentCart),
}
}
export default removeItem

View File

@ -5,17 +5,10 @@ import updateCartItemQuantityMutation from '../../../api/mutations/updateCartIte
const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
req,
res,
body: { cartId, itemId, item },
body: { itemId, item },
config,
}) => {
if (!itemId || !item) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
const encodedToken = req.cookies[config.customerCookie]
const encodedToken = req.cookies.get(config.cartCookie)
const token = encodedToken
? Buffer.from(encodedToken, 'base64').toString('ascii')
: null
@ -39,7 +32,8 @@ const updateItem: CartEndpoint['handlers']['updateItem'] = async ({
)
currentCart = result?.data?.currentCart
}
res.status(200).json({ data: normalizeCart(currentCart) })
return { data: normalizeCart(currentCart) }
}
export default updateItem

View File

@ -2,16 +2,15 @@ import { Product } from '@vercel/commerce/types/product'
import { ProductsEndpoint } from '.'
import productSearchQuery from '../../../queries/product-search-query'
import { buildProductSearchVars } from '../../../../lib/product-search-vars'
import {normalizeProduct} from '../../../../lib/normalize'
import { normalizeProduct } from '../../../../lib/normalize'
const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
res,
body: { search, categoryId, brandId, sort },
config,
}) => {
const pageSize = 100;
const filters = {};
const startIndex = 0;
const pageSize = 100
const filters = {}
const startIndex = 0
const variables = buildProductSearchVars({
categoryCode: categoryId,
pageSize,
@ -20,12 +19,14 @@ const getProducts: ProductsEndpoint['handlers']['getProducts'] = async ({
filters,
startIndex,
})
const {data} = await config.fetch(productSearchQuery, { variables });
const found = data?.products?.items?.length > 0 ? true : false;
let productsResponse= data?.products?.items.map((item: any) =>normalizeProduct(item,config));
const products: Product[] = found ? productsResponse : [];
const { data } = await config.fetch(productSearchQuery, { variables })
const found = data?.products?.items?.length > 0 ? true : false
let productsResponse = data?.products?.items.map((item: any) =>
normalizeProduct(item, config)
)
const products: Product[] = found ? productsResponse : []
res.status(200).json({ data: { products, found } });
return { data: { products, found } }
}
export default getProducts

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

View File

@ -1 +0,0 @@
export default function noopApi(...args: any[]): void {}

Some files were not shown because too many files have changed in this diff Show More