Add URL param encoding/decoding helpers

This commit is contained in:
Kimmo Puputti 2017-03-22 12:16:50 +02:00
parent f75cb14a8a
commit f138fcc0bc
2 changed files with 287 additions and 3 deletions

View file

@ -1,4 +1,166 @@
/* eslint-disable import/prefer-default-export */
import queryString from 'query-string';
import { types } from './sdkLoader';
export const createSlug = (str) =>
encodeURIComponent(str.toLowerCase().split(' ').join('-'))
const { LatLng, LatLngBounds } = types;
export const createSlug = str => encodeURIComponent(str.toLowerCase().split(' ').join('-'));
/**
* Parse float from a string
*
* @param {String} str - string to parse
*
* @return {Number|null} number parsed from the string, null if not a number
*/
export const parseFloatNum = str => {
const num = window.parseFloat(str);
return window.isNaN(num) ? null : num;
};
/**
* Encode a location to use in a URL
*
* @param {LatLng} location - location instance to encode
*
* @return {String} location coordinates separated by a comma
*/
export const encodeLatLng = location => `${location.lat},${location.lng}`;
/**
* Decode a location from a string
*
* @param {String} str - string encoded with `encodeLatLng`
*
* @return {LatLng|null} location instance, null if could not parse
*/
export const decodeLatLng = str => {
const parts = str.split(',');
if (parts.length !== 2) {
return null;
}
const lat = parseFloatNum(parts[0]);
const lng = parseFloatNum(parts[1]);
if (lat === null || lng === null) {
return null;
}
return new LatLng(lat, lng);
};
/**
* Encode a location bounds to use in a URL
*
* @param {LatLngBounds} bounds - bounds instance to encode
*
* @return {String} bounds coordinates separated by a comma
*/
export const encodeLatLngBounds = bounds => `${encodeLatLng(bounds.ne)},${encodeLatLng(bounds.sw)}`;
/**
* Decode a location bounds from a string
*
* @param {String} str - string encoded with `encodeLatLngBounds`
*
* @return {LatLngBounds|null} location bounds instance, null if could not parse
*/
export const decodeLatLngBounds = str => {
const parts = str.split(',');
if (parts.length !== 4) {
return null;
}
const ne = decodeLatLng(`${parts[0]},${parts[1]}`);
const sw = decodeLatLng(`${parts[2]},${parts[3]}`);
if (ne === null || sw === null) {
return null;
}
return new LatLngBounds(ne, sw);
};
// Serialise SDK types in given object values into strings
const serialiseSdkTypes = obj =>
Object.keys(obj).reduce(
(result, key) => {
const val = obj[key];
/* eslint-disable no-param-reassign */
if (val instanceof LatLngBounds) {
result[key] = encodeLatLngBounds(val);
} else if (val instanceof LatLng) {
result[key] = encodeLatLng(val);
} else {
result[key] = val;
}
/* eslint-enable no-param-reassign */
return result;
},
{}
);
/**
* Serialise given object into a string that can be used in a
* URL. Encode SDK types into a format that can be parsed with `parse`
* defined below.
*
* @param {Object} params - object with strings/numbers/booleans or
* SDK types as values
*
* @return {String} query string with sorted keys and serialised
* values, `undefined` and `null` values are removed
*/
export const stringify = params => {
const serialised = serialiseSdkTypes(params);
const cleaned = Object.keys(serialised).reduce(
(result, key) => {
const val = serialised[key];
/* eslint-disable no-param-reassign */
if (val !== null) {
result[key] = val;
}
/* eslint-enable no-param-reassign */
return result;
},
{}
);
return queryString.stringify(cleaned);
};
/**
* Parse a URL search query. Converts numeric values into numbers,
* 'true' and 'false' as booleans, and serialised LatLng and
* LatLngBounds into respective instances based on given options.
*
* @param {String} search - query string to parse, optionally with a
* leading '?' or '#' character
*
* @param {Object} options - Options for parsing:
*
* - latlng {Array<String} keys to parse as LatLng instances, null if
* not able to parse
* - latlngBounds {Array<String} keys to parse as LatLngBounds
* instances, null if not able to parse
*
* @return {Object} key/value pairs parsed from the given String
*/
export const parse = (search, options = {}) => {
const { latlng = [], latlngBounds = [] } = options;
const params = queryString.parse(search);
return Object.keys(params).reduce(
(result, key) => {
const val = params[key];
/* eslint-disable no-param-reassign */
if (latlng.includes(key)) {
result[key] = decodeLatLng(val);
} else if (latlngBounds.includes(key)) {
result[key] = decodeLatLngBounds(val);
} else if (val === 'true') {
result[key] = true;
} else if (val === 'false') {
result[key] = false;
} else {
const num = parseFloatNum(val);
result[key] = num === null ? val : num;
}
/* eslint-enable no-param-reassign */
return result;
},
{}
);
};

122
src/util/urlHelpers.test.js Normal file
View file

@ -0,0 +1,122 @@
import { types } from './sdkLoader';
import {
parseFloatNum,
encodeLatLng,
decodeLatLng,
encodeLatLngBounds,
decodeLatLngBounds,
stringify,
parse,
} from './urlHelpers';
const { LatLng, LatLngBounds } = types;
const SPACE = encodeURIComponent(' ');
const COMMA = encodeURIComponent(',');
describe('urlHelpers', () => {
describe('parseFloatNum()', () => {
it('handles empty value', () => {
expect(parseFloatNum('')).toBeNull();
});
it('handles non-numeric value', () => {
expect(parseFloatNum('abc')).toBeNull();
});
it('handles int value with surrounding whitespace', () => {
expect(parseFloatNum(' 123 \t')).toEqual(123);
});
it('handles float value', () => {
expect(parseFloatNum('123.01')).toBeCloseTo(123.01, 2);
});
it('handles trailing chars', () => {
expect(parseFloatNum('123abc')).toEqual(123);
});
});
describe('LatLng serialisation', () => {
it('encodes and decodes', () => {
const location = new LatLng(40, 60);
expect(decodeLatLng(encodeLatLng(location))).toEqual(location);
});
});
describe('LatLngBounds serialisation', () => {
it('encodes and decodes', () => {
const bounds = new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50));
expect(decodeLatLngBounds(encodeLatLngBounds(bounds))).toEqual(bounds);
});
});
describe('stringify()', () => {
it('handles empty params', () => {
expect(stringify({})).toEqual('');
});
it('sorts params', () => {
const params = { b: 'B', c: 'C', a: 'A' };
expect(stringify(params)).toEqual('a=A&b=B&c=C');
});
it('encodes values', () => {
const params = {
space: 'A and b',
num: 123,
bool: true,
undef: undefined,
nil: null,
};
expect(stringify(params)).toEqual(`bool=true&num=123&space=A${SPACE}and${SPACE}b`);
});
it('encodes SDK types', () => {
const params = {
origin: new LatLng(40, 60),
bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)),
};
const origin = `40${COMMA}60`;
const bounds = `50${COMMA}70${COMMA}30${COMMA}50`;
expect(stringify(params)).toEqual(`bounds=${bounds}&origin=${origin}`);
});
});
describe('parse()', () => {
it('handles empty string', () => {
expect(parse('')).toEqual({});
});
it('handles question mark', () => {
expect(parse('?')).toEqual({});
});
it('decodes values', () => {
const search = `bool1=true&bool2=false&num1=123&num2=-1.01&space=A${SPACE}and${SPACE}b`;
expect(parse(search)).toEqual({
space: 'A and b',
num1: 123,
num2: -1.01,
bool2: false,
bool1: true,
});
});
it('decodes SDK types', () => {
const origin = `40${COMMA}60`;
const bounds = `50${COMMA}70${COMMA}30${COMMA}50`;
const search = `bounds=${bounds}&origin=${origin}&invalid=a,10&badBounds=true`;
const options = {
latlng: ['origin', 'invalid'],
latlngBounds: ['bounds', 'badBounds'],
};
expect(parse(search, options)).toEqual({
origin: new LatLng(40, 60),
invalid: null,
bounds: new LatLngBounds(new LatLng(50, 70), new LatLng(30, 50)),
badBounds: null,
});
});
});
});