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;