├── .dockerignore ├── .gitignore ├── .npmignore ├── .typedoc-plugin-external-module-name.js ├── LICENSE.txt ├── README.md ├── lib ├── Collection.ts ├── Field.ts ├── Resource.ts ├── Schema.ts ├── State.ts ├── abstract │ ├── @.ts │ ├── Controllable.ts │ ├── Validatable.ts │ └── index.ts ├── cache │ ├── LocalCache.ts │ └── index.ts ├── client │ ├── Client.ts │ └── index.ts ├── control │ ├── Controller.ts │ ├── Manager.ts │ ├── Operation.ts │ ├── Router.ts │ └── index.ts ├── fields │ ├── Boolean.ts │ ├── Email.ts │ ├── Enum.ts │ ├── Float.ts │ ├── Hash.ts │ ├── Id.ts │ ├── Integer.ts │ ├── Number.ts │ ├── Text.ts │ ├── Word.ts │ └── index.ts ├── index.ts ├── protocol │ ├── http.ts │ ├── index.ts │ ├── sse.ts │ └── ws.ts └── utility │ ├── Callable.ts │ ├── Relation.ts │ └── index.ts ├── package-lock.json ├── package.json ├── test ├── __test__ │ └── User.test.ts ├── etc │ └── database.ts ├── fields │ └── MongoId.ts ├── index.html ├── index.js ├── index.ts └── resources │ ├── Comment.ts │ ├── Session.ts │ └── User.ts ├── tsconfig.browser.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | test/etc/secrets.ts 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build/test 2 | lib 3 | node_modules 4 | test 5 | test/etc/secrets.ts -------------------------------------------------------------------------------- /.typedoc-plugin-external-module-name.js: -------------------------------------------------------------------------------- 1 | module.exports = function customMappingFunction(explicit, implicit) { 2 | return implicit === 'lib' ? 'synapse' : implicit.replace('lib/', ''); 3 | }; 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Madison Brown, Mark Lee, Denys Dekhtiarenko, Hang Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # synapse 2 | 3 | Real-Time API Library 4 | 5 | - [Homepage](https://synapsejs.org) 6 | - [Quickstart](https://synapsejs.org/quickstart/installation/) 7 | - [Guide](https://synapsejs.org/guide/overview/) 8 | - [Reference](https://synapsejs.org/reference/modules/synapse/) 9 | 10 | _Synapse_ is an open-source JavaScript library offering an elegant solution to the challenge of orchestrating input validation and normalization, response caching, and state synchronization between clients in distributed, real-time applications. [Learn more »](https://synapsejs.org/quickstart/installation/) 11 | 12 | ### Authors 13 | 14 | - [Madison Brown](https://github.com/madisonbrown) 15 | - [Denys Dekhtiarenko](https://github.com/denskarlet) 16 | - [Mark Lee](https://github.com/markcmlee) 17 | - [Hang Xu](https://github.com/nplaner) 18 | -------------------------------------------------------------------------------- /lib/Collection.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable import/no-cycle */ 3 | /* eslint-disable import/extensions */ 4 | /* eslint-disable class-methods-use-this */ 5 | /* eslint-disable max-classes-per-file */ 6 | 7 | import State from './State'; 8 | import Resource from './Resource'; 9 | 10 | /** Represents of a collection of {@linkcode Resource} instances. As the {@linkcode Collection} class inherits from {@linkcode State}, instances of {@linkcode Collection} also represent valid request responses. */ 11 | export default class Collection extends State { 12 | resources: Array; 13 | 14 | constructor(resources: Array) { 15 | super(200); 16 | 17 | this.resources = []; 18 | 19 | resources.forEach((el) => { 20 | if (!(el instanceof Resource)) { 21 | throw new Error('Expected array containing only values of type Resource.'); 22 | } 23 | this.resources.push(el); 24 | }); 25 | 26 | this.resources.forEach((el) => this.$dependencies.push(...el.$dependencies)); 27 | } 28 | 29 | render(): object { 30 | return this.resources.map((el) => el.render()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Field.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | 3 | /** Abstract class representing a subset of any primitive value type, herein referred to as a _fieldtype_. For example, a hypothetical ```class Email extends Field``` would represent a subset of ```string``` (i.e. strings that are valid email addresses). A class which extends {@linkcode Field} should define the requirements of its _fieldtype_ by overriding {@linkcode Field.parse|Field.prototype.parse}. Instances of {@linkcode Field} are used to validate values and compose {@linkcode Schema|Schemas}. */ 4 | export default class Field { 5 | static Flags = { 6 | /** _OPTIONAL_ denotes that a field should have a default value of null. */ 7 | OPT: 0b001, 8 | /** _PRIVATE_ denotes that a field should not be exposed. */ 9 | PRV: 0b010, 10 | }; 11 | 12 | /** The value to be returned from {@linkcode Field.parse|Field.prototype.parse} when invoked with ```undefined``` or ```null```. Note that providing a defualt value effectively renders the field optional. */ 13 | default: any; 14 | 15 | /** A bit field representing a set of boolean {@linkcode Field.Flags|flags}. */ 16 | flags: number; 17 | 18 | /** The error message produced by the last call to {@linkcode Field.parse|Field.prototype.parse}, if it was unsuccessful. */ 19 | lastError: string; 20 | 21 | /** 22 | * @param defaultVal A {@linkcode Field.default|default} value. 23 | * @param flags A bit field. 24 | */ 25 | constructor(defaultVal: any = undefined, flags: number = null) { 26 | this.default = defaultVal; 27 | this.flags = flags || 0; 28 | } 29 | 30 | /** Checks if the specified flag is set on {@linkcode Field.flags|Field.prototype.flags}. 31 | * @param flag A bit mask. 32 | * @returns A boolean determining whether or not the flag is present. 33 | */ 34 | hasFlag(flag: number): boolean { 35 | return !!(this.flags & flag); 36 | } 37 | 38 | /** Returns a copy of the {@linkcode Field} instance. */ 39 | clone(): Field { 40 | const Type = <{ new (): Field }>this.constructor; 41 | return Object.assign(new Type(), this); 42 | } 43 | 44 | /** _**(async)**_ Checks if the input ```value``` is, or can be converted to, a valid case of the instance's _fieldtype_. If the input is ```null``` or ```undefined```, uses the {@linkcode Field.default|default} value in its place. 45 | * @param value The value to be parsed. 46 | * @returns The parsed value, or ```undefined``` if the ```input``` was invalid. 47 | */ 48 | async parse(value: any): Promise { 49 | this.lastError = null; 50 | if (value === undefined || value === null) { 51 | if (this.hasFlag(Field.Flags.OPT) && this.default === undefined) { 52 | return null; 53 | } 54 | return this.default; 55 | } 56 | return value; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/Resource.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | /* eslint-disable no-underscore-dangle */ 3 | /* eslint-disable no-param-reassign */ 4 | /* eslint-disable import/no-cycle */ 5 | /* eslint-disable import/extensions */ 6 | 7 | import Controllable from './abstract/Controllable'; 8 | import Collection from './Collection'; 9 | import Schema from './Schema'; 10 | import Field from './Field'; 11 | import Id from './fields/Id'; 12 | import { mergePaths } from './utility'; 13 | 14 | const { PRV } = Field.Flags; 15 | 16 | /** Abstract class representing a RESTful resource. As the {@linkcode Resource} class inherits from {@linkcode Controllable}, its derived classes can be exposed by a Synapse API. As it inherits from {@linkcode State}, its instances also represent valid request responses. */ 17 | export default class Resource extends Controllable { 18 | /** Returns the _path_ that uniquely locates an instance (i.e. the path to which a ```GET``` request would return the instance). By default, this is the {@linkcode Resource.root|root} path followed by the value on the instance corresponding to the first field on the derived class's schema that extends type {@linkcode Id} (e.g. '/user/123'); however, derived classes may override this behavior. */ 19 | path(): string { 20 | const Class = this.constructor; 21 | 22 | const { fields } = Class.schema; 23 | const keys = Object.keys(fields); 24 | for (let i = 0; i < keys.length; ++i) { 25 | const key = keys[i]; 26 | if (fields[key] instanceof Id) { 27 | return mergePaths(Class.root(), this[key]); 28 | } 29 | } 30 | 31 | throw new Error(`No field of type 'Id' found for class ${Class.name}.`); 32 | } 33 | 34 | render(): object { 35 | const Class: any = this.constructor; 36 | const { fields } = Class.schema; 37 | 38 | const result = {}; 39 | Object.keys(fields).forEach((key) => { 40 | const field: Field = fields[key]; 41 | if (!field.hasFlag(PRV)) { 42 | result[key] = this[key]; 43 | } 44 | }); 45 | return result; 46 | } 47 | 48 | /** Returns an object containing all properties of the instance, excluding {@linkcode State} _metadata_. */ 49 | export(): object { 50 | const result = { ...this }; 51 | Object.keys(result).forEach((key) => { 52 | if (key[0] === '$') { 53 | delete result[key]; 54 | } 55 | }); 56 | return result; 57 | } 58 | 59 | /** Returns the _path_ from which all endpoints on the derived class originate. */ 60 | static root(): string { 61 | const Class = this; 62 | 63 | const name = Class.name 64 | .split(/(?=[A-Z])/) 65 | .join('_') 66 | .toLowerCase(); 67 | return `/${name}`; 68 | } 69 | 70 | /** Returns a new {@linkcode Schema} containing all fields of the derived class's schema plus all fields defined on the schemas of each {@linkcode Resource} type in ```Classes```. In case of a collision between field names, precedence will be given to former {@linkcode Resource|Resources} in ```Classes```, with highest precedence given to the derived class on which the method was called. 71 | * @param Classes The {@linkcode Resource} 72 | */ 73 | static union(...Classes: Array): Schema { 74 | const fields = []; 75 | Classes.reverse().forEach((Class: typeof Resource) => { 76 | if (Class.prototype instanceof Resource) { 77 | fields.push(Class.schema.fields); 78 | } 79 | }); 80 | 81 | const Class = this; 82 | return new Schema(Object.assign({}, ...fields, Class.schema.fields)); 83 | } 84 | 85 | /** _**(async)**_ Attempts to create a new instance of the derived class from the plain object ```data```. Throws an ```Error``` if ```data``` cannot be validated using the derived class's {@linkcode Resource.schema|schema}. The resulting {@linkcode State} will have the HTTP status ```OK```. 86 | * @param data The key-value pairs from which to construct the {@linkcode Resource} instance. 87 | */ 88 | static async restore(this: T, data: object): Promise> { 89 | const Type = this; 90 | // validate in the input data using the derived class's schema. 91 | const result = await Type.schema.validate(data); 92 | if (!result) { 93 | console.log(data, Type.schema.lastError); 94 | throw new Error(`Invalid properties for type '${Type.name}'.`); 95 | } 96 | 97 | // transfer the resulting values to a new instance of the derived class 98 | const instance = new Type(200); 99 | Object.keys(result).forEach((key) => { 100 | instance[key] = result[key]; 101 | }); 102 | instance.$dependencies.push(instance.path()); 103 | 104 | return >instance; 105 | } 106 | 107 | /** _**(async)**_ Given an array of objects ```data```, attempts to {@linkcode Resource.restore|restore} each object and convert the resulting {@linkcode Resource} instances to a {@linkcode Collection}. 108 | * @param data An array of objects representing resource states. 109 | * @return A promise resolving to a collection of resources. 110 | */ 111 | static async collection(this: T, data: Array): Promise { 112 | const Type = this; 113 | 114 | const pending = data.map((obj) => Type.restore(obj)); 115 | return new Collection(await Promise.all(pending)); 116 | } 117 | 118 | /** _**(async)**_ Like {@linkcode Resource.restore}, attempts to create a new instance of the derived class from the plain object ```data```. Throws an ```Error``` if ```data``` cannot be validated using the derived class's {@linkcode Resource.schema|schema}. The resulting {@linkcode State} will have the HTTP status ```CREATED```. 119 | * @param data The key-value pairs from which to construct the {@linkcode Resource} instance. 120 | */ 121 | static async create(this: T, data: object): Promise> { 122 | const instance = await this.restore(data); 123 | instance.$status = 201; 124 | return instance; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/Schema.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable import/extensions */ 4 | /* eslint-disable no-await-in-loop */ 5 | 6 | import Field from './Field'; 7 | import { isCollectionOf } from './utility'; 8 | 9 | /** An instance of {@linkcode Schema} defines a set of parameters by name and _fieldtype_ (see {@linkcode Field}). */ 10 | export default class Schema { 11 | /** An object whose values are all instances of {@linkcode Field} (herein _fieldset_). */ 12 | fields: object; 13 | 14 | /** The error message produced by the last call to {@linkcode Schema.validate|Schema.prototype.validate}, if it was unsuccessful. */ 15 | lastError: object; 16 | 17 | /** 18 | * @param fields See {@linkcode Schema.fields|Schema.prototype.fields}. 19 | */ 20 | constructor(fields: object = {}) { 21 | // assert that the input is a collection of fields 22 | isCollectionOf(Field, fields, true); 23 | 24 | this.fields = fields; 25 | } 26 | 27 | /** Returns a copy of the {@linkcode Schema} instance. */ 28 | clone(): Schema { 29 | const fields = {}; 30 | Object.keys(this.fields).forEach((name) => { 31 | fields[name] = this.fields[name].clone(); 32 | }); 33 | return new Schema(fields); 34 | } 35 | 36 | /** Creates a new schema containing all of the instance's fields, plus additional ```fields```. 37 | * @param fields A _fieldset_. 38 | * @returns A new instance of {@linkcode Schema}. 39 | */ 40 | extend(fields: object | Schema): Schema { 41 | // if the input is a schema, extract its fields 42 | if (fields instanceof Schema) { 43 | fields = fields.clone().fields; 44 | } 45 | 46 | // assert that the input is a collection of fields 47 | isCollectionOf(Field, fields, true); 48 | 49 | return new Schema({ ...this.fields, ...fields }); 50 | } 51 | 52 | /** Creates a new schema containing a subset of the instance's fields. 53 | * @param keys The names of the fields which should be transferred to the new schema. 54 | * @return A new instance of {@linkcode Schema}. 55 | */ 56 | select(fields: object | string = null, ...keys: Array): Schema { 57 | if (typeof fields === 'string') { 58 | keys.unshift(fields); 59 | } 60 | 61 | const result = {}; 62 | keys.forEach((key) => { 63 | if (this.fields[key]) { 64 | result[key] = this.fields[key].clone(); 65 | } 66 | }); 67 | return new Schema(result); 68 | } 69 | 70 | /** Creates a new schema containing a subset of the instance's fields. 71 | * @param keys The names of the fields which should not be transferred to the new schema. 72 | * @return A new instance of {@linkcode Schema}. 73 | */ 74 | exclude(...keys: Array): Schema { 75 | const result = this.clone(); 76 | keys.forEach((key) => { 77 | delete result.fields[key]; 78 | }); 79 | return result; 80 | } 81 | 82 | /** Given an object ```values``` whose keys correspond to fields on the instance's _fieldset_ and whose values represent default values of those fields, applies those default values to the corresponding fields on a clone of the instance. 83 | * @param values An object with keys corresponding to field names and values representing default field values. 84 | * @return A new instance of {@linkcode Schema}. 85 | */ 86 | default(values: object): Schema { 87 | const result = this.clone(); 88 | Object.keys(result.fields).forEach((name) => { 89 | result.fields[name].default = values[name]; 90 | }); 91 | return result; 92 | } 93 | 94 | /** Given an object ```values``` whose keys correspond to fields on the instance's _fieldset_ and whose values represent {@linkcode Field.flags|flag} values, applies those flag values to the corresponding fields on a clone of the instance. 95 | * @param values An object with keys corresponding to field names and values representing flag values. 96 | * @return A new instance of {@linkcode Schema}. 97 | */ 98 | flags(values: object): Schema { 99 | const result = this.clone(); 100 | Object.keys(result.fields).forEach((name) => { 101 | result.fields[name].flags = values[name] || 0; 102 | }); 103 | return result; 104 | } 105 | 106 | /** _**(async)**_ Determines if the key-value pairs in ```data``` match, or can be converted to, the format of the instance's _fieldset_. 107 | * @param data An object to validate. 108 | * @returns A new object containing only the values that have been parsed by corresponding fields in the _fieldset_, or undefined if a corresponding value for any field was not present. 109 | */ 110 | async validate(data: object): Promise { 111 | if (!data || typeof data !== 'object') { 112 | return undefined; 113 | } 114 | // for each field in the schema, parse the corresponding input value from 'data' 115 | const keys = Object.keys(this.fields); 116 | const parsed = await Promise.all(keys.map((key) => this.fields[key].parse(data[key]))); 117 | 118 | // initialize the output object and reset the lastError property 119 | let output = {}; 120 | this.lastError = null; 121 | parsed.forEach((value, i) => { 122 | const key = keys[i]; 123 | if (value === undefined) { 124 | // if any result is undefined, the input data is invalid 125 | if (!this.lastError) { 126 | // set the lastError property to a new object and the output to undefined 127 | this.lastError = {}; 128 | output = undefined; 129 | } 130 | // transfer the error message from the field to the lastError object 131 | this.lastError[key] = this.fields[key].lastError; 132 | } else if (output) { 133 | // if no errors have occured yet, transfer the successfully parse value to the output object 134 | output[key] = value; 135 | } 136 | }); 137 | 138 | return output; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/State.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable lines-between-class-members */ 2 | /* eslint-disable max-classes-per-file */ 3 | /* eslint-disable import/extensions */ 4 | /* eslint-disable no-underscore-dangle */ 5 | /* eslint-disable class-methods-use-this */ 6 | 7 | /** Represents both 1) a response to a request, and 2) the state at a given _path_. Properties prefixed with ```$``` represent _metadata_ associated with the request/response cycle that produced the instance, while _payload_ data encompasses all other properties attached to the instance by derived classes. */ 8 | export default class State { 9 | /** 10 | * The derived class name of the instance. 11 | * @category Metadata 12 | */ 13 | $type: string = null; 14 | /** 15 | * An HTTP status code describing the response. 16 | * @category Metadata 17 | */ 18 | $status: number; 19 | /** 20 | * A string describing the response. 21 | * @category Metadata 22 | */ 23 | $message: string = ''; 24 | /** 25 | * An HTTP query string representing the requested _path_ and validated arguments. 26 | * @category Metadata 27 | */ 28 | $query: string = null; 29 | /** 30 | * An array of _paths_ upon which the response data depends. 31 | * @category Metadata 32 | */ 33 | $dependencies: Array = []; 34 | 35 | /** 36 | * @param status The HTTP status code to be assigned to the instance's _metadata_. 37 | * @param message A string message to be assigned to the instance's _metadata_. 38 | */ 39 | constructor(status: number, message: string = '') { 40 | this.$type = this.constructor.name; 41 | this.$status = status; 42 | this.$message = message; 43 | } 44 | 45 | /** Checks if the instance represents an error. */ 46 | isError(): boolean { 47 | // return true if the status code is a 4xx or 5xx error 48 | return ['4', '5'].includes(this.$status.toString()[0]); 49 | } 50 | 51 | /** Returns a public representation of the instance _payload_. By default, this is the instance's {@linkcode State.$message|message}, though derived classes should override this behavior. */ 52 | render(): any { 53 | return this.$message; 54 | } 55 | 56 | /** Returns a serialized version of the public representation of the instance for network transport. */ 57 | serialize() { 58 | return JSON.stringify(this.render()); 59 | } 60 | 61 | /** Adds the given states to the instance's {@linkcode State.$dependencies|dependencies}, such that when those states are invalidated, so will be the instance. */ 62 | uses(...states: Array) { 63 | states.forEach((state) => this.$dependencies.push(...state.$dependencies)); 64 | return this; 65 | } 66 | 67 | /** Returns a public representation of the instance _metadata_, with the instance's {@linkcode State.render|rendered} _payload_ assigned to the property ```payload``` on the resulting object. Called when an the instance is converted to JSON via ```JSON.stringify```. */ 68 | toJSON() { 69 | return { 70 | type: this.$type, 71 | status: this.$status, 72 | message: this.$message, 73 | query: this.$query, 74 | payload: this.render(), 75 | }; 76 | } 77 | 78 | /** 79 | * Creates a standard HTTP response. 80 | * @category Factory 81 | */ 82 | static OK(message: any = null) { 83 | return new State(200, message); 84 | } 85 | /** 86 | * Creates a standard HTTP response. 87 | * @category Factory 88 | */ 89 | static CREATED(message: any = null) { 90 | return new State(201, message); 91 | } 92 | /** 93 | * Creates a standard HTTP response. 94 | * @category Factory 95 | */ 96 | static ACCEPTED(message: any = null) { 97 | return new State(202, message); 98 | } 99 | /** 100 | * Creates a standard HTTP response. 101 | * @category Factory 102 | */ 103 | static NO_CONTENT() { 104 | return new State(204); 105 | } 106 | /** 107 | * Creates a standard HTTP response. 108 | * @category Factory 109 | */ 110 | static BAD_REQUEST(message: any = null) { 111 | return new State(400, message); 112 | } 113 | /** 114 | * Creates a standard HTTP response. 115 | * @category Factory 116 | */ 117 | static UNAUTHORIZED(message: any = null) { 118 | return new State(401, message); 119 | } 120 | /** 121 | * Creates a standard HTTP response. 122 | * @category Factory 123 | */ 124 | static FORBIDDEN(message: any = null) { 125 | return new State(403, message); 126 | } 127 | /** 128 | * Creates a standard HTTP response. 129 | * @category Factory 130 | */ 131 | static NOT_FOUND(message: any = null) { 132 | return new State(404, message); 133 | } 134 | /** 135 | * Creates a standard HTTP response. 136 | * @category Factory 137 | */ 138 | static CONFLICT(message: any = null) { 139 | return new State(409, message); 140 | } 141 | /** 142 | * Creates a standard HTTP response. 143 | * @category Factory 144 | */ 145 | static INTERNAL_SERVER_ERROR(message: any = null) { 146 | return new State(500, message); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/abstract/@.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import { field } from './Validatable'; 4 | import { endpoint, authorizer, schema, instance, affects, uses } from './Controllable'; 5 | 6 | export default { field, endpoint, authorizer, schema, instance, affects, uses }; 7 | -------------------------------------------------------------------------------- /lib/abstract/Controllable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-cycle */ 2 | /* eslint-disable import/extensions */ 3 | /* eslint-disable no-param-reassign */ 4 | 5 | import Validatable from './Validatable'; 6 | import Controller from '../control/Controller'; 7 | import Router from '../control/Router'; 8 | import Schema from '../Schema'; 9 | import { mergePaths, parseEndpoint, invokeChain } from '../utility'; 10 | 11 | const toController = (target: Function, props: object = {}) => { 12 | return Object.assign(target instanceof Controller ? target : new Controller(target), props); 13 | }; 14 | 15 | const applyEndpoint = (Class: any, pattern: string, target: Function) => { 16 | const custom = ['read', 'write']; 17 | const { method, path, flags } = parseEndpoint(pattern, custom, Class.root()); 18 | 19 | if (!method || !path) { 20 | throw new Error(`Invalid pattern '${pattern}'.`); 21 | } 22 | 23 | if (custom.includes(method)) { 24 | return toController(target, { 25 | pattern: path, 26 | isRead: method === 'read', 27 | isCacheable: !flags.includes('nocache'), 28 | }); 29 | } 30 | 31 | const controller = toController(target, { 32 | pattern: path, 33 | isRead: method === 'get', 34 | isCacheable: !flags.includes('nocache'), 35 | }); 36 | 37 | if (!Class.router) { 38 | Class.router = new Router(); 39 | } 40 | Class.router.declare(method, controller.pattern, controller.try); 41 | 42 | return controller; 43 | }; 44 | 45 | const applyAuthorizer = (Class: any, ...chain: Array) => { 46 | const target = chain.pop(); 47 | 48 | return toController(target, { 49 | authorizer: async (args: object) => { 50 | const result = await invokeChain(chain, args); 51 | return Array.isArray(result) ? result[0] : result; 52 | }, 53 | }); 54 | }; 55 | 56 | const applySchema = (Class: any, from: Schema | Function | object, target: Function) => { 57 | return toController(target, { 58 | validator: from instanceof Schema || typeof from === 'function' ? from : new Schema(from), 59 | }); 60 | }; 61 | 62 | const applyInstance = (Class: any, from: Function, target: Function) => { 63 | return toController(target, { instance: from }); 64 | }; 65 | 66 | const applyUses = (Class: any, paths: Array, target: Function) => { 67 | const root = Class.root(); 68 | return toController(target, { 69 | dependencies: paths.map((path) => mergePaths(root, path)), 70 | }); 71 | }; 72 | 73 | const applyAffects = (Class: any, paths: Array, target: Function) => { 74 | const root = Class.root(); 75 | return toController(target, { 76 | dependents: paths.map((path) => mergePaths(root, path)), 77 | }); 78 | }; 79 | 80 | export interface ControllerOptions { 81 | endpoint: string; 82 | authorizer: Array; 83 | schema: Schema | object; 84 | instance: Function; 85 | uses: Array; 86 | affects: Array; 87 | } 88 | 89 | /** Represents a type which can be exposed by a Synapse API. Defines the functionality necessary to create {@linkcode Controller|Controllers} and add them to a static {@linkcode Controllable.router|router} property on the derived class. */ 90 | export default class Controllable extends Validatable { 91 | static router: Router; 92 | 93 | /** _**(abstract)**_ Returns the _path_ from which all endpoints on the derived class originate. */ 94 | static root(): string { 95 | throw new Error("Classes that extend Controllable must implement the 'root' method."); 96 | } 97 | 98 | /** Creates an instance of {@linkcode Controller} intended to be attached to a derived class as a static property. 99 | * @param options An object defining the endpoint method and pattern, authorizers, schema, and dependencies. 100 | * @param method A function defining endpoint business logic. 101 | */ 102 | protected static controller(options: ControllerOptions, method): Controller { 103 | const { endpoint, authorizer, schema, instance, uses, affects } = options; 104 | 105 | const controller = new Controller(method); 106 | if (endpoint) { 107 | applyEndpoint(this, endpoint, controller); 108 | } 109 | if (authorizer) { 110 | applyAuthorizer(this, ...(Array.isArray(authorizer) ? authorizer : [authorizer]), controller); 111 | } 112 | if (schema) { 113 | applySchema(this, schema, controller); 114 | } 115 | if (instance) { 116 | applyInstance(this, instance, controller); 117 | } 118 | if (uses) { 119 | applyUses(this, uses, controller); 120 | } 121 | if (affects) { 122 | applyAffects(this, affects, controller); 123 | } 124 | 125 | return controller; 126 | } 127 | } 128 | 129 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.isRead}, {@linkcode Controller.isCacheable}, {@linkcode Controller.pattern}. 130 | * @category Decorator 131 | * @param endpoint An string defining an endpoint HTTP method and _path pattern_ in the format ```METHOD /path/:param [NOCACHE]```. 132 | * @param authorizers An array of functions ```(args) => {...}``` that will authorize input arguments of requests to the resulting controller. Should return either an array containg arguments to be passed to the next authorizer, or any other value to abort the operation. 133 | */ 134 | export const endpoint = (value: string): Function => { 135 | return (Class, methodName, descriptor) => { 136 | if (!(Class.prototype instanceof Controllable)) { 137 | throw new Error("The '@endpoint' decorator can only be used within 'Controllable' types."); 138 | } 139 | 140 | const method = descriptor.value; // class method to be decorated 141 | descriptor.value = applyEndpoint(Class, value, method); 142 | }; 143 | }; 144 | 145 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.authorizer}. 146 | * @category Decorator 147 | * @param endpoint An string defining an endpoint HTTP method and _path pattern_ in the format ```METHOD /path/:param [NOCACHE]```. 148 | * @param authorizers An array of functions ```(args) => {...}``` that will authorize input arguments of requests to the resulting controller. Should return either an array containg arguments to be passed to the next authorizer, or any other value to abort the operation. 149 | */ 150 | export const authorizer = (...authorizers: Array): Function => { 151 | return (Class, methodName, descriptor) => { 152 | if (!(Class.prototype instanceof Controllable)) { 153 | throw new Error("The '@authorizer' decorator can only be used within 'Controllable' types."); 154 | } 155 | 156 | const method = descriptor.value; // class method to be decorated 157 | descriptor.value = applyAuthorizer(Class, ...authorizers, method); 158 | }; 159 | }; 160 | 161 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.schema}. 162 | * @category Decorator 163 | * @param source An instance of {@linkcode Schema}, or an object which can be used to construct an instance of {@linkcode Schema}. 164 | */ 165 | export const schema = (source: Schema | object): Function => { 166 | return (Class, methodName, descriptor) => { 167 | if (!(Class.prototype instanceof Controllable)) { 168 | throw new Error("The '@schema' decorator can only be used within 'Controllable' types."); 169 | } 170 | 171 | const method = descriptor.value; 172 | descriptor.value = applySchema(Class, source, method); 173 | }; 174 | }; 175 | 176 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.instance}. 177 | * @category Decorator 178 | * @param source A function which returns an instance of the derived class. 179 | */ 180 | export const instance = (source: Function): Function => { 181 | return (Class, methodName, descriptor) => { 182 | if (!(Class.prototype instanceof Controllable)) { 183 | throw new Error("The '@instance' decorator can only be used within 'Controllable' types."); 184 | } 185 | 186 | const method = descriptor.value; 187 | descriptor.value = applyInstance(Class, source, method); 188 | }; 189 | }; 190 | 191 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.dependencies}. 192 | * @category Decorator 193 | * @param paths An array of _path patterns_ representing the paths that, when invalidated, should cause the outputs of the resulting {@linkcode Controller|controller's} operations to be invalidated. 194 | */ 195 | export const uses = (...paths: Array): Function => { 196 | return (Class, methodName, descriptor) => { 197 | if (!(Class.prototype instanceof Controllable)) { 198 | throw new Error("The '@uses' decorator can only be used within 'Controllable' types."); 199 | } 200 | 201 | const method = descriptor.value; 202 | descriptor.value = applyUses(Class, paths, method); 203 | }; 204 | }; 205 | 206 | /** Decorator function that creates a partially defined instance of {@linkcode Controller}. Defines {@linkcode Controller.dependents}. 207 | * @category Decorator 208 | * @param paths An array of _path patterns_ representing the paths that should be recalculated when the resulting {@linkcode Controller|controller} executes an operation. 209 | */ 210 | export const affects = (...paths: Array): Function => { 211 | return (Class, methodName, descriptor) => { 212 | if (!(Class.prototype instanceof Controllable)) { 213 | throw new Error("The '@affects' decorator can only be used within 'Controllable' types."); 214 | } 215 | 216 | const method = descriptor.value; 217 | descriptor.value = applyAffects(Class, paths, method); 218 | }; 219 | }; 220 | -------------------------------------------------------------------------------- /lib/abstract/Validatable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable import/extensions */ 4 | 5 | import State from '../State'; 6 | import Schema from '../Schema'; 7 | import Field from '../Field'; 8 | 9 | const applyField = (Class: any, field: Field, name: string) => { 10 | if (!(field instanceof Field)) { 11 | throw new Error('Expected instance of Field.'); 12 | } 13 | 14 | if (!Class.schema) { 15 | Class.schema = new Schema(); 16 | } 17 | 18 | Class.schema = Class.schema.extend({ [name]: field }); 19 | }; 20 | 21 | /** Represents a type that has a well-defined {@linkcode Schema}. */ 22 | export default class Validatable extends State { 23 | /** An instance of {@linkcode Schema} defining the properties necessary to construct an instance of the derived class. */ 24 | static schema: Schema; 25 | } 26 | 27 | /** Decorator function that adds a {@linkcode Field} to the target class's {@linkcode Validatable.schema|schema} using the provided ```instance``` of {@linkcode Field} and the decorated property name. 28 | * @category Decorator 29 | * @param instance An instance of field 30 | * @param flags {@linkcode Field.flags|Flags} to be applied to the {@linkcode Field} ```instance```. 31 | */ 32 | export const field = (instance: Field, flags: number = 0): Function => { 33 | return (target, fieldName) => { 34 | const Class = target.constructor; 35 | 36 | if (!(Class.prototype instanceof Validatable)) { 37 | throw new Error("The '@field' decorator can only be used within 'Validatable' types."); 38 | } 39 | 40 | instance.flags |= flags; 41 | applyField(Class, instance, fieldName); 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /lib/abstract/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | export { default as Controllable } from './Controllable'; 4 | export { default as Validatable } from './Validatable'; 5 | -------------------------------------------------------------------------------- /lib/cache/LocalCache.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | /* eslint-disable import/no-cycle */ 3 | /* eslint-disable import/extensions */ 4 | 5 | import State from '../State'; 6 | import Relation from '../utility/Relation'; 7 | import { Cache } from '../control/Manager'; 8 | 9 | /** An extension of the abstract {@linkcode Cache} interface which implements all required functionality locally (i.e. within the process's memory). */ 10 | export default class LocalCache extends Cache { 11 | /** Maps _queries_ to the {@linkcode State|states} produced by invoking their associated {@linkcode Manager.operations|operations}. */ 12 | private states: Map = new Map(); 13 | 14 | /** Maps _paths_ to _queries_. Whenever a _path_ is {@linkcode Manager.invalidate|invalidated}, its associated _queries_ will be {@linkcode Manager.cache|recalculated}. */ 15 | private dependents: Relation = new Relation(); 16 | 17 | async setState(state: State) { 18 | const query = state.$query; 19 | 20 | this.states.set(query, state); 21 | 22 | this.dependents.unlink(null, query); 23 | state.$dependencies.forEach((path: string) => { 24 | this.dependents.link(path, query); 25 | }); 26 | 27 | return true; 28 | } 29 | 30 | async unset(query: string) { 31 | this.states.delete(query); 32 | this.dependents.unlink(null, query); 33 | 34 | return true; 35 | } 36 | 37 | async getState(query: string) { 38 | return this.states.get(query); 39 | } 40 | 41 | async getQueries(paths: string | Array) { 42 | const queries = new Set(); 43 | new Set(paths).forEach((path) => { 44 | for (const query of this.dependents.from(path)) { 45 | queries.add(query); 46 | } 47 | }); 48 | return Array.from(queries); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/cache/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | /* eslint-disable import/extensions */ 3 | 4 | export { default as LocalCache } from './LocalCache'; 5 | -------------------------------------------------------------------------------- /lib/client/Client.ts: -------------------------------------------------------------------------------- 1 | export interface Response { 2 | message: string; 3 | payload: any; 4 | query: string; 5 | status: number; 6 | type: string; 7 | } 8 | 9 | export default class Client { 10 | private ws: WebSocket; 11 | 12 | private index: number; 13 | 14 | private callbacks: object; 15 | 16 | static async connect(uri: string, onClose: Function = null) { 17 | return new Promise((resolve, reject) => { 18 | const instance = new Client(); 19 | 20 | const ws = new WebSocket(uri); 21 | ws.onopen = () => { 22 | resolve(instance); 23 | }; 24 | ws.onerror = reject; 25 | ws.onclose = onClose || undefined; 26 | ws.onmessage = (msg) => { 27 | const data = JSON.parse(msg.data); 28 | Object.entries(data).forEach(([req, res]) => { 29 | instance.callbacks[req](res); 30 | }); 31 | }; 32 | 33 | instance.ws = ws; 34 | instance.index = 0; 35 | instance.callbacks = {}; 36 | }); 37 | } 38 | 39 | async request(method: string, path: string, args: object = {}) { 40 | return new Promise((resolve, reject) => { 41 | const req = `${method} ${path} ${this.index++}`; 42 | this.callbacks[req] = (res) => { 43 | delete this.callbacks[req]; 44 | resolve(res); 45 | }; 46 | this.ws.send(JSON.stringify({ [req]: args })); 47 | }); 48 | } 49 | 50 | async get(path: string, args: object = {}) { 51 | return this.request('GET', path, args); 52 | } 53 | 54 | async post(path: string, args: object = {}) { 55 | return this.request('POST', path, args); 56 | } 57 | 58 | async put(path: string, args: object = {}) { 59 | return this.request('PUT', path, args); 60 | } 61 | 62 | async patch(path: string, args: object = {}) { 63 | return this.request('PATCH', path, args); 64 | } 65 | 66 | async delete(path: string, args: object = {}) { 67 | return this.request('DELETE', path, args); 68 | } 69 | 70 | async options(path: string, args: object = {}) { 71 | return this.request('OPTIONS', path, args); 72 | } 73 | 74 | async subscribe(path: string, args: object = {}, onChange: Function = null) { 75 | return this.request('SUBSCRIBE', path, args).then((res: Response) => { 76 | if (res.status.toString()[0] !== '2') { 77 | return Promise.reject(res); 78 | } 79 | 80 | this.callbacks[res.query] = (state: any) => { 81 | if (onChange) { 82 | onChange(state, res.query); 83 | } 84 | }; 85 | 86 | return res; 87 | }); 88 | } 89 | 90 | async unsubscribe(query: string) { 91 | this.request('UNSUBSCRIBE', query).then((res: Response) => { 92 | if (res.status.toString()[0] !== '2') { 93 | return Promise.reject(res) 94 | } 95 | delete this.callbacks[res.query]; 96 | return res; 97 | }); 98 | } 99 | 100 | disconnect() { 101 | this.ws.close(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/client/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | /* eslint-disable import/extensions */ 3 | 4 | export { default as Client } from './Client'; 5 | -------------------------------------------------------------------------------- /lib/control/Controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | /* eslint-disable import/extensions */ 4 | /* eslint-disable lines-between-class-members */ 5 | 6 | import Callable from '../utility/Callable'; 7 | import State from '../State'; 8 | import Schema from '../Schema'; 9 | import Operation from './Operation'; 10 | import Manager from './Manager'; 11 | import { routeToPath } from '../utility'; 12 | 13 | /** Callable type representing a method exposed to an API at a _path {@linkcode Controller.pattern|pattern}_ in the format```/path/:param```. When invoked with an _argument set_, creates an instance of {@linkcode Operation} which is executed by the {@linkcode Manager}. There are two ways to invoke a controller instance: 1) _untrustedly_, using {@linkcode Controller.try|Controller.prototype.try}, which first passes the _argument set_ through an optional {@linkcode Controller.authorizer|authorizer} function, or 2) _trustedly_, using ```()```, which bypasses the authorizer. In both cases, the _argument set_ will first be validated by the {@linkcode Controller.schema}. Optionally, {@linkcode Controller.dependencies|dependencies} and {@linkcode Controller.dependents|dependents} may also be specified as an array of _path patterns_ which will be evaluated at invocation and transferred to the resulting {@linkcode Operation|operation}. */ 14 | export default class Controller extends Callable { 15 | /** A _path pattern_ */ 16 | pattern: string; 17 | /** An array of _path patterns_. */ 18 | dependencies: Array = []; 19 | /** An array of _path patterns_. */ 20 | dependents: Array = []; 21 | /** A {@linkcode Schema}, or a function that evaluates to one, which will be used to validate all invocations of the instance. This allows schemas to be evaluated when needed, prevent circular dependencies at import time. */ 22 | validator: Schema | Function = new Schema(); 23 | /** A function ```(args) => {...}```that will be used to authorize invocations made using {@linkcode Controller.try|Controller.prototype.try}. Should return an object if the _argument set_ was valid, or an instance of {@linkcode State} to abort the operation. */ 24 | authorizer: Function; 25 | /** An optional function returning an object to which the controller function will be bound before invocation. */ 26 | instance: Function; 27 | /** Determines whether the instance represents a _read_ or _write_ operation. */ 28 | isRead: boolean; 29 | /** Determines whether the instance represents a cacheable operation. */ 30 | isCacheable: boolean; 31 | 32 | /** The {@linkcode Schema}, which will be used to validate all invocations of the instance. Evaluates the {@linkcode Controller.validator|Controller.prototype.validator} if necessary, replacing it with the resulting schema. */ 33 | get schema(): Schema { 34 | if (typeof this.validator === 'function') { 35 | this.validator = this.validator(); 36 | if (!(this.validator instanceof Schema)) { 37 | this.validator = new Schema(this.validator); 38 | } 39 | } 40 | return this.validator; 41 | } 42 | 43 | /** 44 | * @param target The function to be transferred to all generated operations. 45 | */ 46 | constructor(target: Function) { 47 | super(async (_this: object, args: object = {}) => { 48 | if (this.instance && !(_this instanceof State)) { 49 | _this = await this.instance(args); 50 | if (!(_this instanceof State) || _this.isError()) { 51 | return State.NOT_FOUND(); 52 | } 53 | } 54 | 55 | if (_this instanceof State) { 56 | args = { ..._this, ...args }; 57 | } 58 | 59 | const validated = await this.schema.validate(args); 60 | if (!validated) { 61 | return State.BAD_REQUEST(this.schema.lastError); 62 | } 63 | 64 | const bound = target.bind(_this); 65 | if (!this.pattern) { 66 | return bound(validated); 67 | } 68 | 69 | const path = routeToPath(this.pattern, validated); 70 | const dependents = this.dependents.map((pattern) => routeToPath(pattern, validated)); 71 | const dependencies = this.dependencies.map((pattern) => routeToPath(pattern, validated)); 72 | 73 | const op = new Operation( 74 | path, 75 | bound, 76 | validated, 77 | this.isRead, 78 | this.isCacheable, 79 | dependents, 80 | dependencies 81 | ); 82 | 83 | return Manager.access().execute(op); 84 | }); 85 | } 86 | 87 | /** When invoked, {@linkcode Controller.authorizer|authorizes} the _argument set_ ```args```, then invokes the instance _trustedly_. 88 | * @param args An _argument set_. 89 | */ 90 | try = async (args: object) => { 91 | const authorized = this.authorizer ? await this.authorizer(args) : args; 92 | 93 | if (authorized instanceof State) { 94 | return authorized; 95 | } 96 | 97 | return this(authorized); 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /lib/control/Manager.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | /* eslint-disable import/first */ 3 | /* eslint-disable import/no-cycle */ 4 | /* eslint-disable class-methods-use-this */ 5 | /* eslint-disable max-classes-per-file */ 6 | /* eslint-disable no-restricted-syntax */ 7 | /* eslint-disable import/extensions */ 8 | 9 | import State from '../State'; 10 | import Relation from '../utility/Relation'; 11 | import Operation from './Operation'; 12 | 13 | /** Abstract interface representing a subset of the {@linkcode Manager} class caching functionality that can be maintained in a simple key-value store. */ 14 | export class Cache { 15 | /** 16 | * Stores a {@linkcode State} instance such that it can later be retrieved by its _{@linkcode State.$query|query} string_ via a call to {@linkcode Cache.getState|Cache.prototype.getState}, and such that its _query string_ can be retrieved via a call to {@linkcode Cache.getQueries|Cache.prototype.getQueries} with any of the state's _{@linkcode State.$dependencies|dependent} paths_. 17 | * @param state 18 | */ 19 | async setState(state: State): Promise { 20 | return false; 21 | } 22 | 23 | /** 24 | * Removes the {@linkcode State} instance associated with a given _```query``` string_ from the cache. 25 | * @param query A _query string_. 26 | */ 27 | async unset(query: string): Promise { 28 | return false; 29 | } 30 | 31 | /** 32 | * Retrieves the state associated with a given _```query``` string_ from the cache, or ```undefined``` if no such state exists. 33 | * @param query A _query string_. 34 | */ 35 | async getState(query: string): Promise { 36 | return null; 37 | } 38 | 39 | /** 40 | * Returns the _query strings_ of all cached {@linkcode State|states} that are dependent on the state at the given ```paths```. 41 | * @param paths A _path_ string or array of _path_ strings. 42 | */ 43 | async getQueries(paths: string | Array): Promise> { 44 | return []; 45 | } 46 | } 47 | 48 | import LocalCache from '../cache/LocalCache'; 49 | 50 | /** Singleton which manages state of all known _paths_. It provides two functionalities: 1) Executes {@linkcode Operation|Operations} to either cache the resulting {@linkcode State} in conjuction with its {@linkcode State.$query|_query_} or invalidate its dependent _paths_, and 2) Accepts subscriptions to cached {@linkcode State} via _queries_ and notifies relevant subscribers whenever cached {@linkcode State} change. */ 51 | export default class Manager { 52 | /** The instance of Manager (or derived class) currently accessible via the Manager singleton, using {@linkcode Mangager.access}. */ 53 | private static instance: Manager; 54 | 55 | /** The maximum number of queries that can be cached before an {@linkcode Manager.evict|eviction} occurs. */ 56 | protected maxSize: number = 25000; 57 | 58 | /** An object that implements the {@linkcode Cache} interface, which will be used to store cached {@linkcode State} and dependency graphs. By defualt, this is an instance of {@linkcode LocalCache}, but when operating in a clustered environment, this should overriden with a global cache. */ 59 | private globals: Cache = new LocalCache(); 60 | 61 | /** Stores _queries_ which are actively being calculated, along with a promise resolving to their eventual state. */ 62 | private active: Map> = new Map(); 63 | 64 | /** Maps _queries_ to cacheable {@linkcode Operation|operations} that will be invoked to recalculate their {@linkcode Manager.states|state}. */ 65 | private operations: Map = new Map(); 66 | 67 | /** Maps _subscribers_ (represented by callback functions) to _queries_ and vice versa. Whenever a _query_ is {@linkcode Manager.cache|recalculated}, its associated _subscribers_ will be invoked with the resulting state. */ 68 | private subscriptions: Relation = new Relation(); 69 | 70 | /** A set containing functions ```(path, internal) => {...}``` which will be invoked when any _path_ is invalidated, with the invalidated _path_ string and a boolean denoting whether the invalidating initiated by a caller other than the {@linkcode Manager}. */ 71 | private listeners: Set = new Set(); 72 | 73 | constructor(maxSize: number = null) { 74 | if (maxSize) { 75 | this.maxSize = maxSize; 76 | } 77 | } 78 | 79 | /** Executes the given {@linkcode Operation|operation} and stores it as well as the resulting {@linkcode State|state} in association with the operation's {@linkcode Operation.query|query}. 80 | */ 81 | private async cache(operation: Operation): Promise { 82 | const { query } = operation; 83 | 84 | // if the query is already being calculated, return the promise resolving to it's eventual state 85 | if (this.active.has(query)) { 86 | return this.active.get(query); 87 | } 88 | 89 | // otherwise, remove the operation from the operation map if it already exists, in order to maintain the map keys in order of LRU 90 | this.operations.delete(query); 91 | 92 | // execute the operation, first storing a promsie in the active map to prevent dependency cycles 93 | this.active.set(query, operation()); 94 | const state = await this.active.get(query); 95 | this.active.delete(query); 96 | 97 | // if the result is an error, don't cache it and cancel all subscriptions 98 | if (state.isError()) { 99 | await this.unset(query); 100 | return state; 101 | } 102 | 103 | // otherwise, cache the operation and resulting state, and run an eviction check 104 | this.operations.set(query, operation); 105 | await this.globals.setState(state); 106 | await this.evict(); 107 | 108 | // and notify subscribers with the new state 109 | for (const client of this.subscriptions.to(query)) { 110 | client(query, state); 111 | } 112 | 113 | return state; 114 | } 115 | 116 | /** Removes the oldest cached query with no associated subscribers. 117 | * @param query A _query_ string. 118 | */ 119 | private async evict(): Promise { 120 | if (this.operations.size > this.maxSize) { 121 | for (const query of this.operations.keys()) { 122 | if (!this.subscriptions.to(query).next().value) { 123 | await this.unset(query); 124 | return true; 125 | } 126 | } 127 | } 128 | return false; 129 | } 130 | 131 | /** Removes a _query_ from the cache along with all of its associations. 132 | * @param query A _query_ string. 133 | */ 134 | private async unset(query: string) { 135 | this.operations.delete(query); 136 | this.globals.unset(query); // fix 137 | 138 | for (const client of this.subscriptions.to(query)) { 139 | this.unsubscribe(client, query); 140 | client(query, null); 141 | } 142 | } 143 | 144 | /** Invalidates a _path_, possibly alerting {@linkcode Manager.listeners|listeners} and causing all associated _queries_ to be recalculated. 145 | * @param path A _path_ string or array of _path_ strings. 146 | * @param override If ```true```, signifies that listeners should not be notified of the invalidated paths. 147 | */ 148 | async invalidate(paths: string | Array, override: boolean = false) { 149 | // get all of the unique queries associated with the collection of paths 150 | const queries = await this.globals.getQueries(paths); 151 | 152 | await Promise.all( 153 | queries.map(async (query: string) => { 154 | if (!this.operations.has(query)) { 155 | return null; 156 | } 157 | if (!this.subscriptions.to(query).next().value) { 158 | return this.unset(query); // if the query has no subscribers, remove it from the cache 159 | } 160 | return this.cache(this.operations.get(query)); // otherwise, recalculate its state 161 | }) 162 | ); 163 | 164 | // notify the listeners of the invalidated paths 165 | if (!override) { 166 | this.listeners.forEach((client) => { 167 | client(paths, State.OK()); 168 | }); 169 | } 170 | } 171 | 172 | /** Safely invokes an {@linkcode Operation|operation}, caching the resulting {@linkcode State|state} if applicable or otherwise invalidating dependent paths. */ 173 | async execute(operation: Operation): Promise { 174 | const { query } = operation; 175 | 176 | if (operation.isCacheable) { 177 | if (this.operations.has(query)) { 178 | return this.globals.getState(query); 179 | } 180 | 181 | return this.cache(operation); 182 | } 183 | 184 | const state = await operation(); 185 | 186 | await this.invalidate(operation.dependents); 187 | 188 | return state; 189 | } 190 | 191 | /** Registers a function ```callback``` to be invoked whenever a path is invalidated. 192 | * @param callback A function ```(path, internal) => {}```. 193 | */ 194 | listen(callback: Function): void { 195 | this.listeners.add(callback); 196 | } 197 | 198 | /** Unregisters a listener function ```callback```. */ 199 | unlisten(callback: Function): void { 200 | this.listeners.delete(callback); 201 | } 202 | 203 | /** Registers a ```subscriber``` to be invoked whenever the state of ```query``` changes. 204 | * @param callback A function ```(path, state) => {}```. 205 | */ 206 | subscribe(client: Function, query: string = null): boolean { 207 | if (!this.operations.has(query)) { 208 | return false; 209 | } 210 | this.subscriptions.link(client, query); 211 | return true; 212 | } 213 | 214 | /** Unregisters a ```subscriber``` from the given ```query```, or from all subscribed queries if no ```query``` is provided. */ 215 | unsubscribe(client: Function, query: string = null): void { 216 | this.subscriptions.unlink(client, query); 217 | } 218 | 219 | /** Returns the Manager singleton instance. If the singleton instance has not been manually defined using {@linkcode Manager.initialize}, a default instance of {@linkcode Manager} will be created. */ 220 | static access(): Manager { 221 | if (!this.instance) { 222 | this.initialize(new Manager()); 223 | } 224 | return this.instance; 225 | } 226 | 227 | /** 228 | * Sets the Manager singleton instance that will be {@linkcode Manager.access|accessible} throughout the application instance. 229 | * @param instance 230 | */ 231 | static initialize(instance: Manager): void { 232 | this.instance = instance; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /lib/control/Operation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import Callable from '../utility/Callable'; 5 | import State from '../State'; 6 | import { routeToPath } from '../utility'; 7 | 8 | /** Callable type representing a function that either _reads_ or _writes_ the state at a given _path_. When invoked, returns an instance of {@linkcode State}. */ 9 | export default class Operation extends Callable { 10 | /** The _query_ representing the operation. */ 11 | query: string; 12 | 13 | /** Determines whether the operation may be cached. */ 14 | isCacheable: boolean; 15 | 16 | /** The _paths_ which should be invalidated whenever the operation is invoked. */ 17 | dependents: Array; 18 | 19 | /** The _paths_ upon which any {@linkcode State} instance produced by invoking the operation will depend. */ 20 | dependencies: Array; 21 | 22 | /** 23 | * @param path The _path_ which the operation evaluates. Used to determine the resulting _query_. 24 | * @param func The function that will be invoked when the instance is invoked. 25 | * @param args The arguments with which the ```func``` will be invoked and which will be used to construct the instance's {@linkcode Operation.query|query} string. 26 | * @param isRead Determines whether is the operation is a _read_ or _write_. If the operation is a _read_, the ```path``` will be considered a {@linkcode Operation.dependency|dependency}. If it's a _write_, the ```path``` will be considered a {@linkcode Operation.dependents|dependent}. 27 | * @param isCacheable See {@linkcode Operation.isCacheable}. Note that this parameter can only be used to make a _read_ operation non-cacheable, not to make a _write_ operation cacheable. 28 | * @param dependents See {@linkcode Operation.dependents}. 29 | * @param dependencies See {@linkcode Operation.dependencies}. 30 | */ 31 | constructor( 32 | path: string, 33 | func: Function, 34 | args: object, 35 | isRead: boolean, 36 | isCacheable: boolean, 37 | dependents = [], 38 | dependencies = [] 39 | ) { 40 | super(async () => { 41 | let result: State; 42 | 43 | try { 44 | result = await func(args); 45 | 46 | if (!(result instanceof State)) { 47 | console.log('Unexpected result:', result); 48 | throw new Error('Internal Server Error.'); 49 | } 50 | } catch (err) { 51 | console.log(err); 52 | result = State.INTERNAL_SERVER_ERROR('An error occurred.'); 53 | } 54 | 55 | result.$query = this.query; 56 | result.$dependencies.push(...this.dependencies); 57 | 58 | return result; 59 | }); 60 | 61 | this.query = routeToPath(path, args, true); 62 | this.isCacheable = isCacheable && isRead; 63 | this.dependents = isRead ? [] : [path, ...dependents]; 64 | this.dependencies = isRead ? [path, ...dependencies] : []; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/control/Router.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import * as express from 'express'; 5 | import State from '../State'; 6 | import { parseEndpoint } from '../utility'; 7 | 8 | /** Generic wrapper for an ```express``` router. Associates _endpoint templates_ in the format ```METHOD /path/:param``` with handler functions. */ 9 | export default class Router { 10 | /** An ```express``` router */ 11 | router: Function; 12 | 13 | transform: Function; 14 | 15 | constructor(transform: Function = null) { 16 | this.router = express.Router(); 17 | 18 | this.transform = 19 | transform || ((path, params, ...args) => [{ ...args.shift(), ...params }, path, ...args]); 20 | } 21 | 22 | /** Associates a callback function with an HTTP method and _route_. 23 | * @param method An HTTP method 24 | * @param route A _route_ in the ```express``` syntax (e.g ```/user/:id```) 25 | * @param callback A callback function 26 | */ 27 | declare(method: string, route: string, callback: Function | Router): void { 28 | if (!this.router[method]) { 29 | throw new Error(`Unkown method '${method}'.`); 30 | } 31 | 32 | const handler = 33 | callback instanceof Router ? callback.router : (req, res) => res.send(callback, req.params); 34 | 35 | this.router[method](route, handler); 36 | } 37 | 38 | /** Finds the function or Router associated with a given HTTP method and _route_. Returns undefined if none exist. 39 | * @param method An HTTP method 40 | * @param route A _route_ in the ```express``` syntax (e.g ```/user/:id```) 41 | * @param callback A callback function 42 | */ 43 | async retrieve(method: string, path: string): Promise { 44 | return new Promise((resolve) => { 45 | return this.router( 46 | { method: method.toUpperCase(), url: path }, 47 | { send: (callback, params) => resolve(callback) }, 48 | () => resolve(undefined) 49 | ); 50 | }); 51 | } 52 | 53 | /** _**(async)**_ Attempts to execute a request using the constructed router. 54 | * @param method An HTTP method. 55 | * @param path A _path_ string. 56 | * @param args An object containing the arguments to be passed to the callback method, if one is found. 57 | * @returns A promise that evaluates to the result of invoking the callback function associated with the provided method and path, or a ```NOT_FOUND``` {@linkcode State} if no matching _endpoint_ exists. 58 | */ 59 | async request(method: string, path: string, ...args: Array): Promise { 60 | return new Promise((resolve) => { 61 | const { method: _method } = parseEndpoint(method); 62 | 63 | if (!_method) { 64 | resolve(State.BAD_REQUEST()); 65 | } 66 | 67 | return this.router( 68 | { method: _method.toUpperCase(), url: path }, 69 | { send: (callback, params) => resolve(callback(...this.transform(path, params, ...args))) }, 70 | () => resolve(State.NOT_FOUND()) 71 | ); 72 | }); 73 | } 74 | 75 | /** _**(async)**_ Returns a promise resolving to either: 1) an array containing all HTTP methods available at the given ```path```, or 2) a ```NOT_FOUND``` error. 76 | * @param path A _path_ string. 77 | */ 78 | async options(path: string): Promise | State> { 79 | return new Promise((resolve) => { 80 | return this.router( 81 | { method: 'OPTIONS', url: path }, 82 | { set: () => { }, send: (options) => resolve(options.split(',')) }, 83 | () => resolve(State.NOT_FOUND()) 84 | ); 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/control/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | export { default as Controller } from './Controller'; 4 | export { default as Manager } from './Manager'; 5 | export { default as Operation } from './Operation'; 6 | export { default as Router } from './Router'; 7 | -------------------------------------------------------------------------------- /lib/fields/Boolean.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import Enum from './Enum'; 4 | 5 | export default class Boolean extends Enum { 6 | constructor(defaultVal, flags) { 7 | super(['true', 'false'], defaultVal, flags); 8 | } 9 | 10 | async parse(value: any): Promise { 11 | if (typeof value === 'boolean') { 12 | return value; 13 | } 14 | if (value === 0 || value === 1) { 15 | return !!value; 16 | } 17 | return super.parse(value); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/fields/Email.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable no-control-regex */ 3 | 4 | import Text from './Text'; 5 | 6 | const REGEX = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; 7 | 8 | export default class Email extends Text { 9 | constructor(flags: number = null) { 10 | super(null, null, undefined, flags); 11 | 12 | this.assert(REGEX, true, 'must be a valid email address'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/fields/Enum.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import Text from './Text'; 4 | 5 | export default class Enum extends Text { 6 | constructor(options, defaultVal, flags) { 7 | super(null, null, defaultVal, flags); 8 | 9 | this.assert( 10 | options.map((val) => `(${val})`).join('|'), 11 | true, 12 | `must be one of the following values: ${options.join(', ')}.` 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/fields/Float.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable class-methods-use-this */ 3 | 4 | import Number from './Number'; 5 | 6 | export default class Float extends Number { 7 | async parse(value: any): Promise { 8 | return parseFloat(value); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/fields/Hash.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import * as bcrypt from 'bcryptjs'; 5 | import Text from './Text'; 6 | 7 | export default class Hash extends Text { 8 | saltRounds: number; 9 | 10 | constructor(min: number = null, max: number = null, flags: number = null, saltRounds: number = 10) { 11 | super(min, max, undefined, flags); 12 | 13 | this.saltRounds = saltRounds; 14 | } 15 | 16 | async parse(value: any): Promise { 17 | if (await super.parse(value)) { 18 | return bcrypt.hash(value, this.saltRounds); 19 | } 20 | return undefined; 21 | } 22 | 23 | static async validate(value, hash) { 24 | return bcrypt.compare(value, hash); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/fields/Id.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import Text from './Text'; 4 | 5 | export default class Id extends Text { 6 | constructor(length = null, flags = null) { 7 | super(length, length, undefined, flags); 8 | 9 | this.assert(/[^\w-]/, false, 'must contain only alphanumeric characters, underscores and dashes'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/fields/Integer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable class-methods-use-this */ 3 | 4 | import Number from './Number'; 5 | 6 | export default class Integer extends Number { 7 | async parse(value: any): Promise { 8 | return parseInt(value, 10); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/fields/Number.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable no-bitwise */ 3 | 4 | import Field from '../Field'; 5 | 6 | export default class Number extends Field { 7 | min: number; 8 | 9 | max: number; 10 | 11 | constructor( 12 | min: number = undefined, 13 | max: number = undefined, 14 | defaultVal: number = undefined, 15 | flags: number = null 16 | ) { 17 | super(defaultVal, flags); 18 | 19 | this.min = min; 20 | this.max = max; 21 | } 22 | 23 | async parse(value: any): Promise { 24 | const number = typeof value === 'number' ? value : super.parse(value) - 0; 25 | 26 | if (typeof value !== 'number' || this.min < number || number > this.max) { 27 | return undefined; 28 | } 29 | 30 | return number; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/fields/Text.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable no-bitwise */ 4 | 5 | import Field from '../Field'; 6 | 7 | export default class Text extends Field { 8 | rules = []; 9 | 10 | constructor(min: number = null, max: number = null, defaultVal: any = undefined, flags: number = null) { 11 | super(defaultVal, flags); 12 | 13 | if (min) { 14 | this.assert(`.{${min}}`, true, `must be at least ${min} characters`); 15 | } 16 | if (max) { 17 | this.assert(`.{${max + 1}}`, false, `must be at most ${max} characters`); 18 | } 19 | } 20 | 21 | assert(rule: any, expect: boolean = true, message: string = '') { 22 | const regex = rule instanceof RegExp ? rule : new RegExp(rule); 23 | this.rules.push({ regex, expect, message }); 24 | } 25 | 26 | async parse(value: any): Promise { 27 | if (value && typeof value === 'object' && value.toString) { 28 | value = value.toString(); 29 | } 30 | if (typeof value === 'string') { 31 | for (let i = 0; i < this.rules.length; ++i) { 32 | const { regex, expect, message } = this.rules[i]; 33 | if (!!value.match(regex) !== expect) { 34 | this.lastError = message; 35 | return undefined; 36 | } 37 | } 38 | return value; 39 | } 40 | if (!value) { 41 | return super.parse(value); 42 | } 43 | return undefined; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/fields/Word.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import Text from './Text'; 4 | 5 | export default class Word extends Text { 6 | saltRounds: number; 7 | 8 | constructor( 9 | min: number = undefined, 10 | max: number = undefined, 11 | defaultVal: any = undefined, 12 | flags: number = null 13 | ) { 14 | super(min, max, defaultVal, flags); 15 | 16 | this.assert(/[^\w]/, false, 'must contain only alphanumeric characters'); 17 | } 18 | 19 | async parse(value: any): Promise { 20 | const check = await super.parse(value); 21 | if (!check) { 22 | return check; 23 | } 24 | return check.toLowerCase(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/fields/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | export { default as Boolean } from './Boolean'; 4 | export { default as Email } from './Email'; 5 | export { default as Enum } from './Enum'; 6 | export { default as Float } from './Float'; 7 | export { default as Hash } from './Hash'; 8 | export { default as Id } from './Id'; 9 | export { default as Integer } from './Integer'; 10 | export { default as Number } from './Number'; 11 | export { default as Text } from './Text'; 12 | export { default as Word } from './Word'; 13 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import Controllable from './abstract/Controllable'; 4 | import Router from './control/Router'; 5 | import http from './protocol/http'; 6 | import sse from './protocol/sse'; 7 | import ws from './protocol/ws'; 8 | import { requireAll, makeChain } from './utility'; 9 | 10 | /** Initializes API request handlers from {@linkcode Controllable} type definitions in the given ```directory```. 11 | * @param directory A directory containing {@linkcode Controllable} type definitions. 12 | * @param accept An array or Promise resolving to an array containing the IP addresses of all peer servers. 13 | * @param join An array or Promise resolving to an array containing the WebSocket connection URIs all peer servers. 14 | * @returns An object containing properties ```ws```, ```http```, and ```sse```, whose values are request handlers for the respective protocol. 15 | */ 16 | export function synapse( 17 | directory: string, 18 | accept: Array | Promise> = [], 19 | join: Array | Promise> = [] 20 | ): object { 21 | const router = new Router(); 22 | 23 | requireAll(directory).forEach((module) => { 24 | if (module) { 25 | const Type = module.default || module; 26 | if (Type.prototype instanceof Controllable) { 27 | router.declare('use', '/', Type.router); 28 | } 29 | } 30 | }); 31 | 32 | const callback: any = makeChain(); 33 | 34 | return { 35 | http: http(router, callback), 36 | sse: sse(router, callback), 37 | ws: ws(router, callback, accept, join), 38 | use: callback.add, 39 | }; 40 | } 41 | 42 | export { default as State } from './State'; 43 | export { default as Resource } from './Resource'; 44 | export { default as Collection } from './Collection'; 45 | export { default as Field } from './Field'; 46 | export { default as Schema } from './Schema'; 47 | 48 | export * as abstract from './abstract'; 49 | export * as control from './control'; 50 | export * as decorators from './abstract/@'; 51 | export * as fields from './fields'; 52 | export * as protocol from './protocol'; 53 | export * as utility from './utility'; 54 | -------------------------------------------------------------------------------- /lib/protocol/http.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import Router from '../control/Router'; 5 | import { parseEndpoint } from '../utility'; 6 | import State from '../State'; 7 | 8 | /** 9 | * Creates an ```express``` middleware function to handle HTTP requests 10 | */ 11 | const http = (router: Router, callback: Function): Function => { 12 | return async (req: any, res: any) => { 13 | const { method } = parseEndpoint(req.method); 14 | 15 | let result; 16 | if (method === 'options') { 17 | const options = await router.options(req.path); 18 | if (Array.isArray(options)) { 19 | res.set('Allow', options.join(',')); 20 | result = State.NO_CONTENT(); 21 | } 22 | } else { 23 | const args = { ...req.cookies, ...req.query, ...req.body, ...req.params }; 24 | result = await router.request(method, req.path, args); 25 | } 26 | 27 | return callback(req, Object.assign(res, { locals: result })); 28 | }; 29 | }; 30 | 31 | export default http; 32 | -------------------------------------------------------------------------------- /lib/protocol/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | export { default as http } from './http'; 4 | export { default as sse } from './sse'; 5 | export { default as ws } from './ws'; 6 | -------------------------------------------------------------------------------- /lib/protocol/sse.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import Router from '../control/Router'; 5 | import Manager from '../control/Manager'; 6 | import State from '../State'; 7 | import { parseEndpoint } from '../utility'; 8 | 9 | /** Creates an ```express``` middleware function to handle requests for SSE subscriptions (simply GET requests with the appropriate headers set). 10 | */ 11 | const sse = (router: Router, callback: Function): Function => { 12 | return async (req: any, res: any, next: Function) => { 13 | if (req.get('Accept') !== 'text/event-stream') { 14 | return next(); 15 | } 16 | 17 | // Since a request for SSE constitutes a request for a subscription only a get request will be allowed. 18 | const { method } = parseEndpoint(req.method); 19 | if (method !== 'get') { 20 | return res.status(400).send('Invalid Method'); 21 | } 22 | 23 | // upgrade the connection 24 | const headers = { 25 | 'Content-Type': 'text/event-stream', 26 | 'Cache-Control': 'no-cache', 27 | Connection: 'keep-alive', 28 | }; 29 | res.set(headers).status(200); 30 | 31 | // create a function to handle updates to the client 32 | const client = (path: string, state: any, render: boolean = true) => { 33 | const _req = {}; 34 | const _res = { 35 | locals: state, 36 | stream: (data: State) => res.write(`data: ${JSON.stringify(render ? data.render() : data)}\n\n`), 37 | }; 38 | callback(_req, _res); 39 | }; 40 | 41 | // validate the request by attempting to GET the requested resource 42 | const endpoint = `${req.method} ${req.path}`; 43 | const args = { ...req.cookies, ...req.query, ...req.body, ...req.params }; 44 | const state = await router.request('get', req.path, args); 45 | 46 | Manager.access().subscribe(client, state.$query); 47 | 48 | return client(endpoint, state, false); 49 | }; 50 | }; 51 | 52 | export default sse; 53 | -------------------------------------------------------------------------------- /lib/protocol/ws.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-underscore-dangle */ 3 | /* eslint-disable import/extensions */ 4 | 5 | import * as WebSocket from 'ws'; 6 | import State from '../State'; 7 | import Router from '../control/Router'; 8 | import Manager from '../control/Manager'; 9 | import { tryParseJSON, parseEndpoint } from '../utility'; 10 | 11 | /** Creates an ```express-ws``` middleware function to handle new WebSocket connections. Receives messages in the form of an object whose keys represent endpoints in the format 'METHOD /path' and whose values are objects containing the arguments to be passed to the associated endpoint. 12 | */ 13 | const ws = ( 14 | router: Router, 15 | callback: Function, 16 | accept: Array | Promise> = [], 17 | join: Array | Promise> = [] 18 | ): Function => { 19 | // the WebSocket interface accepts two custom methods 20 | const customMethods = ['subscribe', 'unsubscribe']; 21 | 22 | const manager = Manager.access(); 23 | 24 | const newPeer = (socket: any) => { 25 | const listener = (paths: string | Array) => { 26 | console.log(`${paths} changed -- notifying peers.`); 27 | socket.send(JSON.stringify({ UPDATE: paths })); 28 | }; 29 | 30 | manager.listen(listener); 31 | 32 | socket.on('message', (msg: string) => { 33 | console.log(msg); 34 | 35 | // make sure the message can be parsed to an object 36 | const data = tryParseJSON(msg); 37 | if (typeof data !== 'object') { 38 | return; 39 | } 40 | 41 | // attempt to execute each request on the object 42 | Object.entries(data).forEach(([method, args]) => { 43 | method = method.toLowerCase(); 44 | 45 | if (method === 'update' && (typeof args === 'string' || Array.isArray(args))) { 46 | return manager.invalidate(args, true); 47 | } 48 | 49 | return null; 50 | }); 51 | }); 52 | 53 | socket.on('close', () => { 54 | manager.unlisten(listener); 55 | }); 56 | }; 57 | 58 | const newClient = (socket: any, req: any) => { 59 | // create a function to handle updates to that client 60 | const client = (path: string, state: State, render: boolean = true) => { 61 | const _req = {}; 62 | const _res = { 63 | locals: state, 64 | stream: (data: State) => socket.send(JSON.stringify({ [path]: render ? data.render() : data })), 65 | }; 66 | callback(_req, _res); 67 | }; 68 | 69 | socket.on('message', async (msg: string) => { 70 | // make sure the message can be parsed to an object 71 | const data = tryParseJSON(msg); 72 | if (typeof data !== 'object') { 73 | return client('?', State.BAD_REQUEST('Invalid Format')); 74 | } 75 | 76 | // attempt to execute each request on the object 77 | const requests = Object.keys(data); 78 | return requests.forEach(async (endpoint: string) => { 79 | // make sure each method is valid 80 | const { method, path } = parseEndpoint(endpoint, customMethods); 81 | 82 | if (!method) { 83 | return client(endpoint, State.BAD_REQUEST('Invalid Method')); 84 | } 85 | 86 | const args = { ...req.cookies, ...data[endpoint] }; 87 | 88 | if (method === 'unsubscribe') { 89 | manager.unsubscribe(client, path); 90 | return client(endpoint, State.OK(), false); 91 | } 92 | 93 | if (method === 'subscribe') { 94 | const state = await router.request('get', path, args); 95 | manager.subscribe(client, state.$query); 96 | return client(endpoint, state, false); 97 | } 98 | 99 | return client(endpoint, await router.request(method, path, args), false); 100 | }); 101 | }); 102 | 103 | // when a client disconnects, cancel all their subscriptions 104 | socket.on('close', () => { 105 | manager.unsubscribe(client); 106 | }); 107 | }; 108 | 109 | Promise.resolve(accept).then((ips) => { 110 | accept = ips; 111 | }); 112 | 113 | Promise.resolve(join).then((peers) => { 114 | peers.forEach((uri) => { 115 | newPeer(new WebSocket(uri)); 116 | }); 117 | }); 118 | 119 | return (socket: any, req: any) => { 120 | // when a new connection is received, determine if the client is a peer server 121 | if (Array.isArray(accept) && accept.indexOf(req.connection.remoteAddress) !== -1) { 122 | return newPeer(socket); 123 | } 124 | 125 | return newClient(socket, req); 126 | }; 127 | }; 128 | 129 | export default ws; 130 | -------------------------------------------------------------------------------- /lib/utility/Callable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | /** Defines a callable object. */ 4 | export default class Callable extends Function { 5 | /** The function that will be executed when the instance is invoked. */ 6 | __call__: Function; 7 | 8 | constructor(fn: Function = null) { 9 | super('return arguments.callee.__call__.apply(arguments.callee, [this, ...arguments])'); 10 | 11 | this.__call__ = fn || (() => { }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/utility/Relation.ts: -------------------------------------------------------------------------------- 1 | /** Data structure storing many-to-many relationships between two value types -- essentially a bidirectional map. */ 2 | export default class Relation { 3 | private fromMap: Map> = new Map(); 4 | 5 | private toMap: Map> = new Map(); 6 | 7 | /** Creates an association _from_ the first argument _to_ the second. */ 8 | link(from: F, to: T) { 9 | if (!this.fromMap.has(from)) { 10 | this.fromMap.set(from, new Set()); 11 | } 12 | this.fromMap.get(from).add(to); 13 | 14 | if (!this.toMap.has(to)) { 15 | this.toMap.set(to, new Set()); 16 | } 17 | this.toMap.get(to).add(from); 18 | } 19 | 20 | /** Removes the association between two values if one exists. If either argument is ```null```, removes all associations _from_ or _to_ the other argument. */ 21 | unlink(from: F = null, to: T = null) { 22 | if (from && to) { 23 | if (this.fromMap.has(from)) { 24 | this.fromMap.get(from).delete(to); 25 | } 26 | if (this.toMap.has(to)) { 27 | this.toMap.get(to).delete(from); 28 | } 29 | } else if (from) { 30 | if (this.fromMap.has(from)) { 31 | this.fromMap.get(from).forEach((val: T) => { 32 | this.toMap.get(val).delete(from); 33 | }); 34 | this.fromMap.delete(from); 35 | } 36 | } else if (to) { 37 | if (this.toMap.has(to)) { 38 | this.toMap.get(to).forEach((val: F) => { 39 | this.fromMap.get(val).delete(to); 40 | }); 41 | this.toMap.delete(to); 42 | } 43 | } 44 | } 45 | 46 | /** Returns all associations _from_ the given value _to_ any other value. */ 47 | from(val: F = null): IterableIterator { 48 | if (val === null) { 49 | return this.toMap.keys(); 50 | } 51 | return this.fromMap.has(val) ? this.fromMap.get(val).values() : [].values(); 52 | } 53 | 54 | /** Returns all associations _to_ the given value _from_ any other value. */ 55 | to(val: T = null): IterableIterator { 56 | if (val === null) { 57 | return this.fromMap.keys(); 58 | } 59 | return this.toMap.has(val) ? this.toMap.get(val).values() : [].values(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/utility/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-const */ 2 | /* eslint-disable valid-typeof */ 3 | /* eslint-disable import/no-cycle */ 4 | /* eslint-disable import/extensions */ 5 | 6 | import * as fs from 'fs'; 7 | import * as querystring from 'querystring'; 8 | 9 | /** 10 | * Verifies that all elements of the input collection are of type 'Type'. 11 | * @param Type A constructor function 12 | * @param col The object or array to search 13 | * @param assert If true, the function will throw an error in case of false result. 14 | * @returns A boolean 15 | */ 16 | export const isCollectionOf = (type: any, col: object, assert: boolean = false) => { 17 | if (!Array.isArray(col)) { 18 | return false; 19 | } 20 | for (let i = 0; i < col.length; ++i) { 21 | const invalid = 22 | (typeof type === 'string' && typeof col[i] !== type) || 23 | (typeof type === 'function' && !(col[i] instanceof type)); 24 | 25 | if (invalid) { 26 | if (assert) { 27 | throw new Error(`Expected collection containing only values of type ${type}.`); 28 | } 29 | return false; 30 | } 31 | } 32 | return true; 33 | }; 34 | 35 | export const tryParseJSON = (json: string) => { 36 | try { 37 | return JSON.parse(json); 38 | } catch (err) { 39 | return undefined; 40 | } 41 | }; 42 | 43 | export const requireAll = (path: string) => { 44 | const files = fs.readdirSync(path); 45 | // eslint-disable-next-line global-require, import/no-dynamic-require 46 | return files.map((file: string) => require(`${path}/${file}`)); 47 | }; 48 | 49 | export const mergePaths = (...paths) => { 50 | let result = ''; 51 | // eslint-disable-next-line consistent-return 52 | for (let i = 0; i < paths.length; ++i) { 53 | let path = paths[i]; 54 | if (!path) { 55 | return undefined; 56 | } 57 | 58 | if (path[0] !== '/') { 59 | // eslint-disable-next-line no-param-reassign 60 | path = `/${path}`; 61 | } 62 | 63 | const end = path.length - 1; 64 | if (path[end] === '/') { 65 | // eslint-disable-next-line no-param-reassign 66 | path = path.substr(0, end); 67 | } 68 | 69 | result += path; 70 | } 71 | 72 | return result || '/'; 73 | }; 74 | 75 | export const parseEndpoint = (endpoint: string, custom: Array = [], root: string = '/') => { 76 | if (!endpoint || typeof endpoint !== 'string') { 77 | return {}; 78 | } 79 | 80 | let [method, path, _flags] = endpoint.split(' '); 81 | 82 | method = method.toLowerCase(); 83 | path = mergePaths(root, path); 84 | 85 | const standard = ['get', 'post', 'put', 'patch', 'delete', 'options']; 86 | if (!standard.includes(method) && !custom.includes(method)) { 87 | return {}; 88 | } 89 | 90 | const flags = _flags ? _flags.split('|').map((flag) => flag.toLowerCase()) : []; 91 | 92 | return { method, path, flags }; 93 | }; 94 | 95 | export const routeToPath = (route: string, args: object, query: boolean = false) => { 96 | const segs = []; 97 | const data = query ? { ...args } : {}; 98 | 99 | route.split('/').forEach((seg) => { 100 | if (seg[0] === ':') { 101 | const key = seg.substr(1); 102 | segs.push(args[key]); 103 | delete data[key]; 104 | } else { 105 | segs.push(seg); 106 | } 107 | }); 108 | 109 | const qs = query ? `?${querystring.encode(data)}` : ''; 110 | 111 | return segs.join('/') + qs; 112 | }; 113 | 114 | export const invokeChain = async (middleware: Array, ...args) => { 115 | const chain = [...middleware]; 116 | 117 | let baton = args; // pass the input arguments to the first function in the chain 118 | while (chain.length) { 119 | const current = chain.shift(); 120 | 121 | // eslint-disable-next-line no-await-in-loop 122 | baton = await current(...baton); // then store the return value to be used as input arguments for the next function 123 | 124 | if (!Array.isArray(baton)) { 125 | break; // if the middleware function did not return an array of arguments, break the chain 126 | } 127 | } 128 | 129 | return baton; 130 | }; 131 | 132 | export const makeChain = () => { 133 | const chain = []; 134 | async function caller(...args) { 135 | let index = 0; 136 | const next = () => { 137 | if (index < chain.length) { 138 | chain[index++](...args, next); 139 | } 140 | }; 141 | next(); 142 | } 143 | caller.add = (...middleware) => chain.push(...middleware); 144 | return caller; 145 | }; 146 | 147 | export { default as Callable } from './Callable'; 148 | export { default as Relation } from './Relation'; 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@synapsejs/synapse", 3 | "version": "1.0.7", 4 | "description": "Realtime API Library", 5 | "main": "build/lib/index.js", 6 | "types": "build/lib/index.d.ts", 7 | "files": [ 8 | "build/lib", 9 | "build/lib.browser" 10 | ], 11 | "scripts": { 12 | "build": "tsc && tsc -p tsconfig.browser.json", 13 | "test": "jest", 14 | "doc": "typedoc", 15 | "start": "NODE_ENV=production node ./build/test/index.js", 16 | "dev": "NODE_ENV=development nodemon", 17 | "deploy": "git add . && npm version $1 && git push --tags && npm publish" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/oslabs-beta/synapse" 22 | }, 23 | "author": "", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/oslabs-beta/synapse/issues" 27 | }, 28 | "homepage": "https://synapsejs.org", 29 | "dependencies": { 30 | "express": "^4.17.1", 31 | "ws": "^7.3.0" 32 | }, 33 | "devDependencies": { 34 | "@types/bcryptjs": "^2.4.2", 35 | "@types/express": "^4.17.6", 36 | "@types/jest": "^25.2.3", 37 | "@types/mongoose": "^5.7.21", 38 | "@types/node": "^14.0.5", 39 | "@types/supertest": "^2.0.9", 40 | "bcrypt": "^5.0.0", 41 | "bcryptjs": "^2.4.3", 42 | "cookie-parser": "^1.4.5", 43 | "cors": "^2.8.5", 44 | "express-ws": "^4.0.0", 45 | "jest": "^26.0.1", 46 | "mongoose": "^5.9.16", 47 | "nodemon": "^2.0.4", 48 | "supertest": "^4.0.2", 49 | "ts-jest": "^26.1.0", 50 | "ts-node": "^8.10.1", 51 | "typedoc": "^0.17.8", 52 | "typedoc-plugin-external-module-name": "^4.0.3", 53 | "typedoc-plugin-markdown": "^2.3.1", 54 | "typescript": "^3.9.3", 55 | "uuid": "^8.1.0" 56 | }, 57 | "jest": { 58 | "testEnvironment": "node", 59 | "moduleFileExtensions": [ 60 | "ts", 61 | "txs", 62 | "js" 63 | ], 64 | "transform": { 65 | "^.+\\.(ts|tsx)$": "ts-jest" 66 | }, 67 | "globals": { 68 | "ts-jest": { 69 | "tsConfigFile": "tsconfig.json" 70 | } 71 | } 72 | }, 73 | "nodemonConfig": { 74 | "watch": [ 75 | "test", 76 | "synapse" 77 | ], 78 | "ext": "ts", 79 | "exec": "ts-node ./test/index.ts" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /test/__test__/User.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable no-undef */ 3 | 4 | const request = require('supertest'); 5 | const mongoose = require('mongoose'); 6 | 7 | const server = require('../index'); 8 | 9 | describe('HTTP requests', () => { 10 | afterAll(async (done) => { 11 | try { 12 | await mongoose.disconnect(); 13 | } catch (error) { 14 | console.log('Could not disconnect from db *sadface*'); 15 | } 16 | }); 17 | 18 | describe('GET / ', () => { 19 | it('Should respond with 200 status', async () => { 20 | const response = await request(server).get('/'); 21 | expect(response.statusCode).toBe(200); 22 | }); 23 | }); 24 | describe('GET /api/user ', () => { 25 | it('Should respond with an array of users', async () => { 26 | const response = await request(server).get('/api/user'); 27 | expect(response.statusCode).toBe(200); 28 | expect(Array.isArray(response.body)).toBe(true); 29 | }); 30 | it('Response body array should contain objects of users', async () => { 31 | const response = await request(server).get('/api/user'); 32 | expect(response.statusCode).toBe(200); 33 | response.body.forEach((element) => { 34 | expect(typeof element).toBe('object'); 35 | expect(Array.isArray(element)).toBe(false); 36 | expect(element).toBeTruthy(); 37 | }); 38 | }); 39 | }); 40 | describe('POST /api/user ', () => { 41 | it('Should successfully add another user', async () => { 42 | const responseBefore = await request(server).get('/api/user'); 43 | const postResponse = await request(server) 44 | .post('/api/user') 45 | .send({ username: 'Testing', password: 'testtesttest', email: 'test@gmail.com' }); 46 | const responseAfter = await request(server).get('/api/user'); 47 | const { _id, username, email } = postResponse; 48 | 49 | expect(responseBefore.statusCode).toBe(200); 50 | expect(postResponse.statusCode).toBe(201); 51 | expect(responseAfter.statusCode).toBe(200); 52 | expect(responseAfter.body.length).toEqual(responseBefore.body.length + 1); 53 | expect(typeof responseAfter).toBe('object'); 54 | expect(username).toEqual('testing'); 55 | expect(email).toEqual('test@gmail.com'); 56 | expect(_id).toBeTruthy(); 57 | 58 | await request(server).delete(`/api/user/${_id}`); 59 | }); 60 | it('Should not update with bad body', async () => { 61 | const body1 = { 62 | username: '!te*s t', 63 | password: '123', 64 | email: 'testing.gmail.com', 65 | }; 66 | const body2 = { 67 | username: 'noPassword', 68 | }; 69 | const body3 = { 70 | password: 'noUsername', 71 | }; 72 | const body4 = { 73 | email: 'nobodyorpass@gmail.com', 74 | }; 75 | const res1 = await request(server).post('/api/user').send(body1); 76 | const res2 = await request(server).post('/api/user').send(body2); 77 | const res3 = await request(server).post('/api/user').send(body4); 78 | const res4 = await request(server).post('/api/user').send(body4); 79 | expect(res1.statusCode).toBe(400); 80 | expect(Object.prototype.hasOwnProperty.call(res1.body, 'username')).toBeTruthy(); 81 | expect(Object.prototype.hasOwnProperty.call(res1.body, 'password')).toBeTruthy(); 82 | expect(Object.prototype.hasOwnProperty.call(res1.body, 'email')).toBeTruthy(); 83 | expect(res2.statusCode).toBe(400); 84 | expect(Object.prototype.hasOwnProperty.call(res2.body, 'password')).toBeTruthy(); 85 | expect(res3.statusCode).toBe(400); 86 | expect(Object.prototype.hasOwnProperty.call(res3.body, 'username')).toBeTruthy(); 87 | expect(res4.statusCode).toBe(400); 88 | expect(Object.prototype.hasOwnProperty.call(res4.body, 'username')).toBeTruthy(); 89 | expect(Object.prototype.hasOwnProperty.call(res4.body, 'password')).toBeTruthy(); 90 | }); 91 | }); 92 | describe('PATCH /api/user ', async () => { 93 | const body = { 94 | username: 'validUser', 95 | password: '1234567', 96 | email: 'validemail@gmail.com', 97 | }; 98 | const userPost = await request(server).post('/api/user').send(body); 99 | const { _id } = userPost.body; 100 | it('Should update a property on user object', async () => { 101 | const userUpdate = { 102 | username: 'anotherValidUser', 103 | email: 'diffvalidemail@gmail.com', 104 | }; 105 | const updatedUser = await request(server).patch(`/api/user${_id}`).send(userUpdate); 106 | expect(updatedUser.statusCode).toBe(200); 107 | expect(updatedUser.body.username).toBe('anothervaliduser'); 108 | expect(updatedUser.body.email).toBe('diffvalidemail@gmail.com'); 109 | }); 110 | it('Should not update with bad body', async () => { 111 | const invalidUpdate = { 112 | username: 'asd', 113 | email: 'badrequest.com', 114 | }; 115 | const invalidUser = await request(server).patch(`/api/user${_id}`).send(invalidUpdate); 116 | expect(invalidUser.statusCode).toBe(400); 117 | expect(Object.prototype.hasOwnProperty.call(invalidUser.body, 'username')).toBeTruthy(); 118 | expect(Object.prototype.hasOwnProperty.call(invalidUser.body, 'email')).toBeTruthy(); 119 | }); 120 | afterAll(async (done) => { 121 | try { 122 | await request(server).delete(`/api/user/${_id}`); 123 | } catch (error) { 124 | console.log('Could not delete user *sadface*'); 125 | } 126 | }); 127 | }); 128 | xdescribe('PUT /api/user ', async () => { 129 | const body = { 130 | username: 'validuser', 131 | password: 'validpassword', 132 | email: 'valid@gmail.com', 133 | }; 134 | const user = await request(server).post(`/api/user`).send(body); 135 | const { _id } = user.body; 136 | const updateBody = { 137 | username: 'newname', 138 | password: 'differentpassword', 139 | email: 'diffvalid@gmail.com', 140 | }; 141 | const missingProps = { 142 | email: 'newvalidmail@gmail.com', 143 | }; 144 | const invalidProps = { 145 | username: '@notvalid', 146 | password: '1', 147 | email: 'test.test.com', 148 | }; 149 | it('Should update all properties of user object', async () => { 150 | const updatedUser = await request(server).put(`/api/user/${_id}`).send(updateBody); 151 | expect(updatedUser.statusCode).toBe(200); 152 | const { username, email, password } = updatedUser.body; 153 | expect(username).toBe('newname'); 154 | expect(email).toBe('diffvalid@gmail.com'); 155 | expect(password).toBeTruthy(); 156 | }); 157 | it('Should not update with missing props', async () => { 158 | const userMissingProps = await request(server).put(`/api/user/${_id}`).send(missingProps); 159 | expect(userMissingProps.statusCode).toBe(400); 160 | }); 161 | it('Should not update if props are not valid', async () => { 162 | const userInvalidProps = await request(server).put(`/api/user/${_id}`).send(invalidProps); 163 | expect(userInvalidProps.statusCode).toBe(400); 164 | }); 165 | afterAll(async (done) => { 166 | try { 167 | await request(server).delete(`/api/user/${_id}`); 168 | } catch (error) { 169 | console.log('Could not delete user *sadface*'); 170 | } 171 | }); 172 | }); 173 | xdescribe('DELETE /api/user ', async () => { 174 | const body = { 175 | username: 'validuser', 176 | password: 'validpassword', 177 | email: 'valid@gmail.com', 178 | }; 179 | const user = await request(server).post(`/api/user`).send(body); 180 | const { _id } = user.body; 181 | it('Should successfully delete user', async () => { 182 | const userGetBefore = await request(server).get(`/api/user`); 183 | const deleteUser = await request(server).delete(`/api/user/${_id}`); 184 | const userGetAfter = await request(server).get('/api/user'); 185 | const getDeletedUser = await request(server).get(`/api/user/${_id}`); 186 | expect(userGetBefore.body.length).toBe(userGetAfter.body.length + 1); 187 | expect(deleteUser.statusCode).toBe(200); 188 | expect(getDeletedUser.statusCode).toBe(404); 189 | }); 190 | xit('Should respond with 404 with nonexistant user', async () => { 191 | const deleteAgain = await request(server).delete(`/api/user/${_id}`); 192 | expect(deleteAgain.statusCode).toBe(404); 193 | }); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /test/etc/database.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable import/extensions */ 3 | 4 | import * as mongoose from 'mongoose'; 5 | import { MONGO_URI } from './secrets'; 6 | 7 | const options = { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true, 10 | dbName: 'synapse', 11 | useFindAndModify: false, 12 | }; 13 | 14 | mongoose 15 | .connect(MONGO_URI, options) 16 | .then(() => console.log('Connected to Mongo DB.')) 17 | .catch((err) => console.log(err)); 18 | 19 | const schema = new mongoose.Schema({}, { strict: false }); 20 | 21 | export default (model: string) => { 22 | return mongoose.model(model, schema); 23 | }; 24 | -------------------------------------------------------------------------------- /test/fields/MongoId.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | 3 | import { Id } from '../../lib/fields'; 4 | 5 | export default class MongoId extends Id { 6 | constructor(flags = null) { 7 | super(); 8 | 9 | this.assert( 10 | /^[0-9a-f]{24}$/i, 11 | true, 12 | 'must be a single String of 12 bytes or a string of 24 hex characters' 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/synapse/585fe7285b663602a6cec0d948af95179b95278f/test/index.js -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const path = require('path'); 4 | const express = require('express'); 5 | const cookieParser = require('cookie-parser'); 6 | const enableWs = require('express-ws'); 7 | const cors = require('cors'); 8 | 9 | const { synapse } = require('../lib/index'); 10 | const { identify } = require('./resources/Session'); 11 | 12 | const PORT = process.argv[2] || 3000; 13 | const PEERS = process.argv.slice(3).map((port) => `ws://[::1]:${port}/api`); 14 | 15 | const app = express(); 16 | enableWs(app); 17 | 18 | // standard parsers 19 | app.use(express.json(), express.urlencoded({ extended: true }), cookieParser(), cors()); 20 | 21 | // ensure that all clients have a client_id cookie 22 | app.use('/', identify); 23 | 24 | // initialize an instance of the synapse API with the directory containing the Resource definitions 25 | const api = synapse(path.resolve(__dirname, './resources')); // ["::1"], PEERS 26 | // define global middleware for all api requests 27 | api.use((req, res) => { 28 | const state = res.locals; 29 | if (res.stream) { 30 | return res.stream(state); 31 | } 32 | return res.status(state.$status).send(state.serialize()); 33 | }); 34 | // route requests to api routers by protocol 35 | app.ws('/api', api.ws); 36 | app.use('/api', api.sse, api.http); 37 | 38 | // serve static content 39 | app.use(express.static(path.resolve(__dirname, './public'))); 40 | 41 | // catch-all error handlers 42 | app.use((req, res) => res.status(404).send('Not Found')); 43 | app.use((err, req, res, next) => { 44 | console.log(err); 45 | res.status(err.status || 500).send(err.toString()); 46 | }); 47 | 48 | // if not in test mode, start the server 49 | if (process.env.NODE_ENV !== 'test') { 50 | app.listen(PORT, () => console.log(`listening on port ${PORT}`)); 51 | } 52 | 53 | module.exports = app; 54 | -------------------------------------------------------------------------------- /test/resources/Comment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable lines-between-class-members */ 3 | 4 | import { Resource, State } from '../../lib'; 5 | import decorators from '../../lib/abstract/@'; 6 | import { Id, Text, Integer } from '../../lib/fields'; 7 | 8 | const { field, endpoint, schema, uses } = decorators; 9 | 10 | const pageSize = 10; 11 | const ledger = []; 12 | 13 | export default class Comment extends Resource { 14 | @field(new Id()) id: string; 15 | @field(new Text()) text: string; 16 | 17 | @endpoint('GET /last') 18 | @uses('/') 19 | static Last() { 20 | if (!ledger[ledger.length - 1]) { 21 | return State.NOT_FOUND(); 22 | } 23 | return Comment.restore(ledger[ledger.length - 1]); 24 | } 25 | 26 | @endpoint('GET /:id') 27 | @schema(Comment.schema.select('id')) 28 | static Find({ id }) { 29 | if (!ledger[id]) { 30 | return State.NOT_FOUND(); 31 | } 32 | return Comment.restore(ledger[id]); 33 | } 34 | 35 | @endpoint('GET /page/:index') 36 | @schema({ index: new Integer() }) 37 | @uses('/') 38 | static List({ index }) { 39 | const start = ledger.length - pageSize * index; 40 | return Comment.collection(ledger.slice(start, start + pageSize)); 41 | } 42 | 43 | @endpoint('POST /') 44 | @schema(Comment.schema.select('text')) 45 | static async Post({ text }) { 46 | const comment = await Comment.create({ id: `${ledger.length}`, text }); 47 | ledger.push(comment.export()); 48 | return comment; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/resources/Session.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable camelcase */ 3 | /* eslint-disable import/extensions */ 4 | /* eslint-disable lines-between-class-members */ 5 | 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import { Resource, State } from '../../lib'; 8 | import decorators from '../../lib/abstract/@'; 9 | import { Id } from '../../lib/fields'; 10 | import User from './User'; 11 | 12 | const { field, endpoint, authorizer, schema } = decorators; 13 | 14 | const sessions = {}; 15 | 16 | /** Express middleware function which sets a cookie ```client_id``` on the client if it doesn't already exist. */ 17 | export const identify = (req, res, next) => { 18 | res.cookie('client_id', req.cookies.client_id || uuidv4()); 19 | next(); 20 | }; 21 | /** Synpase middleware function which checks for a ```client_id``` property on the input arguments object whose value is associated with a valid session instance. */ 22 | export const authorize = (args) => { 23 | const { client_id } = args; 24 | 25 | const client = sessions[client_id]; 26 | 27 | if (!client) { 28 | return State.UNAUTHORIZED(); 29 | } 30 | 31 | return [args]; 32 | }; 33 | 34 | export default class Session extends Resource { 35 | @field(new Id(36)) client_id: string; 36 | @field(new Id(36)) user_id: string; 37 | 38 | @endpoint('POST /') 39 | @schema(Session.union(User).select('username', 'password', 'client_id')) 40 | static async open({ username, password, client_id }) { 41 | const result = await User.authenticate({ username, password }); 42 | 43 | if (result instanceof User) { 44 | sessions[client_id] = result; 45 | } 46 | 47 | return result; 48 | } 49 | 50 | @endpoint('GET /') 51 | @authorizer(authorize) 52 | @schema(Session.schema.select('client_id')) 53 | static async read({ client_id }) { 54 | return sessions[client_id]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/resources/User.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | /* eslint-disable lines-between-class-members */ 3 | 4 | import { Resource, State, Field } from '../../lib'; 5 | import decorators from '../../lib/abstract/@'; 6 | import { Email, Hash, Word, Text } from '../../lib/fields'; 7 | import MongoId from '../fields/MongoId'; 8 | import mongo from '../etc/database'; 9 | 10 | const { OPT, PRV } = Field.Flags; 11 | const { field, schema, endpoint } = decorators; 12 | 13 | const collection = mongo('User'); 14 | 15 | export default class User extends Resource { 16 | @field(new MongoId()) _id: string; 17 | @field(new Word(3, 16)) username: string; 18 | @field(new Email(), OPT) email: string; 19 | @field(new Text(), PRV) password: string; 20 | 21 | @endpoint('GET /:_id') 22 | @schema(User.schema.select('_id')) 23 | static async find({ _id }) { 24 | const document = await collection.findById({ _id }); 25 | if (!document) { 26 | return State.NOT_FOUND(); 27 | } 28 | return User.restore(document.toObject()); 29 | } 30 | 31 | @endpoint('GET /') 32 | static async getAll() { 33 | const documents = await collection.find(); 34 | return User.collection(documents.map((document) => document.toObject())); 35 | } 36 | 37 | @endpoint('POST /') 38 | @schema(User.schema.exclude('_id', 'password').extend({ password: new Hash(6) })) 39 | static async register({ username, email, password }) { 40 | const document = await collection.create({ username, email, password }); 41 | return User.create(document.toObject()); 42 | } 43 | 44 | @endpoint('PATCH /:_id') 45 | @schema(User.schema.select('_id', 'email')) 46 | static async update({ _id, email }) { 47 | const document = await collection.findOneAndUpdate({ _id }, { email }, { new: true }); 48 | if (!document) { 49 | return State.NOT_FOUND(); 50 | } 51 | return User.restore(document.toObject()); 52 | } 53 | 54 | @endpoint('DELETE /:_id') 55 | @schema(User.schema.select('_id')) 56 | static async remove({ _id }) { 57 | const document = await collection.deleteOne({ _id }); 58 | if (!document) { 59 | return State.NOT_FOUND(); 60 | } 61 | return State.OK('User Deleted'); 62 | } 63 | 64 | @schema(User.schema.select('username', 'password')) 65 | static async authenticate({ username, password }) { 66 | const document = await collection.findOne({ username }); 67 | if (document) { 68 | const user = await User.restore(document.toObject()); 69 | if (await Hash.validate(password, user.password)) { 70 | return user; 71 | } 72 | } 73 | return State.FORBIDDEN('Incorrect username/password.'); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./lib", 4 | "outDir": "./build/lib.browser", 5 | "target": "es5", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "lib": [ 9 | "es2016", 10 | "dom", 11 | "es5" 12 | ] 13 | }, 14 | "include": [ 15 | "./lib/client" 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./build", 7 | "rootDir": "./", 8 | "experimentalDecorators": true 9 | }, 10 | "typedocOptions": { 11 | "name": "synapse", 12 | "inputFiles": "lib", 13 | "out": "../docs/docs/reference", 14 | "mode": "modules", 15 | "hideGenerator": true, 16 | "hideBreadcrumbs": true, 17 | "exclude": [ 18 | "index.ts", 19 | "**/index.ts" 20 | ], 21 | "excludeNotExported": true, 22 | } 23 | } --------------------------------------------------------------------------------