adaptorex
Version:
Connect all your live interactive storytelling devices and software
782 lines (689 loc) • 29.7 kB
JavaScript
/**
*
* 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