├── .env.example ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── assets └── logos │ ├── Logo.png │ ├── Logo@2x.png │ └── base.sketch ├── docker-compose.yml ├── examples └── 1. Hello World │ └── hello.ts ├── package-lock.json ├── package.json ├── prettier.json ├── src ├── bootstrap │ ├── drivers │ │ └── Environment.ts │ └── index.ts ├── cryptography │ ├── abstract │ │ ├── AsymmetricSigner.ts │ │ ├── Digest.ts │ │ ├── HMAC.ts │ │ ├── JWTSigner.ts │ │ ├── MessageAuthenticationCode.ts │ │ └── SymmetricEncrypter.ts │ ├── drivers │ │ └── node │ │ │ ├── AsymmetricJWTSigner.ts │ │ │ ├── RSASHA256Signer.ts │ │ │ ├── SHA1HMAC.ts │ │ │ └── SHA256Digest.ts │ └── index.ts ├── datastore │ ├── abstract │ │ ├── PGIsolationLevel.ts │ │ └── SQLStore.ts │ ├── drivers │ │ ├── mysql │ │ │ ├── MySQLStore.ts │ │ │ └── MySQLTransaction.ts │ │ ├── pg │ │ │ ├── PGStore.ts │ │ │ └── PGTransaction.ts │ │ └── redis │ │ │ └── RedisStore.ts │ └── index.ts ├── errors │ ├── InvalidControllerError.ts │ ├── InvalidSignatureError.ts │ ├── StrontiumError.ts │ ├── TransientError.ts │ ├── http │ │ ├── HTTPError.ts │ │ ├── InternalServerError.ts │ │ └── ValidationError.ts │ └── index.ts ├── http │ ├── abstract │ │ ├── EndpointController.ts │ │ └── RouterMap.ts │ ├── drivers │ │ └── FastifyServer.ts │ └── index.ts ├── index.ts ├── logging │ ├── abstract │ │ ├── LogLevel.ts │ │ └── Logger.ts │ ├── drivers │ │ ├── AggregateLogger.ts │ │ └── ConsoleLogger.ts │ └── index.ts ├── query │ ├── abstract │ │ ├── Filter.ts │ │ ├── FilterCompiler.ts │ │ ├── Query.ts │ │ └── Repository.ts │ ├── drivers │ │ ├── pg │ │ │ └── PGQueryPostProcessor.ts │ │ └── sql │ │ │ ├── SQLFilterCompiler.ts │ │ │ └── TableRepository.ts │ └── index.ts ├── queue │ ├── abstract │ │ ├── QueueHandler.ts │ │ ├── QueuePublisher.ts │ │ └── SerializedTask.ts │ ├── drivers │ │ └── gcps │ │ │ ├── GCPSClient.ts │ │ │ ├── GCPSConsumer.ts │ │ │ └── GCPSPublisher.ts │ └── index.ts ├── runtime │ ├── abstract │ │ └── Process.ts │ ├── drivers │ │ └── Runtime.ts │ └── index.ts ├── utils │ ├── list.ts │ ├── typeGuard.ts │ └── types.ts └── validation │ ├── abstract │ ├── ObjectValidator.ts │ └── ValidatorFunction.ts │ ├── drivers │ ├── helpers │ │ ├── combineValidators.ts │ │ ├── either.ts │ │ └── isOptional.ts │ ├── sanitizers │ │ ├── defaultValue.ts │ │ └── normalizeEmail.ts │ └── validators │ │ ├── isArray.ts │ │ ├── isBase64EncodedString.ts │ │ ├── isBoolean.ts │ │ ├── isDictionary.ts │ │ ├── isEnumValue.ts │ │ ├── isExactly.ts │ │ ├── isFilter.ts │ │ ├── isISOCountry.ts │ │ ├── isISODate.ts │ │ ├── isNull.ts │ │ ├── isNumber.ts │ │ ├── isObject.ts │ │ ├── isString.ts │ │ ├── isUUID.ts │ │ └── isUndefined.ts │ └── index.ts ├── tests ├── cryptography │ └── drivers │ │ └── node │ │ ├── AsymmetricJWTSigner.spec.ts │ │ └── SHA256Digest.spec.ts ├── datastore │ └── drivers │ │ ├── pg │ │ └── PGStore.spec.ts │ │ └── redis │ │ └── RedisStore.spec.ts ├── errors │ └── http │ │ └── ValidationError.spec.ts ├── helpers │ └── ExpectToThrowCustomClass.ts ├── http │ └── drivers │ │ └── FastifyServer.spec.ts ├── logger │ └── drivers │ │ ├── AggregateLogger.spec.ts │ │ └── ConsoleLogger.spec.ts ├── query │ └── drivers │ │ ├── PGQueryPostProcessor.spec.ts │ │ └── SQLFilterCompiler.spec.ts ├── queue │ └── drivers │ │ └── GCPSClient.spec.ts ├── runtime │ └── drivers │ │ └── Runtime.spec.ts ├── utils │ ├── list.spec.ts │ └── typeGuard.spec.ts └── validation │ └── drivers │ ├── helpers │ ├── combineValidators.spec.ts │ └── either.spec.ts │ ├── sanitizers │ ├── defaultValue.spec.ts │ └── normalizeEmail.spec.ts │ └── validators │ ├── isArray.spec.ts │ ├── isBoolean.spec.ts │ ├── isExactly.spec.ts │ ├── isISOCountry.spec.ts │ ├── isISODate.spec.ts │ ├── isNull.spec.ts │ ├── isNumber.spec.ts │ ├── isObject.spec.ts │ ├── isString.spec.ts │ └── isUndefined.spec.ts ├── tsconfig.json └── tslint.json /.env.example: -------------------------------------------------------------------------------- 1 | ##################################### 2 | # StrontiumJS Environment Variables # 3 | ##################################### 4 | 5 | ####################### 6 | # Framework Variables # 7 | ####################### 8 | 9 | # These variables are used by end users of the framework. 10 | # They should all have default cases but are set here for documentation and testing purposes. 11 | 12 | ################## 13 | # Test Variables # 14 | ################## 15 | 16 | # These variables are used by the framework internally for it's test suite. 17 | # They are not useful to nor implementable by end users of the framework. 18 | 19 | 20 | # POSTGRES CONNECTION 21 | PG_HOST=localhost:5432 22 | PG_USER=strontium 23 | PG_PASSWORD=strontium 24 | PG_DATABASE=strontium 25 | 26 | # Google Cloud Pub Sub 27 | GCPS_SERVICE_ACCOUNT_EMAIL= 28 | GCPS_SERVICE_ACCOUNT_KEY_ID= 29 | GCPS_SERVICE_ACCOUNT_PRIVATE_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | ### VisualStudioCode ### 62 | .vscode/* 63 | !.vscode/settings.json 64 | !.vscode/tasks.json 65 | !.vscode/launch.json 66 | !.vscode/extensions.json 67 | .history 68 | 69 | ### WebStorm ### 70 | .idea 71 | 72 | # Ignore the Compiled Output 73 | dist 74 | lib 75 | 76 | # Ignore the docker temporary directories 77 | .mysql_data 78 | .pg_data 79 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | services: 5 | - postgresql 6 | - redis-server 7 | before_install: 8 | - npm install -g npm@6 9 | - npm install -g greenkeeper-lockfile@1 10 | before_script: 11 | - greenkeeper-lockfile-update 12 | - psql -c 'create database strontium;' -U postgres 13 | script: 14 | - npm run lint 15 | - npm run build 16 | - npm run test 17 | after_script: 18 | - npm run report-coverage 19 | - greenkeeper-lockfile-upload 20 | deploy: 21 | provider: npm 22 | email: alexanderchristie@outlook.com 23 | api_key: 24 | secure: cW44aOgF4LiL0bcsCeNGevzoi9ob21OodPyn3IZqba0IJ6MV0mMA1eFPwYJEV3i/APyBSmuaybQwJK3KqRl9/6OvoRTWQOXCLSmx1dFIb+3k0YsZWz3kP8POHL1xiyJoRutzNHHfNM7tu02jD1NK9B65TeY9DGItbtOrcgLCcqW90QzJZCgJzdb4j36hg+ugc411PMEZ4/DI+H0uBv+yLZTW3yzViWxBFSE+PSri0HXR/KBwonv+ljzZDJxwA05h7BSjVRerNlsnry0ZuQyZI+9mmfYO19BgN15gHaVJp8F7AM62PuF2Osgz/B/uyU/gde9j7C5LOFHe0mWqG1Jq6ynHK1ANPA/INdmwwQKLMY2RytM4QWtd3uPi2jp6wVEq2rnH17aM79u+NQmSAcYJz0jTc+aLUDxUN6d/PhCYKCS4zw2f6km+LIoJghzGsIYQ4aMqZ9spYxfmOHj3TLYYgF2jTjj1uxXPf8AfgJN6n0tchxDvy72n7ECBbnGuBQowENpQA555eJJCbWB1AXCXIs8lQ38nR9I1AnYSk4yev0NsCj/5iUX3X4o462rtW6d27lfs86B8I0wiCBUgMFTth38k9rwhvhWgXOsKwCSxOO07KGD/xBEO2cQsZM0XdYLHzLmI/mJ0ltqpWC+xtUSJvEHB4uH8c7fm09lraZ6emlg= 25 | skip_cleanup: true 26 | on: 27 | branch: master 28 | env: 29 | global: 30 | secure: fQnkfv2KLHZ2zhXrX6C/s4Czr1/hvzw7z3zi3Z3N1p1c8AJ2V6YUSrhwKlZnWMn9P1S7AHGuNIn/tACmSZbg5Uwo7CyXJl7qqbhpv7lg/Vn/w+ahJF/bvwKhkj7hPSZJY3CGKft3lp44lMhejMEtgzv3t7yfPIU/Zh9dHgLhbwXRR28KUfwo8FrBbVqi7S5sdyVWy2o9G2l54ncwhp76tL4gIx//B80oAzJy/os/ls2PeyTb4IP9/CYWRbG1ESq2B+mkUqyJbCYaxvH9bJSIZ8mi0iPCV3Q43g0pBxaRscjCQ75RfjhEIiH7FaFGLilXi1xgbPtz/RN/CTCRUZNZ1qcsPRjhzPecEqV/MIU6NXwUlMzzANWMYV9qhwRkKHa4SmzdmK6nhbk3pyxkMveYZKxa084OPWRI9NyERpZ8RlzXYIqge9AyB8SswicnU3Oa3T6hnMTGxV1T9BRoCjtuxmeKaTYtsi45/dZ8llJr1JXWZsyXYsboFuDzBZl15RXCwO/A1Udzks3O2HH2yFz71iWjSvmUL7t3FeruPsTW46/PpxSLH/a8Itfo4WC4MQsXHEzjHuP4D0gK9d+cpe5hipNSuiRJyCCsoPskCjaqbfKBDf7D7+VXEP6QGhbQGcdeLX/3Ta3CAmWkVJw6xxAjv1lKThKI7AVRDgxR4+X+Q4g= 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 StrontiumJS 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 | StrontiumJS Logo 2 | 3 | # Strontium 2 4 | 5 | > ### For Production not just Projects 6 | 7 | [![Travis](https://img.shields.io/travis/StrontiumJS/Framework.svg)](https://travis-ci.org/StrontiumJS/Framework) 8 | [![Coveralls github](https://img.shields.io/coveralls/github/StrontiumJS/Framework.svg)](https://coveralls.io/github/StrontiumJS/Framework) 9 | ![Greenkeeper badge](https://badges.greenkeeper.io/StrontiumJS/Framework.svg) 10 | 11 | ## Introduction 12 | 13 | Strontium is designed to make it easy to build a TypeScript code base for a Backend Services with a focus on allowing the 14 | creation of readable and maintainable code which is easy to test. 15 | 16 | Throughout the toolkit Strontium takes advantage of types and descriptive errors to make the developer experience fluid 17 | and productive. All aspects of Strontium are batteries included and designed for Production use. All of our components 18 | are suitable for production use out of the box and have been battle tested. 19 | 20 | ## Maintainers 21 | 22 | Alexander Christie - Head of Engineering @ [Attio](https://attio.com) 23 | 24 | Jamie Davies - Software Engineer @ [Intercom](https://intercom.io) 25 | 26 | -------------------------------------------------------------------------------- /assets/logos/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StrontiumJS/Framework/8bb5608798cd2a19c60da6e1a92d435d0f0ef196/assets/logos/Logo.png -------------------------------------------------------------------------------- /assets/logos/Logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StrontiumJS/Framework/8bb5608798cd2a19c60da6e1a92d435d0f0ef196/assets/logos/Logo@2x.png -------------------------------------------------------------------------------- /assets/logos/base.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StrontiumJS/Framework/8bb5608798cd2a19c60da6e1a92d435d0f0ef196/assets/logos/base.sketch -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | postgres: 5 | image: postgres:9.6 6 | environment: 7 | - POSTGRES_DB=strontium 8 | - POSTGRES_USER=strontium 9 | - POSTGRES_PASSWORD=strontium 10 | volumes: 11 | - ./.pg_data:/var/lib/postgresql/data 12 | ports: 13 | - 5432:5432 14 | 15 | redis: 16 | image: redis:5.0.3-alpine 17 | ports: 18 | - 6379:6379 19 | -------------------------------------------------------------------------------- /examples/1. Hello World/hello.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata" 2 | 3 | import { FastifyServer } from "../../src/http" 4 | import { ControllerInput, ControllerOutput, EndpointController } from "../../src/http" 5 | import { injectable } from "inversify" 6 | import { AggregateLogger, ConsoleLogger, LogLevel } from "../../src/logging" 7 | import { Runtime } from "../../src/runtime" 8 | import { isNull, isString } from "../../src/validation" 9 | 10 | // Create a simple hello world controller 11 | @injectable() 12 | class HelloWorldController extends EndpointController { 13 | public inputValidator = { 14 | body: isNull, 15 | headers: {}, 16 | query: {}, 17 | params: { 18 | name: isString 19 | }, 20 | meta: {} 21 | } 22 | 23 | public outputValidator = isString 24 | 25 | public async handle(input: ControllerInput): Promise> { 26 | return `Hello ${input.params.name || "World"}` 27 | } 28 | } 29 | 30 | // Create a simple process runtime with a console logger and a Fastify web server 31 | let helloRuntime = new Runtime([ 32 | new AggregateLogger([ 33 | new ConsoleLogger(LogLevel.INFO) 34 | ]), 35 | new FastifyServer([{ 36 | method: "GET", 37 | route: "/hello", 38 | endpointController: HelloWorldController 39 | }, { 40 | method: "GET", 41 | route: "/hello/:name", 42 | endpointController: HelloWorldController 43 | }]) 44 | ]) 45 | 46 | // Start the server 47 | helloRuntime.startup() -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strontium", 3 | "version": "2.9.12", 4 | "description": "Strontium is a TypeScript toolkit for High Performance API servers built for Production not Projects.", 5 | "main": "lib/src/index.js", 6 | "types": "lib/src/index.d.ts", 7 | "scripts": { 8 | "build": "./node_modules/.bin/tsc", 9 | "test": "npx nyc mocha -u tdd --require ts-node/register --require dotenv/config --require reflect-metadata 'tests/**/*.spec.ts'", 10 | "report-coverage": "npx nyc report --reporter=text-lcov | npx coveralls", 11 | "lint": "./node_modules/.bin/prettier --list-different --config ./prettier.json \"{src,tests}/**/*.ts\" && tslint --project ./tsconfig.json \"{src,tests}/**/*.ts\"", 12 | "fix-lint": "./node_modules/.bin/prettier --config ./prettier.json --write \"{src,tests}/**/*.ts\" && tslint --fix --project ./tsconfig.json \"{src,tests}/**/*.ts\"", 13 | "develop": "./node_modules/.bin/mocha -w -u tdd --require ts-node/register --require reflect-metadata 'tests/**/*.spec.ts'" 14 | }, 15 | "engines": { 16 | "node": ">=8.0.0" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/StrontiumJS/Framework.git" 21 | }, 22 | "keywords": [ 23 | "nodejs", 24 | "typescript", 25 | "saas" 26 | ], 27 | "author": "Alexander Christie ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/StrontiumJS/Framework/issues" 31 | }, 32 | "homepage": "https://github.com/StrontiumJS/Framework#readme", 33 | "files": [ 34 | "lib/src", 35 | "package-lock.json", 36 | "README.md", 37 | ".gitignore" 38 | ], 39 | "devDependencies": { 40 | "@types/chai": "^4.1.6", 41 | "@types/chai-as-promised": "^7.1.0", 42 | "@types/mocha": "^5.2.5", 43 | "@types/sinon": "^5.0.2", 44 | "chai": "^4.2.0", 45 | "chai-as-promised": "^7.1.1", 46 | "coveralls": "^3.0.2", 47 | "dotenv": "^6.2.0", 48 | "lint-staged": "^7.3.0", 49 | "mocha": "^5.2.0", 50 | "mock-express-request": "^0.2.2", 51 | "mock-express-response": "^0.2.2", 52 | "nyc": "^13.3.0", 53 | "prettier": "^1.14.3", 54 | "reflect-metadata": "^0.1.12", 55 | "sinon": "^6.3.4", 56 | "ts-node": "^7.0.1", 57 | "tslint": "^5.11.0", 58 | "typescript": "^3.8.3" 59 | }, 60 | "dependencies": { 61 | "@types/lodash": "^4.14.117", 62 | "@types/mysql": "^2.15.5", 63 | "@types/node": "^13.13.4", 64 | "@types/pg": "^7.14.3", 65 | "@types/redis": "^2.8.11", 66 | "@types/uuid": "^3.4.4", 67 | "@types/validator": "^10.11.0", 68 | "axios": "^0.18.0", 69 | "base64url": "^3.0.0", 70 | "fastify": "^2.14.0", 71 | "inversify": "^5.0.1", 72 | "jwa": "^1.2.0", 73 | "lodash": "^4.17.11", 74 | "mysql": "^2.16.0", 75 | "pg": "^8.0.3", 76 | "redis": "^2.8.0", 77 | "uuid": "^3.3.2", 78 | "validator": "^10.11.0" 79 | }, 80 | "nyc": { 81 | "include": [ 82 | "src/*.ts", 83 | "src/**/*.ts" 84 | ], 85 | "exclude": [ 86 | "typings" 87 | ], 88 | "extension": [ 89 | ".ts" 90 | ], 91 | "require": [ 92 | "ts-node/register" 93 | ], 94 | "reporter": [ 95 | "json", 96 | "html", 97 | "text" 98 | ] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /prettier.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 4, 4 | "semi": false, 5 | "trailingComma": "es5", 6 | "arrowParens": "always", 7 | "parser": "typescript" 8 | } 9 | -------------------------------------------------------------------------------- /src/bootstrap/drivers/Environment.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "inversify" 2 | import { Logger } from "../../logging" 3 | import { Process } from "../../runtime" 4 | import { ObjectValidator, ValidatedObject, isObject } from "../../validation" 5 | 6 | export class Environment implements Process { 7 | private validatedEnvironment?: ValidatedObject 8 | 9 | constructor(private validator: O) {} 10 | 11 | public getKey>( 12 | key: K 13 | ): ValidatedObject[K] { 14 | if (this.validatedEnvironment !== undefined) { 15 | return this.validatedEnvironment[key] 16 | } else { 17 | throw new Error( 18 | "An environment value was accessed before the Environment container completed initialization." 19 | ) 20 | } 21 | } 22 | 23 | public isHealthy(): boolean { 24 | return this.validatedEnvironment !== undefined 25 | } 26 | 27 | public async startup(container: Container): Promise { 28 | container.bind(Environment).toConstantValue(this) 29 | 30 | try { 31 | this.validatedEnvironment = await isObject(this.validator)( 32 | process.env 33 | ) 34 | } catch (e) { 35 | if (container.isBound(Logger)) { 36 | container.get(Logger).error("Environment validation failed!", e) 37 | } else { 38 | console.error(e) 39 | } 40 | 41 | throw e 42 | } 43 | } 44 | 45 | public async shutdown(container: Container): Promise { 46 | container.unbind(Environment) 47 | 48 | this.validatedEnvironment = undefined 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/bootstrap/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./drivers/Environment" 2 | -------------------------------------------------------------------------------- /src/cryptography/abstract/AsymmetricSigner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An AsymmetricSigner represents a category of cryptographic algorithm that is able 3 | * to sign information using a secret referred to as a "Private Key". 4 | * 5 | * The authenticity of this signature can then be verified by anyone who possess the "Public Key" 6 | * which is generally safe to distribute. 7 | * 8 | * This allows for a small number of secure nodes to sign information which can then be generally 9 | * verified as correct by a large fleet of less secure entities. 10 | */ 11 | export abstract class AsymmetricSigner { 12 | /** 13 | * Create a new Asymmetric Signer. 14 | * 15 | * @param publicKey - The Public Key used to verify tokens 16 | * @param privateKey - The Private Key used to sign tokens 17 | */ 18 | constructor(protected publicKey: Buffer, protected privateKey?: Buffer) {} 19 | 20 | /** 21 | * Sign the provided plaintext using the private key. 22 | * 23 | * N.B This method will throw an error if the private key has not been provided to the constructor. 24 | * 25 | * @param plaintext {Buffer} - Plaintext to undergo signing 26 | */ 27 | public abstract sign(plaintext: Buffer): Promise 28 | 29 | /** 30 | * Verify that a signature is valid for a provided plaintext using the Public Key provided. 31 | * 32 | * @param plaintext {Buffer} - Plaintext from which the signature claims to originate 33 | * @param signature {Buffer} - The signature which claims to verify the authenticity of the plaintext 34 | */ 35 | public abstract verify( 36 | plaintext: Buffer, 37 | signature: Buffer 38 | ): Promise 39 | } 40 | -------------------------------------------------------------------------------- /src/cryptography/abstract/Digest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Digest ( commonly referred to as a Hash ) is a function which takes an input 3 | * and maps it to a string which deterministically fingerprints the input. 4 | * 5 | * Strontium assumes that Digest functions will run on input that is entirely 6 | * in memory. The interface does not currently support calculations against a 7 | * stream. 8 | * 9 | * The exact nature or security properties of a given Digest implementation 10 | * will vary based on the implementing class. 11 | */ 12 | export abstract class Digest { 13 | /** 14 | * Calculate and return the digest of a given input. 15 | * 16 | * @param input {Buffer} The input to be hashed 17 | */ 18 | public abstract calculate(input: Buffer): Promise 19 | } 20 | -------------------------------------------------------------------------------- /src/cryptography/abstract/HMAC.ts: -------------------------------------------------------------------------------- 1 | import { Digest } from "./Digest" 2 | /** 3 | * A HMAC (hash-based message authentication 4 | * code) is a type of message authentication code (MAC) that uses a 5 | * cryptographic hash function and a secret key. It may be used to verify data 6 | * integrity and authentication of a message. 7 | * 8 | * Strontium assumes that HMAC functions will run on input that is entirely 9 | * in memory. The interface does not currently support calculations against a 10 | * stream. 11 | * 12 | * The exact nature or security properties of a given HMAC implementation 13 | * will vary based on the implementing class. 14 | */ 15 | export abstract class HMAC extends Digest { 16 | /** 17 | * Create a new HMAC Encrypter. 18 | * 19 | * @param secretKey - The shared secret to use for encryption. 20 | */ 21 | constructor(protected secretKey: Buffer) { 22 | super() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/cryptography/abstract/JWTSigner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A JWTSigner is used to create, verify and unwrap JWT tokens. 3 | * 4 | * A JWT - JSON Web Token - is a format for representing a claim alongside the data 5 | * necessary to authenticate the origin of the claim. In recent years they 6 | * have found favour as a mechanism for authentication. 7 | * 8 | * The Strontium library relies heavily on JWA - the open source library created 9 | * to provide Cryptographic primitives for JWT. Some developers may ask why we 10 | * decided to reimplement the JWTSigner class internally in the framework instead 11 | * of using popular open source JWT libraries. This is due to our wish for the internal 12 | * signing framework to be extensible. 13 | * 14 | * For example at Fundstack, where Strontium is developed, all of our cryptographic 15 | * key management is run inside of Google Cloud KMS backed by a HSM, largely for 16 | * compliance reasons. We needed to be able to have our JWT tokens signed by 17 | * this secure oracle which using the open source libraries available would not have supported. 18 | * 19 | * If an end user wishes to use a trusted open source library they can simply build a version of this 20 | * class which uses it accordingly. 21 | */ 22 | export abstract class JWTSigner { 23 | /** 24 | * Sign the provided claim using the private key. The claim will be manipulated into JSON and signed. 25 | * 26 | * @param plaintext {Buffer} - Plaintext to undergo signing 27 | * @return The signed JWT token representing the claim 28 | */ 29 | public abstract sign(claim: any): Promise 30 | 31 | /** 32 | * Verify that the JWT is valid for it's claim. 33 | * 34 | * Returns the claim if verified or throws an error if the claim cannot be authenticated. 35 | * 36 | * @param token - The JWT Token 37 | * @return The verified claim of the Token 38 | */ 39 | public abstract verify(token: string): Promise 40 | } 41 | -------------------------------------------------------------------------------- /src/cryptography/abstract/MessageAuthenticationCode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A MessageAuthenticationCode ( often called a tag, MAC or a HMAC ) is a fingerprint 3 | * used to confirm the origin and authenticity of a message and it's origin. 4 | * 5 | * Often confused with Digest Hashes - HMACs ( Hash based MACs ) differ significantly 6 | * from Digest hashes in that they require a secret key. 7 | * 8 | * Depending on the underlying implementation used the MAC class may provide stronger 9 | * or weaker guarantees of certain properties. 10 | */ 11 | export abstract class MessageAuthenticationCode { 12 | /** 13 | * Create a new MessageAuthenticationCode generator. 14 | * 15 | * @param secretKey - The shared secret to use when generating MACs 16 | */ 17 | constructor(protected secretKey: Buffer) {} 18 | 19 | /** 20 | * Calculate and return the MAC of a given input using the secretKey provided 21 | * to the constructor. 22 | * 23 | * @param input {Buffer} The input to be MAC-ed 24 | */ 25 | public abstract calculate(input: Buffer): Promise 26 | } 27 | -------------------------------------------------------------------------------- /src/cryptography/abstract/SymmetricEncrypter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Symmetric Encrypter represents a black box that implements single shared secret 3 | * cryptographic algorithms for symmetric encryption. 4 | * 5 | * The underlying implementation of the Symmetric Encrypter will determine the 6 | * security properties of the Encrypter. 7 | * 8 | * Depending on the exact nature of the implementation it may also 9 | * support additional features such as Authentication (AAED). 10 | */ 11 | export abstract class SymmetricEncrypter { 12 | /** 13 | * Create a new Symmetric Encrypter. 14 | * 15 | * @param secretKey - The shared secret to use for encryption. 16 | */ 17 | constructor(protected secretKey: Buffer) {} 18 | 19 | /** 20 | * Encrypt the provided Plaintext using the Symmetric Encryption algorithm 21 | * and return the Ciphertext. 22 | * 23 | * @param plaintext {Buffer} - Plaintext to undergo encryption 24 | */ 25 | public abstract encrypt(plaintext: Buffer): Promise 26 | 27 | /** 28 | * Decrypt encrypted Ciphertext to it's original Plaintext form. 29 | * 30 | * @param ciphertext {Buffer} - Ciphertext to undergo decryption 31 | */ 32 | public abstract decrypt(ciphertext: Buffer): Promise 33 | } 34 | -------------------------------------------------------------------------------- /src/cryptography/drivers/node/AsymmetricJWTSigner.ts: -------------------------------------------------------------------------------- 1 | import { AsymmetricSigner } from "../../abstract/AsymmetricSigner" 2 | import { JWTSigner } from "../../abstract/JWTSigner" 3 | 4 | // Types are dodgy on this library 5 | // @ts-ignore 6 | import { decode, encode, fromBase64 } from "base64url" 7 | 8 | export class AsymmetricJWTSigner extends JWTSigner { 9 | constructor( 10 | private signer: AsymmetricSigner, 11 | private algorithmCode: string, 12 | private keyId?: string 13 | ) { 14 | super() 15 | } 16 | 17 | public async sign(claim: any): Promise { 18 | // JSONify the claim 19 | let stringifiedClaim = JSON.stringify(claim) 20 | let encodedClaim = encode(stringifiedClaim) 21 | 22 | let stringifiedHeader = JSON.stringify({ 23 | alg: this.algorithmCode, 24 | typ: "JWT", 25 | kid: this.keyId, 26 | }) 27 | let encodedHeader = encode(stringifiedHeader) 28 | 29 | let signature = await this.signer.sign( 30 | new Buffer(`${encodedHeader}.${encodedClaim}`) 31 | ) 32 | 33 | return `${encodedHeader}.${encodedClaim}.${fromBase64( 34 | signature.toString("base64") 35 | )}` 36 | } 37 | 38 | public async verify(token: string): Promise { 39 | // Split the token into three parts 40 | let claimComponents = token.split(".") 41 | 42 | if (claimComponents.length !== 3) { 43 | throw new Error( 44 | "JWT supplied the incorrect number of components to verify." 45 | ) 46 | } 47 | 48 | let claimHeader = claimComponents[0] 49 | let parsedClaimHeader = JSON.parse(decode(claimHeader)) 50 | 51 | if (parsedClaimHeader.alg !== this.algorithmCode) { 52 | throw new Error( 53 | "JWT supplied a signing algorithm that is not supported by this validator." 54 | ) 55 | } 56 | 57 | let claimBody = claimComponents[1] 58 | let claimSignature = claimComponents[2] 59 | 60 | // Delegate validation of the signature to the signer 61 | await this.signer.verify( 62 | new Buffer(`${claimHeader}.${claimBody}`), 63 | new Buffer(claimSignature) 64 | ) 65 | 66 | // If no error occurred then the token is valid. Parse the claim and return 67 | let parsedClaimBody = JSON.parse(decode(claimBody)) 68 | return parsedClaimBody 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/cryptography/drivers/node/RSASHA256Signer.ts: -------------------------------------------------------------------------------- 1 | import { AsymmetricSigner } from "../../abstract/AsymmetricSigner" 2 | import { InvalidSignatureError } from "../../../errors/InvalidSignatureError" 3 | 4 | // Typescript Types not available for JWT - Proceed with caution 5 | // @ts-ignore 6 | import * as JWA from "jwa" 7 | 8 | export class RSASHA256Signer extends AsymmetricSigner { 9 | private signer = JWA(this.algorithm) 10 | 11 | constructor( 12 | public publicKey: Buffer, 13 | public privateKey?: Buffer, 14 | private algorithm: "RS256" | "PS256" = "RS256" 15 | ) { 16 | super(publicKey, privateKey) 17 | } 18 | 19 | public async sign(plaintext: Buffer): Promise { 20 | return this.signer.sign(plaintext, this.privateKey) 21 | } 22 | 23 | public async verify(plaintext: Buffer, signature: Buffer): Promise { 24 | let isValid = await this.signer.verify( 25 | plaintext, 26 | signature.toString(), 27 | this.publicKey 28 | ) 29 | 30 | if (!isValid) { 31 | throw new InvalidSignatureError() 32 | } 33 | 34 | // Spawn an empty buffer to fulfill the return type 35 | return Buffer.from([]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/cryptography/drivers/node/SHA1HMAC.ts: -------------------------------------------------------------------------------- 1 | import { HMAC } from "../../abstract/HMAC" 2 | import { createHmac } from "crypto" 3 | 4 | /** 5 | * The SHA1HMAC class provides a HMAC implementation using SHA1 based on Node's 6 | * OpenSSL. 7 | * 8 | * This implementation relies on the node crypto implementation and may vary based 9 | * on the build flags of the underlying runtime. 10 | */ 11 | export class SHA1HMAC extends HMAC { 12 | public async calculate(input: Buffer): Promise { 13 | let hmacBuilder = createHmac("sha1", this.secretKey) 14 | hmacBuilder.update(input) 15 | 16 | return hmacBuilder.digest() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/cryptography/drivers/node/SHA256Digest.ts: -------------------------------------------------------------------------------- 1 | import { Digest } from "../../abstract/Digest" 2 | import { createHash } from "crypto" 3 | 4 | /** 5 | * The SHA256Digest provides a Digest implementation using SHA256 based on Node's 6 | * OpenSSL. 7 | * 8 | * This implementation relies on the node crypto implementation and may vary based 9 | * on the build flags of the underlying runtime. 10 | */ 11 | export class SHA256Digest extends Digest { 12 | public async calculate(input: Buffer): Promise { 13 | const hashBuilder = createHash("sha256") 14 | hashBuilder.update(input) 15 | 16 | return hashBuilder.digest() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/cryptography/index.ts: -------------------------------------------------------------------------------- 1 | export { AsymmetricSigner } from "./abstract/AsymmetricSigner" 2 | export { Digest } from "./abstract/Digest" 3 | export { HMAC } from "./abstract/HMAC" 4 | export { JWTSigner } from "./abstract/JWTSigner" 5 | export { MessageAuthenticationCode } from "./abstract/MessageAuthenticationCode" 6 | export { SymmetricEncrypter } from "./abstract/SymmetricEncrypter" 7 | 8 | export { AsymmetricJWTSigner } from "./drivers/node/AsymmetricJWTSigner" 9 | export { RSASHA256Signer } from "./drivers/node/RSASHA256Signer" 10 | export { SHA256Digest } from "./drivers/node/SHA256Digest" 11 | export { SHA1HMAC } from "./drivers/node/SHA1HMAC" 12 | -------------------------------------------------------------------------------- /src/datastore/abstract/PGIsolationLevel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * PostgreSQL transaction isolation levels 3 | */ 4 | export enum PGIsolationLevel { 5 | SERIALIZABLE = "SERIALIZABLE", 6 | REPEATABLE_READ = "REPEATABLE_READ", 7 | READ_COMMITED = "READ_COMMITED", 8 | READ_UNCOMMITTED = "READ_UNCOMMITTED", 9 | } 10 | -------------------------------------------------------------------------------- /src/datastore/abstract/SQLStore.ts: -------------------------------------------------------------------------------- 1 | export interface SQLStore { 2 | query(queryString: string, parameters: Array): Promise> 3 | } 4 | -------------------------------------------------------------------------------- /src/datastore/drivers/mysql/MySQLStore.ts: -------------------------------------------------------------------------------- 1 | import { MySQLTransaction } from "./MySQLTransaction" 2 | import { SQLStore } from "../../abstract/SQLStore" 3 | import { Container } from "inversify" 4 | import { Logger } from "../../../logging" 5 | import { Pool, PoolConfig, createPool } from "mysql" 6 | import { Process } from "../../../runtime" 7 | import { promisify } from "util" 8 | 9 | export class MySQLStore implements Process, SQLStore { 10 | public healthyState: boolean = false 11 | private connection?: Pool 12 | private logger?: Logger 13 | 14 | constructor(private connectionOptions: PoolConfig) {} 15 | 16 | public isHealthy(): boolean { 17 | return this.healthyState 18 | } 19 | 20 | public async query( 21 | queryString: string, 22 | parameters: Array 23 | ): Promise> { 24 | if (this.connection) { 25 | try { 26 | // @ts-ignore 27 | let queryResult = await promisify( 28 | this.connection.query.bind(this.connection) 29 | )(queryString, parameters) 30 | return queryResult 31 | } catch (e) { 32 | if (e.fatal) { 33 | if (this.logger) { 34 | this.logger.error( 35 | "MySQL Store encountered a fatal error", 36 | e 37 | ) 38 | } 39 | 40 | this.healthyState = false 41 | } 42 | 43 | throw e 44 | } 45 | } else { 46 | throw new Error( 47 | "The MySQLStore is not currently open and cannot be used. Check that the store has had .startup() called and that the Promise has successfully returned." 48 | ) 49 | } 50 | } 51 | 52 | public async createTransaction(): Promise { 53 | if (this.connection === undefined) { 54 | throw new Error( 55 | "MySQL cannot open a transaction on a closed Pool. This usually happens from forgetting to call startup." 56 | ) 57 | } 58 | 59 | let connection = await promisify( 60 | this.connection.getConnection.bind(this.connection) 61 | )() 62 | await promisify(connection.beginTransaction.bind(this.connection))() 63 | 64 | return new MySQLTransaction(this, connection, this.logger) 65 | } 66 | 67 | public async startup(container: Container): Promise { 68 | if (this.connection === undefined) { 69 | if (container.isBound(Logger)) { 70 | this.logger = container.get(Logger) 71 | } 72 | 73 | this.connection = createPool(this.connectionOptions) 74 | this.healthyState = true 75 | 76 | container.bind(MySQLStore).toConstantValue(this) 77 | } else { 78 | throw new Error( 79 | "A MySQL Pool already exists and cannot be reinstated without first being closed. This usually happens from calling startup on an existing Runtime." 80 | ) 81 | } 82 | } 83 | 84 | public async shutdown(container: Container): Promise { 85 | if (this.connection) { 86 | container.unbind(MySQLStore) 87 | 88 | await promisify(this.connection.end.bind(this.connection))() 89 | 90 | // Dereference the pool 91 | this.connection = undefined 92 | this.healthyState = false 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/datastore/drivers/mysql/MySQLTransaction.ts: -------------------------------------------------------------------------------- 1 | import { MySQLStore } from "./MySQLStore" 2 | import { SQLStore } from "../../abstract/SQLStore" 3 | import { Logger } from "../../../logging" 4 | import { PoolConnection } from "mysql" 5 | import { promisify } from "util" 6 | import { v4 } from "uuid" 7 | 8 | export class MySQLTransaction implements SQLStore { 9 | private transactionId: string = v4() 10 | 11 | constructor( 12 | private store: MySQLStore, 13 | private connection: PoolConnection, 14 | private logger?: Logger 15 | ) { 16 | if (this.logger) { 17 | this.logger.debug("Transaction Opened", { 18 | transactionId: this.transactionId, 19 | }) 20 | } 21 | } 22 | 23 | public async query( 24 | queryString: string, 25 | parameters: Array 26 | ): Promise> { 27 | try { 28 | // @ts-ignore 29 | let queryResult = await promisify( 30 | this.connection.query.bind(this.connection) 31 | )(queryString, parameters) 32 | 33 | return queryResult 34 | } catch (e) { 35 | if (e.fatal) { 36 | if (this.logger) { 37 | this.logger.error( 38 | "MySQL Transaction encountered a fatal error", 39 | e 40 | ) 41 | } 42 | 43 | this.store.healthyState = false 44 | } 45 | 46 | throw e 47 | } 48 | } 49 | 50 | public async commit(): Promise { 51 | await promisify(this.connection.commit.bind(this.connection))() 52 | await this.finalizeTransaction() 53 | } 54 | 55 | public async rollback(): Promise { 56 | await promisify(this.connection.rollback.bind(this.connection))() 57 | await this.finalizeTransaction() 58 | } 59 | 60 | public async finalizeTransaction(): Promise { 61 | if (this.logger) { 62 | this.logger.debug(`Transaction Closed`, { 63 | transactionId: this.transactionId, 64 | }) 65 | } 66 | 67 | await promisify(this.connection.release.bind(this.connection))() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/datastore/drivers/pg/PGStore.ts: -------------------------------------------------------------------------------- 1 | import { PGIsolationLevel } from "../../abstract/PGIsolationLevel" 2 | import { PGTransaction } from "./PGTransaction" 3 | import { SQLStore } from "../../abstract/SQLStore" 4 | import { Container } from "inversify" 5 | import { Logger } from "../../../logging" 6 | import { Pool, PoolConfig } from "pg" 7 | import { Process } from "../../../runtime" 8 | 9 | export class PGStore implements Process, SQLStore { 10 | private connection?: Pool 11 | private logger?: Logger 12 | private healthyState: boolean = false 13 | 14 | constructor(private connectionOptions: PoolConfig) {} 15 | 16 | public isHealthy(): boolean { 17 | return this.healthyState 18 | } 19 | 20 | public async query( 21 | queryString: string, 22 | parameters: Array 23 | ): Promise> { 24 | if (this.connection) { 25 | let queryResult = await this.connection.query( 26 | queryString, 27 | parameters 28 | ) 29 | 30 | return queryResult.rows 31 | } else { 32 | throw new Error( 33 | "The PGStore is not currently open and cannot be used. Check that the store has had .startup() called and that the Promise has successfully returned." 34 | ) 35 | } 36 | } 37 | 38 | public async createTransaction( 39 | isolationLevel?: PGIsolationLevel 40 | ): Promise { 41 | if (this.connection === undefined) { 42 | throw new Error( 43 | "PGStore cannot open a transaction on a closed Pool. This usually happens from forgetting to call startup." 44 | ) 45 | } 46 | 47 | let connection = await this.connection.connect() 48 | 49 | // By default, use PostgreSQL default isolation level (READ COMMITTED out of the box) 50 | let query: string 51 | switch (isolationLevel) { 52 | case PGIsolationLevel.SERIALIZABLE: 53 | query = "BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE" 54 | break 55 | case PGIsolationLevel.REPEATABLE_READ: 56 | query = "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ" 57 | break 58 | case PGIsolationLevel.READ_COMMITED: 59 | query = "BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED" 60 | break 61 | case PGIsolationLevel.READ_UNCOMMITTED: 62 | query = "BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED" 63 | break 64 | default: 65 | query = "BEGIN" 66 | break 67 | } 68 | 69 | await connection.query(query) 70 | 71 | return new PGTransaction(connection, this.logger) 72 | } 73 | 74 | public async startup(container: Container): Promise { 75 | if (this.connection === undefined) { 76 | this.connection = new Pool(this.connectionOptions) 77 | this.healthyState = true 78 | 79 | // Handle errors and mark the connection as unhealthy 80 | this.connection.on("error", (err) => { 81 | if (container.isBound(Logger)) { 82 | this.logger = container.get(Logger) 83 | this.logger.error("PGStore encountered an error", err) 84 | } 85 | 86 | this.healthyState = false 87 | }) 88 | 89 | container.bind(PGStore).toConstantValue(this) 90 | } else { 91 | throw new Error( 92 | "A PG Pool already exists and cannot be reinstated without first being closed. This usually happens from calling startup on an existing Runtime." 93 | ) 94 | } 95 | } 96 | 97 | public async shutdown(container: Container): Promise { 98 | if (this.connection) { 99 | container.unbind(PGStore) 100 | 101 | await this.connection.end() 102 | 103 | // Dereference the pool 104 | this.connection = undefined 105 | this.healthyState = false 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/datastore/drivers/pg/PGTransaction.ts: -------------------------------------------------------------------------------- 1 | import { SQLStore } from "../../abstract/SQLStore" 2 | import { Logger } from "../../../logging" 3 | import { PoolClient } from "pg" 4 | import { v4 } from "uuid" 5 | 6 | export class PGTransaction implements SQLStore { 7 | private transactionId: string = v4() 8 | 9 | constructor(private connection: PoolClient, private logger?: Logger) { 10 | if (this.logger) { 11 | this.logger.debug("Transaction Opened", { 12 | transactionId: this.transactionId, 13 | }) 14 | } 15 | } 16 | 17 | public async query( 18 | queryString: string, 19 | parameters: Array 20 | ): Promise> { 21 | let queryResult = await this.connection.query(queryString, parameters) 22 | 23 | return queryResult.rows 24 | } 25 | 26 | public async commit(): Promise { 27 | await this.connection.query("COMMIT") 28 | await this.finalizeTransaction() 29 | } 30 | 31 | public async rollback(): Promise { 32 | await this.connection.query("ROLLBACK") 33 | await this.finalizeTransaction() 34 | } 35 | 36 | public async finalizeTransaction(): Promise { 37 | if (this.logger) { 38 | this.logger.debug(`Transaction Closed`, { 39 | transactionId: this.transactionId, 40 | }) 41 | } 42 | this.connection.release() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/datastore/drivers/redis/RedisStore.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "inversify" 2 | import { Logger } from "../../../logging" 3 | import { ClientOpts, RedisClient, createClient } from "redis" 4 | import { Process } from "../../../runtime" 5 | import { promisify } from "util" 6 | 7 | export class RedisStore implements Process { 8 | private client?: RedisClient 9 | private logger?: Logger 10 | private healthyState: boolean = false 11 | 12 | constructor(private connectionOptions?: ClientOpts) {} 13 | 14 | public isHealthy(): boolean { 15 | return this.healthyState 16 | } 17 | 18 | public getClient(): RedisClient { 19 | if (this.client === undefined) { 20 | throw new Error( 21 | "The RedisStore is not currently open and cannot be used. Check that the store has had .startup() called and that the Promise has successfully returned." 22 | ) 23 | } 24 | 25 | return this.client 26 | } 27 | 28 | public async startup(container: Container): Promise { 29 | if (this.client === undefined) { 30 | this.client = createClient(this.connectionOptions) 31 | 32 | // Handle errors and mark the connection as unhealthy 33 | this.client.on("error", (err) => { 34 | if (container.isBound(Logger)) { 35 | this.logger = container.get(Logger) 36 | this.logger.error("RedisStore encountered an error", err) 37 | } 38 | 39 | this.healthyState = false 40 | }) 41 | 42 | this.client.on("end", (err) => { 43 | if (container.isBound(Logger)) { 44 | this.logger = container.get(Logger) 45 | this.logger.error("RedisStore encountered an error", err) 46 | } 47 | 48 | this.healthyState = false 49 | }) 50 | 51 | // listen for the connection to mark it as healthy 52 | this.client.on("connect", () => { 53 | this.healthyState = true 54 | }) 55 | 56 | container.bind(RedisStore).toConstantValue(this) 57 | } else { 58 | throw new Error( 59 | "A RedisClient already exists and cannot be reinstated without first being closed. This usually happens from calling startup on an existing Runtime." 60 | ) 61 | } 62 | } 63 | 64 | public async shutdown(container: Container): Promise { 65 | if (this.client) { 66 | container.unbind(RedisStore) 67 | 68 | await promisify(this.client.quit.bind(this.client))() 69 | 70 | // Dereference the client 71 | this.client = undefined 72 | this.healthyState = false 73 | } 74 | } 75 | 76 | public async sendCommand( 77 | command: string, 78 | args?: Array 79 | ): Promise { 80 | if (this.client === undefined) { 81 | throw new Error( 82 | "RedisStore cannot send a command on a closed connection. This usually happens from forgetting to call startup." 83 | ) 84 | } 85 | 86 | let result: R = await promisify( 87 | this.client.sendCommand.bind(this.client) 88 | )(command, args) 89 | 90 | return result 91 | } 92 | 93 | public async eval( 94 | script: string, 95 | args: Array 96 | ): Promise { 97 | if (this.client === undefined) { 98 | throw new Error( 99 | "RedisStore cannot send a command on a closed connection. This usually happens from forgetting to call startup." 100 | ) 101 | } 102 | 103 | let result: R = await promisify(this.client.eval.bind(this.client))( 104 | script, 105 | ...args 106 | ) 107 | 108 | return result 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/datastore/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/SQLStore" 2 | export * from "./abstract/PGIsolationLevel" 3 | 4 | export * from "./drivers/mysql/MySQLStore" 5 | export * from "./drivers/mysql/MySQLTransaction" 6 | export * from "./drivers/pg/PGStore" 7 | export * from "./drivers/pg/PGTransaction" 8 | export * from "./drivers/redis/RedisStore" 9 | -------------------------------------------------------------------------------- /src/errors/InvalidControllerError.ts: -------------------------------------------------------------------------------- 1 | import { StrontiumError } from "./StrontiumError" 2 | 3 | /** 4 | * An InvalidControllerError represents a Controller that has been provided to a HTTP Server implementation erroneously. 5 | * 6 | * This may occur if the Controller is malformed or un-buildable. 7 | */ 8 | export class InvalidControllerError extends StrontiumError { 9 | constructor(route: string) { 10 | super( 11 | `The controller provided for the route (${route}) is invalid. Please double check that it is Injectable and is registered correctly with the Web Server.` 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/errors/InvalidSignatureError.ts: -------------------------------------------------------------------------------- 1 | import { StrontiumError } from "./StrontiumError" 2 | 3 | /** 4 | * An InvalidSignatureError is thrown when a Cryptographic signature does not match. 5 | */ 6 | export class InvalidSignatureError extends StrontiumError { 7 | constructor() { 8 | super(`The signature provided was not valid.`) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/errors/StrontiumError.ts: -------------------------------------------------------------------------------- 1 | export abstract class StrontiumError { 2 | public stack?: string 3 | 4 | constructor(public message?: string) { 5 | let error = new Error() 6 | 7 | this.stack = error.stack 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/errors/TransientError.ts: -------------------------------------------------------------------------------- 1 | import { StrontiumError } from "./StrontiumError" 2 | 3 | export class TransientError extends StrontiumError {} 4 | -------------------------------------------------------------------------------- /src/errors/http/HTTPError.ts: -------------------------------------------------------------------------------- 1 | import { StrontiumError } from "../StrontiumError" 2 | 3 | export abstract class HTTPError extends StrontiumError { 4 | constructor( 5 | public statusCode: number, 6 | public externalMessage: string, 7 | public internalMessage?: string 8 | ) { 9 | super(internalMessage) 10 | } 11 | 12 | toResponseBody(): { statusCode: number; errorMessage: string } { 13 | return { 14 | statusCode: this.statusCode, 15 | errorMessage: this.externalMessage, 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/errors/http/InternalServerError.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from "./HTTPError" 2 | 3 | export class InternalServerError extends HTTPError { 4 | constructor() { 5 | super( 6 | 500, 7 | "An internal error occurred. The system administrator has been notified.", 8 | "Internal Error occurred." 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/errors/http/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from "./HTTPError" 2 | 3 | export class ValidationError extends HTTPError { 4 | constructor( 5 | public constraintName: string, 6 | internalMessage: string, 7 | externalMessage: string = internalMessage, 8 | public fieldPath?: string 9 | ) { 10 | super(400, externalMessage, internalMessage) 11 | } 12 | 13 | public toResponseBody(): { 14 | statusCode: number 15 | errorMessage: string 16 | path?: string 17 | } { 18 | return { 19 | statusCode: this.statusCode, 20 | errorMessage: this.externalMessage, 21 | path: this.fieldPath, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./StrontiumError" 2 | export * from "./InvalidControllerError" 3 | export * from "./TransientError" 4 | 5 | export * from "./http/HTTPError" 6 | export * from "./http/InternalServerError" 7 | export * from "./http/ValidationError" 8 | -------------------------------------------------------------------------------- /src/http/abstract/EndpointController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectValidator, 3 | ValidatedObject, 4 | ValidatorFunction, 5 | ValidatorOutput, 6 | } from "../../validation" 7 | 8 | export abstract class EndpointController { 9 | public abstract inputValidator: { 10 | body: ValidatorFunction 11 | headers: ObjectValidator 12 | query: ObjectValidator 13 | params: ObjectValidator 14 | meta: ObjectValidator 15 | } 16 | 17 | public abstract outputValidator: ValidatorFunction 18 | 19 | public abstract async handle(input: any): Promise 20 | } 21 | 22 | export type ControllerInput = { 23 | body: ValidatorOutput 24 | headers: ValidatedObject 25 | query: ValidatedObject 26 | params: ValidatedObject 27 | meta: ValidatedObject 28 | } 29 | 30 | export type ControllerOutput = ValidatorOutput< 31 | unknown, 32 | E["outputValidator"] 33 | > 34 | -------------------------------------------------------------------------------- /src/http/abstract/RouterMap.ts: -------------------------------------------------------------------------------- 1 | import { EndpointController } from "./EndpointController" 2 | import { ConstructorOf } from "../../utils/types" 3 | 4 | export type RouterMap = Array<{ 5 | method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "OPTIONS" 6 | route: string 7 | endpointController: ConstructorOf 8 | metadata?: { [key: string]: any } 9 | }> 10 | -------------------------------------------------------------------------------- /src/http/drivers/FastifyServer.ts: -------------------------------------------------------------------------------- 1 | import { RouterMap } from "../abstract/RouterMap" 2 | import { HTTPError } from "../../errors" 3 | import { InvalidControllerError } from "../../errors" 4 | import { InternalServerError } from "../../errors" 5 | import { ServerOptions } from "fastify" 6 | import * as Fastify from "fastify" 7 | import { Container } from "inversify" 8 | import { Logger } from "../../logging" 9 | import { AddressInfo } from "net" 10 | import { Process } from "../../runtime" 11 | import { ConstructorOf } from "../../utils/types" 12 | import { isObject } from "../../validation" 13 | 14 | import { EndpointController } from ".." 15 | 16 | export class FastifyServer implements Process { 17 | protected server: Fastify.FastifyInstance = Fastify(this.fastifyOptions) 18 | protected isAlive: boolean = false 19 | 20 | constructor( 21 | public routes: RouterMap, 22 | private port: number = 8080, 23 | private host: string = "127.0.0.1", 24 | private fastifyOptions?: ServerOptions 25 | ) { 26 | /* 27 | To handle limitations in Find My Way (Fastify's internal routing library) 28 | Strontium provides a preprocess format for routes to prevent certain conflicts. 29 | */ 30 | let processedRoutes = FastifyServer.preProcessRoutes(routes) 31 | 32 | for (let route of processedRoutes) { 33 | switch (route.method) { 34 | case "GET": 35 | this.server.get( 36 | route.route, 37 | this.requestHandler( 38 | route.endpointController, 39 | route.route, 40 | "GET", 41 | route.metadata 42 | ) 43 | ) 44 | break 45 | case "POST": 46 | this.server.post( 47 | route.route, 48 | this.requestHandler( 49 | route.endpointController, 50 | route.route, 51 | "POST", 52 | route.metadata 53 | ) 54 | ) 55 | break 56 | case "PATCH": 57 | this.server.patch( 58 | route.route, 59 | this.requestHandler( 60 | route.endpointController, 61 | route.route, 62 | "PATCH", 63 | route.metadata 64 | ) 65 | ) 66 | break 67 | case "PUT": 68 | this.server.put( 69 | route.route, 70 | this.requestHandler( 71 | route.endpointController, 72 | route.route, 73 | "PUT", 74 | route.metadata 75 | ) 76 | ) 77 | break 78 | case "DELETE": 79 | this.server.delete( 80 | route.route, 81 | this.requestHandler( 82 | route.endpointController, 83 | route.route, 84 | "DELETE", 85 | route.metadata 86 | ) 87 | ) 88 | break 89 | case "OPTIONS": 90 | this.server.options( 91 | route.route, 92 | this.requestHandler( 93 | route.endpointController, 94 | route.route, 95 | "OPTIONS", 96 | route.metadata 97 | ) 98 | ) 99 | break 100 | } 101 | } 102 | } 103 | 104 | public static preProcessRoutes(routes: RouterMap): RouterMap { 105 | let processedRoutes: RouterMap = [] 106 | for (let route of routes) { 107 | // Check if there are any enum param blocks 108 | let enumeratedBlocks = route.route.match( 109 | /{([a-zA-Z0-9_-]*)\|([a-zA-Z0-9_,-]*)}/g 110 | ) 111 | 112 | if (enumeratedBlocks === null) { 113 | processedRoutes.push(route) 114 | } else { 115 | const generatePermutations = ( 116 | index: number 117 | ): Array> => { 118 | // Typescript correctly identifies this function may run when enumeratedBlocks is null 119 | // however within this context that is not possible - so ! to overcome 120 | let currentBlock = enumeratedBlocks![index] 121 | 122 | let childPermutations: Array> = [] 123 | if (index < enumeratedBlocks!.length - 1) { 124 | childPermutations = generatePermutations(index + 1) 125 | } 126 | 127 | let permutations: Array> = [] 128 | 129 | // Use a more direct method because the absence of "matchAll" in Node would make the RegEx method uglier 130 | let fieldContents = currentBlock 131 | .replace("{", "") 132 | .replace("}", "") 133 | 134 | let [ 135 | fieldName, 136 | serializedFieldValues, 137 | ] = fieldContents.split("|") 138 | let fieldValues = serializedFieldValues.split(",") 139 | 140 | for (let value of fieldValues) { 141 | if (childPermutations.length === 0) { 142 | permutations.push([value]) 143 | } else { 144 | for (let childPermutation of childPermutations) { 145 | permutations.push([value, ...childPermutation]) 146 | } 147 | } 148 | } 149 | 150 | return permutations 151 | } 152 | 153 | let routePermutations = generatePermutations(0) 154 | 155 | for (let permutation of routePermutations) { 156 | let pathPermutation = route.route 157 | let parameters: { [key: string]: string } = { 158 | ...(route.metadata || {}), 159 | } 160 | 161 | for (let i = 0; i < enumeratedBlocks.length; i++) { 162 | let currentBlock = enumeratedBlocks[i] 163 | let fieldContents = currentBlock 164 | .replace("{", "") 165 | .replace("}", "") 166 | let [fieldName] = fieldContents.split("|") 167 | 168 | parameters[fieldName] = permutation[i] 169 | 170 | pathPermutation = pathPermutation.replace( 171 | currentBlock, 172 | permutation[i] 173 | ) 174 | } 175 | 176 | processedRoutes.push({ 177 | endpointController: route.endpointController, 178 | method: route.method, 179 | route: pathPermutation, 180 | metadata: parameters, 181 | }) 182 | } 183 | } 184 | } 185 | 186 | return processedRoutes 187 | } 188 | 189 | public isHealthy(): boolean { 190 | return this.isAlive 191 | } 192 | 193 | public async shutdown(container: Container): Promise { 194 | // Cleanly close the server to requests. 195 | await new Promise((resolve) => { 196 | this.server.close(resolve) 197 | }) 198 | 199 | this.isAlive = false 200 | } 201 | 202 | public async startup(container: Container): Promise { 203 | // Attach the container to the server 204 | this.server.decorateRequest("container", container) 205 | 206 | let plugins = this.getPlugins(container) 207 | for (let p of plugins) { 208 | this.server.register(p) 209 | } 210 | 211 | let middleware = this.getMiddleware(container) 212 | for (let m of middleware) { 213 | this.server.use(m) 214 | } 215 | 216 | await this.server.listen(this.port, this.host) 217 | this.isAlive = true 218 | 219 | let loggerInstance = container.get(Logger) 220 | if (loggerInstance) { 221 | loggerInstance.info( 222 | `Fastify HTTP Server started on port ${ 223 | (this.server.server.address() as AddressInfo).port 224 | }`, 225 | { 226 | address: this.server.server.address(), 227 | } 228 | ) 229 | } 230 | } 231 | 232 | protected getMiddleware( 233 | container: Container 234 | ): Array> { 235 | return [] 236 | } 237 | 238 | protected getPlugins( 239 | container: Container 240 | ): Array> { 241 | return [] 242 | } 243 | 244 | protected getRequestMetadata = ( 245 | request: Fastify.FastifyRequest 246 | ): { [key: string]: any } => ({}) 247 | 248 | protected requestHandler( 249 | controller: ConstructorOf, 250 | path: string, 251 | method: string, 252 | routeMetadata: { [key: string]: any } = {} 253 | ): ( 254 | request: Fastify.FastifyRequest, 255 | response: Fastify.FastifyReply 256 | ) => Promise { 257 | return async (request, response) => { 258 | // Force any to circumvent poor typing from Fastify for their Declare functions 259 | let applicationContainer = (request as any).container as Container 260 | 261 | // Create a new DI Container for the life of this request 262 | let requestContainer = new Container({ 263 | autoBindInjectable: true, 264 | skipBaseClassChecks: true, 265 | }) 266 | 267 | requestContainer.parent = applicationContainer 268 | 269 | // Register request and response with the DI container directly so that Controller's can access 270 | // if an escape hatch is required 271 | requestContainer.bind("request").toConstantValue(request) 272 | requestContainer.bind("response").toConstantValue(response) 273 | 274 | let endpointController: 275 | | EndpointController 276 | | undefined = requestContainer.get(controller) 277 | 278 | if (endpointController === undefined) { 279 | throw new InvalidControllerError(path) 280 | } 281 | 282 | // Validate the input 283 | let inputValidatorSchema = endpointController.inputValidator 284 | 285 | let validatedInput 286 | let rawResponse 287 | try { 288 | let validator = isObject({ 289 | body: inputValidatorSchema.body, 290 | headers: isObject(inputValidatorSchema.headers), 291 | query: isObject(inputValidatorSchema.query), 292 | params: isObject(inputValidatorSchema.params), 293 | meta: isObject(inputValidatorSchema.meta), 294 | }) 295 | 296 | validatedInput = await validator({ 297 | // Don't send the body through if the 298 | body: method === "GET" ? undefined : request.body, 299 | headers: request.headers, 300 | query: request.query, 301 | params: request.params, 302 | meta: { 303 | ...routeMetadata, 304 | ...this.getRequestMetadata(request), 305 | }, 306 | }) 307 | 308 | rawResponse = await endpointController.handle(validatedInput) 309 | } catch (e) { 310 | // Detect Input Validation issues. 311 | // Any other errors will be thrown directly if they are HTTPError compatible or 500 and logged if not. 312 | if (e instanceof HTTPError) { 313 | response.code(e.statusCode) 314 | return e.toResponseBody() 315 | } else { 316 | let logger = requestContainer.get(Logger) 317 | if (logger) { 318 | logger.error("[HTTP - REQUEST - FAILED]", e) 319 | } 320 | 321 | let publicError = new InternalServerError() 322 | response.code(publicError.statusCode) 323 | return publicError.toResponseBody() 324 | } 325 | } 326 | 327 | try { 328 | let validatedOutput = await endpointController.outputValidator( 329 | rawResponse 330 | ) 331 | 332 | // Handle an edge case in Fastify that doesn't allow async/await undefined returns 333 | if (validatedOutput === undefined) { 334 | return response.send() 335 | } 336 | 337 | return validatedOutput 338 | } catch (e) { 339 | // Handle errors in the output validation. 340 | // Returns 500 errors as this shouldn't really happen and is normally a developer issue. 341 | let logger = requestContainer.get(Logger) 342 | if (logger) { 343 | logger.error( 344 | `[HTTP - VALIDATION - FAILED] An error occurred validating the output of ${ 345 | controller.name 346 | }. Check that you are returning the correct value.`, 347 | e 348 | ) 349 | } 350 | 351 | let publicError = new InternalServerError() 352 | response.code(publicError.statusCode) 353 | return publicError.toResponseBody() 354 | } 355 | } 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/EndpointController" 2 | export * from "./abstract/RouterMap" 3 | 4 | export * from "./drivers/FastifyServer" 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bootstrap" 2 | export * from "./cryptography" 3 | export * from "./datastore" 4 | export * from "./errors" 5 | export * from "./http" 6 | export * from "./logging" 7 | export * from "./query" 8 | export * from "./queue" 9 | export * from "./runtime" 10 | export * from "./validation" 11 | -------------------------------------------------------------------------------- /src/logging/abstract/LogLevel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The standardize levels used across all Logger integrations 3 | */ 4 | export enum LogLevel { 5 | /** 6 | * Temporary operation details ( i.e. too verbose to be included in "INFO" level ). 7 | */ 8 | DEBUG, 9 | 10 | /** 11 | * Detail on regular operation. 12 | */ 13 | INFO, 14 | 15 | /** 16 | * A note on something that should probably be reviewed by an operator. 17 | */ 18 | WARN, 19 | 20 | /** 21 | * Fatal for a particular request, but the runtime will continue servicing 22 | * other requests. An operator should review this. 23 | */ 24 | ERROR, 25 | 26 | /** 27 | * The runtime is unable to continue operation. 28 | * An operator should review this urgently. 29 | */ 30 | FATAL, 31 | } 32 | -------------------------------------------------------------------------------- /src/logging/abstract/Logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger provides an abstract definition of the API Strontium feels a Logger 3 | * should expose. 4 | */ 5 | import { LogLevel } from "./LogLevel" 6 | 7 | export abstract class Logger { 8 | /** 9 | * Log a message at a FATAL log level 10 | * 11 | * @param message {string} The core message to be logged 12 | * @param metadata {Object} Optional additional data to be attached to the log message 13 | */ 14 | public fatal(message: string, metadata?: Object): void { 15 | this.log(message, LogLevel.FATAL, metadata) 16 | } 17 | 18 | /** 19 | * Log a message at a ERROR log level 20 | * 21 | * @param message {string} The core message to be logged 22 | * @param metadata {Object} Optional additional data to be attached to the log message 23 | */ 24 | public error(message: string, metadata?: Object): void { 25 | this.log(message, LogLevel.ERROR, metadata) 26 | } 27 | 28 | /** 29 | * Log a message at a WARN log level 30 | * 31 | * @param message {string} The core message to be logged 32 | * @param metadata {Object} Optional additional data to be attached to the log message 33 | */ 34 | public warn(message: string, metadata?: Object): void { 35 | this.log(message, LogLevel.WARN, metadata) 36 | } 37 | 38 | /** 39 | * Log a message at a INFO log level 40 | * 41 | * @param message {string} The core message to be logged 42 | * @param metadata {Object} Optional additional data to be attached to the log message 43 | */ 44 | public info(message: string, metadata?: Object): void { 45 | this.log(message, LogLevel.INFO, metadata) 46 | } 47 | 48 | /** 49 | * Log a message at a DEBUG log level 50 | * 51 | * @param message {string} The core message to be logged 52 | * @param metadata {Object} Optional additional data to be attached to the log message 53 | */ 54 | public debug(message: string, metadata?: Object): void { 55 | this.log(message, LogLevel.DEBUG, metadata) 56 | } 57 | 58 | public abstract log( 59 | message: string, 60 | level: LogLevel, 61 | metadata?: Object 62 | ): void 63 | } 64 | -------------------------------------------------------------------------------- /src/logging/drivers/AggregateLogger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "../abstract/Logger" 2 | import { Container } from "inversify" 3 | import { Process, isProcess } from "../../runtime" 4 | 5 | import { LogLevel } from ".." 6 | 7 | /** 8 | * The AggregateLogger accepts several other Logger instances - potentially Processes themselves - 9 | * and distributes the log to each of the registered loggers above a given level. 10 | */ 11 | export class AggregateLogger extends Logger implements Process { 12 | constructor(private loggers: Array) { 13 | super() 14 | } 15 | 16 | public isHealthy(): boolean { 17 | return this.loggers.reduce( 18 | (memo, l) => { 19 | if (isProcess(l)) { 20 | return memo && l.isHealthy() 21 | } else { 22 | return memo 23 | } 24 | }, 25 | true as boolean 26 | ) 27 | } 28 | 29 | public async shutdown(container: Container): Promise { 30 | // Stop any loggers that are Processes 31 | for (let l of this.loggers) { 32 | if (isProcess(l)) { 33 | await l.shutdown(container) 34 | } 35 | } 36 | 37 | container.unbind(Logger) 38 | } 39 | 40 | public async startup(container: Container): Promise { 41 | // Boot each of the loggers if they are Processes 42 | for (let l of this.loggers) { 43 | if (isProcess(l)) { 44 | await l.startup(container) 45 | } 46 | } 47 | 48 | // Bind this container to the logging implementation 49 | container.bind(Logger).toConstantValue(this) 50 | } 51 | 52 | public log(message: string, level: LogLevel, metadata?: Object): void { 53 | for (let l of this.loggers) { 54 | l.log(message, level, metadata) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/logging/drivers/ConsoleLogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "../abstract/LogLevel" 2 | import { Logger } from "../abstract/Logger" 3 | 4 | /** 5 | * A simple Logging Driver used to ouput log messages to STDOUT and STDERR. 6 | */ 7 | export class ConsoleLogger extends Logger { 8 | /** 9 | * Creates an instance of ConsoleLogger. 10 | * 11 | * @param level {LogLevel} The min level of logs the logger should output to the console 12 | * @param injectedConsole {Console} System console to log to (optional) 13 | */ 14 | public constructor( 15 | private level: LogLevel, 16 | private injectedConsole: Console = console 17 | ) { 18 | super() 19 | } 20 | 21 | public log(message: string, level: LogLevel, metadata: Object): void { 22 | if (!this.shouldLog(level)) { 23 | return 24 | } 25 | 26 | switch (level) { 27 | case LogLevel.DEBUG: 28 | this.injectedConsole.log(message, metadata) 29 | break 30 | case LogLevel.INFO: 31 | this.injectedConsole.info(message, metadata) 32 | break 33 | case LogLevel.WARN: 34 | this.injectedConsole.warn(message, metadata) 35 | break 36 | case LogLevel.ERROR: 37 | case LogLevel.FATAL: 38 | this.injectedConsole.error(message, metadata) 39 | break 40 | } 41 | } 42 | 43 | private shouldLog(level: LogLevel): boolean { 44 | // Don't log messages of precedence less than the current logger level 45 | return level >= this.level 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/logging/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/Logger" 2 | export * from "./abstract/LogLevel" 3 | export * from "./drivers/AggregateLogger" 4 | export * from "./drivers/ConsoleLogger" 5 | -------------------------------------------------------------------------------- /src/query/abstract/Filter.ts: -------------------------------------------------------------------------------- 1 | export type FilterObject = { [P in keyof T]?: FieldFilter } 2 | 3 | export type FieldFilter

