Skip to content

Instantly share code, notes, and snippets.

@markogresak
Created July 24, 2018 08:35
Show Gist options
  • Save markogresak/beeec2d8b4097fb9ee1a7673740d1d6c to your computer and use it in GitHub Desktop.
Save markogresak/beeec2d8b4097fb9ee1a7673740d1d6c to your computer and use it in GitHub Desktop.
url-from-template.js
import isProduction from './is-production';
const placeholderRe = /:([A-Za-z0-9_]+)\??/;
const allPlaceholdersRe = new RegExp(placeholderRe, 'g');
function getType(val) {
return Array.isArray(val) ? 'array' : typeof val;
}
/**
* Replacer to be used with JSON.stringify.
* Replaces undefined with null so the missing key is not removed from the stringified object.
*/
function replacer(_key, val) {
return val === undefined ? 'undefined' : val;
}
/**
* A helper function for displaying errors.
* It throws the error with `msg` as message in development,
* but only logs the error via console.error in production.
*
* @param {String} msg Error messqge.
*/
function err(msg) {
if (isProduction()) {
console.error(msg); // eslint-disable-line no-console
} else {
throw new Error(msg);
}
}
function urlFromTemplate(templateUrl, params = {}) {
if (getType(templateUrl) !== 'string') {
return err(
`urlFromTemplate: Expecting templateUrl to be a string, got ${getType(templateUrl)}`,
);
}
const placeholders = (templateUrl.match(allPlaceholdersRe) || []).map((placeholder) => ({
key: placeholder.match(placeholderRe)[1],
regex: new RegExp(`(${placeholder.replace('?', '\\?')})`),
optional: placeholder[placeholder.length - 1] === '?',
}));
const missingParams = placeholders
.filter(({ key, optional }) => !optional && params[key] === undefined)
.map(({ key }) => `"${key}"`);
if (missingParams.length !== 0) {
return err(
`urlFromTemplate: Missing ${missingParams.join(
', ',
)} params, got params = ${JSON.stringify(params, replacer)}`,
);
}
const arePlaceholdersValid = placeholders.every(({ key, optional }) => {
const type = getType(params[key]);
if (!optional && type !== 'number' && type !== 'string') {
return err(
`urlFromTemplate: Expecting params "${key}" value to be a number or a string, got ${type}`,
);
}
return true;
});
if (!arePlaceholdersValid) {
return templateUrl;
}
return placeholders
.reduce((url, { key, regex }) => url.replace(regex, params[key] || ''), templateUrl)
.replace(/\/*$/, '') // remove trailing slash(es)
.replace(/\/+/g, '/'); // replace multiple slashes with a single slash
}
export default urlFromTemplate;
import urlFromTemplate from '../url-from-template';
describe('urlFromTemplate', () => {
let oldNodeEnv = process.env.NODE_ENV;
beforeEach(() => {
oldNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
});
afterEach(() => {
process.env.NODE_ENV = oldNodeEnv;
});
describe('validation', () => {
test('call with non-string templateUrl should throw', () => {
expect(() => urlFromTemplate()).toThrowError(
'urlFromTemplate: Expecting templateUrl to be a string, got undefined',
);
expect(() => urlFromTemplate(123)).toThrowError(
'urlFromTemplate: Expecting templateUrl to be a string, got number',
);
expect(() => urlFromTemplate({})).toThrowError(
'urlFromTemplate: Expecting templateUrl to be a string, got object',
);
expect(() => urlFromTemplate([])).toThrowError(
'urlFromTemplate: Expecting templateUrl to be a string, got array',
);
});
test('call with url including an id template without params should throw an error', () => {
expect(() => urlFromTemplate('/courses/:id')).toThrow(
'urlFromTemplate: Missing "id" params, got params = {}',
);
expect(() => urlFromTemplate('/courses/:courseId')).toThrow(
'urlFromTemplate: Missing "courseId" params, got params = {}',
);
expect(() => urlFromTemplate('/courses/:courseId/units/:unitId')).toThrow(
'urlFromTemplate: Missing "courseId", "unitId" params, got params = {}',
);
});
test('call with invalid params should throw an error', () => {
expect(() => urlFromTemplate('/courses/:id', { courseId: 123 })).toThrow(
'urlFromTemplate: Missing "id" params, got params = {"courseId":123}',
);
expect(() => urlFromTemplate('/courses/:course_id', { courseId: 123 })).toThrow(
'urlFromTemplate: Missing "course_id" params, got params = {"courseId":123}',
);
});
test('call with url including an optional id template and invalid params should not throw an error', () => {
expect(() => urlFromTemplate('/courses/:id?', { courseId: 123 })).not.toThrow();
expect(() => urlFromTemplate('/courses/:course_id?', { courseId: 123 })).not.toThrow();
});
test('call with one required and one optional param and invalid params should throw an error', () => {
expect(() => urlFromTemplate('/courses/:id/units/unitId?', { courseId: 123 })).toThrow(
'urlFromTemplate: Missing "id" params, got params = {"courseId":123}',
);
expect(() =>
urlFromTemplate('/courses/:course_id/units/unitId?', { courseId: 123 }),
).toThrow('urlFromTemplate: Missing "course_id" params, got params = {"courseId":123}');
});
describe('call with params other than numeric or string should trhow', () => {
test('number should not throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: 123 })).not.toThrow();
});
test('string should not throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: 'abc' })).not.toThrow();
});
test('boolean should throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: false })).toThrow(
'urlFromTemplate: Expecting params "id" value to be a number or a string, got boolean',
);
});
test('function should throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: () => {} })).toThrow(
'urlFromTemplate: Expecting params "id" value to be a number or a string, got function',
);
});
test('array should throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: [] })).toThrow(
'urlFromTemplate: Expecting params "id" value to be a number or a string, got array',
);
});
test('object should throw', () => {
expect(() => urlFromTemplate('/courses/:id', { id: {} })).toThrow(
'urlFromTemplate: Expecting params "id" value to be a number or a string, got object',
);
});
});
describe('call with optional params other than numeric or string should not trhow', () => {
test('number should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: 123 })).not.toThrow();
});
test('string should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: 'abc' })).not.toThrow();
});
test('boolean should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: false })).not.toThrow();
});
test('function should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: () => {} })).not.toThrow();
});
test('array should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: [] })).not.toThrow();
});
test('object should not throw', () => {
expect(() => urlFromTemplate('/courses/:id?', { id: {} })).not.toThrow();
});
});
});
describe('base cases', () => {
test('call with plain url without params should return an unmodified version of templateUrl', () => {
expect(urlFromTemplate('/courses')).toBe('/courses');
});
});
describe('simple urls', () => {
test('the :id in teplate should be replaced with params.id', () => {
const url = '/courses/:id';
expect(urlFromTemplate(url, { id: 123 })).toBe('/courses/123');
expect(urlFromTemplate(url, { id: 'abc' })).toBe('/courses/abc');
});
});
describe('url after template', () => {
test('the :id in teplate should be replaced with params.id', () => {
const url = '/courses/:id/units';
expect(urlFromTemplate(url, { id: 123 })).toBe('/courses/123/units');
expect(urlFromTemplate(url, { id: 'abc' })).toBe('/courses/abc/units');
});
});
describe('url with multiple params', () => {
test('the :courseId in teplate should be replaced with params.courseId and :unitId with params.unitId', () => {
const url = '/courses/:courseId/units/:unitId';
expect(urlFromTemplate(url, { courseId: 123, unitId: 456 })).toBe(
'/courses/123/units/456',
);
expect(urlFromTemplate(url, { courseId: 123, unitId: 'def' })).toBe(
'/courses/123/units/def',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 456 })).toBe(
'/courses/abc/units/456',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 'def' })).toBe(
'/courses/abc/units/def',
);
});
});
describe('url after template with multiple params', () => {
test('the :courseId in teplate should be replaced with params.courseId and :unitId with params.unitId', () => {
const url = '/courses/:courseId/units/:unitId/lesson';
expect(urlFromTemplate(url, { courseId: 123, unitId: 456 })).toBe(
'/courses/123/units/456/lesson',
);
expect(urlFromTemplate(url, { courseId: 123, unitId: 'def' })).toBe(
'/courses/123/units/def/lesson',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 456 })).toBe(
'/courses/abc/units/456/lesson',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 'def' })).toBe(
'/courses/abc/units/def/lesson',
);
});
});
describe('url with multiple params and one optional param', () => {
test('param ending in ? should be optional and can be ignored', () => {
const url = '/courses/:courseId/units/:unitId?';
expect(urlFromTemplate(url, { courseId: 123 })).toBe('/courses/123/units');
expect(urlFromTemplate(url, { courseId: 'abc' })).toBe('/courses/abc/units');
expect(urlFromTemplate(url, { courseId: 123, unitId: 456 })).toBe(
'/courses/123/units/456',
);
expect(urlFromTemplate(url, { courseId: 123, unitId: 'def' })).toBe(
'/courses/123/units/def',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 456 })).toBe(
'/courses/abc/units/456',
);
expect(urlFromTemplate(url, { courseId: 'abc', unitId: 'def' })).toBe(
'/courses/abc/units/def',
);
});
});
describe('optional param', () => {
test('param ending in ? should be optional and can be ignored', () => {
const url = '/courses/:id?';
expect(urlFromTemplate(url)).toBe('/courses');
expect(urlFromTemplate(url, { id: 123 })).toBe('/courses/123');
expect(urlFromTemplate(url, { id: 'abc' })).toBe('/courses/abc');
});
});
describe('sanitizing', () => {
test('trailing slash should be stripped', () => {
expect(urlFromTemplate('/courses/')).toBe('/courses');
});
test('multiple slashes should be replaced with a single slash', () => {
expect(urlFromTemplate('/courses/:page', { page: '/abc' })).toBe('/courses/abc');
});
test('do not omit undefined keys in params validation error', () => {
expect(() => urlFromTemplate('/courses/:id', { id: undefined })).toThrow(
'urlFromTemplate: Missing "id" params, got params = {"id":"undefined"}',
);
});
});
describe('production', () => {
let prodOldNodeEnv;
let consoleErrorSpy;
beforeEach(() => {
prodOldNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
process.env.NODE_ENV = prodOldNodeEnv;
consoleErrorSpy.mockRestore();
});
test('should not throw in production env, it should just output the error via console.error', () => {
expect(() => urlFromTemplate()).not.toThrowError();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'urlFromTemplate: Expecting templateUrl to be a string, got undefined',
);
});
test('should return templateUrl if type validation fails', () => {
const templateUrl = '/courses/:id';
let result;
expect(() => (result = urlFromTemplate(templateUrl, { id: {} }))).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'urlFromTemplate: Expecting params "id" value to be a number or a string, got object',
);
expect(result).toBe(templateUrl);
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment