Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Catalog service #26

Merged
merged 15 commits into from
Oct 22, 2024
39 changes: 39 additions & 0 deletions src/catalog/fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

/* eslint-disable no-await-in-loop */

import { errorResponse } from '../utils/http.js';
import { fetchProduct } from '../utils/r2.js';

/**
* Handles a GET request for a product.
* @param {Context} ctx - The context object containing request information and utilities.
* @param {Config} config - The configuration object with application settings.
* @returns {Promise<Response>} - A promise that resolves to the product response.
*/
export async function handleProductFetchRequest(ctx, config) {
try {
const sku = ctx.url.pathname.split('/').pop();
const product = await fetchProduct(ctx, config, sku);

return new Response(JSON.stringify(product), {
headers: { 'Content-Type': 'application/json' },
});
} catch (e) {
if (e.response) {
return e.response;
}
ctx.log.error(e);
return errorResponse(500, 'internal server error');
}
}
20 changes: 14 additions & 6 deletions src/catalog/handler.js
Original file line number Diff line number Diff line change
@@ -12,7 +12,8 @@

import { errorResponse } from '../utils/http.js';
import { handleProductLookupRequest } from './lookup.js';
import { handleProductGetRequest, handleProductPutRequest } from './product.js';
import { handleProductFetchRequest } from './fetch.js';
import { handleProductSaveRequest } from './update.js';

const ALLOWED_METHODS = ['GET', 'PUT'];

