Created
July 24, 2018 08:35
-
-
Save markogresak/beeec2d8b4097fb9ee1a7673740d1d6c to your computer and use it in GitHub Desktop.
url-from-template.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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