= 4 | | { 5 | $in?: Array 6 | $nin?: Array 7 | $eq?: T[P] 8 | // TODO: Review if the null type on $neq is necessary 9 | $neq?: T[P] | null 10 | $gt?: T[P] 11 | $gte?: T[P] 12 | $lt?: T[P] 13 | $lte?: T[P] 14 | $contains?: T[P] 15 | $arr_contains?: T[P] // Could be made conditional so it only appears where T[P] is an Array 16 | } 17 | | T[P] 18 | 19 | export type Filter = { 20 | $and?: Array> 21 | $or?: Array> 22 | } & FilterObject 23 | -------------------------------------------------------------------------------- /src/query/abstract/FilterCompiler.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from "./Filter" 2 | 3 | export type FilterCompiler = (filter: Filter) => R 4 | -------------------------------------------------------------------------------- /src/query/abstract/Query.ts: -------------------------------------------------------------------------------- 1 | export abstract class Query { 2 | abstract execute(...args: Array): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/query/abstract/Repository.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from "./Filter" 2 | 3 | /** 4 | * A Repository represents an access mechanism for the underlying data in a store. 5 | * 6 | * It is responsible for fetching the data from a store and using it to instantiate Model objects. 7 | * Repositories in the abstract sense are not tied to any given type of datastore however classes 8 | * that extend Repository may begin to make more assertions about the nature of the underlying store. 9 | */ 10 | export abstract class Repository { 11 | /** 12 | * Create a new record in the underlying data store. 13 | * 14 | * @param payload The objects to be created in the datastore 15 | */ 16 | abstract async create(payload: Partial): Promise 17 | 18 | /** 19 | * Read a collection of records from the underlying data store which match the provided filter. 20 | * 21 | * @param filter A filter to select which objects should be returned in the read response 22 | */ 23 | abstract async read(filter: Filter): Promise> 24 | 25 | /** 26 | * Update a collection of records in the underlying data store to the provided values where 27 | * the original record matches the filter provided. 28 | * 29 | * @param payload An payload object representing the delta that should be applied to updated records. 30 | * @param filter A filter to select which records should be updated. 31 | */ 32 | abstract async update(payload: Partial, filter: Filter): Promise 33 | 34 | /** 35 | * Delete a collection of records from the underlying data store where the record matches 36 | * the filter provided. 37 | * 38 | * @param filter A filter to select which records should be deleted 39 | */ 40 | abstract async delete(filter: Filter): Promise 41 | } 42 | -------------------------------------------------------------------------------- /src/query/drivers/pg/PGQueryPostProcessor.ts: -------------------------------------------------------------------------------- 1 | export const pgQueryPostProcessor = ( 2 | queryString: string, 3 | queryParameters: Array 4 | ): [string, Array] => { 5 | let parameterCount = 0 6 | let tokenizedQuery = queryString.split("") 7 | let outputQuery = "" 8 | let outputParameters = [] 9 | 10 | for (let i = 0; i < tokenizedQuery.length; i++) { 11 | if (tokenizedQuery[i] === "?") { 12 | if (tokenizedQuery[i + 1] === "?") { 13 | // If the MySQL style "??" is used then pass the parameter in directly as 14 | // PostgreSQL doesn't support column parameter injection 15 | outputQuery += queryParameters[parameterCount] 16 | parameterCount++ 17 | i = i + 1 18 | } else { 19 | // Add one because Postgres parameters are 1 indexed not 0 20 | outputQuery += `$${outputParameters.length + 1}` 21 | outputParameters.push(queryParameters[parameterCount]) 22 | parameterCount++ 23 | } 24 | } else if (tokenizedQuery[i] === "$") { 25 | // Search for the first character that isn't a number 26 | for (let j = i + 1; j < tokenizedQuery.length; j++) { 27 | if (Number.isNaN(Number(tokenizedQuery[j]))) { 28 | i = j + 1 29 | break 30 | } else if (j === tokenizedQuery.length - 1) { 31 | i = j 32 | break 33 | } 34 | } 35 | 36 | // Add the new parameter to the query 37 | // Add one because Postgres parameters are 1 indexed not 0 38 | outputQuery += `$${outputParameters.length + 1}` 39 | outputParameters.push(queryParameters[parameterCount]) 40 | parameterCount++ 41 | } else { 42 | outputQuery += tokenizedQuery[i] 43 | } 44 | } 45 | 46 | return [outputQuery, outputParameters] 47 | } 48 | -------------------------------------------------------------------------------- /src/query/drivers/sql/SQLFilterCompiler.ts: -------------------------------------------------------------------------------- 1 | import { Filter, FilterCompiler } from "../.." 2 | 3 | /** 4 | * The SQL query compiler takes a standard Strontium Query and returns a SQL 5 | * query with arguments. It uses a MySQL dialect which requires Post Processing to operate 6 | * in other databases. 7 | */ 8 | export const compileSQLFilter: FilterCompiler<[string, Array]> = ( 9 | filter: Filter 10 | ): [string, Array] => { 11 | let queries: Array<[string, Array]> = [] 12 | 13 | if (filter.$or) { 14 | let subqueries = filter.$or.map(compileSQLFilter) 15 | 16 | let orQuery = concatQueryStringsWithConjunction(subqueries, "OR") 17 | 18 | queries.push(orQuery) 19 | } 20 | 21 | if (filter.$and) { 22 | let subqueries = filter.$and.map(compileSQLFilter) 23 | 24 | let andQuery = concatQueryStringsWithConjunction(subqueries, "AND") 25 | 26 | queries.push(andQuery) 27 | } 28 | 29 | for (let field in filter) { 30 | if (field === "$or" || field === "$and") { 31 | continue 32 | } 33 | 34 | // Don't process prototype values - separated from the special 35 | // keywords for TypeScript's benefit 36 | if (!filter.hasOwnProperty(field)) { 37 | continue 38 | } 39 | 40 | let subquery = filter[field] 41 | 42 | if (subquery === undefined) { 43 | continue 44 | } else if (subquery === null) { 45 | queries.push(["?? IS NULL", [field]]) 46 | } else if (subquery.$in !== undefined) { 47 | if (subquery.$in.length === 0) { 48 | // IN with an empty array typically causes an error - just make a tautological filter instead 49 | queries.push(["TRUE = FALSE", []]) 50 | } else { 51 | queries.push([ 52 | `?? IN (${subquery.$in.map((p: any) => "?").join(", ")})`, 53 | [field, ...subquery.$in], 54 | ]) 55 | } 56 | } else if (subquery.$nin !== undefined) { 57 | if (subquery.$nin.length === 0) { 58 | queries.push(["TRUE = TRUE", []]) 59 | } else { 60 | queries.push([ 61 | `?? NOT IN (${subquery.$nin 62 | .map((p: any) => "?") 63 | .join(", ")})`, 64 | [field, ...subquery.$nin], 65 | ]) 66 | } 67 | } else if (subquery.$neq !== undefined) { 68 | if (subquery.$neq === null) { 69 | queries.push(["?? IS NOT NULL", [field]]) 70 | } else { 71 | queries.push(["?? != ?", [field, subquery.$neq]]) 72 | } 73 | } else if (subquery.$gt !== undefined) { 74 | queries.push(["?? > ?", [field, subquery.$gt]]) 75 | } else if (subquery.$gte !== undefined) { 76 | queries.push(["?? >= ?", [field, subquery.$gte]]) 77 | } else if (subquery.$lt !== undefined) { 78 | queries.push(["?? < ?", [field, subquery.$lt]]) 79 | } else if (subquery.$lte !== undefined) { 80 | queries.push(["?? <= ?", [field, subquery.$lte]]) 81 | } else if (subquery.$contains !== undefined) { 82 | queries.push(["?? LIKE ?", [field, `%${subquery.$contains}%`]]) 83 | } else if (subquery.$arr_contains !== undefined) { 84 | // This implementation is currently unique to PostgreSQL - We should consider how to add similar functionality to MySQL 85 | queries.push(["?? @> ?", [field, subquery.$arr_contains]]) 86 | } else if (subquery.$eq !== undefined) { 87 | queries.push(["?? = ?", [field, subquery.$eq]]) 88 | } else { 89 | queries.push(["?? = ?", [field, subquery]]) 90 | } 91 | } 92 | 93 | // Submit the final queries AND'd together 94 | return concatQueryStringsWithConjunction(queries, "AND") 95 | } 96 | 97 | export const concatQueryStringsWithConjunction = ( 98 | queries: Array<[string, Array]>, 99 | conjunction: "AND" | "OR" 100 | ): [string, Array] => { 101 | if (queries.length === 1) { 102 | return [queries[0][0], queries[0][1]] 103 | } 104 | 105 | return queries.reduce( 106 | (memo, [subqueryString, subqueryArguments], idx) => { 107 | if (idx !== 0) { 108 | memo[0] += ` ${conjunction} ` 109 | } 110 | 111 | memo[0] += "(" 112 | memo[0] += subqueryString 113 | memo[0] += ")" 114 | 115 | memo[1].push(...subqueryArguments) 116 | 117 | return memo 118 | }, 119 | ["", []] 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /src/query/drivers/sql/TableRepository.ts: -------------------------------------------------------------------------------- 1 | import { pgQueryPostProcessor } from "../pg/PGQueryPostProcessor" 2 | import { Repository } from "../../abstract/Repository" 3 | import { 4 | MySQLStore, 5 | MySQLTransaction, 6 | PGStore, 7 | PGTransaction, 8 | SQLStore, 9 | } from "../../../datastore" 10 | import { injectable } from "inversify" 11 | import { isUndefined, omitBy } from "lodash" 12 | import { Logger } from "../../../logging" 13 | 14 | import { Filter, compileSQLFilter } from "../.." 15 | 16 | /** 17 | * A TableRepository represents a one to one mapping with an underlying SQL table. 18 | * 19 | * It is designed to provide an 80% solution for common SQL workloads with the other 20% being taken up by custom 20 | * Repository classes or direct queries. 21 | */ 22 | @injectable() 23 | export abstract class TableRepository< 24 | T extends any, 25 | K extends keyof T 26 | > extends Repository { 27 | protected postProcessor: ( 28 | query: string, 29 | parameters: Array 30 | ) => [string, Array] = (q, p) => [q, p] 31 | 32 | constructor( 33 | protected store: SQLStore, 34 | protected tableName: string, 35 | protected queryFields: Array, 36 | protected primaryKeyField: K, 37 | protected logger?: Logger 38 | ) { 39 | super() 40 | 41 | if (store instanceof PGStore || store instanceof PGTransaction) { 42 | this.postProcessor = pgQueryPostProcessor 43 | this.tableName = `"${this.tableName}"` 44 | } 45 | } 46 | 47 | async create( 48 | payload: Partial, 49 | connection: SQLStore = this.store 50 | ): Promise { 51 | // Generate an ID for the new record 52 | let id = await this.generateID() 53 | payload[this.primaryKeyField] = payload[this.primaryKeyField] || id 54 | 55 | // Filter the payload for any undefined keys 56 | let filteredPayload = (omitBy(payload, isUndefined) as unknown) as T 57 | 58 | if ( 59 | connection instanceof MySQLStore || 60 | connection instanceof MySQLTransaction 61 | ) { 62 | let insertQuery = ` 63 | INSERT INTO 64 | ?? 65 | SET 66 | ? 67 | ` 68 | 69 | // This can throw a SQL error which will be returned directly to the caller rather than handled here. 70 | let result: any = await connection.query(insertQuery, [ 71 | this.tableName, 72 | filteredPayload, 73 | ]) 74 | 75 | return result.insertId || id 76 | } else { 77 | let query = ` 78 | INSERT INTO 79 | ?? (${Object.keys(filteredPayload).map(() => `"??"`)}) 80 | VALUES 81 | (${Object.keys(filteredPayload).map(() => "?")}) 82 | RETURNING ?? 83 | ` 84 | 85 | let parameters: Array = [this.tableName] 86 | 87 | Object.keys(filteredPayload).forEach((k: string) => { 88 | parameters.push(k) 89 | }) 90 | 91 | Object.keys(filteredPayload).forEach((k: string) => { 92 | parameters.push(filteredPayload[k as keyof T]) 93 | }) 94 | 95 | parameters.push(this.primaryKeyField) 96 | 97 | let [processedQuery, processedParameters] = this.postProcessor( 98 | query, 99 | parameters 100 | ) 101 | 102 | let results = await connection.query<{ [key: string]: any }>( 103 | processedQuery, 104 | processedParameters 105 | ) 106 | 107 | return results[0][this.primaryKeyField as string] || id 108 | } 109 | } 110 | 111 | async read( 112 | filter: Filter, 113 | pagination: { 114 | order?: [keyof T, "DESC" | "ASC"] 115 | limit?: number 116 | offset?: number 117 | } = {}, 118 | connection: SQLStore = this.store 119 | ): Promise> { 120 | let startTime = process.hrtime() 121 | let [filterQuery, filterParameters] = compileSQLFilter(filter) 122 | let parameters = [this.tableName, ...filterParameters] 123 | 124 | let lookupQuery = ` 125 | SELECT 126 | ${this.queryFields.map((f) => `"${f}"`).join(", ")} 127 | FROM 128 | ?? 129 | ${filterQuery !== "" ? "WHERE" : ""} 130 | ${filterQuery} 131 | ` 132 | 133 | if (pagination.order) { 134 | lookupQuery = `${lookupQuery} 135 | ORDER BY ?? ${pagination.order[1]}` 136 | parameters.push(pagination.order[0]) 137 | } 138 | 139 | if (pagination.limit) { 140 | lookupQuery = `${lookupQuery} 141 | LIMIT ${pagination.limit}` 142 | } 143 | 144 | if (pagination.offset) { 145 | lookupQuery = `${lookupQuery} 146 | OFFSET ${pagination.offset}` 147 | } 148 | 149 | let [processedQuery, processedParameters] = this.postProcessor( 150 | lookupQuery, 151 | parameters 152 | ) 153 | let results = await connection.query( 154 | processedQuery, 155 | processedParameters 156 | ) 157 | 158 | this.recordQueryTime(processedQuery, startTime) 159 | return results 160 | } 161 | 162 | async update( 163 | payload: Partial, 164 | filter: Filter, 165 | connection: SQLStore = this.store 166 | ): Promise { 167 | // Strip the object of the undefined parameters 168 | let filteredPayload = (omitBy(payload, isUndefined) as unknown) as T 169 | let [filterQuery, filterParameters] = compileSQLFilter(filter) 170 | 171 | let lookupQuery = ` 172 | UPDATE 173 | ?? 174 | SET 175 | ${Object.keys(filteredPayload).map(() => "?? = ?")} 176 | ${filterQuery !== "" ? "WHERE" : ""} 177 | ${filterQuery} 178 | ` 179 | 180 | let payloadParameters: Array = [] 181 | Object.keys(filteredPayload).forEach((k) => { 182 | payloadParameters.push(k) 183 | payloadParameters.push(filteredPayload[k]) 184 | }) 185 | 186 | let [processedQuery, processedParameters] = this.postProcessor( 187 | lookupQuery, 188 | [this.tableName, ...payloadParameters, ...filterParameters] 189 | ) 190 | await connection.query(processedQuery, processedParameters) 191 | } 192 | 193 | async delete( 194 | filter: Filter, 195 | connection: SQLStore = this.store 196 | ): Promise { 197 | let [filterQuery, filterParameters] = compileSQLFilter(filter) 198 | let parameters = [this.tableName, ...filterParameters] 199 | 200 | let lookupQuery = ` 201 | DELETE FROM 202 | ?? 203 | ${filterQuery !== "" ? "WHERE" : ""} 204 | ${filterQuery} 205 | ` 206 | 207 | let [processedQuery, processedParameters] = this.postProcessor( 208 | lookupQuery, 209 | parameters 210 | ) 211 | await connection.query(processedQuery, processedParameters) 212 | } 213 | 214 | async generateID(): Promise { 215 | return undefined 216 | } 217 | 218 | protected recordQueryTime( 219 | queryString: string, 220 | startTime: [number, number] 221 | ): void { 222 | if (this.logger !== undefined) { 223 | let runtime = process.hrtime(startTime) 224 | 225 | this.logger.debug( 226 | `[REPOSITORY - QUERY - DIAGNOSTICS] ${ 227 | this.tableName 228 | } query complete - ${runtime[0]} s and ${runtime[1] / 229 | 1000000} ms`, 230 | { 231 | query: queryString, 232 | seconds: runtime[0], 233 | milliseconds: runtime[1] / 1000000, 234 | } 235 | ) 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/query/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/Filter" 2 | export * from "./abstract/FilterCompiler" 3 | export * from "./abstract/Query" 4 | export * from "./abstract/Repository" 5 | 6 | export * from "./drivers/sql/SQLFilterCompiler" 7 | export * from "./drivers/sql/TableRepository" 8 | -------------------------------------------------------------------------------- /src/queue/abstract/QueueHandler.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorFunction } from "../../validation" 2 | 3 | export abstract class QueueHandler

{ 4 | public abstract inputValidator: ValidatorFunction 5 | 6 | public abstract async handle(message: P): Promise 7 | } 8 | 9 | export type QueueHanderPayload< 10 | Q extends QueueHandler 11 | > = Q extends QueueHandler ? P : never 12 | -------------------------------------------------------------------------------- /src/queue/abstract/QueuePublisher.ts: -------------------------------------------------------------------------------- 1 | import { QueueHanderPayload, QueueHandler } from "./QueueHandler" 2 | 3 | export abstract class QueuePublisher { 4 | public abstract publish>( 5 | queueName: string, 6 | eventName: string, 7 | message: QueueHanderPayload 8 | ): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/queue/abstract/SerializedTask.ts: -------------------------------------------------------------------------------- 1 | export interface SerializedTask { 2 | message: any 3 | } 4 | -------------------------------------------------------------------------------- /src/queue/drivers/gcps/GCPSClient.ts: -------------------------------------------------------------------------------- 1 | import Axios from "axios" 2 | import { AsymmetricJWTSigner, RSASHA256Signer } from "../../../cryptography" 3 | 4 | export interface GCPSMessage { 5 | data: any 6 | attributes: { 7 | [key: string]: string 8 | } 9 | } 10 | 11 | export interface GCPSSubscription { 12 | name: string 13 | topic: string 14 | pushConfig: { 15 | pushEndpoint?: string 16 | } 17 | ackDeadlineSeconds: number 18 | retainAckedMessages: boolean 19 | messageRetentionDuration: string 20 | } 21 | 22 | export class GCPSClient { 23 | private signer: AsymmetricJWTSigner 24 | 25 | constructor( 26 | private serviceAccountEmail: string, 27 | private keyId: string, 28 | private privateKey: string 29 | ) { 30 | // Sanitize the private key 31 | let sanitizedPrivateKey = privateKey.replace( 32 | /\\n/g, 33 | ` 34 | ` 35 | ) 36 | 37 | this.signer = new AsymmetricJWTSigner( 38 | new RSASHA256Signer( 39 | // Public key is empty as this will never be used to validate a token 40 | new Buffer(""), 41 | new Buffer(sanitizedPrivateKey) 42 | ), 43 | "RS256", 44 | keyId 45 | ) 46 | } 47 | 48 | public async signRequest( 49 | audience: 50 | | "https://pubsub.googleapis.com/google.pubsub.v1.Publisher" 51 | | "https://pubsub.googleapis.com/google.pubsub.v1.Subscriber" 52 | ): Promise { 53 | let currentUnixTimestamp = Math.round(new Date().getTime() / 1000) 54 | 55 | return this.signer.sign({ 56 | iss: this.serviceAccountEmail, 57 | sub: this.serviceAccountEmail, 58 | aud: audience, 59 | iat: currentUnixTimestamp, 60 | exp: currentUnixTimestamp + 3600, 61 | }) 62 | } 63 | 64 | public async publish( 65 | topic: string, 66 | messages: Array 67 | ): Promise { 68 | await Axios.post( 69 | `https://pubsub.googleapis.com/v1/${topic}:publish`, 70 | { 71 | messages: messages.map((m) => ({ 72 | attributes: m.attributes, 73 | data: Buffer.from(JSON.stringify(m.data)).toString( 74 | "base64" 75 | ), 76 | })), 77 | }, 78 | { 79 | headers: { 80 | Authorization: `Bearer ${await this.signRequest( 81 | "https://pubsub.googleapis.com/google.pubsub.v1.Publisher" 82 | )}`, 83 | }, 84 | maxContentLength: 10 * 1024 * 1024, // 10 MB in Bytes 85 | } 86 | ) 87 | } 88 | 89 | public async getSubscriptionData( 90 | subscriptionName: string 91 | ): Promise { 92 | let subscriptionResp = await Axios.get( 93 | `https://pubsub.googleapis.com/v1/${subscriptionName}`, 94 | { 95 | headers: { 96 | Authorization: `Bearer ${await this.signRequest( 97 | "https://pubsub.googleapis.com/google.pubsub.v1.Subscriber" 98 | )}`, 99 | }, 100 | } 101 | ) 102 | 103 | return subscriptionResp.data 104 | } 105 | 106 | public async pullTasks( 107 | subscriptionName: string, 108 | maxMessages: number = 10, 109 | returnImmediately: boolean = false 110 | ): Promise< 111 | Array<{ 112 | ackId: string 113 | message: GCPSMessage 114 | }> 115 | > { 116 | let taskResp = await Axios.post( 117 | `https://pubsub.googleapis.com/v1/${subscriptionName}:pull`, 118 | { 119 | returnImmediately: returnImmediately, 120 | maxMessages: maxMessages, 121 | }, 122 | { 123 | headers: { 124 | Authorization: `Bearer ${await this.signRequest( 125 | "https://pubsub.googleapis.com/google.pubsub.v1.Subscriber" 126 | )}`, 127 | }, 128 | // Set a 90 second timeout to take advantage of long polling 129 | timeout: 120 * 1000, 130 | } 131 | ) 132 | 133 | return taskResp.data.receivedMessages 134 | ? taskResp.data.receivedMessages.map((m: any) => { 135 | return { 136 | ackId: m.ackId, 137 | message: { 138 | attributes: m.attributes, 139 | data: JSON.parse( 140 | Buffer.from(m.message.data, "base64").toString() 141 | ), 142 | }, 143 | } 144 | }) 145 | : [] 146 | } 147 | 148 | public async modifyAckDeadline( 149 | subscriptionName: string, 150 | ackIds: Array, 151 | ackExtensionSeconds: number 152 | ): Promise { 153 | await Axios.post( 154 | `https://pubsub.googleapis.com/v1/${subscriptionName}:modifyAckDeadline`, 155 | { 156 | ackIds: ackIds, 157 | ackDeadlineSeconds: ackExtensionSeconds, 158 | }, 159 | { 160 | headers: { 161 | Authorization: `Bearer ${await this.signRequest( 162 | "https://pubsub.googleapis.com/google.pubsub.v1.Subscriber" 163 | )}`, 164 | }, 165 | } 166 | ) 167 | } 168 | 169 | public async acknowledge( 170 | subscriptionName: string, 171 | ackIds: Array 172 | ): Promise { 173 | await Axios.post( 174 | `https://pubsub.googleapis.com/v1/${subscriptionName}:acknowledge`, 175 | { 176 | ackIds: ackIds, 177 | }, 178 | { 179 | headers: { 180 | Authorization: `Bearer ${await this.signRequest( 181 | "https://pubsub.googleapis.com/google.pubsub.v1.Subscriber" 182 | )}`, 183 | }, 184 | } 185 | ) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/queue/drivers/gcps/GCPSConsumer.ts: -------------------------------------------------------------------------------- 1 | import { GCPSClient } from "./GCPSClient" 2 | import { QueueHandler } from "../../abstract/QueueHandler" 3 | import { SerializedTask } from "../../abstract/SerializedTask" 4 | import { TransientError } from "../../../errors/TransientError" 5 | import { Container } from "inversify" 6 | import { isEmpty } from "lodash" 7 | import { Logger } from "../../../logging" 8 | import { Process } from "../../../runtime" 9 | import { ConstructorOf } from "../../../utils/types" 10 | import Timer = NodeJS.Timer 11 | 12 | export class GCPSConsumer implements Process { 13 | public isEnabled: boolean = false 14 | private ackDeadlineSeconds: number = 0 15 | private client: GCPSClient 16 | private logger?: Logger 17 | 18 | constructor( 19 | serviceAccountEmail: string, 20 | keyId: string, 21 | privateKey: string, 22 | public subscriptionName: string, 23 | public taskHandler: ConstructorOf>, 24 | public prefetchCount: number = 15 25 | ) { 26 | this.client = new GCPSClient(serviceAccountEmail, keyId, privateKey) 27 | } 28 | 29 | public isHealthy(): boolean { 30 | return this.isEnabled 31 | } 32 | 33 | public async shutdown(container: Container): Promise { 34 | this.isEnabled = false 35 | this.logger = undefined 36 | } 37 | 38 | public async startup(container: Container): Promise { 39 | // Start the process 40 | this.isEnabled = true 41 | 42 | if (container.isBound(Logger)) { 43 | this.logger = container.get(Logger) 44 | } 45 | 46 | // Fetch the subscription configuration 47 | let subscription = await this.client.getSubscriptionData( 48 | this.subscriptionName 49 | ) 50 | 51 | if (!isEmpty(subscription.pushConfig)) { 52 | throw new Error( 53 | "The Strontium GCPS Consumer does not support Push based GCPS subscriptions. " + 54 | "Please change the subscription inside Google Cloud Platform to operate on a Pull Based model if you wish " + 55 | "to use this queue processor." 56 | ) 57 | } 58 | 59 | this.ackDeadlineSeconds = subscription.ackDeadlineSeconds 60 | 61 | this.pollAndExecute(container) 62 | return 63 | } 64 | 65 | public async ack(ackId: string): Promise { 66 | return this.client.acknowledge(this.subscriptionName, [ackId]) 67 | } 68 | 69 | public async nack(ackId: string, requeue: boolean = false): Promise { 70 | if (requeue) { 71 | return this.client.modifyAckDeadline( 72 | this.subscriptionName, 73 | [ackId], 74 | 0 75 | ) 76 | } else { 77 | return this.ack(ackId) 78 | } 79 | } 80 | 81 | public async extendAck(ackId: string): Promise { 82 | return this.client.modifyAckDeadline( 83 | this.subscriptionName, 84 | [ackId], 85 | this.ackDeadlineSeconds 86 | ) 87 | } 88 | 89 | public async pollAndExecute(container: Container): Promise { 90 | while (this.isEnabled) { 91 | let messages = await this.client.pullTasks( 92 | this.subscriptionName, 93 | this.prefetchCount, 94 | false 95 | ) 96 | 97 | await Promise.all( 98 | messages.map(async (m) => { 99 | return this.executeTask( 100 | m.ackId, 101 | { 102 | message: m.message.data, 103 | }, 104 | container 105 | ) 106 | }) 107 | ) 108 | } 109 | } 110 | 111 | public async executeTask( 112 | ackId: string, 113 | task: SerializedTask, 114 | applicationContainer: Container 115 | ): Promise { 116 | // Create a new DI Container for the life of this request 117 | let requestContainer = new Container({ 118 | autoBindInjectable: true, 119 | skipBaseClassChecks: true, 120 | }) 121 | 122 | requestContainer.parent = applicationContainer 123 | 124 | // Spawn a handler for the Task type 125 | let handlerType = this.taskHandler 126 | if (this.logger) { 127 | this.logger.info( 128 | `[GCPS - TASK - START] Event received by Consumer for topic.`, 129 | { 130 | subscription: this.subscriptionName, 131 | } 132 | ) 133 | } 134 | 135 | if (handlerType === undefined) { 136 | if (this.logger) { 137 | this.logger.error( 138 | `[GCPS - TASK - NO_IMPLEMENTATION_FAIL] No implementation found for topic.`, 139 | { 140 | subscription: this.subscriptionName, 141 | } 142 | ) 143 | } 144 | await this.nack(ackId) 145 | return 146 | } 147 | 148 | let requestHandler = requestContainer.get(handlerType) 149 | 150 | // Set a regular task to extend the lifespan of the job until we are done processing it. 151 | let ackInterval: Timer = setInterval(() => { 152 | this.extendAck(ackId) 153 | }, this.ackDeadlineSeconds * 1000) 154 | 155 | try { 156 | // Validation errors aren't caught explicitly as they should never happen in Production. 157 | // They are instead designed to prevent edge case errors and are thrown as such. 158 | let validatedMessage = await requestHandler.inputValidator( 159 | task.message 160 | ) 161 | 162 | await requestHandler.handle(validatedMessage) 163 | 164 | await this.ack(ackId) 165 | if (this.logger) { 166 | this.logger.info( 167 | `[GCPS - TASK - SUCCESS] Event successfully completed by Consumer.`, 168 | { 169 | subscription: this.subscriptionName, 170 | } 171 | ) 172 | } 173 | } catch (e) { 174 | if (e instanceof TransientError) { 175 | if (this.logger) { 176 | this.logger.error( 177 | "[GCPS - TASK - TRANSIENT_FAIL] Task failed with transient error. Attempting to reschedule execution.", 178 | e 179 | ) 180 | } 181 | 182 | await this.nack(ackId, true) 183 | } else { 184 | if (this.logger) { 185 | this.logger.error( 186 | "[GCPS - TASK - PERMANENT_FAIL] Task failed with permanent error.", 187 | e 188 | ) 189 | } 190 | 191 | // For permanent errors we stop attempting to process the object 192 | await this.nack(ackId) 193 | } 194 | } finally { 195 | clearInterval(ackInterval) 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/queue/drivers/gcps/GCPSPublisher.ts: -------------------------------------------------------------------------------- 1 | import { GCPSClient } from "./GCPSClient" 2 | import { QueuePublisher } from "../../abstract/QueuePublisher" 3 | import { Container } from "inversify" 4 | import { Process } from "../../../runtime" 5 | 6 | export class GCPSPublisher extends QueuePublisher implements Process { 7 | private client: GCPSClient 8 | 9 | constructor( 10 | serviceAccountEmail: string, 11 | keyId: string, 12 | privateKey: string 13 | ) { 14 | super() 15 | 16 | this.client = new GCPSClient(serviceAccountEmail, keyId, privateKey) 17 | } 18 | 19 | public isHealthy(): boolean { 20 | // GCPS is a REST service - it is incapable of having a fundamentally unhealthy state. 21 | return true 22 | } 23 | 24 | public async publish( 25 | queueName: string, 26 | eventName: string, 27 | messages: Q | Array 28 | ): Promise { 29 | if (!Array.isArray(messages)) { 30 | messages = [messages] 31 | } 32 | 33 | return this.client.publish( 34 | queueName, 35 | messages.map((m) => ({ 36 | attributes: { 37 | STRONTIUM_EVENT_NAME: eventName, 38 | }, 39 | data: m, 40 | })) 41 | ) 42 | } 43 | 44 | public async shutdown(container: Container): Promise { 45 | container.unbind(QueuePublisher) 46 | container.unbind(GCPSPublisher) 47 | } 48 | 49 | public async startup(container: Container): Promise { 50 | container.bind(QueuePublisher).toConstantValue(this) 51 | container.bind(GCPSPublisher).toConstantValue(this) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/queue/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/QueueHandler" 2 | export * from "./abstract/QueuePublisher" 3 | export * from "./abstract/SerializedTask" 4 | 5 | export * from "./drivers/gcps/GCPSClient" 6 | export * from "./drivers/gcps/GCPSConsumer" 7 | export * from "./drivers/gcps/GCPSPublisher" 8 | -------------------------------------------------------------------------------- /src/runtime/abstract/Process.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "inversify" 2 | 3 | /** 4 | * A Process represents a long running logical process within the wider scope 5 | * of an application runtime. 6 | * 7 | * An example might be a Web Server or a Database Connection pool. 8 | * 9 | * Conceptually a Process has a startup procedure, a shutdown procedure and an 10 | * ongoing state. 11 | * 12 | * The lifetime expectations of a Process are as follows: 13 | * 1. It should not have any effect until started 14 | * 2. All effects should cease and be cleaned once shutdown is closed. 15 | * 3. The isHealthy check should only return true if the process is functioning nominally. Any abnormal behaviour or errors 16 | * should trigger an error. 17 | */ 18 | export interface Process { 19 | /** 20 | * Start the process. 21 | * 22 | * Implementing processes should take care to ensure that startup is roughly idempotent ( i.e subsequent calls will 23 | * not cause issues in an already started process ). 24 | * 25 | * Implementations should also ensure not to cause any side effects prior to Startup being called. 26 | * 27 | * @param container {Container} - The Inversify container used by the Runtime for type resolution. This should 28 | * be used by implementations to register the started process with the Runtime for use. 29 | */ 30 | startup(container: Container): Promise 31 | 32 | /** 33 | * Stop the process. 34 | * 35 | * Implementing processes should use this hook to close all open connections, sockets and event loop items 36 | * ( intervals, timeouts, etc. ). 37 | * 38 | * Runtimes will expect that upon the completion of the Promise shutdown is complete to the level that node 39 | * will gracefully terminate ( the event loop is empty ). 40 | * 41 | * @param container {Container} - The Inversify container used by the Runtime for type resolution. This should 42 | * be used by implementations to deregister the stopped process from the Runtime. 43 | */ 44 | shutdown(container: Container): Promise 45 | 46 | /** 47 | * Return the health status of the Process. 48 | * 49 | * If the process is unable to function as originally anticipated then it should return false. 50 | * 51 | * Runtime implementations will decide what to do in the case of health check failure but actions may include 52 | * attempting to restart the process using Shutdown and Startup sequentially or simply killing the entire runtime. 53 | */ 54 | isHealthy(): boolean 55 | } 56 | 57 | export const isProcess = (p: any): p is Process => { 58 | return p !== undefined && typeof p.isHealthy === "function" 59 | } 60 | -------------------------------------------------------------------------------- /src/runtime/drivers/Runtime.ts: -------------------------------------------------------------------------------- 1 | import { Process } from "../abstract/Process" 2 | import { Container } from "inversify" 3 | 4 | /** 5 | * A Runtime represents a collection of Processes run together to form an Application. 6 | * 7 | * Runtimes are designed to provide an easy to work with wrapper to build reliable 8 | * applications and abstract the nasty underlayers of DI and subprocess monitoring 9 | * that are often discarded due to their complexity. 10 | */ 11 | export class Runtime implements Process { 12 | private container: Container = new Container() 13 | 14 | constructor(private processes: Array) {} 15 | 16 | public async startup(): Promise { 17 | // Start each process in order, waiting for it to be fully booted before moving to the next. 18 | for (let p of this.processes) { 19 | await p.startup(this.container) 20 | } 21 | } 22 | 23 | public async shutdown(): Promise { 24 | // Stop each process in reverse order, waiting for it to be full closed before moving to the next. 25 | for (let p of this.processes.reverse()) { 26 | await p.shutdown(this.container) 27 | } 28 | } 29 | 30 | public isHealthy(): boolean { 31 | // Aggregate the health status of each of the sub processes 32 | return this.processes.reduce((memo: boolean, p) => { 33 | return memo && p.isHealthy() 34 | }, true) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export { Process, isProcess } from "./abstract/Process" 2 | export { Runtime } from "./drivers/Runtime" 3 | -------------------------------------------------------------------------------- /src/utils/list.ts: -------------------------------------------------------------------------------- 1 | import { notMissing } from "./typeGuard" 2 | 3 | export const compact = (input: T[]): Exclude[] => { 4 | return input.filter(notMissing) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/typeGuard.ts: -------------------------------------------------------------------------------- 1 | export type TypeGuard = (val: T) => val is V 2 | 3 | export function notMissing(input: T): input is Exclude { 4 | return input !== null && input !== undefined 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type ConstructorOf = { 2 | new (...arg: any[]): T 3 | } 4 | 5 | export type UUID = string 6 | 7 | export type Nullable = T | null 8 | -------------------------------------------------------------------------------- /src/validation/abstract/ObjectValidator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorFunction, ValidatorOutput } from "./ValidatorFunction" 2 | 3 | export type ObjectValidator = { 4 | [key: string]: ValidatorFunction 5 | } & Object 6 | 7 | export type ValidatedObject = { 8 | [P in keyof O]: ValidatorOutput 9 | } 10 | -------------------------------------------------------------------------------- /src/validation/abstract/ValidatorFunction.ts: -------------------------------------------------------------------------------- 1 | export type ValidatorFunction = (input: I) => O | Promise 2 | 3 | export type ValidatorOutput< 4 | I, 5 | P extends ValidatorFunction 6 | > = P extends ValidatorFunction ? O : ReturnType

7 | 8 | export type ValidatorInput< 9 | P extends ValidatorFunction 10 | > = P extends ValidatorFunction ? I : ReturnType

11 | -------------------------------------------------------------------------------- /src/validation/drivers/helpers/combineValidators.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorFunction } from "../../abstract/ValidatorFunction" 2 | 3 | export function combineValidators( 4 | V1: ValidatorFunction, 5 | V2: ValidatorFunction 6 | ): ValidatorFunction 7 | export function combineValidators( 8 | V1: ValidatorFunction, 9 | V2: ValidatorFunction, 10 | V3: ValidatorFunction 11 | ): ValidatorFunction 12 | export function combineValidators( 13 | V1: ValidatorFunction, 14 | V2: ValidatorFunction, 15 | V3: ValidatorFunction, 16 | V4: ValidatorFunction 17 | ): ValidatorFunction 18 | export function combineValidators( 19 | V1: ValidatorFunction, 20 | V2: ValidatorFunction, 21 | V3: ValidatorFunction, 22 | V4: ValidatorFunction, 23 | V5: ValidatorFunction 24 | ): ValidatorFunction 25 | 26 | export function combineValidators( 27 | V1: ValidatorFunction, 28 | V2: ValidatorFunction, 29 | V3?: ValidatorFunction, 30 | V4?: ValidatorFunction, 31 | V5?: ValidatorFunction 32 | ): 33 | | ValidatorFunction 34 | | ValidatorFunction 35 | | ValidatorFunction 36 | | ValidatorFunction { 37 | // This is split into if statements so Type completion is rigid. It's possible this could 38 | // be better written in the future but for now TypeScript is happy. 39 | if (V5 !== undefined && V4 !== undefined && V3 !== undefined) { 40 | return async (i: I) => { 41 | let r1 = await V1(i) 42 | let r2 = await V2(r1) 43 | let r3 = await V3(r2) 44 | let r4 = await V4(r3) 45 | return await V5(r4) 46 | } 47 | } else if (V4 !== undefined && V3 !== undefined) { 48 | return async (i: I) => { 49 | let r1 = await V1(i) 50 | let r2 = await V2(r1) 51 | let r3 = await V3(r2) 52 | return await V4(r3) 53 | } 54 | } else if (V3 !== undefined) { 55 | return async (i: I) => { 56 | let r1 = await V1(i) 57 | let r2 = await V2(r1) 58 | return await V3(r2) 59 | } 60 | } else { 61 | return async (i: I) => { 62 | let r1 = await V1(i) 63 | return await V2(r1) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/validation/drivers/helpers/either.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | import { ValidatorFunction } from "../../abstract/ValidatorFunction" 3 | 4 | export function either( 5 | V1: ValidatorFunction, 6 | V2: ValidatorFunction 7 | ): ValidatorFunction 8 | export function either( 9 | V1: ValidatorFunction, 10 | V2: ValidatorFunction, 11 | V3: ValidatorFunction 12 | ): ValidatorFunction 13 | export function either( 14 | V1: ValidatorFunction, 15 | V2: ValidatorFunction, 16 | V3: ValidatorFunction, 17 | V4: ValidatorFunction 18 | ): ValidatorFunction 19 | export function either( 20 | V1: ValidatorFunction, 21 | V2: ValidatorFunction, 22 | V3: ValidatorFunction, 23 | V4: ValidatorFunction, 24 | V5: ValidatorFunction 25 | ): ValidatorFunction 26 | 27 | export function either( 28 | V1: ValidatorFunction, 29 | V2: ValidatorFunction, 30 | V3?: ValidatorFunction, 31 | V4?: ValidatorFunction, 32 | V5?: ValidatorFunction 33 | ): 34 | | ValidatorFunction 35 | | ValidatorFunction 36 | | ValidatorFunction 37 | | ValidatorFunction { 38 | return async (i: I) => { 39 | let errors: Array = [] 40 | 41 | // Iterate over each validator in descending order until one succeeds. 42 | for (let validator of [V1, V2, V3, V4, V5]) { 43 | if (validator !== undefined) { 44 | try { 45 | return await validator(i) 46 | } catch (e) { 47 | errors.push(e) 48 | } 49 | } 50 | } 51 | 52 | // If we get to this stage then we have failed the validator - throw a Validation Error unless one of 53 | // the validators threw a different error type 54 | let failedConstraints: Array = [] 55 | let failedInternalMessages: Array = [] 56 | let failedExternalMessages: Array = [] 57 | 58 | for (let error of errors) { 59 | if (error instanceof ValidationError) { 60 | failedConstraints.push(error.constraintName) 61 | 62 | if (error.internalMessage) { 63 | failedInternalMessages.push(error.internalMessage) 64 | } 65 | 66 | if (error.externalMessage) { 67 | failedExternalMessages.push(error.externalMessage) 68 | } 69 | } else { 70 | throw error 71 | } 72 | } 73 | 74 | throw new ValidationError( 75 | `EITHER(${failedConstraints.join(",")})`, 76 | `No compatible validators found: (${failedInternalMessages.join( 77 | ", " 78 | )})`, 79 | `This value did not match any of the following validators: (${failedExternalMessages.join( 80 | " | " 81 | )})` 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/validation/drivers/helpers/isOptional.ts: -------------------------------------------------------------------------------- 1 | import { either } from "./either" 2 | import { isUndefined } from "../validators/isUndefined" 3 | 4 | import { ValidatorFunction } from "../.." 5 | 6 | export const isOptional = , I, O>( 7 | validator: V 8 | ) => either(isUndefined, validator) 9 | -------------------------------------------------------------------------------- /src/validation/drivers/sanitizers/defaultValue.ts: -------------------------------------------------------------------------------- 1 | export const defaultValue = (defaultValue: D) => (input?: I): I | D => { 2 | if (input === undefined) { 3 | return defaultValue 4 | } 5 | 6 | return input 7 | } 8 | -------------------------------------------------------------------------------- /src/validation/drivers/sanitizers/normalizeEmail.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | import * as Validator from "validator" 3 | import NormalizeEmailOptions = ValidatorJS.NormalizeEmailOptions 4 | 5 | export const normalizeEmail = (options?: NormalizeEmailOptions) => ( 6 | input: I 7 | ): string => { 8 | let normalizedEmail = Validator.normalizeEmail(String(input), options) 9 | let isValid = Validator.isEmail(String(normalizedEmail), { 10 | ignore_max_length: true, 11 | } as any) 12 | 13 | if (isValid && typeof normalizedEmail === "string") { 14 | return normalizedEmail 15 | } else { 16 | throw new ValidationError( 17 | "NORMALIZE_EMAIL", 18 | "Invalid Email provided", 19 | "This value must be a valid email address." 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isArray.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | 3 | import { ValidatorFunction, ValidatorOutput } from "../.." 4 | 5 | export const isArray = >( 6 | innerValidator: V 7 | ) => async (input: unknown): Promise>> => { 8 | if (!Array.isArray(input)) { 9 | throw new ValidationError( 10 | "IS_ARRAY", 11 | "Value not an array", 12 | "This value must be an array." 13 | ) 14 | } 15 | 16 | let validatedArray: Array> = await Promise.all( 17 | input.map(innerValidator) 18 | ) 19 | 20 | return validatedArray 21 | } 22 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isBase64EncodedString.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | import { isBase64 } from "validator" 3 | 4 | export const isBase64EncodedString = (i: string): string => { 5 | if (isBase64(i)) { 6 | return i 7 | } 8 | 9 | throw new ValidationError( 10 | "IS_BASE_64", 11 | "Value is not a Base64 string", 12 | "This value is not a Base64 string" 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isBoolean.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | 3 | export const isBoolean = (input?: unknown): boolean => { 4 | if (typeof input === "boolean") { 5 | return input 6 | } 7 | 8 | throw new ValidationError( 9 | "IS_BOOLEAN", 10 | "Value not a boolean", 11 | "This value must be a boolean." 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isDictionary.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | 3 | import { ValidatorFunction } from "../.." 4 | 5 | export const isDictionary = ( 6 | keyValidator: ValidatorFunction, 7 | valueValidator: ValidatorFunction 8 | ) => async ( 9 | i: unknown 10 | ): Promise<{ 11 | [index: string]: V 12 | }> => { 13 | if (typeof i !== "object" || i === null) { 14 | throw new ValidationError( 15 | "IS_OBJECT", 16 | "Object validation failed", 17 | "This value should be an object." 18 | ) 19 | } 20 | 21 | let response: { 22 | [index: string]: V 23 | } = {} 24 | for (let p in i) { 25 | if (i.hasOwnProperty(p)) { 26 | let validatedKey = await keyValidator(p) 27 | 28 | // Sadly ts-ignore this as TS doesn't understand that we have strongly ensured 29 | // that this property is present. 30 | // @ts-ignore 31 | let rawValue = i[p] 32 | 33 | let validatedOutput = await valueValidator(rawValue) 34 | 35 | if (validatedOutput !== undefined) { 36 | response[validatedKey] = validatedOutput 37 | } 38 | } 39 | } 40 | 41 | return response 42 | } 43 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isEnumValue.ts: -------------------------------------------------------------------------------- 1 | import { isExactly } from "./isExactly" 2 | 3 | export const isEnumValue = (enumObject: any): ((i: unknown) => T) => { 4 | let values = Object.values(enumObject) 5 | 6 | return isExactly(values) as any 7 | } 8 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isExactly.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | 3 | export const isExactly = (values: Array) => (i: unknown): O => { 4 | for (let value of values) { 5 | if (value === i) { 6 | return value 7 | } 8 | } 9 | 10 | throw new ValidationError( 11 | "IS_EXACTLY", 12 | "Value not in permitted set", 13 | `The provided value was not allowed for this field. Allowed values are: '${values.join( 14 | "', '" 15 | )}'` 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isFilter.ts: -------------------------------------------------------------------------------- 1 | import { either } from "../helpers/either" 2 | import { Filter } from "../../../query" 3 | 4 | import { 5 | ObjectValidator, 6 | ValidatedObject, 7 | ValidatorFunction, 8 | isArray, 9 | isObject, 10 | isUndefined, 11 | } from "../.." 12 | 13 | export const isFilter = ( 14 | objectSchema: V, 15 | depth: number = 0 16 | ): ((i: unknown) => Promise>>) => { 17 | if (depth > 10) { 18 | // Stub this to any as it is a library operational detail and would pollute the type system 19 | return undefined as any 20 | } 21 | 22 | let validatorObject: { [key: string]: ValidatorFunction } = { 23 | $and: either(isUndefined, isArray(isFilter(objectSchema, depth + 1))), 24 | $or: either(isUndefined, isArray(isFilter(objectSchema, depth + 1))), 25 | } 26 | 27 | for (let key of Object.keys(objectSchema)) { 28 | validatorObject[key] = isFieldSelector(objectSchema[key]) 29 | } 30 | 31 | // Flag as any so that TypeScript doesn't get scared about the use of a recursive builder function 32 | return isObject(validatorObject) as any 33 | } 34 | 35 | const isFieldSelector = (keyValidator: ValidatorFunction) => 36 | either( 37 | isUndefined, 38 | keyValidator, 39 | isObject({ 40 | $in: either(isUndefined, isArray(keyValidator)), 41 | $nin: either(isUndefined, isArray(keyValidator)), 42 | $eq: either(isUndefined, keyValidator), 43 | $neq: either(isUndefined, keyValidator), 44 | $gt: either(isUndefined, keyValidator), 45 | $gte: either(isUndefined, keyValidator), 46 | $lt: either(isUndefined, keyValidator), 47 | $lte: either(isUndefined, keyValidator), 48 | $contains: either(isUndefined, keyValidator), 49 | $arr_contains: either(isUndefined, keyValidator), 50 | }) 51 | ) 52 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isISOCountry.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | import * as Validator from "validator" 3 | 4 | export const isISOAlpha2CountryCode = (input: unknown): string => { 5 | if (Validator.isISO31661Alpha2(String(input))) { 6 | return String(input) 7 | } else { 8 | throw new ValidationError( 9 | "IS_ISO_ALPHA_2_COUNTRY", 10 | "Value not an ISO Alpha-2 Country Code", 11 | "This value must be a valid ISO Alpha-2 Country Code." 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isISODate.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | import * as Validator from "validator" 3 | 4 | export const isISODate = (input: unknown): Date => { 5 | if (Validator.isISO8601(String(input))) { 6 | return new Date(String(input)) 7 | } else { 8 | throw new ValidationError( 9 | "IS_ISO_8601", 10 | "Value not an ISO Date", 11 | "This value must be a valid ISO 8601 Date string." 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isNull.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | 3 | export const isNull = (input?: unknown): null => { 4 | if (input === null) { 5 | return null 6 | } 7 | 8 | throw new ValidationError( 9 | "IS_NULL", 10 | "Value was not null", 11 | "This value must be `null`. It's possible you sent undefined, no value or the string null instead." 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isNumber.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | 3 | export const isNumber = (input?: unknown): number => { 4 | if (typeof input === "number" && !isNaN(input)) { 5 | return input 6 | } 7 | 8 | if (typeof input === "string") { 9 | let parsedNumber = Number(input) 10 | 11 | if (!isNaN(parsedNumber)) { 12 | return parsedNumber 13 | } 14 | } 15 | 16 | throw new ValidationError( 17 | "IS_NUMBER", 18 | "Value not a number", 19 | "This value must be a number." 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isObject.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | 3 | import { ObjectValidator, ValidatedObject } from "../.." 4 | 5 | export const isObject = (validator: V) => async ( 6 | i: unknown 7 | ): Promise> => { 8 | if (typeof i !== "object" || i === null) { 9 | throw new ValidationError( 10 | "IS_OBJECT", 11 | "Object validation failed", 12 | "This value should be an object." 13 | ) 14 | } 15 | 16 | let response: Partial> = {} 17 | 18 | for (let p in validator) { 19 | if (validator.hasOwnProperty(p)) { 20 | // Sadly ts-ignore this as TS doesn't understand that we have strongly ensured 21 | // that this property is present. 22 | // @ts-ignore 23 | let rawValue = i[p] 24 | 25 | try { 26 | let validatedOutput = await validator[p](rawValue) 27 | 28 | if (validatedOutput !== undefined) { 29 | response[p] = validatedOutput 30 | } 31 | } catch (e) { 32 | if (e instanceof ValidationError) { 33 | // Append the path to the error message 34 | if (e.fieldPath) { 35 | e.fieldPath = `${p}.${e.fieldPath}` 36 | } else { 37 | e.fieldPath = p 38 | } 39 | } 40 | 41 | throw e 42 | } 43 | } 44 | } 45 | 46 | return response as ValidatedObject 47 | } 48 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isString.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors/http/ValidationError" 2 | 3 | export const isString = (input?: unknown): string => { 4 | if (typeof input === "string") { 5 | return input 6 | } 7 | 8 | throw new ValidationError( 9 | "IS_STRING", 10 | "Value not a string", 11 | "This value must be a string." 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isUUID.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | import { UUID } from "../../../utils/types" 3 | 4 | import { isUUID as uuidValidator } from "validator" 5 | 6 | export const isUUID = (i: unknown): UUID => { 7 | if (typeof i === "string" && uuidValidator(i)) { 8 | return i 9 | } 10 | 11 | throw new ValidationError( 12 | "IS_UUID", 13 | "Value must be a UUID V4", 14 | "This value must be a UUID V4" 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/validation/drivers/validators/isUndefined.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "../../../errors" 2 | 3 | export const isUndefined = (input?: unknown): undefined => { 4 | if (input === undefined) { 5 | return undefined 6 | } 7 | 8 | throw new ValidationError( 9 | "IS_UNDEFINED", 10 | "Value not undefined", 11 | "This value must be undefined." 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abstract/ObjectValidator" 2 | export * from "./abstract/ValidatorFunction" 3 | 4 | export * from "./drivers/helpers/combineValidators" 5 | export * from "./drivers/helpers/either" 6 | 7 | export * from "./drivers/sanitizers/defaultValue" 8 | export * from "./drivers/sanitizers/normalizeEmail" 9 | 10 | export * from "./drivers/validators/isArray" 11 | export * from "./drivers/validators/isBase64EncodedString" 12 | export * from "./drivers/validators/isBoolean" 13 | export * from "./drivers/validators/isDictionary" 14 | export * from "./drivers/validators/isEnumValue" 15 | export * from "./drivers/validators/isExactly" 16 | export * from "./drivers/validators/isFilter" 17 | export * from "./drivers/validators/isISOCountry" 18 | export * from "./drivers/validators/isISODate" 19 | export * from "./drivers/validators/isNull" 20 | export * from "./drivers/validators/isNumber" 21 | export * from "./drivers/validators/isObject" 22 | export * from "./drivers/validators/isString" 23 | export * from "./drivers/validators/isUndefined" 24 | export * from "./drivers/validators/isUUID" 25 | -------------------------------------------------------------------------------- /tests/cryptography/drivers/node/AsymmetricJWTSigner.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { 3 | AsymmetricJWTSigner, 4 | RSASHA256Signer, 5 | } from "../../../../src/cryptography" 6 | 7 | const publicKey = new Buffer( 8 | `-----BEGIN PUBLIC KEY----- 9 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd 10 | UWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs 11 | HUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D 12 | o2kQ+X5xK9cipRgEKwIDAQAB 13 | -----END PUBLIC KEY----- 14 | ` 15 | ) 16 | 17 | const privateKey = new Buffer( 18 | `-----BEGIN RSA PRIVATE KEY----- 19 | MIICWwIBAAKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw 20 | 33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW 21 | +jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB 22 | AoGAD+onAtVye4ic7VR7V50DF9bOnwRwNXrARcDhq9LWNRrRGElESYYTQ6EbatXS 23 | 3MCyjjX2eMhu/aF5YhXBwkppwxg+EOmXeh+MzL7Zh284OuPbkglAaGhV9bb6/5Cp 24 | uGb1esyPbYW+Ty2PC0GSZfIXkXs76jXAu9TOBvD0ybc2YlkCQQDywg2R/7t3Q2OE 25 | 2+yo382CLJdrlSLVROWKwb4tb2PjhY4XAwV8d1vy0RenxTB+K5Mu57uVSTHtrMK0 26 | GAtFr833AkEA6avx20OHo61Yela/4k5kQDtjEf1N0LfI+BcWZtxsS3jDM3i1Hp0K 27 | Su5rsCPb8acJo5RO26gGVrfAsDcIXKC+bQJAZZ2XIpsitLyPpuiMOvBbzPavd4gY 28 | 6Z8KWrfYzJoI/Q9FuBo6rKwl4BFoToD7WIUS+hpkagwWiz+6zLoX1dbOZwJACmH5 29 | fSSjAkLRi54PKJ8TFUeOP15h9sQzydI8zJU+upvDEKZsZc/UhT/SySDOxQ4G/523 30 | Y0sz/OZtSWcol/UMgQJALesy++GdvoIDLfJX5GBQpuFgFenRiRDabxrE9MNUZ2aP 31 | FaFp+DyAe+b4nDwuJaW2LURbr8AEZga7oQj0uYxcYw== 32 | -----END RSA PRIVATE KEY----- 33 | ` 34 | ) 35 | 36 | describe("AsymmetricJWTSigner", () => { 37 | const jwtSigner: AsymmetricJWTSigner = new AsymmetricJWTSigner( 38 | new RSASHA256Signer(publicKey, privateKey), 39 | "RS256" 40 | ) 41 | 42 | describe("Sign", () => { 43 | it("Should generate a valid JWT representing the claim", async () => { 44 | let token = await jwtSigner.sign({ 45 | name: "Hello World", 46 | admin: true, 47 | }) 48 | 49 | expect(token).to.equal( 50 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" + 51 | "." + 52 | "eyJuYW1lIjoiSGVsbG8gV29ybGQiLCJhZG1pbiI6dHJ1ZX0" + 53 | "." + 54 | "kMTtIIVjOHvWn0MriZ846b9xUpiLczu0N2JdzAt6sDR1ZY87e3JGriVmP-7h8zXLFwp0ElDq4Q0urPv_MROBEc_ZV2-AmjoAvzHjZNeGFajTTfdzEd9kgQ6BzKtLrrv3WsU2P_ZQk920_ySNmjG0RukWnaRgwKJTn75UNsh8eLw" 55 | ) 56 | }) 57 | }) 58 | 59 | describe("Verify", () => { 60 | it("Should throw if the JWT is malformed (too many sections)", async () => { 61 | try { 62 | await jwtSigner.verify("a.b.c.d") 63 | expect(true).to.equal(false) 64 | } catch (e) { 65 | expect(e.message).to.equal( 66 | "JWT supplied the incorrect number of components to verify." 67 | ) 68 | } 69 | }) 70 | 71 | it("Should throw if the JWT is using a different algorithm to the one supported", async () => { 72 | try { 73 | await jwtSigner.verify( 74 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + 75 | "." + 76 | "eyJuYW1lIjoiSGVsbG8gV29ybGQiLCJhZG1pbiI6dHJ1ZX0" + 77 | "." + 78 | "1WT_DHWL_7pvzCfBdzzC3i0XqBsiz7wPvZzua1CezCM" 79 | ) 80 | expect(true).to.equal(false) 81 | } catch (e) { 82 | expect(e.message).to.equal( 83 | "JWT supplied a signing algorithm that is not supported by this validator." 84 | ) 85 | } 86 | }) 87 | 88 | it("Should return the validated claim if the token is valid", async () => { 89 | let claim = await jwtSigner.verify( 90 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" + 91 | "." + 92 | "eyJuYW1lIjoiSGVsbG8gV29ybGQiLCJhZG1pbiI6dHJ1ZX0" + 93 | "." + 94 | "kMTtIIVjOHvWn0MriZ846b9xUpiLczu0N2JdzAt6sDR1ZY87e3JGriVmP-7h8zXLFwp0ElDq4Q0urPv_MROBEc_ZV2-AmjoAvzHjZNeGFajTTfdzEd9kgQ6BzKtLrrv3WsU2P_ZQk920_ySNmjG0RukWnaRgwKJTn75UNsh8eLw" 95 | ) 96 | 97 | expect(claim).to.deep.equal({ 98 | name: "Hello World", 99 | admin: true, 100 | }) 101 | }) 102 | 103 | it("Should throw if the JWT is invalid", async () => { 104 | try { 105 | await jwtSigner.verify( 106 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" + 107 | "." + 108 | "eyJuYW1lIjoiSGVsbG8gV29ybGQiLCJhZG1pbiI6dHJ1ZX0" + 109 | "." + 110 | "abcdefg" 111 | ) 112 | expect(true).to.equal(false) 113 | } catch (e) { 114 | expect(e.message).to.equal( 115 | "The signature provided was not valid." 116 | ) 117 | } 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /tests/cryptography/drivers/node/SHA256Digest.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { SHA256Digest } from "../../../../src" 3 | 4 | describe("SHA256Digest", () => { 5 | /** 6 | * Verify that the implementation passes the NIST cryptographic test vectors. 7 | * 8 | * Source: https://csrc.nist.gov/csrc/media/projects/cryptographic-standards-and-guidelines/documents/examples/sha_all.pdf 9 | */ 10 | describe("Cryptographic Test Vectors", () => { 11 | let hasher: SHA256Digest 12 | 13 | before(() => { 14 | hasher = new SHA256Digest() 15 | }) 16 | 17 | const testHash = async (plaintext: string, digest: string) => { 18 | let result = await hasher.calculate(new Buffer(plaintext, "utf8")) 19 | 20 | expect(result.toString("hex")).to.equal(digest) 21 | } 22 | 23 | it('"abc", the bit string (0x)616263 of length 24 bits', async () => { 24 | await testHash( 25 | "abc", 26 | "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" 27 | ) 28 | }) 29 | 30 | it('the empty string "", the bit string of length 0', async () => { 31 | await testHash( 32 | "", 33 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 34 | ) 35 | }) 36 | 37 | it('"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" (length 448 bits)', async () => { 38 | await testHash( 39 | "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 40 | "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" 41 | ) 42 | }) 43 | 44 | it('one million (1,000,000) repetitions of the character "a" (0x61).', async () => { 45 | let testString = "" 46 | for (let i = 0; i < 100000; i++) { 47 | testString += "aaaaaaaaaa" 48 | } 49 | 50 | await testHash( 51 | testString, 52 | "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0" 53 | ) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tests/datastore/drivers/pg/PGStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { pgQueryPostProcessor } from "../../../../src/query/drivers/pg/PGQueryPostProcessor" 2 | import { PGStore } from "../../../../src/datastore/drivers/pg/PGStore" 3 | import { expect } from "chai" 4 | import { Container } from "inversify" 5 | 6 | describe("PGStore", () => { 7 | const testStore = new PGStore({ 8 | host: process.env.PG_HOST, 9 | port: process.env.PG_PORT ? Number(process.env.PG_PORT) : undefined, 10 | database: process.env.PG_DATABASE, 11 | user: process.env.PG_USER, 12 | password: process.env.PG_PASSWORD, 13 | }) 14 | const container: Container = new Container() 15 | 16 | beforeEach(async () => { 17 | await testStore.startup(container) 18 | }) 19 | 20 | afterEach(async () => { 21 | await testStore.shutdown(container) 22 | }) 23 | 24 | describe("startup", () => { 25 | it("should register the PG Driver implementation with the container", () => { 26 | let store = container.get(PGStore) 27 | 28 | expect(store).to.equal(testStore) 29 | }) 30 | }) 31 | 32 | describe("shutdown", () => { 33 | it("should unregister the PG Driver implementation with the container", async () => { 34 | await testStore.shutdown(container) 35 | expect(container.isBound(PGStore)).to.equal(false) 36 | }) 37 | }) 38 | 39 | describe("query", () => { 40 | it("should submit queries to the PG database and return their results as an array of rows", async () => { 41 | let testQueryResults = await testStore.query( 42 | "SELECT 15 AS test", 43 | [] 44 | ) 45 | 46 | expect(testQueryResults).to.deep.equal([{ test: 15 }]) 47 | }) 48 | }) 49 | 50 | describe("Transaction Builder", () => { 51 | beforeEach(async () => { 52 | await testStore.query( 53 | `CREATE TABLE trxIntegrationTest ( 54 | id SERIAL, 55 | testcolumn VARCHAR 56 | )`, 57 | [] 58 | ) 59 | }) 60 | 61 | afterEach(async () => { 62 | await testStore.query(`DROP TABLE trxIntegrationTest`, []) 63 | }) 64 | 65 | it("should create a SQL transaction", async () => { 66 | let trx = await testStore.createTransaction() 67 | 68 | let [finalQuery, finalParameters] = pgQueryPostProcessor( 69 | "INSERT INTO trxIntegrationTest (??) VALUES (?)", 70 | ["testcolumn", "Hello Integration Test!"] 71 | ) 72 | 73 | await trx.query(finalQuery, finalParameters) 74 | 75 | let preCommitResults = await testStore.query( 76 | "SELECT * FROM trxIntegrationTest", 77 | [] 78 | ) 79 | 80 | expect(preCommitResults).to.deep.equal([]) 81 | 82 | await trx.commit() 83 | 84 | let postCommitResults = await testStore.query<{ 85 | id: number 86 | testcolumn: string 87 | }>("SELECT * FROM trxIntegrationTest", []) 88 | 89 | expect(postCommitResults.length).to.equal(1) 90 | expect(postCommitResults[0].testcolumn).to.equal( 91 | "Hello Integration Test!" 92 | ) 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /tests/datastore/drivers/redis/RedisStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { RedisStore } from "../../../../src/datastore" 3 | import { Container } from "inversify" 4 | 5 | describe("RedisStore", () => { 6 | let testStore = new RedisStore() 7 | let container: Container = new Container() 8 | let key = "test-key" 9 | let value = "test-value" 10 | 11 | beforeEach(async () => { 12 | await testStore.startup(container) 13 | }) 14 | 15 | afterEach(async () => { 16 | await testStore.shutdown(container) 17 | }) 18 | 19 | describe("startup", () => { 20 | it("should register the RedisStore implementation with the container", () => { 21 | let store = container.get(RedisStore) 22 | 23 | expect(store).to.equal(testStore) 24 | expect(testStore.isHealthy()).to.equal(true) 25 | }) 26 | }) 27 | 28 | describe("shutdown", () => { 29 | it("should unregister the RedisStore implementation with the container", async () => { 30 | await testStore.shutdown(container) 31 | 32 | expect(container.isBound(RedisStore)).to.equal(false) 33 | expect(testStore.isHealthy()).to.equal(false) 34 | }) 35 | }) 36 | 37 | describe("sendCommand", () => { 38 | it("should send commands to redis and get the results", async () => { 39 | let setResult = await testStore.sendCommand("set", [ 40 | key, 41 | value, 42 | ]) 43 | 44 | expect(setResult).to.equal("OK") 45 | }) 46 | 47 | it("should throw errors", async () => { 48 | let hasThrown = false 49 | try { 50 | await testStore.sendCommand("wrong command", [ 51 | key, 52 | value, 53 | ]) 54 | 55 | expect(false).to.equal(true) 56 | } catch (e) { 57 | hasThrown = true 58 | } 59 | expect(hasThrown).to.equal(true) 60 | }) 61 | }) 62 | 63 | describe("eval", () => { 64 | it("should execute lua scripts and get the results", async () => { 65 | let luaScript1 = 'return redis.call("set", KEYS[1], ARGV[1])' 66 | let setResult1 = await testStore.eval(luaScript1, [ 67 | 1, 68 | key, 69 | value, 70 | ]) 71 | 72 | expect(setResult1).to.deep.equal("OK") 73 | 74 | let luaScript2 = "return {1,2,3.33,nil,4}" 75 | let setResult2 = await testStore.eval(luaScript2, [0]) 76 | 77 | expect(setResult2).to.deep.equal([1, 2, 3]) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /tests/errors/http/ValidationError.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { ValidationError } from "../../../src" 3 | 4 | describe("ValidationError", () => { 5 | it("should create a ValidationError", () => { 6 | let constraintName = "THING" 7 | let systemMessage = "a specific message" 8 | let userMessage = "a nice message." 9 | let error = new ValidationError( 10 | constraintName, 11 | systemMessage, 12 | userMessage 13 | ) 14 | 15 | expect(error).to.be.instanceof(ValidationError) 16 | expect(error.constraintName).to.equal(constraintName) 17 | expect(error.internalMessage).to.equal(systemMessage) 18 | expect(error.externalMessage).to.equal(userMessage) 19 | expect(error.message).to.equal(systemMessage) 20 | }) 21 | 22 | it("should default the friendly message to the system message", () => { 23 | let systemMessage = "a specific message" 24 | let error = new ValidationError("THING", systemMessage) 25 | 26 | expect(error.externalMessage).to.equal(systemMessage) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/helpers/ExpectToThrowCustomClass.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { ConstructorOf } from "../../src/utils/types" 3 | 4 | export const expectToThrowCustomClass = ( 5 | candidateFunction: Function, 6 | classContructor: ConstructorOf 7 | ) => { 8 | try { 9 | candidateFunction() 10 | expect(false).to.equal(true) 11 | } catch (e) { 12 | expect(e).to.be.instanceOf(classContructor) 13 | } 14 | } 15 | 16 | export const expectToThrowCustomClassAsync = async ( 17 | candidateFunction: Function, 18 | classContructor: ConstructorOf 19 | ) => { 20 | try { 21 | await candidateFunction() 22 | expect(false).to.equal(true) 23 | } catch (e) { 24 | expect(e).to.be.instanceOf(classContructor) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/http/drivers/FastifyServer.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { FastifyServer, RouterMap } from "../../../src/http" 3 | 4 | describe("FastifyServer", () => { 5 | describe("Route Preprocessor", () => { 6 | it("should not affect normal route assignments", () => { 7 | let simpleRoutes: RouterMap = [ 8 | { 9 | endpointController: {} as any, // Irrelevant to this test 10 | metadata: { 11 | hello: "world!", 12 | }, 13 | route: "/v1/test", 14 | method: "GET", 15 | }, 16 | ] 17 | 18 | let processedRoutes = FastifyServer.preProcessRoutes(simpleRoutes) 19 | 20 | expect(processedRoutes).to.deep.eq(simpleRoutes) 21 | }) 22 | 23 | it("should rewrite enum parametrized routes to multiple fixed routes with metadata fields", () => { 24 | let complexRoutes: RouterMap = [ 25 | { 26 | endpointController: {} as any, // Irrelevant to this test 27 | metadata: { 28 | hello: "world!", 29 | }, 30 | route: 31 | "/v1/test/{enum_parameter|test,enum,values}/more-testing", 32 | method: "GET", 33 | }, 34 | ] 35 | 36 | let processedRoutes = FastifyServer.preProcessRoutes(complexRoutes) 37 | 38 | expect(processedRoutes).to.deep.eq([ 39 | { 40 | endpointController: {} as any, 41 | metadata: { 42 | hello: "world!", 43 | enum_parameter: "test", 44 | }, 45 | route: "/v1/test/test/more-testing", 46 | method: "GET", 47 | }, 48 | { 49 | endpointController: {} as any, 50 | metadata: { 51 | hello: "world!", 52 | enum_parameter: "enum", 53 | }, 54 | route: "/v1/test/enum/more-testing", 55 | method: "GET", 56 | }, 57 | { 58 | endpointController: {} as any, 59 | metadata: { 60 | hello: "world!", 61 | enum_parameter: "values", 62 | }, 63 | route: "/v1/test/values/more-testing", 64 | method: "GET", 65 | }, 66 | ]) 67 | }) 68 | 69 | it("should handle rewriting nested enum parameters", () => { 70 | let complexRoutes: RouterMap = [ 71 | { 72 | endpointController: {} as any, // Irrelevant to this test 73 | route: 74 | "/v1/test/{enum_parameter_1|a,b}/more-testing/:normal_param/{enum_parameter_2|x,y}", 75 | method: "GET", 76 | }, 77 | ] 78 | 79 | let processedRoutes = FastifyServer.preProcessRoutes(complexRoutes) 80 | 81 | expect(processedRoutes).to.deep.eq([ 82 | { 83 | endpointController: {} as any, 84 | metadata: { 85 | enum_parameter_1: "a", 86 | enum_parameter_2: "x", 87 | }, 88 | route: "/v1/test/a/more-testing/:normal_param/x", 89 | method: "GET", 90 | }, 91 | { 92 | endpointController: {} as any, 93 | metadata: { 94 | enum_parameter_1: "a", 95 | enum_parameter_2: "y", 96 | }, 97 | route: "/v1/test/a/more-testing/:normal_param/y", 98 | method: "GET", 99 | }, 100 | { 101 | endpointController: {} as any, 102 | metadata: { 103 | enum_parameter_1: "b", 104 | enum_parameter_2: "x", 105 | }, 106 | route: "/v1/test/b/more-testing/:normal_param/x", 107 | method: "GET", 108 | }, 109 | { 110 | endpointController: {} as any, 111 | metadata: { 112 | enum_parameter_1: "b", 113 | enum_parameter_2: "y", 114 | }, 115 | route: "/v1/test/b/more-testing/:normal_param/y", 116 | method: "GET", 117 | }, 118 | ]) 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /tests/logger/drivers/AggregateLogger.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { Container } from "inversify" 3 | import { stub } from "sinon" 4 | import { AggregateLogger, LogLevel, Logger, Process } from "../../../src" 5 | 6 | class TestLogger extends Logger { 7 | public log() {} 8 | } 9 | 10 | class TestProcessLogger extends Logger implements Process { 11 | public log() {} 12 | public async startup() { 13 | return Promise.resolve() 14 | } 15 | public async shutdown() { 16 | return Promise.resolve() 17 | } 18 | public isHealthy() { 19 | return true 20 | } 21 | } 22 | 23 | describe("AggregateLogger", () => { 24 | let fakeLogger: Logger 25 | let fakeProcessLogger1: Logger & Process 26 | let fakeProcessLogger2: Logger & Process 27 | let aggregateLogger: AggregateLogger 28 | 29 | beforeEach(() => { 30 | fakeLogger = new TestLogger() 31 | fakeProcessLogger1 = new TestProcessLogger() 32 | fakeProcessLogger2 = new TestProcessLogger() 33 | 34 | aggregateLogger = new AggregateLogger([ 35 | fakeLogger, 36 | fakeProcessLogger1, 37 | fakeProcessLogger2, 38 | ]) 39 | }) 40 | 41 | describe("isHealthy", () => { 42 | it("should return true if all loggers are healthy", () => { 43 | stub(fakeProcessLogger1, "isHealthy").returns(true) 44 | stub(fakeProcessLogger2, "isHealthy").returns(true) 45 | expect(aggregateLogger.isHealthy()).to.equal(true) 46 | }) 47 | 48 | it("should return false if any loggers are un-healthy", () => { 49 | stub(fakeProcessLogger1, "isHealthy").returns(true) 50 | stub(fakeProcessLogger2, "isHealthy").returns(false) 51 | expect(aggregateLogger.isHealthy()).to.equal(false) 52 | }) 53 | }) 54 | 55 | describe("startup", () => { 56 | it("should call startup on each logger", async () => { 57 | let processLogger1Stub = stub( 58 | fakeProcessLogger1, 59 | "startup" 60 | ).returns(Promise.resolve()) 61 | let processLogger2Stub = stub( 62 | fakeProcessLogger2, 63 | "startup" 64 | ).returns(Promise.resolve()) 65 | 66 | await aggregateLogger.startup(new Container()) 67 | 68 | expect(processLogger1Stub.called).to.equal(true) 69 | expect(processLogger2Stub.called).to.equal(true) 70 | }) 71 | }) 72 | 73 | describe("shutdown", () => { 74 | it("should call shutdown on each logger", async () => { 75 | let processLogger1Stub = stub( 76 | fakeProcessLogger1, 77 | "shutdown" 78 | ).returns(Promise.resolve()) 79 | let processLogger2Stub = stub( 80 | fakeProcessLogger2, 81 | "shutdown" 82 | ).returns(Promise.resolve()) 83 | 84 | let container = new Container() 85 | await aggregateLogger.startup(container) 86 | await aggregateLogger.shutdown(container) 87 | 88 | expect(processLogger1Stub.called).to.equal(true) 89 | expect(processLogger2Stub.called).to.equal(true) 90 | }) 91 | }) 92 | 93 | describe("log", () => { 94 | it("should call log on each logger", async () => { 95 | let loggerStub = stub(fakeLogger, "log") 96 | let processLogger1Stub = stub(fakeProcessLogger1, "log") 97 | let processLogger2Stub = stub(fakeProcessLogger2, "log") 98 | 99 | let message = "hello world" 100 | let level = LogLevel.DEBUG 101 | let metadata = { foo: true } 102 | aggregateLogger.log(message, level, metadata) 103 | 104 | expect(loggerStub.calledWith(message, level, metadata)).to.equal( 105 | true 106 | ) 107 | expect( 108 | processLogger1Stub.calledWith(message, level, metadata) 109 | ).to.equal(true) 110 | expect( 111 | processLogger2Stub.calledWith(message, level, metadata) 112 | ).to.equal(true) 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /tests/logger/drivers/ConsoleLogger.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { SinonStub, stub } from "sinon" 3 | import { ConsoleLogger, LogLevel } from "../../../src" 4 | 5 | describe("ConsoleLogger", () => { 6 | let fakeLogger: Console 7 | let logStub: SinonStub 8 | let infoStub: SinonStub 9 | let warnStub: SinonStub 10 | let errorStub: SinonStub 11 | 12 | beforeEach(() => { 13 | fakeLogger = { 14 | log: () => {}, 15 | info: () => {}, 16 | warn: () => {}, 17 | error: () => {}, 18 | } as Console 19 | logStub = stub(fakeLogger, "log") 20 | infoStub = stub(fakeLogger, "info") 21 | warnStub = stub(fakeLogger, "warn") 22 | errorStub = stub(fakeLogger, "error") 23 | }) 24 | 25 | describe("Basic logging functionality", () => { 26 | let logger: ConsoleLogger 27 | 28 | beforeEach(() => { 29 | logger = new ConsoleLogger(LogLevel.DEBUG, fakeLogger) 30 | }) 31 | 32 | it("should log a debug message to console.log", () => { 33 | const testArgs = { hello: "world" } 34 | const testMessage = "test message" 35 | 36 | logger.debug(testMessage, testArgs) 37 | 38 | expect(logStub.called).to.equal(true) 39 | expect(logStub.args[0]).to.deep.equal([testMessage, testArgs]) 40 | }) 41 | 42 | it("should log a info message to console.info", () => { 43 | const testArgs = { hello: "world" } 44 | const testMessage = "test message" 45 | 46 | logger.info(testMessage, testArgs) 47 | 48 | expect(infoStub.called).to.equal(true) 49 | expect(infoStub.args[0]).to.deep.equal([testMessage, testArgs]) 50 | }) 51 | 52 | it("should log a warn message to console.warn", () => { 53 | const testArgs = { hello: "world" } 54 | const testMessage = "test message" 55 | 56 | logger.warn(testMessage, testArgs) 57 | 58 | expect(warnStub.called).to.equal(true) 59 | expect(warnStub.args[0]).to.deep.equal([testMessage, testArgs]) 60 | }) 61 | 62 | it("should log a error message to console.error", () => { 63 | const testArgs = { hello: "world" } 64 | const testMessage = "test message" 65 | 66 | logger.error(testMessage, testArgs) 67 | 68 | expect(errorStub.called).to.equal(true) 69 | expect(errorStub.args[0]).to.deep.equal([testMessage, testArgs]) 70 | }) 71 | 72 | it("should log a fatal message to console.error", () => { 73 | const testArgs = { hello: "world" } 74 | const testMessage = "test message" 75 | 76 | logger.fatal(testMessage, testArgs) 77 | 78 | expect(errorStub.called).to.equal(true) 79 | expect(errorStub.args[0]).to.deep.equal([testMessage, testArgs]) 80 | }) 81 | }) 82 | 83 | describe("Level precedence checks", () => { 84 | it("should not log a message of lower precedence", () => { 85 | const logger = new ConsoleLogger(LogLevel.FATAL, fakeLogger) 86 | 87 | logger.debug("blah", {}) 88 | expect(logStub.called).to.equal(false) 89 | 90 | logger.info("blah", {}) 91 | expect(infoStub.called).to.equal(false) 92 | 93 | logger.warn("blah", {}) 94 | expect(warnStub.called).to.equal(false) 95 | 96 | logger.error("blah", {}) 97 | expect(errorStub.called).to.equal(false) 98 | }) 99 | 100 | it("should log a message of equal precedence", () => { 101 | const logger = new ConsoleLogger(LogLevel.WARN, fakeLogger) 102 | 103 | logger.warn("blah", {}) 104 | expect(warnStub.called).to.equal(true) 105 | }) 106 | 107 | it("should log a message of higher precedence", () => { 108 | const logger = new ConsoleLogger(LogLevel.WARN, fakeLogger) 109 | 110 | logger.error("blah", {}) 111 | expect(errorStub.called).to.equal(true) 112 | 113 | logger.fatal("blah", {}) 114 | expect(errorStub.called).to.equal(true) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /tests/query/drivers/PGQueryPostProcessor.spec.ts: -------------------------------------------------------------------------------- 1 | import { pgQueryPostProcessor } from "../../../src/query/drivers/pg/PGQueryPostProcessor" 2 | import { compileSQLFilter } from "../../../src/query/drivers/sql/SQLFilterCompiler" 3 | import { expect } from "chai" 4 | 5 | interface PGTestModel { 6 | id: number 7 | uuid?: string 8 | isActivated?: boolean 9 | isDisabled: boolean 10 | createdAt: Date 11 | creatorId: number 12 | removedAt: Date | null 13 | } 14 | 15 | describe("PGQueryPostProcessor", () => { 16 | const expectQueryOutcome = ( 17 | query: string, 18 | parameters: Array, 19 | expectedQuerystring: string, 20 | expectedParameters: Array 21 | ) => { 22 | let result = pgQueryPostProcessor(query, parameters) 23 | expect(result[0]).to.equal(expectedQuerystring) 24 | expect(result[1]).to.deep.equal(expectedParameters) 25 | } 26 | 27 | describe("Simple Queries", () => { 28 | it("Should not modify simple queries", () => { 29 | expectQueryOutcome( 30 | `SELECT * FROM test`, 31 | [], 32 | "SELECT * FROM test", 33 | [] 34 | ) 35 | }) 36 | }) 37 | 38 | describe("Valid Postgres Queries", () => { 39 | it("Should not modify a valid Postgres Query", () => { 40 | expectQueryOutcome( 41 | `SELECT a, b FROM test WHERE a = $1`, 42 | [123], 43 | "SELECT a, b FROM test WHERE a = $1", 44 | [123] 45 | ) 46 | }) 47 | 48 | it("Should recalculate parameters and correctly order them", () => { 49 | expectQueryOutcome( 50 | `SELECT a, b FROM test WHERE a = $2`, 51 | [123], 52 | "SELECT a, b FROM test WHERE a = $1", 53 | [123] 54 | ) 55 | }) 56 | }) 57 | 58 | describe("Valid MySQL Queries", () => { 59 | it("Should convert MySQL parametized queries into PostgreSQL", () => { 60 | expectQueryOutcome( 61 | `SELECT ??, ?? FROM test WHERE ?? = ?`, 62 | ["a", "b", "a", 123], 63 | "SELECT a, b FROM test WHERE a = $1", 64 | [123] 65 | ) 66 | }) 67 | 68 | it("Should support complex MySQL parametized queries into PostgreSQL", () => { 69 | expectQueryOutcome( 70 | `SELECT ??, ?? FROM test WHERE ?? = ? AND ? = ?? AND ( ?? IN ? OR ?? IS NOT NULL) `, 71 | [ 72 | "a", 73 | "b", 74 | "a", 75 | 123, 76 | ["test", "my", "thing"], 77 | "b", 78 | "things", 79 | [1, 2, 3], 80 | "finalThing", 81 | ], 82 | "SELECT a, b FROM test WHERE a = $1 AND $2 = b AND ( things IN $3 OR finalThing IS NOT NULL) ", 83 | [123, ["test", "my", "thing"], [1, 2, 3]] 84 | ) 85 | }) 86 | }) 87 | 88 | describe("Complex hybrid queries", () => { 89 | it("Should handle badly written nested queries with compiled filter output", () => { 90 | let [filterQuery, filterParameters] = compileSQLFilter({ 91 | $or: [ 92 | { 93 | test: "thing", 94 | }, 95 | { 96 | $and: [ 97 | { 98 | otherThing: { 99 | $gt: 123, 100 | }, 101 | moreThings: "test", 102 | }, 103 | { 104 | finalThing: { 105 | $in: ["final", "testing"], 106 | }, 107 | }, 108 | ], 109 | }, 110 | ], 111 | rabbit: "foot", 112 | }) 113 | 114 | let finalQuery = `SELECT ??, ??, ??, ?? FROM myTable WHERE ${filterQuery} LIMIT ?` 115 | 116 | expectQueryOutcome( 117 | finalQuery, 118 | ["a", "b", "c", "d", ...filterParameters, 250], 119 | "SELECT a, b, c, d FROM myTable WHERE ((test = $1) OR (((otherThing > $2) AND (moreThings = $3)) AND (finalThing IN ($4, $5)))) AND (rabbit = $6) LIMIT $7", 120 | ["thing", 123, "test", "final", "testing", "foot", 250] 121 | ) 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /tests/query/drivers/SQLFilterCompiler.spec.ts: -------------------------------------------------------------------------------- 1 | import { compileSQLFilter } from "../../../src/query/drivers/sql/SQLFilterCompiler" 2 | import { expect } from "chai" 3 | import { Filter } from "../../../src/query" 4 | 5 | interface PGTestModel { 6 | id: number 7 | uuid?: string 8 | isActivated?: boolean 9 | isDisabled: boolean 10 | createdAt: Date 11 | creatorId: number 12 | removedAt: Date | null 13 | } 14 | 15 | describe("SQLFilterCompiler", () => { 16 | const expectQueryOutcome = ( 17 | query: Filter, 18 | expectedQuerystring: string, 19 | expectedParameters: Array 20 | ) => { 21 | let result = compileSQLFilter(query) 22 | expect(result[0]).to.equal(expectedQuerystring) 23 | expect(result[1]).to.deep.equal(expectedParameters) 24 | } 25 | 26 | describe("Simple Queries", () => { 27 | it("should create a valid equals query", () => { 28 | expectQueryOutcome( 29 | { 30 | id: 15, 31 | }, 32 | "?? = ?", 33 | ["id", 15] 34 | ) 35 | }) 36 | 37 | it("should create a valid IN query", () => { 38 | expectQueryOutcome( 39 | { 40 | id: { 41 | $in: [15, 16, 17], 42 | }, 43 | }, 44 | "?? IN (?, ?, ?)", 45 | ["id", 15, 16, 17] 46 | ) 47 | }) 48 | 49 | it("should create a always false condition for empty IN queries", () => { 50 | expectQueryOutcome( 51 | { 52 | id: { 53 | $in: [], 54 | }, 55 | }, 56 | "TRUE = FALSE", 57 | [] 58 | ) 59 | }) 60 | 61 | it("should create a valid NOT IN query", () => { 62 | expectQueryOutcome( 63 | { 64 | id: { 65 | $nin: [15, 16, 17], 66 | }, 67 | }, 68 | "?? NOT IN (?, ?, ?)", 69 | ["id", 15, 16, 17] 70 | ) 71 | }) 72 | 73 | it("should create a valid not equal query", () => { 74 | expectQueryOutcome( 75 | { 76 | uuid: { 77 | $neq: "test", 78 | }, 79 | }, 80 | "?? != ?", 81 | ["uuid", "test"] 82 | ) 83 | }) 84 | 85 | it("should create a valid greater than query", () => { 86 | expectQueryOutcome( 87 | { 88 | creatorId: { 89 | $gt: 5, 90 | }, 91 | }, 92 | "?? > ?", 93 | ["creatorId", 5] 94 | ) 95 | }) 96 | 97 | it("should create a valid greater than or equal query", () => { 98 | expectQueryOutcome( 99 | { 100 | creatorId: { 101 | $gte: 12, 102 | }, 103 | }, 104 | "?? >= ?", 105 | ["creatorId", 12] 106 | ) 107 | }) 108 | 109 | it("should create a valid less than query", () => { 110 | expectQueryOutcome( 111 | { 112 | creatorId: { 113 | $lt: 12, 114 | }, 115 | }, 116 | "?? < ?", 117 | ["creatorId", 12] 118 | ) 119 | }) 120 | 121 | it("should create a valid less than or equal query", () => { 122 | expectQueryOutcome( 123 | { 124 | creatorId: { 125 | $lte: 12, 126 | }, 127 | }, 128 | "?? <= ?", 129 | ["creatorId", 12] 130 | ) 131 | }) 132 | 133 | it("should create an always true condition for NOT IN an empty set", () => { 134 | expectQueryOutcome( 135 | { 136 | creatorId: { 137 | $nin: [], 138 | }, 139 | }, 140 | "TRUE = TRUE", 141 | [] 142 | ) 143 | }) 144 | 145 | it("should correctly handle a NULL equality", () => { 146 | expectQueryOutcome( 147 | { 148 | removedAt: null, 149 | }, 150 | "?? IS NULL", 151 | ["removedAt"] 152 | ) 153 | }) 154 | 155 | it("should correctly handle a NOT NULL equality", () => { 156 | expectQueryOutcome( 157 | { 158 | removedAt: { 159 | $neq: null, 160 | }, 161 | }, 162 | "?? IS NOT NULL", 163 | ["removedAt"] 164 | ) 165 | }) 166 | }) 167 | 168 | describe("OR Queries", () => { 169 | it("should correctly build a valid OR equality", () => { 170 | expectQueryOutcome( 171 | { 172 | $or: [ 173 | { 174 | removedAt: { 175 | $neq: null, 176 | }, 177 | }, 178 | { 179 | uuid: "test", 180 | }, 181 | ], 182 | }, 183 | "(?? IS NOT NULL) OR (?? = ?)", 184 | ["removedAt", "uuid", "test"] 185 | ) 186 | }) 187 | }) 188 | 189 | describe("AND Queries", () => { 190 | it("should correctly build a valid AND equality", () => { 191 | expectQueryOutcome( 192 | { 193 | $and: [ 194 | { 195 | removedAt: { 196 | $neq: null, 197 | }, 198 | }, 199 | { 200 | uuid: "test", 201 | }, 202 | ], 203 | }, 204 | "(?? IS NOT NULL) AND (?? = ?)", 205 | ["removedAt", "uuid", "test"] 206 | ) 207 | }) 208 | }) 209 | 210 | describe("Nested Queries", () => { 211 | it("should default to creating an AND statement for multiple root queries", () => { 212 | expectQueryOutcome( 213 | { 214 | uuid: "test", 215 | removedAt: { 216 | $neq: null, 217 | }, 218 | }, 219 | "(?? = ?) AND (?? IS NOT NULL)", 220 | ["uuid", "test", "removedAt"] 221 | ) 222 | }) 223 | 224 | it("should support the creation of deeply nested queries", () => { 225 | expectQueryOutcome( 226 | { 227 | uuid: "test", 228 | $or: [ 229 | { 230 | $or: [ 231 | { 232 | removedAt: { 233 | $neq: null, 234 | }, 235 | }, 236 | { 237 | isDisabled: true, 238 | }, 239 | ], 240 | }, 241 | { 242 | $and: [ 243 | { 244 | createdAt: { 245 | $gte: new Date(2000, 1, 1), 246 | }, 247 | }, 248 | { 249 | creatorId: { 250 | $lt: 15, 251 | }, 252 | }, 253 | ], 254 | }, 255 | ], 256 | }, 257 | "(((?? IS NOT NULL) OR (?? = ?)) OR ((?? >= ?) AND (?? < ?))) AND (?? = ?)", 258 | [ 259 | "removedAt", 260 | "isDisabled", 261 | true, 262 | "createdAt", 263 | new Date(2000, 1, 1), 264 | "creatorId", 265 | 15, 266 | "uuid", 267 | "test", 268 | ] 269 | ) 270 | }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /tests/queue/drivers/GCPSClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { GCPSClient } from "../../../src/queue" 3 | 4 | describe.skip("GCPSClient", () => { 5 | const client = new GCPSClient( 6 | process.env.GCPS_SERVICE_ACCOUNT_EMAIL!, 7 | process.env.GCPS_SERVICE_ACCOUNT_KEY_ID!, 8 | process.env.GCPS_SERVICE_ACCOUNT_PRIVATE_KEY! 9 | ) 10 | 11 | before(function() { 12 | // If the test suite is not running in CI then skip this suite - it's slow and requires credentials 13 | if ( 14 | process.env.CI !== "true" || 15 | isNaN(Number(process.env.TRAVIS_PULL_REQUEST)) 16 | ) { 17 | this.skip() 18 | } 19 | }) 20 | 21 | beforeEach(async function() { 22 | this.timeout(5000) 23 | 24 | // Dump all existing messages 25 | let messages = await client.pullTasks( 26 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 27 | 1000, 28 | true 29 | ) 30 | 31 | if (messages.length > 0) { 32 | await client.acknowledge( 33 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 34 | messages.map((m) => m.ackId) 35 | ) 36 | } 37 | }) 38 | 39 | it("Should publish a message and correctly reconstruct it", async () => { 40 | await client.publish( 41 | "projects/strontium-tests/topics/integrationTestTopic", 42 | [ 43 | { 44 | data: "MY-INTEGRATION-TEST", 45 | attributes: {}, 46 | }, 47 | ] 48 | ) 49 | 50 | let messages = await client.pullTasks( 51 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 52 | 1, 53 | true 54 | ) 55 | 56 | expect(messages[0].message.data).to.equal("MY-INTEGRATION-TEST") 57 | 58 | await client.acknowledge( 59 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 60 | [messages[0].ackId] 61 | ) 62 | }).timeout(5000) 63 | 64 | it("Acking a message should remove it from the queue", async () => { 65 | await client.publish( 66 | "projects/strontium-tests/topics/integrationTestTopic", 67 | [ 68 | { 69 | data: "MY-INTEGRATION-TEST", 70 | attributes: {}, 71 | }, 72 | ] 73 | ) 74 | 75 | let messages = await client.pullTasks( 76 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 77 | 1, 78 | true 79 | ) 80 | 81 | expect(messages[0].message.data).to.equal("MY-INTEGRATION-TEST") 82 | 83 | // Ack the message if all goes to plan - this test suite can cause a build up of messages in the subscription if it 84 | // fails but for now that is just cleaned up manually meaning this is a little flaky. 85 | await client.acknowledge( 86 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 87 | [messages[0].ackId] 88 | ) 89 | 90 | let secondMessages = await client.pullTasks( 91 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 92 | 1, 93 | true 94 | ) 95 | 96 | expect(secondMessages.length).to.equal(0) 97 | }).timeout(5000) 98 | 99 | it("Nacking a message should readd it to the queue", async () => { 100 | await client.publish( 101 | "projects/strontium-tests/topics/integrationTestTopic", 102 | [ 103 | { 104 | data: "NACKED-MESSAGE", 105 | attributes: {}, 106 | }, 107 | ] 108 | ) 109 | 110 | let messages = await client.pullTasks( 111 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 112 | 1, 113 | true 114 | ) 115 | 116 | expect(messages[0].message.data).to.equal("NACKED-MESSAGE") 117 | 118 | // Ack the message if all goes to plan - this test suite can cause a build up of messages in the subscription if it 119 | // fails but for now that is just cleaned up manually meaning this is a little flaky. 120 | await client.modifyAckDeadline( 121 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 122 | [messages[0].ackId], 123 | 0 124 | ) 125 | 126 | let secondMessages = await client.pullTasks( 127 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 128 | 1, 129 | true 130 | ) 131 | 132 | expect(secondMessages[0].message.data).to.equal("NACKED-MESSAGE") 133 | 134 | await client.acknowledge( 135 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest", 136 | [secondMessages[0].ackId] 137 | ) 138 | }).timeout(5000) 139 | 140 | it("Should fetch a GCPS Subscription", async () => { 141 | let subscription = await client.getSubscriptionData( 142 | "projects/strontium-tests/subscriptions/strontiumIntegrationTest" 143 | ) 144 | 145 | expect(subscription.pushConfig).to.deep.equal({}) 146 | expect(subscription.ackDeadlineSeconds).to.equal(10) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /tests/runtime/drivers/Runtime.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { Process, Runtime } from "../../../src/runtime" 3 | import { stub } from "sinon" 4 | 5 | class TestProcess implements Process { 6 | public async startup() { 7 | return Promise.resolve() 8 | } 9 | public async shutdown() { 10 | return Promise.resolve() 11 | } 12 | public isHealthy() { 13 | return true 14 | } 15 | } 16 | 17 | describe("Runtime", () => { 18 | let testProcess1 = new TestProcess() 19 | let testProcess2 = new TestProcess() 20 | 21 | beforeEach(() => { 22 | testProcess1 = new TestProcess() 23 | testProcess2 = new TestProcess() 24 | }) 25 | 26 | describe("startup", () => { 27 | it("Should call startup on each of the provided processes", async () => { 28 | let spy1 = stub(testProcess1, "startup") 29 | let spy2 = stub(testProcess2, "startup") 30 | 31 | let runtime = new Runtime([testProcess1, testProcess2]) 32 | 33 | await runtime.startup() 34 | 35 | expect(spy1.called).to.equal(true) 36 | expect(spy2.called).to.equal(true) 37 | }) 38 | }) 39 | 40 | describe("shutdown", () => { 41 | it("Should call shutdown on each of the provided processes", async () => { 42 | let spy1 = stub(testProcess1, "shutdown") 43 | let spy2 = stub(testProcess2, "shutdown") 44 | 45 | let runtime = new Runtime([testProcess1, testProcess2]) 46 | 47 | await runtime.shutdown() 48 | 49 | expect(spy1.called).to.equal(true) 50 | expect(spy2.called).to.equal(true) 51 | }) 52 | }) 53 | 54 | describe("isHealthy", () => { 55 | it("should return true if all loggers are healthy", () => { 56 | stub(testProcess1, "isHealthy").returns(true) 57 | stub(testProcess2, "isHealthy").returns(true) 58 | 59 | let runtime = new Runtime([testProcess1, testProcess2]) 60 | 61 | expect(runtime.isHealthy()).to.equal(true) 62 | }) 63 | 64 | it("should return false if any loggers are un-healthy", () => { 65 | stub(testProcess1, "isHealthy").returns(true) 66 | stub(testProcess2, "isHealthy").returns(false) 67 | 68 | let runtime = new Runtime([testProcess1, testProcess2]) 69 | 70 | expect(runtime.isHealthy()).to.equal(false) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/utils/list.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { compact } from "../../src/utils/list" 3 | 4 | describe("compact", () => { 5 | it("should remove null and undefined elements", () => { 6 | const input = [0, 1, false, null, "", "hello", undefined, true, {}] 7 | 8 | expect(compact(input)).to.deep.equal([ 9 | 0, 10 | 1, 11 | false, 12 | "", 13 | "hello", 14 | true, 15 | {}, 16 | ]) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/utils/typeGuard.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { notMissing } from "../../src/utils/typeGuard" 3 | 4 | describe("notMissing", () => { 5 | it("should return true for value that isn't null or undefined", () => { 6 | expect(notMissing({})).to.equal(true) 7 | expect(notMissing(1)).to.equal(true) 8 | expect(notMissing(0)).to.equal(true) 9 | expect(notMissing(false)).to.equal(true) 10 | expect(notMissing("abc")).to.equal(true) 11 | expect(notMissing([])).to.equal(true) 12 | expect(notMissing([1, null])).to.equal(true) 13 | expect(notMissing([undefined, null])).to.equal(true) 14 | }) 15 | 16 | it("should return false for value that is null", () => { 17 | expect(notMissing(null)).to.equal(false) 18 | }) 19 | 20 | it("should return false for value that is undefined", () => { 21 | expect(notMissing(undefined)).to.equal(false) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/validation/drivers/helpers/combineValidators.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { 3 | ValidationError, 4 | combineValidators, 5 | isISOAlpha2CountryCode, 6 | isNumber, 7 | isString, 8 | } from "../../../../src" 9 | 10 | describe("combineValidators", () => { 11 | describe("Two Validators", () => { 12 | it("should apply two validators to a value sequentially and throw the first error encountered", async () => { 13 | let testVariable: string | undefined 14 | 15 | // Type test 16 | let testValidator = combineValidators( 17 | isString, 18 | isISOAlpha2CountryCode 19 | ) 20 | 21 | try { 22 | // This useless variable is defined only to ensure type consistency 23 | let testOutput: string = await testValidator(testVariable) 24 | expect(false).to.equal(true) 25 | } catch (e) { 26 | expect(e instanceof ValidationError).to.equal(true) 27 | expect(e.message === "Value was undefined") 28 | } 29 | }) 30 | 31 | it("should correctly apply the validations in the sequence provided", async () => { 32 | let testVariable: string | undefined = "GB" 33 | 34 | // Type test 35 | let testValidator = combineValidators( 36 | isString, 37 | isISOAlpha2CountryCode 38 | ) 39 | 40 | let testOutput: string = await testValidator(testVariable) 41 | expect(testOutput).to.equal("GB") 42 | }) 43 | }) 44 | 45 | describe("Three Validators", () => { 46 | it("should apply three validators to a value sequentially and throw the first error encountered", async () => { 47 | let testVariable: string | undefined 48 | 49 | // Type test 50 | let testValidator = combineValidators( 51 | isString, 52 | isISOAlpha2CountryCode, 53 | (i): "test" => { 54 | throw new ValidationError( 55 | "TEST_CONSTRAINT", 56 | "This is a test" 57 | ) 58 | } 59 | ) 60 | 61 | try { 62 | // This useless variable is defined only to ensure type consistency 63 | let testOutput: "test" = await testValidator(testVariable) 64 | expect(false).to.equal(true) 65 | } catch (e) { 66 | expect(e instanceof ValidationError).to.equal(true) 67 | expect(e.message === "Value was undefined") 68 | } 69 | }) 70 | 71 | it("should correctly apply the validations in the sequence provided", async () => { 72 | let testVariable: string | undefined = "GB" 73 | 74 | // Type test 75 | let testValidator = combineValidators( 76 | isString, 77 | isISOAlpha2CountryCode, 78 | (i): "test" => "test" 79 | ) 80 | 81 | let testOutput: string = await testValidator(testVariable) 82 | expect(testOutput).to.equal("test") 83 | }) 84 | }) 85 | 86 | describe("Four Validators", () => { 87 | it("should apply four validators to a value sequentially and throw the first error encountered", async () => { 88 | let testVariable: number | undefined 89 | 90 | // Type test 91 | let testValidator = combineValidators( 92 | isString, 93 | isISOAlpha2CountryCode, 94 | (i) => i, 95 | (i): "test" => { 96 | throw new ValidationError( 97 | "TEST_CONSTRAINT", 98 | "This is a test" 99 | ) 100 | } 101 | ) 102 | 103 | try { 104 | // This useless variable is defined only to ensure type consistency 105 | let testOutput: "test" = await testValidator(testVariable) 106 | expect(false).to.equal(true) 107 | } catch (e) { 108 | expect(e instanceof ValidationError).to.equal(true) 109 | expect(e.message === "Value was undefined") 110 | } 111 | }) 112 | 113 | it("should correctly apply the validations in the sequence provided", async () => { 114 | let testVariable: string | undefined = "GB" 115 | 116 | // Type test 117 | let testValidator = combineValidators( 118 | isString, 119 | isISOAlpha2CountryCode, 120 | (i): "test" => "test", 121 | (i): "other test" => "other test" 122 | ) 123 | 124 | let testOutput: string = await testValidator(testVariable) 125 | expect(testOutput).to.equal("other test") 126 | }) 127 | }) 128 | 129 | describe("Five Validators", () => { 130 | it("should apply five validators to a value sequentially and throw the first error encountered", async () => { 131 | let testVariable: string | undefined = "GB" 132 | 133 | // Type test 134 | let testValidator = combineValidators( 135 | isString, 136 | isISOAlpha2CountryCode, 137 | (i) => i, 138 | (i): "test" => { 139 | throw new ValidationError( 140 | "TEST_CONSTRAINT", 141 | "This is a test" 142 | ) 143 | }, 144 | (i) => i 145 | ) 146 | 147 | try { 148 | // This useless variable is defined only to ensure type consistency 149 | let testOutput: "test" = await testValidator(testVariable) 150 | expect(false).to.equal(true) 151 | } catch (e) { 152 | expect(e instanceof ValidationError).to.equal(true) 153 | expect(e.message === "Value was undefined") 154 | } 155 | }) 156 | 157 | it("should correctly apply the validations in the sequence provided", async () => { 158 | let testVariable: string | undefined = "GB" 159 | 160 | // Type test 161 | let testValidator = combineValidators( 162 | isString, 163 | isISOAlpha2CountryCode, 164 | (i): "test" => "test", 165 | (i): "other test" => "other test", 166 | (i): "final test" => "final test" 167 | ) 168 | 169 | let testOutput: string = await testValidator(testVariable) 170 | expect(testOutput).to.equal("final test") 171 | }) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /tests/validation/drivers/helpers/either.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { 3 | either, 4 | isBoolean, 5 | isISOAlpha2CountryCode, 6 | isNumber, 7 | } from "../../../../src" 8 | 9 | describe("either", () => { 10 | it("should return the first validator that passes", async () => { 11 | let testVariable: number | boolean = false 12 | let testValidator = either(isNumber, isBoolean, isISOAlpha2CountryCode) 13 | 14 | let testOutput: number | boolean | string = await testValidator( 15 | testVariable 16 | ) 17 | expect(testOutput).to.equal(false) 18 | 19 | testVariable = 26 20 | testOutput = await testValidator(testVariable) 21 | expect(testOutput).to.equal(26) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/validation/drivers/sanitizers/defaultValue.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { defaultValue } from "../../../../src" 3 | 4 | describe("defaultValue", () => { 5 | it("should return the default value if input is undefined", () => { 6 | expect(defaultValue("default")(undefined)).to.equal("default") 7 | expect(defaultValue(null)(undefined)).to.equal(null) 8 | expect(defaultValue(undefined)(undefined)).to.equal(undefined) 9 | }) 10 | 11 | it("should return the input value if input is isn't undefined", () => { 12 | expect(defaultValue("notthing")(true)).to.equal(true) 13 | expect(defaultValue("notthing")(false)).to.equal(false) 14 | expect(defaultValue("notthing")(null)).to.equal(null) 15 | expect(defaultValue("notthing")("abc")).to.equal("abc") 16 | expect(defaultValue("notthing")({})).to.deep.equal({}) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tests/validation/drivers/sanitizers/normalizeEmail.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isString, normalizeEmail } from "../../../../src" 4 | 5 | describe("normalizeEmail", () => { 6 | it("should return the input email address if it is valid", () => { 7 | expect(normalizeEmail({})("jamie@iscool.com")).to.equal( 8 | "jamie@iscool.com" 9 | ) 10 | expect(normalizeEmail({})("jamie+123@iscool.com")).to.equal( 11 | "jamie+123@iscool.com" 12 | ) 13 | }) 14 | 15 | it("should return a validation error if input is not a valid email", () => { 16 | expectToThrowCustomClass( 17 | () => normalizeEmail({})("abc.com"), 18 | ValidationError 19 | ) 20 | expectToThrowCustomClass( 21 | () => normalizeEmail({})("@abc.com"), 22 | ValidationError 23 | ) 24 | expectToThrowCustomClass( 25 | () => normalizeEmail({})("jamie@abc"), 26 | ValidationError 27 | ) 28 | expectToThrowCustomClass(() => normalizeEmail({})({}), ValidationError) 29 | expectToThrowCustomClass(() => normalizeEmail({})(1), ValidationError) 30 | expectToThrowCustomClass(() => normalizeEmail({})(0), ValidationError) 31 | expectToThrowCustomClass( 32 | () => normalizeEmail({})(true), 33 | ValidationError 34 | ) 35 | expectToThrowCustomClass( 36 | () => normalizeEmail({})(false), 37 | ValidationError 38 | ) 39 | expectToThrowCustomClass( 40 | () => normalizeEmail({})(null), 41 | ValidationError 42 | ) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isArray.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expectToThrowCustomClass, 3 | expectToThrowCustomClassAsync, 4 | } from "../../../helpers/ExpectToThrowCustomClass" 5 | import { expect } from "chai" 6 | import { either } from "../../../../src/validation/drivers/helpers/either" 7 | import { isArray } from "../../../../src/validation/drivers/validators/isArray" 8 | import { 9 | ValidationError, 10 | isBoolean, 11 | isNumber, 12 | isObject, 13 | isString, 14 | isUndefined, 15 | } from "../../../../src" 16 | 17 | describe("isArray", () => { 18 | it("should return the validated array if all values are valid", async () => { 19 | let testValidator = isArray( 20 | isObject({ 21 | test: isString, 22 | otherTest: isNumber, 23 | optionalTest: either(isUndefined, isBoolean), 24 | }) 25 | ) 26 | 27 | expect( 28 | await testValidator([ 29 | { 30 | test: "test", 31 | otherTest: 15, 32 | }, 33 | { 34 | test: "more test", 35 | otherTest: 15, 36 | optionalTest: false, 37 | }, 38 | ]) 39 | ).to.deep.equal([ 40 | { 41 | test: "test", 42 | otherTest: 15, 43 | }, 44 | { 45 | test: "more test", 46 | otherTest: 15, 47 | optionalTest: false, 48 | }, 49 | ]) 50 | 51 | expect(await testValidator([])).to.deep.equal([]) 52 | }) 53 | 54 | it("should return a validation error if input is not boolean", async () => { 55 | let testValidator = isArray( 56 | isObject({ 57 | test: isString, 58 | otherTest: isNumber, 59 | optionalTest: either(isUndefined, isBoolean), 60 | }) 61 | ) 62 | 63 | await expectToThrowCustomClassAsync( 64 | async () => await testValidator({}), 65 | ValidationError 66 | ) 67 | await expectToThrowCustomClassAsync( 68 | async () => await testValidator("false"), 69 | ValidationError 70 | ) 71 | await expectToThrowCustomClassAsync( 72 | async () => await testValidator("true"), 73 | ValidationError 74 | ) 75 | await expectToThrowCustomClassAsync( 76 | async () => await testValidator(false), 77 | ValidationError 78 | ) 79 | await expectToThrowCustomClassAsync( 80 | async () => await testValidator([{}]), 81 | ValidationError 82 | ) 83 | await expectToThrowCustomClassAsync( 84 | async () => 85 | await testValidator([ 86 | { 87 | test: "test", 88 | otherTest: 15, 89 | }, 90 | { 91 | test: "test", 92 | otherTest: 15, 93 | optionalTest: "wrong", 94 | }, 95 | ]), 96 | ValidationError 97 | ) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isBoolean.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isBoolean, normalizeEmail } from "../../../../src" 4 | 5 | describe("isBoolean", () => { 6 | it("should return the input boolean if input is boolean", () => { 7 | expect(isBoolean(true)).to.equal(true) 8 | expect(isBoolean(false)).to.equal(false) 9 | }) 10 | 11 | it("should return a validation error if input is not boolean", () => { 12 | expectToThrowCustomClass(() => isBoolean({}), ValidationError) 13 | expectToThrowCustomClass(() => isBoolean(1), ValidationError) 14 | expectToThrowCustomClass(() => isBoolean(0), ValidationError) 15 | expectToThrowCustomClass(() => isBoolean("das"), ValidationError) 16 | expectToThrowCustomClass(() => isBoolean("true"), ValidationError) 17 | expectToThrowCustomClass(() => isBoolean("false"), ValidationError) 18 | expectToThrowCustomClass(() => isBoolean([]), ValidationError) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isExactly.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { isExactly } from "../../../../src/validation/drivers/validators/isExactly" 4 | import { ValidationError, isBoolean } from "../../../../src" 5 | 6 | describe("isExactly", () => { 7 | it("should return the input if the input is included in the allowed set", () => { 8 | let testValidator = isExactly(["test", "other-test", "more-test"]) 9 | 10 | expect(testValidator("other-test")).to.equal("other-test") 11 | expect(testValidator("test")).to.equal("test") 12 | expect(testValidator("more-test")).to.equal("more-test") 13 | }) 14 | 15 | it("should return a validation error if input is not in the allowed set", () => { 16 | let testValidator = isExactly(["test", "other-test", "more-test"]) 17 | 18 | expectToThrowCustomClass(() => testValidator({}), ValidationError) 19 | expectToThrowCustomClass(() => testValidator(1), ValidationError) 20 | expectToThrowCustomClass(() => testValidator(0), ValidationError) 21 | expectToThrowCustomClass(() => testValidator("das"), ValidationError) 22 | expectToThrowCustomClass(() => testValidator("true"), ValidationError) 23 | expectToThrowCustomClass(() => testValidator(false), ValidationError) 24 | expectToThrowCustomClass(() => testValidator([]), ValidationError) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isISOCountry.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { 4 | ValidationError, 5 | isBoolean, 6 | isISOAlpha2CountryCode, 7 | } from "../../../../src" 8 | 9 | describe("isISOAlpha2CountryCode", () => { 10 | it("should return the input ISO code if input is valid ISO code", () => { 11 | expect(isISOAlpha2CountryCode("GB")).to.equal("GB") 12 | expect(isISOAlpha2CountryCode("US")).to.equal("US") 13 | expect(isISOAlpha2CountryCode("CN")).to.equal("CN") 14 | }) 15 | 16 | it("should return a validation error if input is not boolean", () => { 17 | expectToThrowCustomClass( 18 | () => isISOAlpha2CountryCode("UQ"), 19 | ValidationError 20 | ) 21 | expectToThrowCustomClass( 22 | () => isISOAlpha2CountryCode("USA"), 23 | ValidationError 24 | ) 25 | expectToThrowCustomClass( 26 | () => isISOAlpha2CountryCode({}), 27 | ValidationError 28 | ) 29 | expectToThrowCustomClass( 30 | () => isISOAlpha2CountryCode(1), 31 | ValidationError 32 | ) 33 | expectToThrowCustomClass( 34 | () => isISOAlpha2CountryCode(0), 35 | ValidationError 36 | ) 37 | expectToThrowCustomClass( 38 | () => isISOAlpha2CountryCode("das"), 39 | ValidationError 40 | ) 41 | expectToThrowCustomClass( 42 | () => isISOAlpha2CountryCode("true"), 43 | ValidationError 44 | ) 45 | expectToThrowCustomClass( 46 | () => isISOAlpha2CountryCode("false"), 47 | ValidationError 48 | ) 49 | expectToThrowCustomClass( 50 | () => isISOAlpha2CountryCode([]), 51 | ValidationError 52 | ) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isISODate.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { 4 | ValidationError, 5 | isISOAlpha2CountryCode, 6 | isISODate, 7 | } from "../../../../src" 8 | 9 | describe("isISODate", () => { 10 | it("should return the a Date object if input is valid ISO code", () => { 11 | expect(isISODate("2018-10-07")!.getTime()).to.equal(1538870400000) 12 | expect(isISODate("2016-01-12")!.getTime()).to.equal(1452556800000) 13 | expect(isISODate("2018-10-07T19:21:40+00:00")!.getTime()).to.equal( 14 | 1538940100000 15 | ) 16 | }) 17 | 18 | it("should return a validation error if input is not boolean", () => { 19 | expectToThrowCustomClass(() => isISODate("12-01-2016"), ValidationError) 20 | expectToThrowCustomClass(() => isISODate("12/01/2016"), ValidationError) 21 | expectToThrowCustomClass(() => isISODate("2016/01/12"), ValidationError) 22 | expectToThrowCustomClass(() => isISODate("USA"), ValidationError) 23 | expectToThrowCustomClass(() => isISODate({}), ValidationError) 24 | expectToThrowCustomClass(() => isISODate(1), ValidationError) 25 | expectToThrowCustomClass(() => isISODate(0), ValidationError) 26 | expectToThrowCustomClass(() => isISODate("das"), ValidationError) 27 | expectToThrowCustomClass(() => isISODate("true"), ValidationError) 28 | expectToThrowCustomClass(() => isISODate("false"), ValidationError) 29 | expectToThrowCustomClass(() => isISODate([]), ValidationError) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isNull.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isNull } from "../../../../src" 4 | 5 | describe("isNull", () => { 6 | it("should return null if the input is null", () => { 7 | expect(isNull(null)).to.equal(null) 8 | }) 9 | 10 | it("should return a validation error if input is not null", () => { 11 | expectToThrowCustomClass(() => isNull({}), ValidationError) 12 | expectToThrowCustomClass(() => isNull("1"), ValidationError) 13 | expectToThrowCustomClass(() => isNull("0"), ValidationError) 14 | expectToThrowCustomClass(() => isNull("das"), ValidationError) 15 | expectToThrowCustomClass(() => isNull("true"), ValidationError) 16 | expectToThrowCustomClass(() => isNull("false"), ValidationError) 17 | expectToThrowCustomClass(() => isNull([]), ValidationError) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isNumber.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isNumber } from "../../../../src" 4 | 5 | describe("isNumber", () => { 6 | it("should return the input number if input is number", () => { 7 | expect(isNumber(0)).to.equal(0) 8 | expect(isNumber(1)).to.equal(1) 9 | expect(isNumber(-999)).to.equal(-999) 10 | expect(isNumber(123456789)).to.equal(123456789) 11 | }) 12 | 13 | it("should parse a string containing a number into a number", () => { 14 | expect(isNumber("1")).to.equal(1) 15 | expect(isNumber("0")).to.equal(0) 16 | expect(isNumber("0.001")).to.equal(0.001) 17 | expect(isNumber("-12423435.1")).to.equal(-12423435.1) 18 | expect(isNumber("+100")).to.equal(100) 19 | }) 20 | 21 | it("should return a validation error if input is not a number", () => { 22 | expectToThrowCustomClass(() => isNumber({}), ValidationError) 23 | expectToThrowCustomClass(() => isNumber("das"), ValidationError) 24 | expectToThrowCustomClass(() => isNumber("true"), ValidationError) 25 | expectToThrowCustomClass(() => isNumber("false"), ValidationError) 26 | expectToThrowCustomClass(() => isNumber([]), ValidationError) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isObject.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { combineValidators } from "../../../../src/validation/drivers/helpers/combineValidators" 3 | import { either } from "../../../../src/validation/drivers/helpers/either" 4 | import { 5 | ValidationError, 6 | isISOAlpha2CountryCode, 7 | isObject, 8 | isString, 9 | isUndefined, 10 | } from "../../../../src" 11 | 12 | describe("isNull", () => { 13 | const objectFilter = isObject({ 14 | test: either(isString, isUndefined), 15 | otherTest: combineValidators(isString, isISOAlpha2CountryCode), 16 | }) 17 | 18 | it("should return the validated object if all keys pass validation", async () => { 19 | expect( 20 | await objectFilter({ 21 | test: "TEST", 22 | otherTest: "GB", 23 | }) 24 | ).to.deep.equal({ 25 | test: "TEST", 26 | otherTest: "GB", 27 | }) 28 | 29 | expect( 30 | await objectFilter({ 31 | otherTest: "GB", 32 | }) 33 | ).to.deep.equal({ 34 | otherTest: "GB", 35 | }) 36 | }) 37 | 38 | it("should return a validation error if the input is not an object", async () => { 39 | try { 40 | await objectFilter("string") 41 | expect(false).to.equal(true) 42 | } catch (e) { 43 | expect(e).to.be.instanceOf(ValidationError) 44 | expect(e.constraintName).to.equal("IS_OBJECT") 45 | } 46 | }) 47 | 48 | it("should return a validation error if any of the keys fail their own validations", async () => { 49 | try { 50 | await objectFilter({ 51 | test: "My Test", 52 | otherTest: null, 53 | }) 54 | expect(false).to.equal(true) 55 | } catch (e) { 56 | expect(e).to.be.instanceOf(ValidationError) 57 | expect(e.constraintName).to.equal("IS_STRING") 58 | } 59 | }) 60 | 61 | it("should return a required validation error if a key is absent", async () => { 62 | try { 63 | await objectFilter({ 64 | test: "Test Value", 65 | }) 66 | expect(false).to.equal(true) 67 | } catch (e) { 68 | expect(e).to.be.instanceOf(ValidationError) 69 | expect(e.constraintName).to.equal("IS_STRING") 70 | } 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isString.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isString } from "../../../../src" 4 | 5 | describe("isString", () => { 6 | it("should return the input string if input is string", () => { 7 | expect(isString("abc")).to.equal("abc") 8 | expect(isString("")).to.equal("") 9 | expect(isString("undefined")).to.equal("undefined") 10 | }) 11 | 12 | it("should return a validation error if input is not a string", () => { 13 | expectToThrowCustomClass(() => isString({}), ValidationError) 14 | expectToThrowCustomClass(() => isString(1), ValidationError) 15 | expectToThrowCustomClass(() => isString(0), ValidationError) 16 | expectToThrowCustomClass(() => isString(true), ValidationError) 17 | expectToThrowCustomClass(() => isString(false), ValidationError) 18 | expectToThrowCustomClass(() => isString(null), ValidationError) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/validation/drivers/validators/isUndefined.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectToThrowCustomClass } from "../../../helpers/ExpectToThrowCustomClass" 2 | import { expect } from "chai" 3 | import { ValidationError, isString, isUndefined } from "../../../../src" 4 | 5 | describe("isString", () => { 6 | it("should return the input string if input is string", () => { 7 | expect(isUndefined(undefined)).to.equal(undefined) 8 | }) 9 | 10 | it("should return a validation error if input is not a string", () => { 11 | expectToThrowCustomClass(() => isUndefined({}), ValidationError) 12 | expectToThrowCustomClass(() => isUndefined(1), ValidationError) 13 | expectToThrowCustomClass(() => isUndefined(0), ValidationError) 14 | expectToThrowCustomClass(() => isUndefined(true), ValidationError) 15 | expectToThrowCustomClass(() => isUndefined(false), ValidationError) 16 | expectToThrowCustomClass(() => isUndefined(null), ValidationError) 17 | expectToThrowCustomClass(() => isUndefined("Test"), ValidationError) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "sourceMap": true, /* Generates corresponding '.map' file. */ 7 | "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 8 | "outDir": "./lib", 9 | "removeComments": true, /* Do not emit comments to output. */ 10 | "allowJs": false, 11 | "declaration": true, 12 | 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | 16 | /* Strict Type-Checking Options */ 17 | "strict": true, /* Enable all strict type-checking options. */ 18 | "strictFunctionTypes": true, /* Disable the new bidirectional covariance check in TS 2.6 */ 19 | "strictBindCallApply": false, /* Disable the strict bind from TS 3.2 as it can't handle overloads correctly. */ 20 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 21 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 22 | 23 | /* Module Resolution Options */ 24 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic ' (TypeScript pre-1.6). */ 25 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 26 | 27 | "lib": [ 28 | "es6", 29 | "es2017.object" 30 | ], 31 | 32 | "typeRoots": [ 33 | "./node_modules/@types" 34 | ], 35 | 36 | "types": [ 37 | "node", 38 | "mocha", 39 | "validator", 40 | "inversify" 41 | ] 42 | 43 | }, 44 | 45 | "include": [ 46 | "src/**/*", 47 | "tests/**/*" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unused-expression": true, 4 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 5 | "no-unbound-method": true, 6 | "no-unnecessary-callback-wrapper": true, 7 | "no-unnecessary-qualifier": true, 8 | "promise-function-async": true, 9 | "await-promise": true, 10 | "unified-signatures": true, 11 | "no-implicit-dependencies": [true, "dev"], 12 | "no-construct": true, 13 | "ordered-imports": [ 14 | true, 15 | { 16 | "import-sources-order": "lowercase-last", 17 | "grouped-imports": true, 18 | "named-imports-order": "lowercase-last", 19 | "module-source-path": "basename" 20 | } 21 | ] 22 | } 23 | } 24 | --------------------------------------------------------------------------------