UNPKG

@megawubs/avid

Version:

API consumption on fire, inspired by Laravel's Eloquent

455 lines (416 loc) 12.9 kB
import {Api} from "./api"; import {map} from "./map"; import {ModelProxy} from "./proxyfy"; import {HasMany} from "./relations/hasMany"; import {BelongsTo} from "./relations/belongsTo"; import {InteractsWith} from "./actions/interactsWith"; import {LoadsFrom} from "./actions/loadsFrom"; import {validate} from "validate.js"; /** * Avid * * An active record like approach to consuming an API, inspired by Laravel's Avid * * Example: * * export class User extends Avid{ * get _version(){ * return 'v1' * } * * posts(){ * return this.hasMany(Post, 'posts'); * } * } * * export class Post extends Avid{ * get _version(){ * return 'v1' * } * * user(){ * return this.belongsTo(User); * } * } * * User.find(1) //model fetched from api * .then(user => user.posts) //fetch relations from api * .then(posts => console.log(posts)); //log relations * * Post.find(1) //model fetched from api * .then(post => post.user) //fetch relation from api * .then(post => console.log(post)); //log relation * * let user = new User(); //new up a user * * user.name = 'John' * user.email = 'do@john.com' * user.email = 'secret' * user.save() * .then(user => console.log(user.id)); //1 (saved user through the API) * * User.find(1).then(user => { * user.name = 'jane' * user.email = 'do@jane.com' * return user.save(); * }).then(updatedUser => console.log(updatedUser.name)); //jane */ let AvidConfig = { baseUrl: null, storage: [] }; export class Avid { /** * Used to globally change the baseUrl. This is useful for when your * api is on an other domain or port. * usage: * Avid.baseUrl = 'http://foo.com' * Do not use a trailing slash! * * @param url */ static set baseUrl(url) { AvidConfig.baseUrl = url; return true; } static get baseUrl() { return AvidConfig.baseUrl; } //noinspection JSAnnotator static set storage(value) { AvidConfig.storage = value; return true; } /** * A way to retrieve the storage * @returns {Array} */ static get storage() { return AvidConfig.storage } /** * The prefix to use for requests, this is appended as the first element * to the baseUrl. By default it is 'api'. * usage: * Avid.baseUrl = 'http://foo.com' * class User extends Avid{ * get _prefix(){ * return 'secret' * } * } * * User.find(1) //request is made to http://foo.com/secret/user/1 * * @returns {string} */ get _prefix() { return 'api'; } /** * Gives Avid models the ability to use the correct versioned url. * For example * get _version(){ * return 'v1'; * } * Will result in the following uri: '/api/v1' */ get _version() { return null } /** * The name of the entity. This is used to build the resource url for the entity. * * @returns {string} * @private */ get _name() { return this.constructor.name.toLowerCase(); } /** * The full resource for the entity. * It includes the prefix, version and name. If no version is set * it's not used and the resource will become the prefix and name * * @returns {string} * @private */ get _resource() { return ((this._version === null && this._prefix !== null) ? [this._prefix, this._name] : (this._version === null && this._prefix === null) ? [this._name] : [this._prefix, this._version, this._name] ).join('/'); } get _rules() { return {}; } get _actionsValidator() { return {} } constructor() { /** * A proxy of this object is returned during construction, this way we * can catch when a relation is requested and keep track of * changes made to the model. */ this.properties = {}; this.originals = {}; return this.proxify(); } /** * Fetch all items from the resource. * * @returns {Promise} */ static all() { let model = new this; if (Avid.storage.hasOwnProperty(model._name)) { return map(this, Avid.storage[model._name]); } let api = new Api(model._resource); return api.all().then(response => map(this, response)); } /** * Find as specific model by its id * @param id * @returns {Promise} */ static find(id) { let model = new this; if (Avid.storage.hasOwnProperty(model._name)) { let items = Avid.storage[model._name].filter(model => model.id === id); return (items.length === 1) ? map(this, items[0]) : Promise.reject(); } let api = new Api(model._resource); return api.find(id).then(response => map(this, response)); } /** * Save or update an existing or new model * @returns {Promise} */ save() { let self = this; let api = new Api(self._resource); /** * Validate the model based on validate.js validation rules */ let validation = self.validate(); if (validation !== undefined) return Promise.reject(validation); /** * Resolve directly when there are no changes to the model, saving us * an accidental update or save of an unchanged model. */ if (self.hasChanged === false) return Promise.resolve(self); /** * When we do `let model = new Model();` it has no id yet, this way we can * check if a model needs to be updated or saved */ if (typeof self.id === 'undefined') { /** * When the id is not set, we assume the model is unknown to * the api. A create is necessary in this case so the api server * knows the model needs to be created, not updated. */ return api.create(self.properties) .then(response => map(this, response)) .catch(error => console.warn("Failed saving " + self._name + " due to'", error)); } /** * When there is an id, preform an update of the model */ return api.update(self.properties) .then(response => map(this, response)) } delete() { let self = this; let api = new Api(self._resource); return api.delete(self.id); } /** * Defines a one to many relationship. * * @param relation * @param resource * @param params * @returns {HasMany} */ hasMany(relation, resource, params = null) { return new HasMany(relation, resource, this.proxify(), params); } /** * Defines a many to one relationship. * * @param parent * @returns {BelongsTo} */ belongsTo(parent) { return new BelongsTo(parent, this.proxify()); } /** * Defines an action method to interact with other * entities or itself, in other words: it changes information. * Lets say we have two models: User and Group. * A user can join a group and a group can invite users. * * group.invite(user); //a group invites a user. * user.accept(invite); // a user accepts a invite. * user.join(group); // a user joins a group. * user.leave(group); //a user leaves a group. * group.ban(user); //a group bans a user. * * @param entity * @param source * @param params * @returns {InteractsWith | Promise} */ interactsWith(entity, source, params = null) { //make it possible to omit 'this' when the interaction is with the same object. if (typeof entity === 'string') { params = source; source = entity; entity = this; } let errors = this.validate(source, params); if (errors) { return Promise.reject(errors); } return new InteractsWith(entity, source, params); } /** * Makes it possible to do Model.interactsWith. * This is useful when you have a interaction directly on the * resource, like this: [post] api/v1/group/search. The method for this will be: * * Definition: * export class Group extends Avid{ * * static search(query){ * return this.interactsWith('search', {query: query}; * } * * } * * Usage: * Group.search('stuff').then(result => {...}); * @param source * @param params * @returns {InteractsWith} */ static interacsWith(source, params = null) { let model = new this; return model.interactsWith(this, source, params); } /** * Defines an action method that only retrieves information. It does not make changes. * Lets say we have two models: User and Group. * * A group has metrics and a user has extended profile information * group.metrics(); //get the group metrics * user.extendedProfile(); //gt the extended user profile * * @param entity * @param source * @param params * @returns {LoadsFrom | Promise} */ loadsFrom(entity, source, params = null) { if (typeof entity === 'string') { params = source; source = entity; entity = this; } let errors = this.validate(source, params); if (errors) { return Promise.reject(errors); } return new LoadsFrom(entity, source, params); } /** * Makes it possible to do Model.loadsFrom. * This is useful when you have an action directly on the * resource, like this: [get] api/v1/group/search. The method for this will be: * * Definition: * export class Group extends Avid{ * * static search(query){ * return this.loadsFrom('search', {query: query}; * } * * } * * Usage: * Group.search('stuff').then(result => {...}); * * @param source * @param params * @returns {LoadsFrom} */ static loadsFrom(source, params = null) { let model = new this; return model.loadsFrom(this, source, params); } /** * We need a way to register get and set actions on a model to be able to set * the properties directly on the model. ES6 has no thing like magic methods, instead * it has Proxy's. This is an object that sits in between the object it proxy's and * the object that the actions are being preformed on. * * This way, we can catch get and set operations on a Avid instance! * let user = new User(); * user.name = 'Bram'; * * the set action for the name property is intercepted by the proxy, * setting it on the properties property. By doing this we can keep track of changes and * revert them if needed. * * * @returns {ModelProxy} */ proxify() { return new ModelProxy(this); } /** * Validate the request. When no parameters are given * the default validation rules in this._rules are used to * validate this.properties. When both the parameters are given * it is assumed to be an interaction, thus this._actionsValidator is * used to find a corresponding method name to the resource url, only the last * part in camelCase. For example, when you have the following request: * `[POST] http://my.api.com/v1/user/5/send-post-cart {cart:5, message: 'hello'}` * the method on the validator object returned by this._actionsValidator should be * sendPostCart. This method should return the validate.js object for the parameters. * * @param name * @param values * @returns {*} */ validate(name = null, values = null) { if (name === null && values === null) { return validate(this.properties, this._rules) } if (name === null) { return; } name = name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); name = name.charAt(0).toLowerCase() + name.slice(1); if (typeof this._actionsValidator[name] == 'function') { return validate(values, this._actionsValidator[name]()); } } reset(error) { let self = this; Object.keys(this.originals).forEach(key => { if (typeof self[key] !== 'function') { self[key] = self.originals[key]; } }); return Promise.reject(error); } static fill() { Object.keys(avidItems).forEach(modelName => { Avid.storage[modelName] = avidItems[modelName]; }); } }