Move API to new Node architecture

Signed-off-by: Loan Laux <loan@outgrow.io>
This commit is contained in:
Loan Laux 2021-07-06 15:43:38 +03:00
parent 8dc6881cd9
commit 23e0d57cf5
No known key found for this signature in database
GPG Key ID: AF9E9BD6548AD52E
32 changed files with 459 additions and 141 deletions

View File

@ -1,60 +0,0 @@
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
ReactionCommerceApiHandler,
ReactionCommerceHandler,
} from '../utils/create-api-handler'
import { ReactionCommerceApiError } from '../utils/errors'
import getCart from './handlers/get-cart'
import addItem from './handlers/add-item'
import type {
Cart,
GetCartHandlerBody,
AddCartItemHandlerBody,
} from '../../types'
export type CartHandlers = {
getCart: ReactionCommerceHandler<Cart, GetCartHandlerBody>
addItem: ReactionCommerceHandler<Cart, AddCartItemHandlerBody>
}
const METHODS = ['GET', 'POST']
// TODO: a complete implementation should have schema validation for `req.body`
const cartApi: ReactionCommerceApiHandler<Cart, CartHandlers> = async (
req,
res,
config,
handlers
) => {
if (!isAllowedMethod(req, res, METHODS)) return
const { cookies } = req
const cartId = cookies[config.anonymousCartTokenCookie]
try {
// Return current cart info
if (req.method === 'GET') {
const body = { cartId }
return await handlers['getCart']({ req, res, config, body })
}
// Create or add an item to the cart
if (req.method === 'POST') {
const body = { ...req.body, cartId }
return await handlers['addItem']({ req, res, config, body })
}
} catch (error) {
console.error(error)
const message =
error instanceof ReactionCommerceApiError
? 'An unexpected error occurred with the Reaction Commerce API'
: 'An unexpected error occurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export const handlers = { getCart, addItem }
export default createApiHandler(cartApi, handlers, {})

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1 +0,0 @@
export default function () {}

View File

@ -1,4 +1,3 @@
import type { CartHandlers } from '..'
import {
addCartItemsMutation,
createCartMutation,
@ -10,8 +9,9 @@ import {
REACTION_CART_ID_COOKIE,
REACTION_CUSTOMER_TOKEN_COOKIE,
} from '@framework/const'
import type { CartEndpoint } from '.'
const addItem: CartHandlers['addItem'] = async ({
const addItem: CartEndpoint['handlers']['addItem'] = async ({
req: { cookies },
res,
body: { item },

View File

@ -1,4 +1,3 @@
import type { CartHandlers } from '../'
import getAnonymousCartQuery from '@framework/utils/queries/get-anonymous-cart'
import accountCartByAccountIdQuery from '@framework/utils/queries/account-cart-by-account-id'
import getCartCookie from '@framework/api/utils/get-cart-cookie'
@ -10,9 +9,13 @@ import {
REACTION_CUSTOMER_TOKEN_COOKIE,
} from '@framework/const'
import { normalizeCart } from '@framework/utils'
import type { CartEndpoint } from '.'
// Return current cart info
const getCart: CartHandlers['getCart'] = async ({ req, res, config }) => {
const getCart: CartEndpoint['handlers']['getCart'] = async ({
req,
res,
config,
}) => {
const {
cookies: {
[REACTION_ANONYMOUS_CART_TOKEN_COOKIE]: anonymousCartToken,

View File

@ -0,0 +1,21 @@
import { CommerceAPI, createEndpoint, GetAPISchema } from '@commerce/api'
import cartEndpoint from '@commerce/api/endpoints/cart'
import type { CartSchema } from '@commerce/types/cart'
import getCart from './get-cart'
import addItem from './add-item'
export type CartAPI = GetAPISchema<CommerceAPI, CartSchema>
export type CartEndpoint = CartAPI['endpoint']
export const handlers: CartEndpoint['handlers'] = {
getCart,
addItem,
}
const cartApi = createEndpoint<CartAPI>({
handler: cartEndpoint,
handlers,
})
export default cartApi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,8 @@
import type { CommerceAPIConfig } from '@commerce/api'
import {
CommerceAPI,
CommerceAPIConfig,
getCommerceApi as commerceApi,
} from '@commerce/api'
import {
API_URL,
REACTION_ANONYMOUS_CART_TOKEN_COOKIE,
@ -17,6 +20,15 @@ if (!API_URL) {
}
import fetchGraphqlApi from './utils/fetch-graphql-api'
import login from './operations/login'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
import getSiteInfo from './operations/get-site-info'
import getCustomerWishlist from './operations/get-customer-wishlist'
import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
import type { CartAPI } from './endpoints/cart'
export interface ReactionCommerceConfig
extends Omit<CommerceAPIConfig, 'apiToken'> {
@ -27,26 +39,7 @@ export interface ReactionCommerceConfig
anonymousCartTokenCookieMaxAge?: number
}
export class Config {
private config: ReactionCommerceConfig
constructor(config: ReactionCommerceConfig) {
this.config = config
}
getConfig(userConfig: Partial<ReactionCommerceConfig> = {}) {
return Object.entries(userConfig).reduce<ReactionCommerceConfig>(
(cfg, [key, value]) => Object.assign(cfg, { [key]: value }),
{ ...this.config }
)
}
setConfig(newConfig: Partial<ReactionCommerceConfig>) {
Object.assign(this.config, newConfig)
}
}
const config = new Config({
const config: ReactionCommerceConfig = {
locale: 'en-US',
commerceUrl: API_URL,
cartCookie: REACTION_ANONYMOUS_CART_TOKEN_COOKIE,
@ -58,12 +51,29 @@ const config = new Config({
fetch: fetchGraphqlApi,
customerCookie: REACTION_CUSTOMER_TOKEN_COOKIE,
shopId: SHOP_ID,
})
export function getConfig(userConfig?: Partial<ReactionCommerceConfig>) {
return config.getConfig(userConfig)
}
export function setConfig(newConfig: Partial<ReactionCommerceConfig>) {
return config.setConfig(newConfig)
const operations = {
login,
getAllPages,
getPage,
getSiteInfo,
getCustomerWishlist,
getAllProductPaths,
getAllProducts,
getProduct,
}
export const provider = { config, operations }
export type Provider = typeof provider
export type APIs = CartAPI
export type ReactionCommerceAPI<P extends Provider = Provider> = CommerceAPI<P>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): ReactionCommerceAPI<P> {
return commerceApi(customProvider)
}

View File

@ -0,0 +1,40 @@
import { ReactionCommerceConfig } from '../'
import { OperationContext } from '@commerce/api/operations'
import { Provider } from '@commerce'
export type Page = any
export type GetAllPagesResult<T extends { pages: any[] } = { pages: Page[] }> =
T
export default function getAllPagesOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllPages(opts?: {
config?: Partial<ReactionCommerceConfig>
preview?: boolean
}): Promise<GetAllPagesResult>
async function getAllPages<T extends { pages: any[] }>(opts: {
url: string
config?: Partial<ReactionCommerceConfig>
preview?: boolean
}): Promise<GetAllPagesResult<T>>
async function getAllPages({
config: cfg,
preview,
}: {
url?: string
config?: Partial<ReactionCommerceConfig>
preview?: boolean
} = {}): Promise<GetAllPagesResult> {
const config = commerce.getConfig(cfg)
return {
pages: [],
}
}
return getAllPages
}

View File

@ -0,0 +1,52 @@
import { OperationContext, OperationOptions } from '@commerce/api/operations'
import type { CatalogItem } from '../../schema'
import { Provider } from '../index'
import getAllProductsPathsQuery from '../../utils/queries/get-all-products-paths-query'
import { GetAllProductPathsOperation } from '@commerce/types/product'
import { ReactionCommerceConfig } from '..'
export type GetAllProductPathsResult = {
products: Array<{ node: { path: string } }>
}
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<
T extends GetAllProductPathsOperation
>(opts?: {
variables?: T['variables']
config?: ReactionCommerceConfig
}): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>(
opts: {
variables?: T['variables']
config?: ReactionCommerceConfig
} & OperationOptions
): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
query = getAllProductsPathsQuery,
variables,
config: cfg,
}: {
query?: string
variables?: T['variables']
config?: ReactionCommerceConfig
} = {}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
// RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query`
const { data } = await config.fetch<CatalogItem>(query, {
variables,
})
const products = data.products.items
return {
products: products.map((p) => ({ path: `/${p.slug}` })),
}
}
return getAllProductPaths
}

View File

@ -0,0 +1,48 @@
import { Product } from '@commerce/types/product'
import { OperationContext } from '@commerce/api/operations'
import { normalizeProduct } from '@framework/utils'
import catalogItemsQuery from '../../utils/queries/catalog-items-query'
import { CatalogItemConnection } from '../../schema'
import { Provider, ReactionCommerceConfig } from '..'
export type ProductVariables = {
first?: number
shopIds?: string[]
}
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts(opts?: {
variables?: ProductVariables
config?: Partial<ReactionCommerceConfig>
preview?: boolean
}): Promise<{ products: Product[] }>
async function getAllProducts({
query = catalogItemsQuery,
variables: { ...vars } = { first: 250 },
config: cfg,
}: {
query?: string
variables?: ProductVariables
config?: Partial<ReactionCommerceConfig>
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
const config = commerce.getConfig(cfg)
const { data } = await config.fetch<CatalogItemConnection>(query, {
variables: {
...vars,
shopIds: [config.shopId],
},
})
return {
products:
data.catalogItems?.edges?.map((item) => normalizeProduct(item?.node)) ||
[],
}
}
return getAllProducts
}

View File

@ -0,0 +1,23 @@
import { OperationContext } from '@commerce/api/operations'
import { Provider, VendureConfig } from '../'
export default function getCustomerWishlistOperation({
commerce,
}: OperationContext<Provider>) {
async function getCustomerWishlist({
config: cfg,
variables,
includeProducts,
}: {
url?: string
variables: any
config?: Partial<VendureConfig>
includeProducts?: boolean
}): Promise<any> {
// Not implemented as Vendure does not ship with wishlist functionality at present
const config = commerce.getConfig(cfg)
return { wishlist: {} }
}
return getCustomerWishlist
}

View File

@ -1,5 +1,6 @@
import { Page } from '../../schema'
import { ReactionCommerceConfig, getConfig } from '..'
import { OperationContext } from '@commerce/api/operations'
import { Page } from '../../common/get-all-pages'
import { ReactionCommerceConfig, Provider } from '..'
export type GetPageResult<T extends { page?: any } = { page?: Page }> = T
@ -7,19 +8,37 @@ export type PageVariables = {
id: string
}
async function getPage({
url,
variables,
config,
preview,
}: {
export default function getPageOperation({
commerce,
}: OperationContext<Provider>) {
async function getPage(opts: {
url?: string
variables: PageVariables
config?: ReactionCommerceConfig
preview?: boolean
}): Promise<GetPageResult> {
config = getConfig(config)
return {}
}
}): Promise<GetPageResult>
export default getPage
async function getPage<T extends { page?: any }, V = any>(opts: {
url: string
variables: V
config?: Partial<VendureConfig>
preview?: boolean
}): Promise<GetPageResult<T>>
async function getPage({
url,
variables,
config: cfg,
preview,
}: {
url?: string
variables: PageVariables
config?: Partial<VendureConfig>
preview?: boolean
}): Promise<GetPageResult> {
const config = commerce.getConfig(cfg)
return {}
}
return getPage
}

View File

@ -0,0 +1,69 @@
import { Product } from '@commerce/types/product'
import { OperationContext } from '@commerce/api/operations'
import { Provider, VendureConfig } from '../'
import { GetProductQuery } from '../../schema'
import { getProductQuery } from '../../utils/queries/get-product-query'
export default function getProductOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct({
query = getProductQuery,
variables,
config: cfg,
}: {
query?: string
variables: { slug: string }
config?: Partial<VendureConfig>
preview?: boolean
}): Promise<Product | {} | any> {
const config = commerce.getConfig(cfg)
const locale = config.locale
const { data } = await config.fetch<GetProductQuery>(query, { variables })
const product = data.product
if (product) {
const getOptionGroupName = (id: string): string => {
return product.optionGroups.find((og) => og.id === id)!.name
}
return {
product: {
id: product.id,
name: product.name,
description: product.description,
slug: product.slug,
images: product.assets.map((a) => ({
url: a.preview,
alt: a.name,
})),
variants: product.variants.map((v) => ({
id: v.id,
options: v.options.map((o) => ({
// This __typename property is required in order for the correct
// variant selection to work, see `components/product/helpers.ts`
// `getVariant()` function.
__typename: 'MultipleChoiceOption',
id: o.id,
displayName: getOptionGroupName(o.groupId),
values: [{ label: o.name }],
})),
})),
price: {
value: product.variants[0].priceWithTax / 100,
currencyCode: product.variants[0].currencyCode,
},
options: product.optionGroups.map((og) => ({
id: og.id,
displayName: og.name,
values: og.options.map((o) => ({ label: o.name })),
})),
} as Product,
}
}
return {}
}
return getProduct
}

View File

@ -0,0 +1,41 @@
import { Provider, ReactionCommerceConfig } from '../'
import { GetCollectionsQuery } from '../../schema'
import getCollectionsQuery from '../../utils/queries/get-all-collections-query'
import { OperationContext } from '@commerce/api/operations'
import { Category } from '@commerce/types/site'
import getCategories from '../../utils/get-categories'
import getVendors from '../../utils/get-vendors'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: any[]
}
> = T
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo({
query = getCollectionsQuery,
variables,
config: cfg,
}: {
query?: string
variables?: any
config?: Partial<ReactionCommerceConfig>
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
const config = commerce.getConfig(cfg)
const categories = await getCategories(config)
const brands = await getVendors(config)
return {
categories: categories ?? [],
brands,
}
}
return getSiteInfo
}

View File

@ -0,0 +1,52 @@
import type { ServerResponse } from 'http'
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { ValidationError } from '@commerce/utils/errors'
import type { LoginOperation } from '../../types/login'
import type { MutationAuthenticateArgs } from '../../schema'
import { Provider, ReactionCommerceConfig } from '..'
import loginMutation from '../../utils/mutations/authenticate'
export default function loginOperation({
commerce,
}: OperationContext<Provider>) {
async function login<T extends LoginOperation>(opts: {
variables: T['variables']
config?: Partial<ReactionCommerceConfig>
res: ServerResponse
}): Promise<T['data']>
async function login<T extends LoginOperation>(
opts: {
variables: T['variables']
config?: Partial<ReactionCommerceConfig>
res: ServerResponse
} & OperationOptions
): Promise<T['data']>
async function login<T extends LoginOperation>({
query = loginMutation,
variables,
res: response,
config: cfg,
}: {
query?: string
variables: T['variables']
res: ServerResponse
config?: Partial<ReactionCommerceConfig>
}): Promise<T['data']> {
const config = commerce.getConfig(cfg)
const { data, res } = await config.fetch<any>(query, {
variables,
})
return {
result: data.authenticate?.tokens?.accessToken,
}
}
return login
}

View File

@ -13,14 +13,16 @@ export const handler: SWRHook<
> = {
fetchOptions: {
method: 'GET',
url: '/api/reactioncommerce/cart',
url: '/api/cart',
},
async fetcher({ input: { cartId }, options, fetch }) {
console.log('cart API fetcher', options)
const data = await fetch(options)
return data
},
useHook: ({ useData }) => (input) => {
useHook:
({ useData }) =>
(input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})

View File

@ -1,7 +1,7 @@
import commerce from '@lib/api/commerce'
import getCategories, { Category } from '../utils/get-categories'
import getVendors, { Brands } from '../utils/get-vendors'
import { getConfig, ReactionCommerceConfig } from '../api'
import { ReactionCommerceConfig } from '../api'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
@ -17,7 +17,7 @@ const getSiteInfo = async (options?: {
}): Promise<GetSiteInfoResult> => {
let { config } = options ?? {}
config = getConfig(config)
config = commerce.getConfig(config)
const categories = await getCategories(config)
const brands = await getVendors(config)

View File

@ -26,6 +26,7 @@ const getCategories = async (
entityId,
name,
path: `/${handle}`,
slug: handle,
})
) ?? []
)

View File

@ -2,7 +2,7 @@
"name": "nextjs-commerce",
"version": "1.0.0",
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev",
"dev": "NODE_OPTIONS='--inspect' next dev -p 4000",
"build": "next build",
"start": "next start",
"analyze": "BUNDLE_ANALYZE=both yarn build",

View File

@ -1,3 +0,0 @@
import cartApi from '@framework/api/cart'
export default cartApi()