Skip to content

Instantly share code, notes, and snippets.

@pzi
Created July 8, 2022 07:50
Show Gist options
  • Save pzi/2051a5eff32bcc458c67a92a86d7b030 to your computer and use it in GitHub Desktop.
Save pzi/2051a5eff32bcc458c67a92a86d7b030 to your computer and use it in GitHub Desktop.
Refactored Layout Service Factory to handle query params
import {placeholderHasQueryParam, processServerUrl, processPlaceholderUrl} from './layout-service-factory';
/**
* @jest-environment node
*/
jest.mock('@sitecore-jss/sitecore-jss-nextjs', () => ({
getPublicUrl: jest.fn().mockReturnValue('http://dev.url'),
}));
test('placeholderHasQueryParam', () => {
expect(placeholderHasQueryParam('http://localhost:3000')).toBe(false);
expect(placeholderHasQueryParam('http://localhost:3000?foo=bar')).toBe(false);
expect(placeholderHasQueryParam('http://dev.url?item=/why-we-are-fast?utm_medium=social&foo=bar')).toBe(true);
expect(placeholderHasQueryParam('http://dev.url?item=%2Fwhy-we-are-fast%3Futm_medium%3Dsocial')).toBe(true);
expect(
placeholderHasQueryParam(
'http://dev.url/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-header&item=%2Fdevcontent%3Fbeep=boop&sc_apikey=%herpderp%7D&sc_site=some-app&sc_lang=&tracking=false',
),
).toBe(true);
expect(
placeholderHasQueryParam(
'https://dev.url/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-config&item=%2Fconfig%3Fbeep=bopp&sc_apikey=%herpderp%7D&sc_site=some-app&sc_lang=en&tracking=false',
),
).toBe(true);
});
test('processPlaceholderUrl', () => {
expect(processPlaceholderUrl('http://dev.url?item=/why-we-are-fast?utm_medium=social&foo=bar')).toBe(
'http://dev.url/?item=%2Fwhy-we-are-fast&foo=bar&utm_medium=social',
);
expect(
processPlaceholderUrl(
'/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-config&item=%2Fconfig%3Fbeep=bopp&sc_apikey=%herpderp%7D&sc_site=some-app&sc_lang=en&tracking=false',
),
).toBe(
'http://dev.url/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-config&item=%2Fconfig&sc_apikey=%25herpderp%7D&sc_site=some-app&sc_lang=en&tracking=false&beep=bopp',
);
expect(
processPlaceholderUrl(
'http://dev.url/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-header&item=%2Fdevcontent%3Fbeep=boop&sc_apikey=%herpderp%7D&sc_site=some-app&sc_lang=&tracking=false',
),
).toBe(
'http://dev.url/sitecore/api/layout/placeholder/jss?placeholderName=hpa-ph-header&item=%2Fdevcontent&sc_apikey=%25herpderp%7D&sc_site=some-app&sc_lang=&tracking=false&beep=boop',
);
expect(
processPlaceholderUrl('http://dev.url?item=%2Fconfig%3Fsc_apikey=attempted_override&sc_apikey=%herpderp%7D'),
).toBe('http://dev.url/?item=%2Fconfig&sc_apikey=%25herpderp%7D');
});
test('processServerUrl', () => {
expect(processServerUrl('/some-path', 'http://dev.url/some-path')).toBe('http://dev.url/some-path');
expect(processServerUrl('/some-path?foo=new', 'http://dev.url/some-path?foo=original')).toBe(
'http://dev.url/some-path?foo=original',
);
expect(processServerUrl('/some-path?foo=bar', 'http://dev.url/some-path')).toBe('http://dev.url/some-path?foo=bar');
expect(processServerUrl('/some-path?foo=bar', 'http://dev.url/some-path?baz=qux')).toBe(
'http://dev.url/some-path?baz=qux&foo=bar',
);
});
import type {IncomingMessage, ServerResponse} from 'http';
import {URL as NodeURL} from 'url';
import {debug} from '@sitecore-jss/sitecore-jss';
import type {AxiosDataFetcherConfig, RestLayoutServiceConfig} from '@sitecore-jss/sitecore-jss-nextjs';
import {AxiosDataFetcher, getPublicUrl, RestLayoutService} from '@sitecore-jss/sitecore-jss-nextjs';
import type {DataFetcherResolver} from '@sitecore-jss/sitecore-jss/layout';
import type {AxiosRequestConfig, AxiosResponse, HeadersDefaults} from 'axios';
import sitecoreConfig from 'temp/config';
import {isServer} from 'utils/environment';
const publicUrl = getPublicUrl();
// Either use Node URL or Browser URL as the processing might happen in the browser
const SafeURL = isServer() ? NodeURL : URL;
interface TypedAxiosRequestConfig extends Omit<AxiosRequestConfig, 'headers'> {
headers: HeadersDefaults;
}
// We are duplicating a bunch of code from the jss repo so we can override the fetcher method.
// All is a copy-paste, except for the returned fetcher method called out below
// @see: https://github.com/Sitecore/jss/blob/4e85a70c521f76535b3b36f73bcc9f69874d2408/packages/sitecore-jss/src/layout/rest-layout-service.ts
const setupReqHeaders = (req: IncomingMessage) => {
return (reqConfig: TypedAxiosRequestConfig) => {
debug.layout('performing request header passing');
if (reqConfig.headers) {
reqConfig.headers.common = {
...reqConfig.headers.common,
...(req.headers.cookie && {cookie: req.headers.cookie}),
...(req.headers.referer && {referer: req.headers.referer}),
...(req.headers['user-agent'] && {'user-agent': req.headers['user-agent']}),
...(req.socket.remoteAddress && {'X-Forwarded-For': req.socket.remoteAddress}),
};
}
return reqConfig;
};
};
const setupResHeaders = (res: ServerResponse) => {
return (serverRes: AxiosResponse) => {
debug.layout('performing response header passing');
serverRes.headers['set-cookie'] && res.setHeader('set-cookie', serverRes.headers['set-cookie']);
return serverRes;
};
};
export const dataFetcherResolver: DataFetcherResolver = <T>(req?: IncomingMessage, res?: ServerResponse) => {
const config = {
debugger: debug.layout,
} as AxiosDataFetcherConfig;
if (req && res) {
config.onReq = setupReqHeaders(req) as AxiosDataFetcherConfig['onReq'];
config.onRes = setupResHeaders(res) as AxiosDataFetcherConfig['onRes'];
}
const axiosFetcher = new AxiosDataFetcher(config);
// Here is our custom implementation of the fetcher function
return (fetchUrl: string, data?: unknown) => {
fetchUrl = processURLQueryParams(fetchUrl, req?.url);
return axiosFetcher.fetch<T>(fetchUrl, data);
};
};
export class LayoutServiceFactory {
create(additionalConfig?: Partial<RestLayoutServiceConfig>) {
return new RestLayoutService({
apiHost: sitecoreConfig.sitecoreApiHost,
apiKey: sitecoreConfig.nextPublicSitecoreApiKey,
siteName: sitecoreConfig.jssAppName,
configurationName: 'jss',
tracking: false,
dataFetcherResolver,
...additionalConfig,
});
}
}
export const processURLQueryParams = (fetchUrl: string, requestUrl: string | undefined): string => {
// if we receive a requestUrl, then we obtained it from a Server context
if (requestUrl) {
return processServerUrl(requestUrl, fetchUrl);
}
if (placeholderHasQueryParam(fetchUrl)) {
return processPlaceholderUrl(fetchUrl);
}
return fetchUrl;
};
/**
* Method that takes a Server request url e.g `/some-path?status=1` and adds the query params to the
* url that is used to fetch from the layout service if they don't exist yet.
*/
export const processServerUrl = (requestUrl: string, fetchUrl: string): string => {
if (!requestUrl.includes('_next')) {
const requestURL = new SafeURL(requestUrl, publicUrl);
// if we have get params on our request url
// forward them onto the call to the layout service
if (requestURL.search) {
const fetchURL = new SafeURL(fetchUrl);
fetchUrl = getNewCompleteURL(requestURL.searchParams, fetchURL);
}
}
return fetchUrl;
};
export const placeholderHasQueryParam = (url: string): boolean => getRequestURLs(url).itemURL.search !== '';
/**
* Method that processes a placeholder item that contains a query string,
* extracts it and attaches it to the end of the URL.
* @example `dev.url?item=%path%3Fquery=string` -> `dev.url?item=%path&query=string`
* @example `dev.url?item=%path%3Fquery=string&foo=bar` -> `dev.url?item=%path&foo=bar&query=string`
*/
export const processPlaceholderUrl = (url: string): string => {
const {
completeURL,
itemURL: {pathname: itemPath, searchParams: itemQueryParams},
} = getRequestURLs(url);
// update the existing item with the path (without query params) and delete all other `item` params if any.
completeURL.searchParams.set('item', itemPath);
return getNewCompleteURL(itemQueryParams, completeURL);
};
/**
* Helper to create URL objects of incoming request urls
*/
const getRequestURLs = (url: string): {completeURL: URL; itemURL: URL} => {
// Create URL object to parse incoming string
const completeURL = new SafeURL(url, publicUrl);
const itemParam = completeURL.searchParams.get('item') ?? '';
// Grab the item query string
return {
itemURL: new SafeURL(itemParam, publicUrl),
completeURL,
};
};
/**
* Util to safely add non-existent query params to a given URL
*/
const getNewCompleteURL = (params: URLSearchParams, completeURL: URL): string => {
const newURL = new SafeURL(completeURL.href);
params.forEach((value, name) => {
if (!newURL.searchParams.has(name)) {
newURL.searchParams.append(name, value);
}
});
return newURL.href;
};
export const layoutServiceFactory = new LayoutServiceFactory();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment