diff --git a/lib/content-api-client.js b/lib/content-api-client.js new file mode 100644 index 00000000..b2160013 --- /dev/null +++ b/lib/content-api-client.js @@ -0,0 +1,97 @@ +require('colors'); +const NetworkUtils = require('./utils/NetworkUtils'); +const { + renderedRegionsByPageTypeQuery, + renderedRegionsByPageTypeAndEntityIdQuery, +} = require('./graphql/query'); + +const networkUtils = new NetworkUtils(); + +/** + * @param {object} options + * @param {string} options.accessToken + * @param {string} options.storeUrl + * @param {string} pageType + * @returns {Promise} + */ +async function getRenderedRegionsByPageType({ accessToken, storeUrl, pageType }) { + try { + const query = renderedRegionsByPageTypeQuery(pageType); + + const response = await networkUtils.sendApiRequest({ + url: `${storeUrl}/graphql`, + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + method: 'POST', + data: JSON.stringify({ + query, + }), + }); + + const { + site: { + content: { + renderedRegionsByPageType: { regions }, + }, + }, + } = response.data.data; + + return { renderedRegions: regions }; + } catch (err) { + throw new Error(`Could not fetch the rendered regions for this page type: ${err.message}`); + } +} + +/** + * @param {object} options + * @param {string} options.accessToken + * @param {string} options.storeUrl + * @param {string} pageType + * @param {number} entityId + * @returns {Promise} + */ +async function getRenderedRegionsByPageTypeAndEntityId({ + accessToken, + storeUrl, + pageType, + entityId, +}) { + try { + const query = renderedRegionsByPageTypeAndEntityIdQuery(pageType, entityId); + + const response = await networkUtils.sendApiRequest({ + url: `${storeUrl}/graphql`, + headers: { + 'cache-control': 'no-cache', + 'content-type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + method: 'POST', + data: JSON.stringify({ + query, + }), + }); + + const { + site: { + content: { + renderedRegionsByPageTypeAndEntityId: { regions }, + }, + }, + } = response.data.data; + + return { + renderedRegions: regions, + }; + } catch (err) { + throw new Error(`Could not fetch the rendered regions for this page type: ${err.message}`); + } +} + +module.exports = { + getRenderedRegionsByPageType, + getRenderedRegionsByPageTypeAndEntityId, +}; diff --git a/lib/graphql/query.js b/lib/graphql/query.js new file mode 100644 index 00000000..95694d75 --- /dev/null +++ b/lib/graphql/query.js @@ -0,0 +1,42 @@ +/** + * @param {string} pageType + * @returns {string}>} + */ + +const renderedRegionsByPageTypeQuery = (pageType) => + `query { + site { + content { + renderedRegionsByPageType(pageType: ${pageType}) { + regions { + name + html + } + } + } + } + }`; + +/** + * @param {string} pageType + * @param {number} entityId + * @returns {string}>} + */ +const renderedRegionsByPageTypeAndEntityIdQuery = (pageType, entityId) => + `query { + site { + content { + renderedRegionsByPageTypeAndEntityId(entityPageType: ${pageType}, entityId: ${entityId}) { + regions { + name + html + } + } + } + } + }`; + +module.exports = { + renderedRegionsByPageTypeQuery, + renderedRegionsByPageTypeAndEntityIdQuery, +}; diff --git a/server/index.js b/server/index.js index d30fd001..44240ed2 100644 --- a/server/index.js +++ b/server/index.js @@ -11,10 +11,10 @@ function buildManifest(srcManifest, options) { const parsedSecureUrl = new URL(options.dotStencilFile.storeUrl); // The url to a secure page (prompted as login page) const parsedNormalUrl = new URL(options.dotStencilFile.normalStoreUrl); // The host url of the homepage; + const storeUrl = parsedSecureUrl.protocol + '//' + parsedSecureUrl.host; resManifest.server.port = options.dotStencilFile.port; - pluginsByName['./plugins/router/router.module'].storeUrl = - parsedSecureUrl.protocol + '//' + parsedSecureUrl.host; + pluginsByName['./plugins/router/router.module'].storeUrl = storeUrl; pluginsByName['./plugins/router/router.module'].normalStoreUrl = parsedNormalUrl.protocol + '//' + parsedNormalUrl.host; pluginsByName['./plugins/router/router.module'].apiKey = options.dotStencilFile.apiKey; @@ -27,6 +27,7 @@ function buildManifest(srcManifest, options) { pluginsByName['./plugins/renderer/renderer.module'].customLayouts = options.dotStencilFile.customLayouts; pluginsByName['./plugins/renderer/renderer.module'].themePath = options.themePath; + pluginsByName['./plugins/renderer/renderer.module'].storeUrl = storeUrl; pluginsByName['./plugins/theme-assets/theme-assets.module'].themePath = options.themePath; resManifest.register.plugins = _.reduce( diff --git a/server/lib/page-type-util.js b/server/lib/page-type-util.js new file mode 100644 index 00000000..df05e9d4 --- /dev/null +++ b/server/lib/page-type-util.js @@ -0,0 +1,101 @@ +const PageTypes = { + PAGE: 'PAGE', + PRODUCT: 'PRODUCT', + CATEGORY: 'CATEGORY', + BRAND: 'BRAND', + ACCOUNT_RETURN_SAVED: 'ACCOUNT_RETURN_SAVED', + ACCOUNT_ADD_RETURN: 'ACCOUNT_ADD_RETURN', + ACCOUNT_RETURNS: 'ACCOUNT_RETURNS', + ACCOUNT_ADD_ADDRESS: 'ACCOUNT_ADD_ADDRESS', + ACCOUNT_ADD_WISHLIST: 'ACCOUNT_ADD_WISHLIST', + ACCOUNT_WISHLISTS: 'ACCOUNT_WISHLISTS', + ACCOUNT_WISHLIST_DETAILS: 'ACCOUNT_WISHLIST_DETAILS', + ACCOUNT_EDIT: 'ACCOUNT_EDIT', + ACCOUNT_ADDRESS: 'ACCOUNT_ADDRESS', + ACCOUNT_INBOX: 'ACCOUNT_INBOX', + ACCOUNT_DOWNLOAD_ITEM: 'ACCOUNT_DOWNLOAD_ITEM', + ACCOUNT_ORDERS_ALL: 'ACCOUNT_ORDERS_ALL', + ACCOUNT_ORDERS_INVOICE: 'ACCOUNT_ORDERS_INVOICE', + ACCOUNT_ORDERS_DETAILS: 'ACCOUNT_ORDERS_DETAILS', + ACCOUNT_ORDERS_COMPLETED: 'ACCOUNT_ORDERS_COMPLETED', + ACCOUNT_RECENT_ITEMS: 'ACCOUNT_RECENT_ITEMS', + AUTH_ACCOUNT_CREATED: 'AUTH_ACCOUNT_CREATED', + AUTH_LOGIN: 'AUTH_LOGIN', + AUTH_CREATE_ACC: 'AUTH_CREATE_ACC', + AUTH_FORGOT_PASS: 'AUTH_FORGOT_PASS', + AUTH_NEW_PASS: 'AUTH_NEW_PASS', + BLOG_POST: 'BLOG_POST', + BLOG: 'BLOG', + BRANDS: 'BRANDS', + CART: 'CART', + COMPARE: 'COMPARE', + CONTACT_US: 'CONTACT_US', + HOME: 'HOME', + GIFT_CERT_PURCHASE: 'GIFT_CERT_PURCHASE', + GIFT_CERT_REDEEM: 'GIFT_CERT_REDEEM', + GIFT_CERT_BALANCE: 'GIFT_CERT_BALANCE', + ORDER_INFO: 'ORDER_INFO', + SEARCH: 'SEARCH', + SITEMAP: 'SITEMAP', + SUBSCRIBED: 'SUBSCRIBED', + UNSUBSCRIBE: 'UNSUBSCRIBE', +}; + +const templateFileToPageTypeMap = { + 'pages/page': PageTypes.PAGE, + 'pages/product': PageTypes.PRODUCT, + 'pages/category': PageTypes.CATEGORY, + 'pages/brand': PageTypes.BRAND, + 'pages/account/return-saved': PageTypes.ACCOUNT_RETURN_SAVED, + 'pages/account/add-return': PageTypes.ACCOUNT_ADD_RETURN, + 'pages/account/returns': PageTypes.ACCOUNT_RETURNS, + 'pages/account/add-address': PageTypes.ACCOUNT_ADD_ADDRESS, + 'pages/account/add-wishlist': PageTypes.ACCOUNT_ADD_WISHLIST, + 'pages/account/wishlists': PageTypes.ACCOUNT_WISHLISTS, + 'pages/account/wishlist-details': PageTypes.ACCOUNT_WISHLIST_DETAILS, + 'pages/account/edit': PageTypes.ACCOUNT_EDIT, + 'pages/account/addresses': PageTypes.ACCOUNT_ADDRESS, + 'pages/account/inbox': PageTypes.ACCOUNT_INBOX, + 'pages/account/download-item': PageTypes.ACCOUNT_DOWNLOAD_ITEM, + 'pages/account/orders/all': PageTypes.ACCOUNT_ORDERS_ALL, + 'pages/account/orders/invoice': PageTypes.ACCOUNT_ORDERS_INVOICE, + 'pages/account/orders/details': PageTypes.ACCOUNT_ORDERS_DETAILS, + 'pages/account/orders/completed': PageTypes.ACCOUNT_ORDERS_COMPLETED, + 'pages/account/recent-items': PageTypes.ACCOUNT_RECENT_ITEMS, + 'pages/auth/account-created': PageTypes.AUTH_ACCOUNT_CREATED, + 'pages/auth/login': PageTypes.AUTH_LOGIN, + 'pages/auth/create-account': PageTypes.AUTH_CREATE_ACC, + 'pages/auth/forgot-password': PageTypes.AUTH_FORGOT_PASS, + 'pages/auth/new-password': PageTypes.AUTH_NEW_PASS, + 'pages/blog-post': PageTypes.BLOG_POST, + 'pages/blog': PageTypes.BLOG, + 'pages/brands': PageTypes.BRANDS, + 'pages/cart': PageTypes.CART, + 'pages/compare': PageTypes.COMPARE, + 'pages/contact-us': PageTypes.CONTACT_US, + 'pages/home': PageTypes.HOME, + 'pages/gift-certificate/purchase': PageTypes.GIFT_CERT_PURCHASE, + 'pages/gift-certificate/redeem': PageTypes.GIFT_CERT_REDEEM, + 'pages/gift-certificate/balance': PageTypes.GIFT_CERT_BALANCE, + 'pages/order-confirmation': PageTypes.ORDER_INFO, + 'pages/search': PageTypes.SEARCH, + 'pages/sitemap': PageTypes.SITEMAP, + 'pages/subscribed': PageTypes.SUBSCRIBED, + 'pages/unsubscribe': PageTypes.UNSUBSCRIBE, +}; + +/** + * Convert a templateFile to pageType + * + * @param {string} templateFile + * @returns {string | null} + */ +function getPageType(templateFile) { + const pageType = templateFileToPageTypeMap[templateFile]; + + return pageType; +} + +module.exports = { + getPageType, +}; diff --git a/server/lib/page-type-util.spec.js b/server/lib/page-type-util.spec.js new file mode 100644 index 00000000..e36dba53 --- /dev/null +++ b/server/lib/page-type-util.spec.js @@ -0,0 +1,14 @@ +const { getPageType } = require('./page-type-util'); + +describe('page-type-util', () => { + describe('getPageType', () => { + it('should return a string pageType value', () => { + expect(getPageType('pages/page')).toEqual('PAGE'); + expect(getPageType('pages/brand')).toEqual('BRAND'); + }); + + it('should should return a null value', () => { + expect(getPageType('pages/something')).toBeUndefined(); + }); + }); +}); diff --git a/server/plugins/renderer/renderer.module.js b/server/plugins/renderer/renderer.module.js index 15dd21d4..e8b9e243 100644 --- a/server/plugins/renderer/renderer.module.js +++ b/server/plugins/renderer/renderer.module.js @@ -12,12 +12,15 @@ const templateAssembler = require('../../../lib/template-assembler'); const utils = require('../../lib/utils'); const { readFromStream } = require('../../../lib/utils/asyncUtils'); const NetworkUtils = require('../../../lib/utils/NetworkUtils'); +const contentApiClient = require('../../../lib/content-api-client'); +const { getPageType } = require('../../lib/page-type-util'); const networkUtils = new NetworkUtils(); const internals = { options: {}, cacheTTL: 1000 * 15, // 15 seconds + graphQLCacheTTL: 1000 * 300, // 5 minutes validCustomTemplatePageTypes: ['brand', 'category', 'page', 'product'], }; @@ -198,7 +201,53 @@ internals.parseResponse = async (bcAppData, request, response, responseArgs) => cache.put(dataRequestSignature, { response2 }, internals.cacheTTL); } - return internals.getPencilResponse(response2.data, request, response2, configuration); + const templateFile = response2.data.template_file; + const entityId = response2.data.entity_id; + + const pageType = getPageType(templateFile); + let regionResponse = []; + + if (pageType) { + // create request signature and use cache, if available + const graphQLUrlSignature = internals.sha1sum(internals.options.storeUrl + '/graphql'); + const graphQLQuerySignature = internals.sha1sum(pageType + entityId); + const graphQLDataReqSignature = `graphql:${graphQLUrlSignature + graphQLQuerySignature}`; + const cachedGraphQLResponse = cache.get(graphQLDataReqSignature); + + if (internals.options.useCache && cachedGraphQLResponse) { + ({ regionResponse } = cachedGraphQLResponse); + } else { + if (typeof entityId === 'number') { + regionResponse = await contentApiClient.getRenderedRegionsByPageTypeAndEntityId({ + accessToken: response2.data.context.settings.storefront_api.token, + storeUrl: internals.options.storeUrl, + pageType, + entityId, + }); + } else { + regionResponse = await contentApiClient.getRenderedRegionsByPageType({ + accessToken: response2.data.context.settings.storefront_api.token, + storeUrl: internals.options.storeUrl, + pageType, + }); + } + + cache.put(graphQLDataReqSignature, { regionResponse }, internals.graphQLCacheTTL); + } + } + + const formattedRegions = {}; + regionResponse.renderedRegions.forEach((region) => { + formattedRegions[region.name] = region.html; + }); + + return internals.getPencilResponse( + response2.data, + request, + response2, + configuration, + formattedRegions, + ); }; /** @@ -319,9 +368,10 @@ internals.getTemplatePath = (requestPath, data) => { * @param request * @param response * @param configuration + * @param renderedRegions * @returns {*} */ -internals.getPencilResponse = (data, request, response, configuration) => { +internals.getPencilResponse = (data, request, response, configuration, renderedRegions = {}) => { const context = { ...data.context, theme_settings: configuration.settings, @@ -343,6 +393,7 @@ internals.getPencilResponse = (data, request, response, configuration) => { templates: data.templates, remote: data.remote, remote_data: data.remote_data, + renderedRegions, context, translations: data.translations, method: request.method, diff --git a/server/plugins/renderer/responses/pencil-response.js b/server/plugins/renderer/responses/pencil-response.js index 0666bf3e..b4fbdd18 100644 --- a/server/plugins/renderer/responses/pencil-response.js +++ b/server/plugins/renderer/responses/pencil-response.js @@ -108,6 +108,7 @@ class PencilResponse { * @param data.acceptLanguage * @param {{[string]: string[]}} data.headers * @param data.statusCode + * @param data.renderedRegions * @param assembler */ constructor(data, assembler) { @@ -137,6 +138,7 @@ class PencilResponse { this.data.context.in_production = false; paper.addDecorator(makeDecorator(request, this.data.context)); + paper.setContent(this.data.renderedRegions); // Plugins have the opportunity to add/modify the response by using decorators _.each(request.app.decorators, (decorator) => {