@@ -24,15 +25,22 @@ const ALLOWED_METHODS = ['GET', 'PUT'];
* @returns {Promise<Response>} - A promise that resolves to the catalog response.
*/
export default async function catalogHandler(ctx, config, request) {
if (!ALLOWED_METHODS.includes(ctx.info.method)) {
const { method } = ctx.info;

// Split the pathname into segments and filter out empty strings
const pathSegments = ctx.url.pathname.split('/').filter(Boolean);

if (!ALLOWED_METHODS.includes(method)) {
return errorResponse(405, 'method not allowed');
}

const pathSegments = ctx.url.pathname.split('/');
const catalogIndex = pathSegments.indexOf('catalog');
if (catalogIndex === -1) {
return errorResponse(400, 'Invalid URL: Missing "catalog" segment');
}

if (catalogIndex === -1 || pathSegments.length < catalogIndex + 5) {
throw new Error('Invalid URL structure: Expected format: /catalog/{env}/{store}/{storeView}/{product}[/{sku}]');
return errorResponse(400, 'Invalid URL structure: Expected format: /catalog/{env}/{store}/{storeView}/product/{sku}');
}

const [env, storeCode, storeViewCode, subRoute, sku] = pathSegments.slice(catalogIndex + 1);
@@ -49,7 +57,7 @@ export default async function catalogHandler(ctx, config, request) {
}

if (ctx.info.method === 'PUT') {
return handleProductPutRequest(ctx, config, request);
return handleProductSaveRequest(ctx, config, request);
}
return handleProductGetRequest(ctx, config);
return handleProductFetchRequest(ctx, config);
}
9 changes: 5 additions & 4 deletions src/catalog/lookup.js
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
*/

import { errorResponse } from '../utils/http.js';
import { listAllProducts, loadProductFromR2, resolveSku } from '../utils/r2.js';
import { listAllProducts, fetchProduct, lookupSku } from '../utils/r2.js';

/**
* Handles a product lookup request.
@@ -21,11 +21,12 @@ import { listAllProducts, loadProductFromR2, resolveSku } from '../utils/r2.js';
*/
export async function handleProductLookupRequest(ctx, config) {
try {
const params = new URLSearchParams(ctx.url.search);
const { search } = ctx.url;
const params = new URLSearchParams(search);

if (params.has('urlKey')) {
const sku = await resolveSku(ctx, config, params.get('urlKey'));
const product = await loadProductFromR2(ctx, config, sku);
const sku = await lookupSku(ctx, config, params.get('urlKey'));
const product = await fetchProduct(ctx, config, sku);
return new Response(JSON.stringify(product), {
headers: { 'Content-Type': 'application/json' },
});
111 changes: 0 additions & 111 deletions src/catalog/product.js

This file was deleted.

80 changes: 80 additions & 0 deletions src/catalog/update.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

/* eslint-disable no-await-in-loop */

import { callAdmin } from '../utils/admin.js';
import { errorResponse, errorWithResponse } from '../utils/http.js';
import { saveProducts } from '../utils/r2.js';

/**
* Saves a product to R2.
* @param {Context} ctx - The context object containing request information and utilities.
* @param {Config} config - The configuration object with application settings.
* @param {Object} product - The product object to be saved.
* @returns {Promise<Object>} - A promise that resolves to the saved product.
*/
async function putProduct(ctx, config, product) {
if (!product.sku) {
throw errorWithResponse(400, 'invalid request body: missing sku');
}

await saveProducts(ctx, config, [product]);
return product;
}

/**
* Handles a PUT request to update a product.
* @param {Context} ctx - The context object containing request information and utilities.
* @param {Config} config - The configuration object with application settings.
* @param {Request} request - The request object.
* @returns {Promise<Response>} - A promise that resolves to the product response.
*/
export async function handleProductSaveRequest(ctx, config, request) {
try {
const requestBody = await request.json();

if (config.sku === '*') {
return errorResponse(501, 'not implemented');
}

const product = await putProduct(ctx, config, requestBody);
const products = [product];

const matchedKeys = Object.keys(config.confMap)
.filter((key) => config.confMap[key].env === config.env);

for (const purgeProduct of products) {
for (const key of matchedKeys) {
let path = key.replace('{{sku}}', purgeProduct.sku);

if (key.includes('{{urlkey}}') && purgeProduct.urlKey) {
path = path.replace('{{urlkey}}', purgeProduct.urlKey);
}

for (const env of ['preview', 'live']) {
const response = await callAdmin(config, env, path, { method: 'post' });
if (!response.ok) {
return errorResponse(400, `failed to ${env} product`);
}
}
}
}
return new Response(undefined, { status: 201 });
} catch (e) {
if (e.response) {
return e.response;
}
ctx.log.error(e);
return errorResponse(500, 'internal server error');
}
}
4 changes: 2 additions & 2 deletions src/content/helix-commerce.js
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
*/

import { errorResponse } from '../utils/http.js';
import { loadProductFromR2 } from '../utils/r2.js';
import { fetchProduct } from '../utils/r2.js';
import HTML_TEMPLATE from '../templates/html.js';

export async function handle(ctx, config) {
@@ -23,7 +23,7 @@ export async function handle(ctx, config) {
}

config.env = 'prod';
const product = await loadProductFromR2(ctx, config, sku);
const product = await fetchProduct(ctx, config, sku);
const html = HTML_TEMPLATE(product, product.variants);
return new Response(html, {
status: 200,
10 changes: 5 additions & 5 deletions src/utils/r2.js
Original file line number Diff line number Diff line change
@@ -15,13 +15,13 @@ import { errorWithResponse } from './http.js';
/* eslint-disable no-await-in-loop */

/**
* Load product from R2 using SKU
* Load product by SKU
* @param {Context} ctx - The context object.
* @param {Config} config - The config object.
* @param {string} sku - The SKU of the product.
* @returns {Promise<Product>} - A promise that resolves to the product.
*/
export async function loadProductFromR2(ctx, config, sku) {
export async function fetchProduct(ctx, config, sku) {
const key = `${config.org}/${config.site}/${config.env}/${config.storeCode}/${config.storeViewCode}/${sku}.json`;
const object = await ctx.env.CATALOG_BUCKET.get(key);

@@ -38,13 +38,13 @@ export async function loadProductFromR2(ctx, config, sku) {
}

/**
* Save products to R2
* Save products
* @param {Context} ctx - The context object.
* @param {Config} config - The config object.
* @param {Product[]} products - The products to save.
* @returns {Promise<void>} - A promise that resolves when the products are saved.
*/
export async function saveProductsToR2(ctx, config, products) {
export async function saveProducts(ctx, config, products) {
const { log } = ctx;
const BATCH_SIZE = 50;

@@ -93,7 +93,7 @@ export async function saveProductsToR2(ctx, config, products) {
* @param {string} urlKey - The URL key.
* @returns {Promise<string>} - A promise that resolves to the SKU.
*/
export async function resolveSku(ctx, config, urlKey) {
export async function lookupSku(ctx, config, urlKey) {
// Make a HEAD request to retrieve the SKU from metadata based on the URL key
const urlKeyPath = `${config.org}/${config.site}/${config.env}/${config.storeCode}/${config.storeViewCode}/urlkeys/${urlKey}`;
const headResponse = await ctx.env.CATALOG_BUCKET.head(urlKeyPath);