src/resources/base/ResourceBase.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, snake as snakeCase } from 'case';
import { AbstractClassError, AbstractError } from '../../errors/AbstractError';
import Mapcreator from '../../Mapcreator';
import SimpleResourceProxy from '../../proxy/SimpleResourceProxy';
import Injectable from '../../traits/Injectable';
import { fnv32b } from '../../utils/hash';
import { isParentOf, mix } from '../../utils/reflection';
import { clone, makeCancelable } from '../../utils/helpers';
function unique (input) {
return input.filter((v, i) => input.findIndex(vv => vv === v) === i);
}
/**
* Resource base
* @abstract
*/
export default class ResourceBase extends mix(null, Injectable) {
/**
* @param {Mapcreator} api - Api instance
* @param {Object<String, *>} data - Item data
* @param {String} [altUrl] - Internal use, Optional alternative url for more complex routing
*/
constructor (api, data = {}, altUrl = null) {
super();
if (this.constructor === ResourceBase) {
throw new AbstractClassError();
}
if (altUrl) {
this.__baseUrl = altUrl;
}
this.api = api;
// De-reference
data = clone(data);
// Normalize keys to snake_case
// Fix data types
for (const key of Object.keys(data)) {
const newKey = snakeCase(key);
if (camelCase(newKey) in this) {
delete data[key];
continue;
}
data[newKey] = this._guessType(newKey, data[key]);
if (newKey !== key) {
delete data[key];
}
}
this._baseProperties = data || {};
this._properties = {};
this._api = api;
const fields = Object.keys(this._baseProperties);
// Apply properties
for (const key of fields) {
this._applyProperty(key);
}
// Add deleted field if possible
if (fields.includes('deleted_at')) {
Object.defineProperty(this, 'deleted', {
enumerable: true,
configurable: true,
get: () => Boolean(this.deletedAt),
});
}
/* We keep track of any new fields by recording the
* keys the object currently has. We don't need no
* fancy-pants observers, Proxies etc.
* snake_case only
*/
this._knownFields = Object.keys(this).filter(x => x[0] !== '_');
}
/**
* Get api instance
* @returns {Mapcreator} - Api instance
*/
get api () {
return this._api;
}
/**
* Set the api instance
* @param {Mapcreator} value - Api instance
*/
set api (value) {
if (!isParentOf(Mapcreator, value)) {
throw new TypeError('Expected api to be of type Mapcreator or null');
}
this._api = value;
}
/**
* Resource path template
* @returns {String} - Path template
*/
static get resourcePath () {
return `/${this.resourceName}/{id}`;
}
/**
* Resource name
* @returns {String} - Resource name
* @abstract
*/
static get resourceName () {
throw new AbstractError();
}
/**
* Returns the url key of the resource
* @returns {String} - Resource key
*/
static get resourceUrlKey () {
return 'id';
}
/**
* Protected read-only fields
* @returns {Array<string>} - Array containing protected read-only fields
* @protected
*/
static get protectedFields () {
return ['id', 'created_at', 'updated_at', 'deleted_at'];
}
/**
* Returns if the resource is readonly
* @returns {boolean} - Readonly
*/
static get readonly () {
return false;
}
/**
* Moves new fields to this._properties and turns them into a getter/setter
* @protected
*/
_updateProperties () {
// Build a list of new fields
let fields = Object.keys(this)
.filter(x => x[0] !== '_')
.filter(x => !this._knownFields.includes(x));
// Move the pointer from this to the properties object
for (const key of fields) {
const newKey = snakeCase(key);
this._properties[newKey] = this[key];
delete this[key];
this._applyProperty(newKey);
this._knownFields.push(newKey);
}
// Build a list of new BaseProperty fields
fields = Object.keys(this._baseProperties)
.filter(x => !this._knownFields.includes(camelCase(x)));
for (const key of fields) {
this._applyProperty(key);
this._knownFields.push(key);
}
this._knownFields = unique(this._knownFields);
}
/**
* Clean up instance and commit all changes locally.
* This means that any changed fields will be marked
* as unchanged whilst keeping their new values. The
* changes will not be saved.
*/
sanitize () {
this._updateProperties();
Object.assign(this._baseProperties, this._properties);
this._properties = {};
}
/**
* Resets model instance to it's original state
* @param {Array<string>|string|null} [fields=null] - Fields to reset, defaults to all fields
*/
reset (fields = null) {
this._updateProperties();
if (typeof fields === 'string') {
this.reset([fields]);
} else if (fields === null) {
this._properties = {}; // Delete all
} else if (Array.isArray(fields)) {
fields
.map(String)
.map(snakeCase)
.forEach(field => delete this._properties[field]);
}
}
/**
* Clone the object
* @returns {ResourceBase} - Exact clone of the object
*/
clone () {
this._updateProperties();
const out = new this.constructor(this.api, this._baseProperties);
for (const key of Object.keys(this._properties)) {
out[key] = this._properties[key];
}
return out;
}
/**
* Refresh the resource by requesting it from the server again
* @param {Boolean} updateSelf - Update the current instance
* @returns {CancelablePromise<ResourceBase>} - Refreshed instance
* @throws {ApiError} - If the api returns errors
*/
refresh (updateSelf = true) {
return makeCancelable(async signal => {
const { data } = await this.api.ky.get(this.url, { signal }).json();
if (updateSelf) {
this._properties = {};
this._baseProperties = data;
this._updateProperties();
}
return new this.constructor(this._api, data);
});
}
/**
* Create proxy for property
* @param {string} key - property key
* @private
*/
_applyProperty (key) {
const desc = {
enumerable: true,
configurable: true,
get: () => {
if (this._properties.hasOwnProperty(key)) {
return this._properties[key];
}
return this._baseProperties[key];
},
};
if (!this.constructor.protectedFields.includes(key) && !this.constructor.readonly) {
desc.set = val => {
this._properties[key] = this._guessType(key, val);
delete this._url; // Clears url cache
};
}
const newKey = camelCase(key);
Object.defineProperty(this, newKey, desc);
}
/**
* Guess type based on property name
* @param {string} name - Field name
* @param {*} value - Field Value
* @protected
* @returns {*} - Original or converted value
*/
_guessType (name, value) {
if (name.endsWith('_at') || name.startsWith('date_')) {
return typeof value === 'string' ? new Date(value.replace(' ', 'T')) : value;
} else if (/(_|^)id$/.test(name) && value !== null) {
return Number.isFinite(Number(value)) ? Number(value) : value;
}
return value;
}
/**
* If the resource can be owned by an organisation
* @returns {boolean} - Can be owned by an organisation
*/
get ownable () {
return false;
}
/**
* Auto generated resource url
* @returns {string} - Resource url
*/
get url () {
if (!this._url) {
let url = `${this._api.url}${this.constructor.resourcePath}`;
// Find and replace any keys
url = url.replace(/{(\w+)}/g, (match, key) => this[camelCase(key)]);
this._url = url;
}
return this._url;
}
/**
* Auto generated Resource base url
* @returns {string} - Resource base url
*/
get baseUrl () {
if (!this.__baseUrl) {
const basePath = this.constructor.resourcePath.match(/^(\/[^{]+\b)/)[1];
this.__baseUrl = `${this._api.url}${basePath}`;
}
return this.__baseUrl;
}
/**
* List fields that contain object data
* @returns {Array<String>} - A list of fields
*/
get fieldNames () {
const keys = unique([
...Object.keys(this._baseProperties),
...Object.keys(this._properties),
]);
return keys.map(camelCase);
}
/**
* String representation of the resource, similar to Python's __repr__
* @returns {string} - Resource name and id
*/
toString () {
return `${this.constructor.name}(${this[this.resourceUrlKey]})`;
}
/**
* Transform instance to object
* @param {boolean} [camelCaseKeys=false] - camelCase object keys
* @returns {{}} - Object
*/
toObject (camelCaseKeys = false) {
this._updateProperties();
const out = { ...this._baseProperties, ...this._properties };
if (camelCaseKeys) {
for (const key of Object.keys(out)) {
const ccKey = camelCase(key);
if (key !== ccKey) {
out[ccKey] = out[key];
delete out[key];
}
}
}
return out;
}
/**
* Macro for resource listing
* @param {string|Class<ResourceBase>} Target - Target object
* @param {?String} url - Target url, if null it will guess
* @param {object} seedData - Internal use, used for seeding SimpleResourceProxy::new
* @returns {SimpleResourceProxy} - A proxy for accessing the resource
* @protected
*/
_proxyResourceList (Target, url = null, seedData = {}) {
if (!url) {
url = `${Target.resourceName.replace(/s+$/, '')}s`;
}
if (typeof url === 'string' && !url.startsWith('/') && !url.match(/https?:/)) {
url = `${this.url}/${url}`;
}
return new SimpleResourceProxy(this.api, Target, url, seedData);
}
/**
* Static proxy generation
* @param {string|Class} Target - Constructor or url
* @param {Class?} Constructor - Constructor for a resource that the results should be cast to
* @param {Object<string, *>} seedData - Optional data to seed the resolved resources
* @returns {SimpleResourceProxy} - A proxy for accessing the resource
* @example
* user.static('jobs').lister();
*
* @example
* class FooBar extends ResourceBase {
* static get resourceName() {
* return 'custom';
* }
* }
*
* api.static(FooBar)
* .get(1)
* .then(console.log);
*/
static (Target, Constructor = ResourceBase, seedData = {}) {
let url;
if (typeof Target === 'string') {
url = `${this.url}/${Target}`;
const name = Constructor.name || 'AnonymousResource';
Target = class AnonymousResource extends Constructor {
static get resourceName () {
return Object.getPrototypeOf(this).resourceName || 'anonymous';
}
static get resourcePath () {
return url;
}
};
Object.defineProperty(Target, 'name', {
value: `${name}_${fnv32b(url)}`,
});
}
if (!isParentOf(ResourceBase, Target)) {
throw new TypeError('Expected Target to be of type String or ResourceBase constructor');
}
return this._proxyResourceList(Target, url, seedData);
}
}