src/RequestParameters.js
/*
* BSD 3-Clause License
*
* Copyright (c) 2020, Mapcreator
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import { camel as camelCase, pascal as pascalCase, snake as snakeCase } from 'case';
import { EventEmitter } from 'events';
import { DeletedState } from './enums';
import { hashObject } from './utils/hash';
import { getTypeName } from './utils/reflection';
import { encodeQueryString } from './utils/requests';
/**
* Used for keeping track of the request parameters
*
* @fires RequestParameters#change
* @fires RequestParameters#change:page
* @fires RequestParameters#change:perPage
* @fires RequestParameters#change:search
* @fires RequestParameters#change:sort
* @fires RequestParameters#change:deleted
* @fires RequestParameters#change:extra
*/
export default class RequestParameters extends EventEmitter {
/**
* RequestParameters constructor
* @param {Object} object - properties
*/
constructor (object = {}) {
super();
// Apply defaults
RequestParameters.keys().forEach(x => this._resolve(x));
// Apply properties
this.apply(object);
}
// region instance
// region instance getters
/**
* Get page number
* @returns {Number} - Page number
* @throws {TypeError}
*/
get page () {
return this._resolve('page');
}
/**
* Get rows per page
* @returns {Number} - Per page
* @throws {TypeError}
*/
get perPage () {
return this._resolve('perPage');
}
/**
* Get pagination offset
* @returns {Number} - Offset
* @throws {TypeError}
*/
get offset () {
return this._resolve('offset');
}
/**
* Search query
* @returns {Object<String, String|Array<String>>} - Query
* @throws {TypeError}
*/
get search () {
return this._resolve('search');
}
/**
* Get sort options
* @returns {Array<String>} - Per page
* @throws {TypeError}
*/
get sort () {
return this._resolve('sort');
}
/**
* If deleted items should be shown
* @returns {String} - Deleted items filter state
* @see {@link DeletedState}
*/
get deleted () {
return this._resolve('deleted');
}
/**
* Extra parameters
* @returns {Object} - Extra parameters
*/
get extra () {
return this._resolve('extra');
}
// endregion instance getters
// region instance setters
/**
* Page number
* @param {Number} value - Page number
*/
set page (value) {
this._update('page', value);
}
/**
* Rows per page
* @param {Number} value - Per page
*/
set perPage (value) {
this._update('perPage', value);
}
/**
* Pagination offset
* @param {Number} value - Offset
*/
set offset (value) {
this._update('offset', value);
}
/**
* Search query
* @param {Object<String, String|Array<String>>} value - Search query
*/
set search (value) {
this._update('search', value);
}
/**
* Sort query
* @param {Array<String>} value - Sort query
*/
set sort (value) {
this._update('sort', value);
}
/**
* Deleted items filter state
* @param {String} value - Deleted items filter state
* @see {@link DeletedState}
*/
set deleted (value) {
this._update('deleted', value);
}
/**
* Extra request parameters
* @param {Object} value - Extra request parameters
*/
set extra (value) {
this._update('extra', value);
}
// endregion instance setters
// endregion instance
// region static
// region getters
/**
* Default page number
* @returns {Number} - Page number
*/
static get page () {
return RequestParameters._page || 1;
}
/**
* Default per page
* @returns {Number} - Per page
*/
static get perPage () {
return RequestParameters._perPage || Number(process.env.PER_PAGE) || 12;
}
/**
* Default pagination offset
* @returns {Number} - Offset
*/
static get offset () {
return RequestParameters._offset || 0;
}
/**
* Gets the maximum allowed value for perPage
* Some users will have a special permission that allows them to fetch more than 50 resources at once
* @returns {Number} - Maximum amount of resources per page
*/
static get maxPerPage () {
return RequestParameters._maxPerPage || 50;
}
/**
* Default search query
* @returns {Object<String, String|Array<String>>} - Search query
*/
static get search () {
return RequestParameters._search || {};
}
/**
* Default sort query
* @returns {Array<String>} - Sort query
*/
static get sort () {
return RequestParameters._sort || [];
}
/**
* Default deleted items filter state
* @returns {String|undefined} - Deleted items filter state
*/
static get deleted () {
return RequestParameters._deleted || void 0;
}
/**
* Default extra request parameters
* @returns {Object} - Extra request parameters
*/
static get extra () {
return RequestParameters._extra || {};
}
// endregion getters
// region setters
/**
* Default page number
* @param {Number} value - Page number
*/
static set page (value) {
RequestParameters._page = RequestParameters._validatePage(value);
}
/**
* Default per page
* @param {Number} value - Per page
*/
static set perPage (value) {
RequestParameters._perPage = RequestParameters._validatePerPage(value);
}
/**
* Default pagination offset
* @param {Number} value - Offset
*/
static set offset (value) {
RequestParameters._offset = RequestParameters._validateOffset(value);
}
/**
* Sets the maximum allowed value for perPage
* Some users will have a special permission that allows them to fetch more than 50 resources at once
* @param {Number} value - Maximum amount of resources per page
*/
static set maxPerPage (value) {
RequestParameters._maxPerPage = RequestParameters._validateMaxPerPage(value);
}
/**
* Default search query
* @param {Object<String, String|Array<String>>} value - Search query
*/
static set search (value) {
RequestParameters._search = RequestParameters._validateSearch(value);
}
/**
* Default sort query
* @param {Array<String>} value - Sort query
*/
static set sort (value) {
RequestParameters._sort = RequestParameters._validateSort(value);
}
/**
* Default deleted items filter state
* @param {String} value - Deleted items filter state
*/
static set deleted (value) {
RequestParameters._deleted = RequestParameters._validateDeleted(value);
}
/**
* Default extra request parameters
* @param {Object} value - Extra request parameters
*/
static set extra (value) {
RequestParameters._extra = RequestParameters._validateExtra(value);
}
// endregion setters
// endregion static
// region validators
/**
* Validators should work the same as laravel's ::validate method. This means
* this means that they will throw a TypeError or return a normalized result.
*/
static _validatePage (value) {
if (typeof value !== 'number') {
throw new TypeError(`Expected page to be of type 'number' instead got '${typeof value}'`);
}
if (value < 0) {
throw new TypeError('Page must be a positive number');
}
if (Number.isNaN(value) || !Number.isFinite(value)) {
throw new TypeError('Page must be a real number');
}
if (Math.round(value) !== value) {
throw new TypeError('Page must be a natural number');
}
return Math.round(value);
}
static _validatePerPage (value) {
if (typeof value !== 'number') {
throw new TypeError(`Expected per page to be of type 'Number' instead got '${getTypeName(value)}'`);
}
if (value <= 0) {
throw new TypeError('Per page must be greater than zero');
}
if (Number.isNaN(value) || !Number.isFinite(value)) {
throw new TypeError('Per page must be a real number');
}
if (Math.round(value) !== value) {
throw new TypeError('Per page must be a natural number');
}
// Upper limit is 50 by default
value = Math.min(RequestParameters.maxPerPage, value);
return value;
}
static _validateOffset (value) {
if (typeof value !== 'number') {
throw new TypeError(`Expected offset to be of type 'Number' instead got '${getTypeName(value)}'`);
}
if (value < 0) {
throw new TypeError('Offset must be a positive number');
}
if (Number.isNaN(value) || !Number.isFinite(value)) {
throw new TypeError('Offset must be a real number');
}
if (Math.round(value) !== value) {
throw new TypeError('Offset must be a natural number');
}
return value;
}
static _validateMaxPerPage (value) {
if (typeof value !== 'number') {
throw new TypeError(`Expected page to be of type 'Number' instead got '${getTypeName(value)}'`);
}
if (value < 1) {
throw new TypeError('Value must be greater or equal to 1');
}
return value;
}
static _validateSearch (value) {
if (typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError(`Expected value to be of type "Object" got "${getTypeName(value)}"`);
}
// Normalization macro
const normalize = x => typeof x === 'number' ? x.toString() : x;
for (let key of Object.keys(value)) {
key = normalize(key);
value[key] = normalize(value[key]);
if (typeof key !== 'string') {
throw new TypeError(`Expected key to be of type "String" got "${getTypeName(key)}"`);
}
if (Array.isArray(value[key])) {
if (value[key].length > 0) {
for (const query of value[key]) {
if (!['string', 'number', 'boolean'].includes(typeof query) && query !== null) {
throw new TypeError(`Expected query for "${key}" to be of type "String", "Boolean", "Number" or "null" got "${getTypeName(query)}"`);
}
}
} else {
// Drop empty nodes
delete value[key];
}
} else if (value[key] === null) {
delete value[key];
} else if (!['string', 'number', 'boolean'].includes(typeof value[key]) && value[key] !== null) {
throw new TypeError(`Expected query value to be of type "String", "Boolean", "Number", "Array" or "null" got "${getTypeName(value[key])}"`);
}
}
return value;
}
static _validateSort (value) {
if (typeof value === 'string') {
return this._validateSort(value.split(','));
}
if (!(value instanceof Array)) {
throw new TypeError(`Expected sort value to be of type "Array" got "${getTypeName(value)}"`);
}
// Array keys type checking
value
.filter(x => typeof x !== 'string')
.forEach(x => {
throw new TypeError(`Expected sort array values to be of type "String" got "${getTypeName(x)}"`);
});
// Don't do regex matching because it's something
// we can just let the server do for us.
return value;
}
static _validateDeleted (value) {
if (typeof value === 'undefined') {
return value;
}
if (typeof value !== 'string') {
throw new TypeError(`Expected deleted to be of type "string" got "${getTypeName(value)}". See: DeletedState`);
}
value = value.toLowerCase();
const possible = DeletedState.values();
if (!possible.includes(value)) {
throw new TypeError(`Expected deleted to be one of ${possible.join(', ')}, got ${value}`);
}
return value;
}
static _validateExtra (value) {
if (typeof value !== 'object') {
throw new TypeError(`Expected extra to be of type 'object', got '${getTypeName(value)}'`);
}
return value;
}
// endregion validators
_resolve (name) {
const _name = `_${name}`;
if (!this[_name]) {
// Confuse esdoc
(this || {})[_name] = RequestParameters[name];
}
return this[_name];
}
_update (name, value, preventEvent = false) {
const _name = `_${name}`;
value = RequestParameters[`_validate${pascalCase(name)}`](value);
(this || {})[_name] = value; // Weird syntax confuses esdoc
if (!preventEvent) {
/**
* Change event.
*
* @event RequestParameters#change
* @type {Array<object>}
* @property {string} name - Parameter name
* @property {*} value - New value
*/
this.emit('change', [{ name, value }]);
this.emit(`change:${name}`, value);
}
return value;
}
// region utils
/**
* Urlencode parameters
* @returns {string} - HTTP query
*/
encode () {
return encodeQueryString(this.toParameterObject());
}
/**
* Convert to object
* @returns {Object} - Object
*/
toObject () {
return RequestParameters
.keys()
.reduce((obj, key) => {
obj[snakeCase(key)] = this._resolve(key);
return obj;
}, {});
}
/**
* Convert to object
* @returns {Object} - Object
*/
toParameterObject () {
const data = {};
RequestParameters
.keys()
.forEach(key => {
// Skip extra key
if (key === 'extra') {
return;
}
data[snakeCase(key)] = this._resolve(key);
});
// Fix column names for sort
data.sort = data.sort.map(snakeCase).map(x => x.replace(/^_/, '-')).join(',');
if (data.offset === 0) {
delete data.offset;
}
// Fix column names for search
for (const key of Object.keys(data.search)) {
const snakeKey = key.split(',').map(snakeCase).join(',');
if (key !== snakeKey) {
data.search[snakeKey] = data.search[key];
delete data.search[key];
}
}
// Cast search values
for (const key of Object.keys(data.search)) {
if (typeof data.search[key] === 'boolean') {
data.search[key] = Number(data.search[key]);
}
if (data.search[key] === null) {
data.search[key] = '';
}
}
// Overwrite using extra properties
const extra = this._resolve('extra');
for (const key of Object.keys(extra)) {
data[key] = extra[key];
}
for (const key of Object.keys(data)) {
if (typeof data[key] === 'undefined') {
delete data[key];
}
}
return data;
}
/**
* Copy object
* @returns {RequestParameters} - Copy
*/
copy () {
return new RequestParameters(this.toObject());
}
/**
* Different parameters
* @returns {Array<String>} - keys
*/
static keys () {
// enumeration is disabled for properties
return [
'page',
'perPage',
'offset',
'search',
'sort',
'deleted',
'extra',
];
}
/**
* Generates a cache token
* @returns {string} - Cache token
*/
token () {
const data = this.toObject();
delete data.page;
delete data['per_page'];
return hashObject(data);
}
/**
* Resets all parameters back to default
*/
static resetDefaults () {
for (const key of RequestParameters.keys()) {
delete RequestParameters[`_${key}`];
}
}
/**
* Apply parameters from object
* @param {object|RequestParameters} params - parameters
* @returns {Object[]} - Array containing the updated values
* @example
* const params = new RequestParameters({perPage: 12});
*
* params.perPage === 12;
*
* params.apply({perPage: 50});
*
* params.perPage === 50;
*/
apply (params) {
if (params instanceof RequestParameters) {
params = params.toObject();
}
const out = [];
for (const key of Object.keys(params)) {
const Key = camelCase(key);
if (key[0] === '_' || !RequestParameters.keys().includes(Key)) {
continue;
}
out.push({
name: Key,
value: this._update(Key, params[key], true),
});
}
this.emit('change', out);
for (const { name, value } of out) {
this.emit(`change:${name}`, value);
}
return out;
}
// endregion utils
}