import stampit from 'stampit'; import Promise from 'bluebird'; import _ from 'lodash'; import {validate} from '../utils'; import QuerySet from '../querySet'; import Request from '../request'; import {ValidationError} from '../errors'; import {ConfigMixin, MetaMixin, ConstraintsMixin} from '../utils'; import {omitBy, pick, mapValues} from 'lodash/fp'; /** * Object which holds whole configuration for {@link Model}. * @constructor * @type {Meta} * @property {String} [name = null] * @property {String} [pluralName = null] * @property {Array} [properties = []] * @property {Array} [endpoints = {}] * @property {Array} [relatedModels = undefined] * @example {@lang javascript} * var MyMeta = Meta({name: 'test'}); * var MyModel = SomeModel.setMeta(MyMeta); */ export const Meta = stampit() .props({ name: null, pluralName: null, properties: [], endpoints: {}, batchMap: { create: { method: 'POST', endpoint: 'list' }, update: { method: 'PATCH', endpoint: 'detail' }, delete: { method: 'DELETE', endpoint: 'detail' } } }) .init(function({ instance }) { _.forEach(instance.endpoints, (value) => { value.properties = this.getPathProperties(value.path); instance.properties = _.union(instance.properties, value.properties); }); }) .methods({ /** * Gets required properties from object. Used mostly during serialization. * @memberOf Meta * @instance * @param {Object} object * @returns {Object} */ getObjectProperties(object) { return _.reduce(this.properties, (result, property) => { result[property] = object[property]; return result; }, {}); }, /** * Makes a copy of target and adds required properties from source. * @memberOf Meta * @instance * @param {Object} source * @param {Object} target * @returns {Object} */ assignProperties(source, target) { const dateFields = this.convertDateFields(target); return _.assign({}, this.getObjectProperties(source), target, dateFields); }, convertDateFields(object) { return _.flow([ omitBy(_.isNull), pick(['created_at', 'updated_at', 'executed_at']), mapValues((o) => new Date(o)) ])(object); }, getPathProperties(path) { const re = /{([^}]*)}/gi; let match = null; let result = []; while ((match = re.exec(path)) !== null) { result.push(match[1]); } return result; }, resolveActionToPath(action, model) { return this.resolveEndpointPath(this.batchMap[action].endpoint, model); }, resolveActionToMethod(action) { return this.batchMap[action].method; }, /** * Resolves endpoint path e.g: `/v1.1/instances/{name}/` will be converted to `/v1.1/instances/someName/`. * @memberOf Meta * @instance * @param {String} endpointName * @param {Object} properties * @returns {String} */ resolveEndpointPath(endpointName, properties) { if (_.isEmpty(this.endpoints[endpointName])) { return Promise.reject(new Error(`Invalid endpoint name: "${endpointName}".`)); } const endpoint = this.endpoints[endpointName]; const diff = _.difference(endpoint.properties, _.keys(properties)); let path = endpoint.path; if (diff.length) { return Promise.reject(new Error(`Missing path properties "${diff.join()}" for "${endpointName}" endpoint.`)); } _.forEach(endpoint.properties, (property) => { path = path.replace(`{${property}}`, properties[property]); }); return path; }, /** * Looks for the first allowed method from `methodNames` for selected endpoint. * @memberOf Meta * @instance * @param {String} endpointName * @param {...String} methodNames * @returns {String} */ findAllowedMethod(endpointName, ...methodNames) { const endpoint = this.endpoints[endpointName]; const methods = _.intersection(_.map(methodNames, (m) => m.toLowerCase()), endpoint.methods); if (_.isEmpty(methods)) { return Promise.reject(new Error(`Unsupported request methods: ${methodNames.join()}.`)); } return methods[0]; } }); export const Rename = stampit().methods({ /** * Method used for making requests to the 'rename' endpoint in models. * @memberOf Model * @instance * @param {Object} payload object containing the payload to be sent * @returns {Promise} * @example {@lang javascript} * Model.rename({ new_name: 'new_name'}).then(function(model) {}); */ rename(payload = { new_name: this.name }) { const meta = this.getMeta(); const path = meta.resolveEndpointPath('rename', this); return this.makeRequest('POST', path, {payload}) .then((response) => { return this.serialize(response); }) } }); /** * Base {@link https://github.com/stampit-org/stampit|stamp} for all models which wraps all raw JavaScript objects. * **Not** meant to be used directly more like mixin in other {@link https://github.com/stampit-org/stampit|stamps}. * @constructor * @type {Model} * @property {Syncano} _config private attribute which holds {@link Syncano} object * @property {Meta} _meta private attribute which holds {@link Meta} object * @property {Object} _constraints private attribute which holds validation constraints * @property {Request} _request private attribute which holds {@link Request} configuration * @property {Request} _querySet private attribute which holds {@link QuerySet} stamp * @example {@lang javascript} * var MyModel = stampit() .compose(Model) .setMeta(MyMeta) .setConstraints(MyConstraints); */ export const Model = stampit({ refs: { _querySet: QuerySet }, static: { /** * Sets {@link QuerySet} and returns new {@link https://github.com/stampit-org/stampit|stampit} definition. * @memberOf Model * @static * @param {QuerySet} querySet {@link QuerySet} definition * @returns {Model} * @example {@lang javascript} * var MyStamp = stampit().compose(Model).setQuerySet({}); */ setQuerySet(querySet) { return this.refs({_querySet: querySet}); }, /** * Gets {@link QuerySet} from {@link https://github.com/stampit-org/stampit|stampit} definition. * @memberOf Model * @static * @returns {QuerySet} * @example {@lang javascript} * var querySet = stampit().compose(Model).getQuerySet(); */ getQuerySet() { return this.fixed.refs._querySet; }, /** * Returns {@link QuerySet} instance which allows to do ORM like operations on {@link https://syncano.io/|Syncano} API. * @memberOf Model * @static * @param {Object} [properties = {}] some default properties for all ORM operations * @returns {QuerySet} * @example {@lang javascript} * MyModel.please().list(); */ please(properties = {}) { const querySet = this.getQuerySet(); const defaultProps = _.assign({}, this.getDefaultProperties(), properties); const {mapDefaults} = this.getMeta(); _.forOwn(defaultProps, (v, k) => { if(_.has(mapDefaults, k)) { _.set(defaultProps, [mapDefaults[k]], v) _.unset(defaultProps, k); } }); return querySet({ model: this, properties: defaultProps, _config: this.getConfig() }); }, /** * Used only for serialization for raw object to {@link https://github.com/stampit-org/stampit|stamp}. * @memberOf Model * @static * @param {Object} rawJSON * @param {Object} [properties = {}] some default properties which will be assigned to model instance * @returns {Model} */ fromJSON(rawJSON, properties = {}) { const meta = this.getMeta(); const attrs = meta.assignProperties(properties, rawJSON); return this(attrs); } }, methods: { /** * Checks if model instance if already saved. * @memberOf Model * @instance * @returns {Boolean} */ isNew() { return (!_.has(this, 'links') && !_.has(this, 'update')); }, /** * Validates current model instance in context of defined constraints. * @memberOf Model * @instance * @returns {Object|undefined} */ validate() { const constraints = this.getConstraints(); const attributes = this.toJSON(); if (_.isEmpty(constraints)) { return; } return validate(attributes, constraints); }, /** * Serializes raw JavaScript object into {@link Model} instance. * @memberOf Model * @instance * @returns {Model} */ serialize(object) { const meta = this.getMeta(); return this.getStamp()(meta.assignProperties(this, object)); }, /** * Creates or updates the current instance. * @memberOf Model * @instance * @returns {Promise} */ save() { const meta = this.getMeta(); const errors = this.validate(); let path = null; let endpoint = 'list'; let method = 'POST'; let payload = this.toJSON(); if (!_.isEmpty(errors)) { return Promise.reject(new ValidationError(errors)); } try { if (!this.isNew()) { endpoint = 'detail'; method = meta.findAllowedMethod(endpoint, 'PUT', 'PATCH', 'POST'); } path = meta.resolveEndpointPath(endpoint, this); } catch(err) { return Promise.reject(err); } return this.makeRequest(method, path, {payload}).then((body) => this.serialize(body)); }, update() { this.update = true; return this.save(); }, /** * Removes the current instance. * @memberOf Model * @instance * @returns {Promise} */ delete() { const meta = this.getMeta(); const path = meta.resolveEndpointPath('detail', this); return this.makeRequest('DELETE', path); }, toBatchObject(action) { const meta = this.getMeta(); return { method: meta.resolveActionToMethod(action), path: meta.resolveActionToPath(action, this), body: this.toJSON() } }, toJSON() { const attrs = [ // Private stuff '_config', '_meta', '_request', '_constraints', '_querySet', // Read only stuff 'links', 'created_at', 'updated_at' ]; return _.omit(this, attrs.concat(_.functions(this).concat(_.functionsIn(this)))); } } }) .init(({instance, stamp}) => { if (!stamp.fixed.methods.getStamp) { stamp.fixed.methods.getStamp = () => stamp; } if(_.has(instance, '_meta.relatedModels')) { const relatedModels = instance._meta.relatedModels; const properties = instance._meta.properties.slice(); const last = _.last(properties); const lastIndex = _.lastIndexOf(properties, last); properties[lastIndex] = _.camelCase(`${instance._meta.name} ${last}`); let map = {}; map[last] = properties[lastIndex]; map = _.reduce(properties, (result, property) => { result[property] = property; return result; }, map); _.forEach(instance.getConfig(), (model, name) => { if(_.includes(relatedModels, name)) { instance[model.getMeta().pluralName] = (_properties = {}) => { const parentProperties = _.reduce(map, (result, target, source) => { const value = _.get(instance, source, null); if (value !== null) { result[target] = value; } return result; }, {}); return stampit().compose(model).please(_.assign(parentProperties, _properties)); }; } }); } if(_.has(instance, '_config')) _.defaults(instance, instance.getDefaultProperties()); }) .compose(ConfigMixin, MetaMixin, ConstraintsMixin, Request); export default Model;