class StructuredNode { constructor(json = {}) { if (this.constructor === StructuredNode) { throw new TypeError('Cannot instantiate object of abstract type StructuredNode!') } this.json = json this.changes = {} this.definition = StructuredNode.classes[this.constructor.name] this.hooks = [] this.deleted = false this.__name__ = this.constructor.name.toLowerCase() // holds a list of all relations that are already loaded this._update_json() if (StructuredNode.instances[this.__name__][this.json.uid]) { console.warn('two instances of ' + this.__name__ + '?') } if (!json.uid) { this.changes = this.json } } _update_json(json) { let old_json = this.json; this.__loaded__ = []; if (json) { this.json = json } else { old_json = {} } Object.values(this.definition.rels).forEach(rel => { // if we did not recieve an update for this relation, don't touch it! // we might have gotten an update without relational information if (this.json[rel.field] === undefined) { return } this.__loaded__.push(rel.field) const type = StructuredNode.classes[rel.target].klass; if (rel.cardinality.endsWith('OrMore')) { this.json[rel.field] = this.json[rel.field].map(x => type.from_json(x)) } else { if (this.json[rel.field] === null) { return } this.json[rel.field] = type.from_json(this.json[rel.field]) } }) Object.values(this.definition.props).forEach(prop => { // no update for this prop? don't put anything in. if (this.json[prop.field] === undefined || this.json[prop.field] === null) return this.json[prop.field] = PropertyConversion.deserialize(prop.type, this.json[prop.field]) }) // keep fields from old_json which are not defined in the new json Object.keys(old_json).forEach(key => { if (this.json[key] === undefined) { this.json = old_json[key]; // keep loaded state if (this.definition.rels[key]) { this.__loaded__.push(key) } } }) return this } check(name, new_value) { return true } _link_relation(name, elm) { const relation = this.definition.rels[name] if (!relation) { throw new ApiError(`Cannot find realation ${name} of object ${this.constructor.name}!`, 404, {obj: this, elm: elm}) } const type = StructuredNode.classes[relation.target].klass if (typeof elm == "string") { elm = type.by_id(elm) } if (elm instanceof type) { if (!elm.exists() || !this.exists()) { throw new ApiError("Cannot connect object before it exists on the backend!") } // make results available immediately if (relation.cardinality.endsWith('OrMore')) { if (this.json[name] && !this.json[name].filter(e => e.json.uid === elm.json.uid)) { this.json[name].push(elm) } } else { this.json[name] = elm } return Api.post(`/${this.__name__}/${this.json.uid}/${name}/${elm.json.uid}`).then(json => { this._update_json(json) this.update('link') return this }) } } _unlink_relation(name, type, elm) { return Api.delete(`/${this.__name__}/${this.json.uid}/${name}/${elm.json.uid}`).then(json => { this._update_json(json) this.update('link') return this }) } _get_relation(name) { if (this.__loaded__.indexOf(name) > -1) { return Promise.resolve(this.json[name]) } else { return this.refresh().then(elm => elm.json[name]) } } _set(name, new_val) { const def = this.definition.props[name] if (!def) throw new Error("No attribute " + name + " found!") if (this.check(def, new_val)) { this.json[name] = new_val this.changes[name] = PropertyConversion.serialize(def.type, new_val) } } save() { if (this.deleted) { throw new ApiError("Cannot save deleted object!", {obj: this}) } if (this.json.uid) { return Api.put(`/${this.__name__}/${this.json.uid}`, this.changes).then(json => { this._update_json(json) this.changes = {} this.update('save') return this }) } else { return Api.post('/' + this.__name__, this.changes).then(json => { this._update_json(json) this.changes = {} this.update('created') return this }) } } delete() { if (!this.exists()) { throw new ApiError("Cannot delete object that does not exist yet!", {obj: this}) } this.deleted = true Api.delete(`/${this.__name__}/${this.json.uid}`).then(r => { this.update('deleted') return this }) delete StructuredNode.instances[this.__name__][this.json.uid] } refresh() { return Api.get(`/${this.__name__}/${this.json.uid}`).then(json => { this._update_json(json) this.update('refresh') return this }) } exists() { return !this.deleted && !!this.json.uid } onupdate(callback) { this.hooks.push(callback) } update(event) { this.hooks.forEach(cb => { try { cb(this, event) } catch (e) { console.error(`Error while running update on ${this.constructor.name} update function ${cb}: ${e}`) console.error(cb, event, e) } }) } static registerClassDefinition(klass, props, rels) { console.log(klass, props, rels) StructuredNode.classes[klass.name] = {klass, props, rels} StructuredNode.instances[klass.name.toLowerCase()] = {} } static by_id(uid) { return Api.get('/' + this.name.toLowerCase() + '/' + uid) .then(x => this.from_json(x)) } static filter(attributes = {}, settings = {}) { Object.keys(settings).forEach(key => { attributes['$' + key] = settings[key] }) return Api.get(`/${this.name.toLowerCase()}?` + Api.queryParams(attributes)) .then(json => json.map(x => this.from_json(x))) } static find(attributes) { return this.filter(attributes, {limit: 1}) .then(r => r.length === 0 ? null : r[0]) } static from_json(json) { if (json.uid && StructuredNode.instances[this.name.toLowerCase()][json.uid]) { return StructuredNode.instances[this.name.toLowerCase()][json.uid]._update_json(json) } const obj = new this(json) StructuredNode.instances[this.name.toLowerCase()][json.uid] = obj return obj } } StructuredNode.classes = {} StructuredNode.instances = {} class Api { static set_auth(token) { Api.token = token; } static get(url) { return fetch('/api' + url, {headers: Api.headers()}).then(r => { if (r.ok) { return r.json() } else { // evaluate response and raise error return ApiError.fromResponse(r) } }) } static post(url, body="") { return fetch('/api' + url, {headers: Api.headers(), body: JSON.stringify(body), method: 'POST'}).then(r => { if (r.ok) { return r.json() } else { // evaluate response and raise error return ApiError.fromResponse(r) } }) } static put(url, body) { return fetch('/api' + url, {headers: Api.headers(), body: JSON.stringify(body), method: 'PUT'}).then(r => { if (r.ok) { return r.json() } else { // evaluate response and raise error return ApiError.fromResponse(r) } }) } static delete(url) { return fetch('/api' + url, {headers: Api.headers(), method: 'DELETE'}).then(r => { if (r.ok) { return r } else { // evaluate response and raise error return ApiError.fromResponse(r) } }) } static headers() { return { 'Content-Type': 'application/json', // 'Authorization': 'Bearer ' + Api.token } } static queryParams(...dicts) { return dicts.map(dict => Object.keys(dict).map(key => encodeURIComponent(key)+'='+encodeURIComponent(dict[key])).join('&') ).join('&') } } class ApiError { constructor(msg, code, data) { this.msg = msg this.code = code this.data = data } static fromResponse(r) { if (r.headers.get('Content-Type') === 'application/json') { return r.json().then(json => { throw new ApiError(json.msg, r.status, json.data) }) } else { return r.text().then(text => { throw new ApiError(text, r.status) }) } } } class PropertyConversion { static serialize(type, value) { if (PropertyConversion.to[type]) { return PropertyConversion.to[type](value) } return value } static deserialize(type, value) { if (PropertyConversion.to[type]) { return PropertyConversion.from[type](value) } return value } } PropertyConversion.from = { DateTimeProperty: (val) => new Date(val), // assume local time DateProperty: (val) => new Date(val) // ensure no minutes/hours are added/subtracted from date } PropertyConversion.to = { DateTimeProperty: (val) => val.toISOString().slice(0,-1), DateProperty: (val) => `${val.getFullYear()}-${val.getMonth()+1}-${val.getDate()}` }