UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

782 lines (689 loc) 29.7 kB
/** * * topics are the main features of each game. * * Each topic creates a connection between API and Database and allows to create, remove and modify elements. * * @requires express * @requires jsonschema * @requires query-types * * @module topic * @copyright Lasse Marburg 2021 * @license MIT */ const { EventEmitter } = require('events') const express = require('express') const jsonschema = require('jsonschema') const { MongoError } = require('mongodb') const queryTypes = require("query-types") /** @typedef {import('./mongo').Collection} DatabaseCollection */ /** * @typedef {Object} Issue - An expected error that did not break the original request but should nevertheless be reported * @property {string} name - The error types name * @property {string} message - The error message including (appended) causing error messages */ /** * @typedef {Object} Issue - An expected error that did not break the original request but should nevertheless be reported * @property {string} name - The error types name * @property {string} message - The error message including (appended) causing error messages */ /** * Install routes for POST, GET, PUT, PATCH and DELETE requests. * * extended methods: * * - GET * Will create a GET endpoint both at this topics root / and /{entity} * * - EDIT * Allow updating an entity using update operators like set, remove, push, pull... * Creates a POST endpoint at /{entity}/{operator} * * - SET * define a property for each topic entity that can be updated via PUT using en extended path * */ class Topic { /** * @param {Object} setup - topic setup options * @param {string} setup.name - topic name * @param {Object} [setup.coll_name] - database collection name of database collection that will be associated with topic API * @param {DatabaseCollection} [setup.collection] - database collection object that will be associated with topic API * @param {Object} setup.schema - path, query and body schema to validate request data against * @param {Object} setup.default - a default entity object that is created on POST * @param {Object} setup.main_properties - list of properties that should be included in preview * @param {string[]} setup.methods - what methods to enable for this topic. EDIT enables update operators for POST requests * @param {boolean} setup.uniqueNames - restrict names to be unique in db collection * @param {boolean} setup.cast_query_types_default - If set to 'true' GET query strings are cast to number or bool if they can be converted. Can be prevented for each request with `_cast_types="off"` query parameter * @param {"data"|"plugin"} setup.data_type - Wether the items in the associated collection are of type "data" or "plugin" * @param {import('./game').Game} game - Game Interface of game that inherits the Topic */ constructor(setup, game) { Object.assign(this, setup) /** @type {import('./game').Game} */ this.game = game /** event emitter for add/remove/change updates * @type {events.EventEmitter} */ this.event = new EventEmitter() this.log = log.getContextLog(`${this.game.name} ${this.name} api`) this.schema.query = { type: "object", properties: { anyOf: this.schema.query } } this.default = setup.default this.router = express.Router({mergeParams: true}) /** potential JS Object instances of collection documents */ this.items = [] this.writeProtectedProperties = ["created_at","created_by","created_with","sessions"] this.validator = new jsonschema.Validator() /** stores functions that are called on specific operators for entities */ this.custom_operator = {} if(this.methods.includes("POST")) { this.router.post('/', (req, res) => { if (!this.valid(req.body, this.schema.body, res, 400)) return this.create(req.body, req) .then(result => { res.status(201).send(result) }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("GET")) { this.router.get('/', (req, res) => { if (!this.valid(req.query, this.schema.query, res, 400)) return if(req.query._cast_types) { if(req.query._cast_types == "true") { req.query = queryTypes.parseObject(req.query) } delete req.query._cast_types } else if(this.cast_query_types_default) { req.query = queryTypes.parseObject(req.query) } let options = {} if (req.query._sort_by) { options.sort = {} if (req.query._sort_direction) { options.sort[req.query._sort_by] = req.query._sort_direction } else { options.sort[req.query._sort_by] = 1 } delete req.query._sort_by } if(typeof req.query._sort_direction !== "undefined") { delete req.query._sort_direction } if(typeof req.query._limit !== "undefined") { options["limit"] = req.query._limit delete req.query._limit } if(typeof req.query._skip !== "undefined") { options["skip"] = req.query._skip delete req.query._skip } this.getMany(req.query, options) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`no ${this.name} items found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) this.router.get('/:entity', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.get(req.params.entity) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("PUT")) { this.router.put('/:entity', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.update(req.params.entity, req.body, req) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("EDIT")) { this.router.post('/:entity/_:operator', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.edit(req.params.entity, req.params.operator, req.body, req) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("SET")) { this.router.put('/:entity/:field', (req, res) => { this.log(req.body) if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body["properties"][req.params.field], res, 400)) return let query = {} query[req.params.field] = req.body this.edit(req.params.entity, "set", query, req) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("PATCH")) { this.router.patch('/:entity', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.edit(req.params.entity, "set", req.body) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } if(this.methods.includes("DELETE")) { this.router.delete('/:entity', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.delete(req.params.entity, req) .then(result => { if (result) { res.status(204).send() } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } } /** * Create database if it does not exist and add index fields and change listener * @returns {Promise} */ async setup() { if(!this.coll_name) { return } if (!this.game.db.hasOwnProperty(this.coll_name)) { await this.game.db.addCollection(this.coll_name) this.db_coll_created = true } /** @type {DatabaseCollection} */ this.collection = this.game.db[this.coll_name] if(this.uniqueNames) { this.collection.createIndex("name", true) } if(this.changes) { this.game.changes[this.name] = this.collection.changes({ fullDocument: 'updateLookup' }) this.game.changes[this.name].on("change", this.documentChanges.bind(this)) } } addCustomOperator(operator, callback) { this.custom_operator[operator] = callback } /** * Validate incoming data against json schema. Respond to request if validation did fail. * * @param {*} val - value to validate against schema * @param {*} schema - validation schema * @param {*} res - express response object * @param {*} code - response code if validation fails * @returns {boolean} false if validation failed. true if validation succeeded */ valid(val, schema, res, code) { let validation = this.validator.validate(val, schema) if (!validation.valid) { this.log.error(validation.errors) res.status(code).send({name:validation.errors.name, message:validation.toString()}) return false } return true } /** * Make error specific response for AdaptorErrors. Otherwise, respond with 500 internal server error. * * @param {Error} err - error object that was caught * @param {Object} res - request response object * @returns undefined */ errorHandling(err, res) { this.log.error(err) if(err.cause) { err.message = err.message + '\n' + err.cause.message this.log.error(err.cause.message) } if(err instanceof adaptor.NotFoundError) { return res.status(404).send({name:err.name,message:err.message}) } else if (err instanceof adaptor.AdaptorError) { return res.status(400).send({name:err.name,message:err.message}) } else if (err instanceof MongoError && err.code == 11000 || err.errorType == 'uniqueViolated') { return res.status(400).send({name:"DuplicateError",message:err.message}) } res.status(500).send({name:err.name, message:err.message, stack:err.stack}) } /** * Add "issue" {@link Issue} property to a request response based on an error that was caught. * The Error will be visible to the client but not indicate that the original operation failed * and it will not skip the rest of the operation. * * Only Errors that are known and do not cause unhandled follow up problems should be appended and * only instances of the AdaptorError Error will be appended as issue. * * @param {Error} err - the error that ocurred during the request * @param {Object} response - response object the issue shall be appended to */ appendIssue(err, response) { if(!(err instanceof adaptor.AdaptorError)) { throw err } this.log.error(`Issue ${err.name}: ${err.message}`) if(!response.issues) { response.issues = [] } let issue = { name: err.name, message: err.message } if(err.cause) { issue.message = err.message + '\n' + err.cause.message this.log.error(err.cause.message) } response.issues.push(issue) } /** * Publish the current topics documents (preview) to connected clients if topic has a socket namespace. */ async publish() { if(this.client) { let preview = await this.preview() this.client.emit(this.name, preview) } } /** * Publish Changes in collection data. * * Skip changes of metadata like modified_at * * @param {Object} change - mongo (style) db change stream data */ documentChanges(change) { switch(change.operationType) { case 'insert': if(change.fullDocument) { this.documentsChanged([change.fullDocument]) } case 'replace': case 'update': if(change.hasOwnProperty("updateDescription")) { if(change.updateDescription.hasOwnProperty("updatedFields")) { if(change.updateDescription.updatedFields.modified_at || change.updateDescription.updatedFields.sessions) { if(change.fullDocument) { this.documentsChanged([change.fullDocument]) } else { this.log.error("Can not publish update event. Change update did not provide updated document. This may be due to a race condition for an update event on a just about deleted document") } } } } break case 'delete': this.documentsRemoved([change.documentKey._id]) break } } documentsChanged(documents) { documents = documents.map(document => { if(this.data_type) { document['type'] = this.data_type } if(this.plugin) { document['plugin'] = this.plugin } document['collection'] = this.name return document }) this.client.emit("changed", documents) } documentsRemoved(ids) { this.client.emit("removed", ids) } /** * add a document to topic collection. * Add default properties if they are not specified. * Append creation date and user. * * @param {Object} data - data object that will be inserted into collection * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * @param {string} origin.originalUrl - request URL * * @returns {Promise<object>} mongo id string of created document, created_at, created_by and created_with (version) information */ async create(data, origin) { if (data.hasOwnProperty('_id')) { delete data._id } for(let def in this.default) { if(!data.hasOwnProperty(def)) { data[def] = this.default[def] } } if(!data.created_at) { data['created_at'] = adaptor.now() } if(!data.modified_at) { data['modified_at'] = adaptor.now() } if(!data.created_by) { data['created_by'] = origin.user.login } if(!data.modified_by) { data['modified_by'] = origin.user.login } if(!data.created_with) { data['created_with'] = adaptor.info.version } if(!data.modified_with) { data['modified_with'] = adaptor.info.version } let insert = await this.collection.insert(data) this.publish() let return_val = { created_id:insert.insertedId, created_at:data.created_at, created_by:data.created_by, created_with:data.created_with } if(origin.originalUrl) { return_val["created_path"] = origin.originalUrl + '/' + insert.insertedId } return return_val } /** * find and return documents by name or id * * @param {string} id - unique identifier for requested document * @returns {Object} requested document or undefined if none was found */ async get(id) { let docs = await this.collection.find(this.getIndexQuery(id)) if(!docs.length) { throw new adaptor.NotFoundError(`${this.name} ${id} was not found`) } return docs[0] } /** * find and return documents by query * * @param {Object} query - find query that matches the requested documents * @param {Object} [options] - Additional query options * @param {Object<string,integer>} [options.sort] - order definition. Order by <key>: <direction> * @param {integer} [options.limit] - maximum number of documents * @param {integer} [options.skip] - return only from document n * @returns {array|undefined} list of requested documents or undefined if none where found */ async getMany(query, options) { let docs = await this.collection.find(query, options) return docs } /** * Get a js item from this collection based on the item documents id. * The topic stores all items that have been requested at some point. * Items are a js representation of the collection document. * * @param {string} id - unique identifier for requested item * @returns {Promise<object>} - The requested item */ async getItem(id) { let document = await this.get(id) if(this.items[document._id]) { return this.items[document._id] } else { this.items[document._id] = document return this.items[document._id] } } /** * Find a js item from in this topics collection based on a mongo find query. * The topic stores all items that have been requested at some point. * Items are a js representation of the collection document that is updated on each request. * * @param {Object} query - mongodb find query * @returns {Promise<object>} - The requested item */ async queryItem(query) { let documents = await this.collection.find(query) if(!documents.length) { throw new adaptor.NotFoundError(`Could not find item. No document with ${JSON.stringify(query)} in ${this.name}`) } if(this.items[documents[0]._id]) { return this.items[documents[0]._id] } else { this.items[documents[0]._id] = documents[0] return this.items[documents[0]._id] } } /** * Get list of a preview of all documents. * * @returns {Promise<array>} preview list of documents containing only most important fields */ async preview() { let projection = {"created_by":1,"created_at":1,"created_with":1,"modified_by":1,"modified_at":1,"modified_with":1} for(let prop of this.main_properties) { projection[prop] = 1 } return this.collection.find({}, {project: projection}) } /** * @typedef {Object} Changes * @property {number} changed - amount of changed documents (0-n) * @property {Date} [modified_at] - date and time of modification * @property {string} [modified_by] - username or name of operation that initiated the update * @property {string} [modified_with] - version number of adaptor:ex server the update was made with */ /** * replace (overwrite) document with new values. * * @param {string} doc - document identifier, a query or a name or _id property * @param {Object} data - new document values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} "changed:1" and modified_at, modified_by and modified_with if document has changed */ async update(doc, data, origin) { if (data.hasOwnProperty('_id')) { delete data._id } let query = this.getIndexQuery(doc) let original_docs = await this.collection.find(query) if(original_docs.length) { let original_doc = original_docs[0] if(data.hasOwnProperty("modified_at")) { data.modified_at = new Date(data.modified_at) } else { data.modified_at = 0 } if(original_doc.hasOwnProperty("modified_at") && original_doc.modified_at > data.modified_at && !data._forceUpdate) { throw new adaptor.OutdatedError(`Can not replace newer document ${original_doc._id} in ${this.collection.name}, last modified ${original_doc.modified_at}, with older version, last modified ${data.modified_at}.`) } if(data._forceUpdate) { delete data._forceUpdate } for(let property of this.writeProtectedProperties) { if(original_doc.hasOwnProperty(property)) { data[property] = original_doc[property] } } } let response = await this.collection.replace(query, data) return this.reportChanges(response, query, origin.user) } /** * Change document * * Use operators to make specific updates. Maps to any mongo update operator: https://docs.mongodb.com/manual/reference/operator/update/ * remove = $unset * add = $addToSet * * @param {string} doc - document identifier, a query or a name or _id property * @param {string} operator - update operator set, remove, push or add * @param {Object} data - update values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} contains modified information and changed is 1-n if one or more element have changed */ async edit(doc, operator, data, origin) { if(this.custom_operator.hasOwnProperty(operator)) { return await this.custom_operator[operator](doc, data, origin) } if (data.hasOwnProperty('_id')) { delete data._id } let updt = {} if(operator == "remove") { updt['$unset'] = data } else if(operator == "add") { updt['$addToSet'] = data } else { updt['$' + operator] = data } let query = this.getIndexQuery(doc) let response = await this.collection.update(query, updt) return this.reportChanges(response, query, origin.user) } /** * Change one document. The first document found based on the `doc` query will be edited. * * Use operators to make specific updates. Maps to any mongo update operator: https://docs.mongodb.com/manual/reference/operator/update/ * remove = $unset * add = $addToSet * * @param {string} doc - document identifier, a query or a name or _id property * @param {string} operator - update operator set, remove, push or add * @param {Object} data - update values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} contains modified information and changed is 1-n if more than one element has changed */ async editOne(doc, operator, data, origin) { let query = this.getIndexQuery(doc) let document = await this.collection.find(query) if(document.length) { return this.edit({_id: document[0]._id}, operator, data, origin) } } /** * Get query based on unique property like _id or name. * * @param {string|object} doc - document identifier, a query, or a name or _id property * @returns {Object} find query */ getIndexQuery(doc) { if(typeof doc === "string") { if(this.collection.validId(doc)) { return { $or: [{ _id: doc }, { name: doc }] } } else { return {name: doc} } } return doc } /** * create response object depending on update outcome * * @param {Object} updateResponse - information about update process * @param {number} updateResponse.matchedCount - amount of elements that where found and updated * @param {number} updateResponse.modifiedCount - amount of elements that changed during updated * @param {string} query - find query with identifier for element to apply update information * @param {Object} user - request user data * @param {Object} [changes={}] - meta change information that is appended to the document and included in the request return value * * @returns {Promise<Changes|undefined>} modification information and changed = <number of changed documents> (0 or more). Resolves undefined if document was not found */ async reportChanges(updateResponse, query, user, changes={}) { if (updateResponse.matchedCount == 0) { return undefined } else if (updateResponse.modifiedCount == 0) { return {changed:0} } changes['modified_at'] = adaptor.now() changes['modified_by'] = user.login changes['modified_with'] = adaptor.info.version await this.collection.set(query, changes) changes['changed'] = updateResponse.modifiedCount changes.query = query return changes } /** * * @param {string} doc - document identifier, a query, or a name or _id property * @returns {Promise<string|undefined>} returns count of deleted documents or undefined if none was deleted */ async delete(doc, options) { let response = await this.collection.delete(this.getIndexQuery(doc), options) this.publish() if(response == 0) { return undefined } else { return response + " documents deleted" } } /** * remove all routes */ close() { let i = this.router.stack.length while(i--) { let routing = this.router.stack.splice(i, 1) if(routing[0].route) { this.log.trace(`Express route removed: ${Object.keys(routing[0].route.methods)[0]} ${routing[0].route.path}`) } } this.log.debug("Topic closed") } } module.exports.Topic = Topic