├── .editorconfig ├── .gitignore ├── .travis.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── benchmark └── index.ts ├── build ├── paths.js ├── tasks │ ├── build.js │ ├── clean.js │ ├── docs.js │ ├── lint.js │ ├── test.js │ └── zip.js └── util.js ├── gulpfile.js ├── icon.png ├── knexfile.ts ├── nodemon.json ├── package.json ├── src ├── RootValue.ts ├── config.ts ├── context │ ├── Context.ts │ ├── DataloadersContext.ts │ ├── ServicesContext.ts │ └── index.ts ├── core │ ├── Database.ts │ ├── Environment.ts │ ├── GraphQLErrorHandling.ts │ ├── Logger.ts │ ├── Server.ts │ ├── Tables.ts │ ├── Utils.ts │ └── index.ts ├── database │ ├── factories │ │ ├── AuthorFactory.ts │ │ └── BookFactory.ts │ ├── migrations │ │ ├── 20170220183349_create_authors_table.ts │ │ └── 20170221195948_create_books_table.ts │ └── seeds │ │ ├── 20170220183349_authors.ts │ │ └── 20170221195948_book.ts ├── exceptions │ ├── Exception.ts │ ├── FieldException.ts │ ├── NotFoundException.ts │ ├── ValidationException.ts │ └── index.ts ├── index.ts ├── middlewares │ ├── OAuthMiddleware.ts │ └── index.ts ├── models │ ├── AbstactModel.ts │ ├── AuthorModel.ts │ ├── BookModel.ts │ └── index.ts ├── repositories │ ├── AbstractRepository.ts │ ├── AuthorRepository.ts │ ├── BookRepository.ts │ └── index.ts ├── routes │ ├── DefaultRoutes.ts │ ├── GraphQLRoutes.ts │ └── index.ts ├── schemas │ ├── arguments │ │ ├── LimitArgument.ts │ │ ├── OffsetArgument.ts │ │ ├── TextArgument.ts │ │ └── index.ts │ ├── fields │ │ ├── AbstractField.ts │ │ ├── AuthorField.ts │ │ ├── BooksField.ts │ │ ├── CreatedAtField.ts │ │ ├── DescriptionField.ts │ │ ├── FirstNameField.ts │ │ ├── IdField.ts │ │ ├── LastNameField.ts │ │ ├── PriceField.ts │ │ ├── PublishedAtField.ts │ │ ├── TitleField.ts │ │ ├── TypeField.ts │ │ ├── UpdatedAtField.ts │ │ └── index.ts │ ├── index.ts │ ├── mutations │ │ ├── AbstractMutation.ts │ │ ├── CreateAuthorMutation.ts │ │ ├── DeleteAuthorMutation.ts │ │ ├── UpdateAuthorMutation.ts │ │ └── index.ts │ ├── queries │ │ ├── AbstractQuery.ts │ │ ├── FindAllAuthorsQuery.ts │ │ ├── FindAllBooksQuery.ts │ │ ├── FindAuthorByIdQuery.ts │ │ ├── FindBookByIdQuery.ts │ │ ├── SearchQuery.ts │ │ └── index.ts │ └── types │ │ ├── AuthorType.ts │ │ ├── BookType.ts │ │ ├── DateType.ts │ │ ├── SearchType.ts │ │ └── index.ts └── services │ ├── AuthorService.ts │ ├── BookService.ts │ └── index.ts ├── test ├── mocks │ └── .gitkeep └── unit │ ├── context │ └── context.spec.ts │ ├── core │ ├── GraphQLErrorHandling.spec.ts │ ├── Utils.spec.ts │ ├── bootstrap.spec.ts │ ├── environment.spec.ts │ ├── logger.spec.ts │ └── server.spec.ts │ └── errors │ └── Exception.spec.ts ├── tsconfig.json ├── tslint.json ├── typings.json ├── typings_custom ├── arguments.d.ts ├── cls.d.ts ├── common.d.ts ├── config.d.ts └── models │ ├── author.d.ts │ └── book.d.ts └── wallaby.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # @w3tec 2 | # http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | # Use 2 spaces since npm does not respect custom indentation settings 18 | [package.json] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # @w3tec 2 | 3 | # Logs # 4 | /logs 5 | *.log 6 | *.log* 7 | 8 | # Node files # 9 | node_modules/ 10 | bower_components/ 11 | npm-debug.log 12 | 13 | # OS generated files # 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # Typing # 18 | typings/ 19 | 20 | # Dist # 21 | dist/ 22 | releases/ 23 | 24 | # Cordova # 25 | cordova/plugins/ 26 | cordova/platforms/ 27 | www/ 28 | plugins/ 29 | platforms/ 30 | 31 | # IDE # 32 | .idea/ 33 | *.swp 34 | .awcache 35 | 36 | # Generated source-code # 37 | src/**/*.js 38 | test/**/*.js 39 | 40 | # Generated documentation # 41 | docs/ 42 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.9.2" 4 | install: 5 | - npm run install:dev 6 | scripts: 7 | - npm test 8 | notifications: 9 | email: false 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch App", 11 | "stopOnEntry": true, 12 | "program": "${workspaceRoot}/src/index.ts", 13 | "cwd": "${workspaceRoot}", 14 | "sourceMaps": true, 15 | "preLaunchTask": "build", 16 | "env": { 17 | "NODE_ENV": "development" 18 | } 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Launch Seeder", 24 | "stopOnEntry": true, 25 | "program": "${workspaceRoot}/src/seed/index.ts", 26 | "cwd": "${workspaceRoot}", 27 | "sourceMaps": true, 28 | "preLaunchTask": "build", 29 | "env": { 30 | "NODE_ENV": "development" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.exclude": { 4 | "src/**/*.js": true, 5 | "test/**/*.js": true 6 | }, 7 | "vsicons.presets.angular": false 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "command": "npm", 4 | "isShellCommand": true, 5 | "suppressTaskName": true, 6 | "tasks": [ 7 | { 8 | // Build task, Cmd+Shift+B 9 | // "npm run build" 10 | "taskName": "build", 11 | "isBuildCommand": true, 12 | "args": [ 13 | "run", 14 | "build" 15 | ] 16 | }, 17 | { 18 | // Test task, Cmd+Shift+T 19 | // "npm test" 20 | "taskName": "test", 21 | "isTestCommand": true, 22 | "args": [ 23 | "test" 24 | ] 25 | }, 26 | { 27 | // "npm run lint" 28 | "taskName": "lint", 29 | "args": [ 30 | "run", 31 | "lint" 32 | ] 33 | }, 34 | { 35 | // "npm run clean" 36 | "taskName": "clean", 37 | "args": [ 38 | "run", 39 | "clean" 40 | ] 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 w3tecch 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 | # express-graphql-typescript-boilerplate 2 | 3 | [![Build Status](https://travis-ci.org/w3tecch/express-graphql-typescript-boilerplate.svg?branch=master)](https://travis-ci.org/w3tecch/express-graphql-typescript-boilerplate.svg?branch=master) 4 | 5 | A [GraphQL](http://graphql.org/) starter kit for building amazing API's in [TypeScript](https://www.typescriptlang.org/) and with [Express.js](http://expressjs.com/) framework. 6 | 7 | This seed repository has a complete GraphQL starter kit written in TypeSciprt. For building our API we use various gulp-tasks. We use jasmine and Wallaby for our unit-testing. And there are a lot more awesome features like 8 | * VSCode tasks and launch configuration 9 | * Improved GraphQL Error Handling, so that the error stack will be shown in the console 10 | * Multiple environemnt configurations 11 | * Basic security configuration 12 | * Basic cors configuration 13 | * Basic logger configuration 14 | * Advanced GraphQL-Context logic, so we can use repos, dataloader and other stuff in each resolver 15 | * Complete [Knex.js](http://knexjs.org/) integration with seeders and migration 16 | * [DataLoaders](https://github.com/facebook/dataloader) 17 | * Extended GraphQL-Query and GraphQL-Field with a lite [Hook-System](###Hook-System) 18 | * A lot of examples like: 19 | * Pagination 20 | * Search query with filter 21 | * Custom GraphQL-Types like a date type 22 | * Migtation and seeders 23 | * Models 24 | * Testing examples 25 | * and many more, just have a look 26 | 27 | ## Getting Started 28 | ### Prerequisites 29 | Install [Node.js](http://nodejs.org) 30 | * on OSX use [homebrew](http://brew.sh) `brew install node` 31 | * on Windows use [chocolatey](https://chocolatey.org/) `choco install nodejs` 32 | 33 | ## Installing 34 | * `fork` this repo 35 | * `clone` your fork 36 | * `npm install` to install all dependencies 37 | * `npm run install:typings` to install all typings 38 | * Create new database. You will find the name in the `src/config.ts` file. 39 | * `npm run db:migrate` to create the schema 40 | * `npm run db:seed` to insert some test data 41 | * `npm run serve` to start the dev server in another tab 42 | 43 | ## Running the app 44 | After you have installed all dependencies you can now run the app. 45 | Run `npm run serve` to start a local server using `nodemon` which will watch for changes and then will restart the sever. 46 | The port will be displayed to you as `http://0.0.0.0:3000` (or if you prefer IPv6, if you're using `express` server, then it's `http://[::1]:3000/`). 47 | 48 | ## Scripts / Commands 49 | ### Install 50 | * Install all dependencies with `npm install` 51 | * Install all typings with `npm run install:typings` 52 | * To install all dependencies and typings use `npm run install:dev` 53 | * Remove not needed libraries with `npm run install:clean` 54 | 55 | ### Linting 56 | * Run code analysis using `npm run lint`. This runs tshint. 57 | * There is also a vscode task for this called lint. 58 | 59 | ### Tests 60 | * Run the unit tests using `npm test` or `npm run test:pretty` for more detailed reporting. 61 | * There is also a vscode task for this called test. 62 | 63 | ### Running in dev mode 64 | * Run `npm run serve` to start nodemon with ts-node, which will serve your app. 65 | * The server address will be displayed to you as `http://0.0.0.0:3000` 66 | 67 | ### Cleaning the project 68 | * Run `npm run clean` to remove all generated JavaScript files. 69 | 70 | ### Building the project and run it 71 | * Run `npm run build` to generated all JavaScript files from your TypeScript sources. After this step you can deploy the app on any server. 72 | * There is also a vscode task for this called build. 73 | * To start the builded app use `npm start`. 74 | * With `npm run zip` it will generate the JavaScript source and pack them into to a deployable zip file into the dist folder. 75 | 76 | ### Docs 77 | * Run `npm run docs` to generate all doc files and serve it on `http://0.0.0.0:8080` 78 | 79 | ### Seed 80 | * Run `npm run db:seed` to seed some data into the database 81 | 82 | ### Migration 83 | * Run `npm run db:migrate` to migration the new schema to the database 84 | * Run `npm run db:migrate:rollback` to rollback one version 85 | 86 | ## Exploring the boilerplate 87 | ### Structure 88 | ``` 89 | express-graphql-typescript-boilerplate 90 | |-- .vscode/ * our vscode tasks, launch configuration and some settings 91 | |-- build/ * our task runner configurations and tasks 92 | | |-- tasks/ * gulp tasks 93 | | |-- paths.js * project path setup for our gulp tasks 94 | | |-- util.js * our gulp helper functions 95 | | 96 | |-- docs/ * our generated doc files 97 | | 98 | |-- src/ * our source files that will be compiled to javascript 99 | | | 100 | | |-- context/ * graphql context 101 | | | |-- Context.ts * our graphql context class 102 | | | |-- DataloadersContext.ts * our collection of all dataloaders 103 | | | |-- ServicesContext.ts * our collection of all repositories 104 | | | 105 | | |-- core/ * our core functionalities 106 | | | |-- Bootstrap.ts * our express helper functions to init and run the server 107 | | | |-- Database.ts * our database setup 108 | | | |-- Environment.ts * gets us the configuration for the given environment 109 | | | |-- GraphQLErrorHandling.ts * our error handling 110 | | | |-- Logger.ts * our logger configurations 111 | | | |-- Server.ts * our server error handling 112 | | | |-- Tables.ts * our database table names 113 | | | |-- Utils.ts * our collection of util functions 114 | | | 115 | | |-- database/ * our database tasks 116 | | | |-- factories * our factories to create simple fake data 117 | | | |-- migrations * our database migration tasks 118 | | | |-- seeds * our database seeder tasks 119 | | | 120 | | |-- exceptions/ * our errors to throw to the user 121 | | | |-- Exception.ts * our basic user error all other errors should inherit from this one 122 | | | |-- NotFoundException.ts * a basic not found error 123 | | | 124 | | |-- middlewares/ * our express custom middlewares (/*.middleware.ts) 125 | | | 126 | | |-- models/ * our database models (/*.model.ts) 127 | | | 128 | | |-- repositories/ * use a repository to fetch the date of the database 129 | | | |-- **/*Repository.ts 130 | | | 131 | | |-- services/ * use a services to separate the logic that retrieves the data and maps it to the entity model from the business logic that acts on the model 132 | | | |-- **/*Services.ts 133 | | | 134 | | |-- routes/ * defines our application routes 135 | | | |-- **/*Routes.ts 136 | | | 137 | | |-- schemas/ * our graphql schema definitions (use a single file for every graphql object action) 138 | | | |-- arguments/ * our graphql argument files 139 | | | |-- fields/ * our graphql field files 140 | | | |-- mutations/ * our graphql mutation files 141 | | | |-- queries/ * our graphql query files 142 | | | |-- types/ * our graphql type files 143 | | | 144 | | |-- index.ts * main entry point for our application 145 | | |-- RootValue.ts * RootValue with some functions for all the queries and mutations 146 | | |-- config.ts * has our configuration for our different environments 147 | | 148 | |-- test/ * our test files that will test our application 149 | | |-- mocks * we use this to simulate other functions, classes or objects 150 | | |-- unit/**/*.spec.ts * our unit test cases 151 | | 152 | |-- typings_custom/ * our local type definitions 153 | | 154 | |-- knexfile.ts * this has our database configuration from the config.ts 155 | |-- gulpfile.js * entry point for our gulp tasks 156 | |-- nodemon.json * nodemon setup, so that it uses typescript and runs tslint 157 | |-- package.json * what npm uses to manage it's dependencies 158 | |-- tslint.json * typescript lint config 159 | |-- typedoc.json * typescript documentation generator 160 | |-- tsconfig.json * typescript config 161 | |-- wallaby.js * our wallaby configuration 162 | ``` 163 | 164 | ### Hook-System 165 | ```typescript 166 | // We extend the AbstractQuery with the hook system. This 167 | // gives us the 3 new methods called before, run and after. 168 | export class FindAllBooksQuery extends AbstractQuery implements GraphQLFieldConfig { 169 | 170 | public type = new GraphQLList(BookType); 171 | public allow = ['admin']; 172 | public args = { 173 | limit: new LimitArgument(), 174 | offset: new OffsetArgument() 175 | }; 176 | 177 | // This will be called after the allow checking 178 | public before(context: Context, args: common.PageinationArguments): Promise { 179 | log.debug('hook before args', args); 180 | LimitArgument.validate(args.limit); 181 | OffsetArgument.validate(args.limit); 182 | return Promise.resolve(args); 183 | } 184 | 185 | // As long as the before function was okay this will be called afterwards 186 | public execute(root: RootValue, args: common.PageinationArguments, context: Context): Promise { 187 | log.debug('resolve findAllBooks()'); 188 | return context.Repositories.BookRepository.findAllBooks({ 189 | limit: args.limit, 190 | offset: args.offset 191 | }); 192 | } 193 | 194 | // And at least before the results go back to our client it will pass this after function 195 | public after(result: models.book.Attributes, context: Context, args: common.PageinationArguments): Promise { 196 | log.debug('hook after args', args); 197 | return Promise.resolve(result); 198 | } 199 | } 200 | ``` 201 | 202 | ## Related Projects 203 | * [GraphQL.js](http://graphql.org/) — The JavaScript reference implementation for GraphQL 204 | * [DataLoader](https://github.com/facebook/dataloader) — Batching and caching for GraphQL data access layer 205 | * [aurelia-typescript-boilerplate](https://github.com/w3tecch/aurelia-typescript-boilerplate) - An Aurelia starter kit with TypeScript 206 | * [express-typescript-boilerplate](https://github.com/w3tecch/express-typescript-boilerplate) - Boilerplate for an restful express-apllication written in TypeScript 207 | 208 | ## License 209 | [MIT](/LICENSE) 210 | 211 | --- 212 | Made with ♥ by Gery Hirschfeld ([@GeryHirschfeld1](https://twitter.com/GeryHirschfeld1)) and [contributors](https://github.com/w3tecch/express-graphql-typescript-boilerplate/graphs/contributors) 213 | -------------------------------------------------------------------------------- /benchmark/index.ts: -------------------------------------------------------------------------------- 1 | // Configure the debug module 2 | process.env.DEBUG = 'app.findAllBooksRequest'; 3 | // imports debug moduel 4 | import * as Debug from 'debug'; 5 | const debugFindAllBooksRequest = Debug('app:findAllBooksRequest'); 6 | const debugRequest = Debug('app:request'); 7 | const debugError = Debug('app:error'); 8 | 9 | import * as request from 'request'; 10 | // import * as _ from 'lodash'; 11 | 12 | let counter = 0; 13 | 14 | const findAllBooksRequest = () => { 15 | return new Promise((resolve, rejct) => { 16 | debugRequest('Start', counter++); 17 | request.post('http://localhost:3000/', { 18 | headers: { 19 | 'Content-Type': 'application/graphql' 20 | }, 21 | body: `query { 22 | findAllBooks { 23 | title 24 | author { 25 | firstName 26 | lastName 27 | } 28 | } 29 | }` 30 | }, (error, response) => { 31 | if (!error && response.statusCode === 200) { 32 | debugRequest('End', counter); 33 | } else { 34 | debugError(error); 35 | } 36 | resolve(); 37 | }); 38 | }); 39 | }; 40 | 41 | 42 | debugFindAllBooksRequest('Start'); 43 | 44 | Promise 45 | .all([ 46 | findAllBooksRequest() 47 | ]) 48 | .then((results) => { 49 | debugFindAllBooksRequest('End'); 50 | }) 51 | .catch((e) => { 52 | debugError(e); 53 | }); 54 | -------------------------------------------------------------------------------- /build/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * PATHS 5 | * Defines the app-structure of this project 6 | */ 7 | module.exports = { 8 | main: 'index.ts', 9 | src: 'src', 10 | test: 'test', 11 | docs: 'docs', 12 | dist: 'dist' 13 | }; 14 | -------------------------------------------------------------------------------- /build/tasks/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const runSequence = require('run-sequence'); 6 | const paths = require('../paths'); 7 | const util = require('../util'); 8 | const $ = require('gulp-load-plugins')({ 9 | lazy: true 10 | }); 11 | 12 | let tsProjectSource, tsProjectTest; 13 | 14 | gulp.task('build', (callback) => runSequence( 15 | 'lint', 16 | 'clean', 17 | [ 18 | 'build:transpile:src', 19 | 'build:transpile:test' 20 | ], 21 | callback 22 | )); 23 | 24 | gulp.task('build:transpile:src', () => transpiler(paths.src)); 25 | gulp.task('build:transpile:test', () => transpiler(paths.test, true)); 26 | 27 | function transpiler(filePath, isTest, files) { 28 | 29 | if (files === undefined) { 30 | files = '/**/*.ts'; 31 | } 32 | 33 | if (!tsProjectSource && !isTest) { 34 | tsProjectSource = $.typescript.createProject('tsconfig.json', { 35 | 'typescript': require('typescript') 36 | }); 37 | } 38 | 39 | if (!tsProjectTest && isTest) { 40 | tsProjectTest = $.typescript.createProject('tsconfig.json', { 41 | 'typescript': require('typescript') 42 | }); 43 | } 44 | 45 | let tsProject = !!isTest ? tsProjectTest : tsProjectSource; 46 | return gulp 47 | .src([ 48 | './typings/index.d.ts', 49 | './typings_custom/**/*.d.ts', 50 | path.join(filePath, files) 51 | ]) 52 | .pipe($.plumber({ errorHandler: $.notify.onError('Error: <%= error.message %>') })) 53 | .pipe($.sourcemaps.init({ loadMaps: true })) 54 | .pipe(tsProject()) 55 | .pipe($.sourcemaps.write()) // inline sourcemaps 56 | .pipe(gulp.dest(filePath)); 57 | } 58 | -------------------------------------------------------------------------------- /build/tasks/clean.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const paths = require('../paths'); 6 | const util = require('../util'); 7 | const $ = require('gulp-load-plugins')({ 8 | lazy: true 9 | }); 10 | 11 | gulp.task('clean', [ 12 | 'clean:docs', 13 | 'clean:build' 14 | ]); 15 | 16 | gulp.task('clean:build', [ 17 | 'clean:src:src', 18 | 'clean:src:map', 19 | 'clean:test:src', 20 | 'clean:test:map' 21 | ]); 22 | 23 | gulp.task('clean:docs', () => cleaner(paths.docs)); 24 | gulp.task('clean:src:src', () => cleaner(paths.src, 'js')); 25 | gulp.task('clean:src:map', () => cleaner(paths.src, 'map')); 26 | gulp.task('clean:test:src', () => cleaner(paths.test, 'js')); 27 | gulp.task('clean:test:map', () => cleaner(paths.test, 'map')); 28 | 29 | function cleaner(filePath, ext) { 30 | let source = ''; 31 | if (ext) { 32 | source = path.join(filePath, '/**/*.' + ext); 33 | } else { 34 | source = path.join(filePath); 35 | } 36 | return gulp 37 | .src(source, { 38 | read: false 39 | }) 40 | .pipe($.clean()); 41 | } 42 | -------------------------------------------------------------------------------- /build/tasks/docs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const paths = require('../paths'); 6 | const util = require('../util'); 7 | const $ = require('gulp-load-plugins')({ 8 | lazy: true 9 | }); 10 | const pkg = require('../../package.json'); 11 | const tsc = require('../../tsconfig.json'); 12 | 13 | gulp.task('docs', ['clean:docs'], function () { 14 | return gulp 15 | .src([ 16 | './typings/index.d.ts', 17 | './typings_custom/**/*.d.ts', 18 | path.join(paths.src, '/**/*.ts') 19 | ]) 20 | .pipe($.typedoc({ 21 | module: tsc.compilerOptions.module, 22 | target: tsc.compilerOptions.target, 23 | moduleResolution: tsc.compilerOptions.moduleResolution, 24 | out: paths.docs, 25 | name: pkg.name 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /build/tasks/lint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const paths = require('../paths'); 6 | const util = require('../util'); 7 | const $ = require('gulp-load-plugins')({ 8 | lazy: true 9 | }); 10 | 11 | function lint(files) { 12 | return gulp 13 | .src(files) 14 | .pipe($.tslint({ 15 | emitError: false, 16 | formatter: 'verbose' 17 | })) 18 | .pipe($.tslint.report()) 19 | .on('error', function () { 20 | util.notify('TSLINT failed!'); 21 | }); 22 | } 23 | 24 | gulp.task('lint', () => lint([ 25 | './typings/index.d.ts', 26 | './typings_custom/**/*.d.ts', 27 | path.join(paths.src, '/**/*.ts'), 28 | path.join(paths.test, '/**/*.ts') 29 | ])); 30 | -------------------------------------------------------------------------------- /build/tasks/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const runSequence = require('run-sequence'); 6 | const paths = require('../paths'); 7 | const util = require('../util'); 8 | const SpecReporter = require('jasmine-spec-reporter').SpecReporter; 9 | const $ = require('gulp-load-plugins')({ 10 | lazy: true 11 | }); 12 | 13 | gulp.task('test', (callback) => { 14 | runSequence( 15 | 'build', 16 | 'test:run', 17 | 'clean', 18 | callback 19 | ) 20 | }); 21 | 22 | gulp.task('test:pretty', (callback) => { 23 | runSequence( 24 | 'build', 25 | 'test:pretty:run', 26 | 'clean', 27 | callback 28 | ) 29 | }); 30 | 31 | gulp.task('test:run', () => 32 | gulp.src([path.join(path.join(paths.test, '**/*.spec.js'))], { read: true }) 33 | .pipe($.jasmine()) 34 | ); 35 | 36 | gulp.task('test:pretty:run', () => 37 | gulp.src([path.join(path.join(paths.test, '**/*.spec.js'))], { read: true }) 38 | .pipe($.jasmine({ 39 | reporter: new SpecReporter() 40 | })) 41 | ); 42 | -------------------------------------------------------------------------------- /build/tasks/zip.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const path = require('path'); 5 | const paths = require('../paths'); 6 | const runSequence = require('run-sequence'); 7 | const pkg = require('../../package.json'); 8 | const $ = require('gulp-load-plugins')({ 9 | lazy: true 10 | }); 11 | 12 | gulp.task('zip', (callback) => runSequence( 13 | 'build', 14 | 'zip:create', 15 | 'clean', 16 | callback 17 | )); 18 | 19 | gulp.task('zip:create', () => { 20 | const packageFileFilter = $.filter(['package.json'], { restore: true }); 21 | return gulp 22 | .src([ 23 | path.join(paths.src, '**/*.js'), 24 | 'manifest.yml', 25 | 'package.json' 26 | ]) 27 | .pipe(packageFileFilter) 28 | .pipe($.jsonEditor((json) => { 29 | json.scripts.start = 'node index.js'; 30 | return json; 31 | })) 32 | .pipe(packageFileFilter.restore) 33 | .pipe($.zip(pkg.name + '-' + pkg.version + '.zip')) 34 | .pipe(gulp.dest(paths.dist)) 35 | }); 36 | -------------------------------------------------------------------------------- /build/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'), 4 | path = require('path'), 5 | paths = require('./paths'), 6 | $ = require('gulp-load-plugins')({ 7 | lazy: true 8 | }); 9 | 10 | var util = { 11 | notify: notify, 12 | getPkg: getPkg, 13 | getJs: getJs, 14 | buildConfig: buildConfig 15 | }; 16 | 17 | module.exports = util; 18 | 19 | function getPkg() { 20 | return require(path.join(process.cwd(), 'package.json')); 21 | } 22 | 23 | function notify(title, message) { 24 | var notifier = require('node-notifier'); 25 | notifier.notify({ 26 | 'title': title || 'Gulp', 27 | 'message': message || 'Please check your log or open your browser.', 28 | icon: process.cwd() + '/icon.png' 29 | }); 30 | } 31 | 32 | function getJs(filename) { 33 | return filename.replace('.ts', '.js'); 34 | } 35 | 36 | function buildConfig(target) { 37 | var env = $.util.env.environment || $.util.env.env || 'dev'; 38 | var configBase = './config'; 39 | var options = getPkg(); 40 | options.env = env; 41 | return gulp 42 | .src(path.join(configBase, env + '.json')) 43 | .pipe($.rename('config.json')) 44 | .pipe($.template(options)) 45 | .pipe(gulp.dest(target)); 46 | } 47 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var requireDir = require('require-dir'); 3 | var tasks = requireDir('./build/tasks'); 4 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3tecch/express-graphql-typescript-boilerplate/28b2e84ee0063d6cc464a45bd05be2bc914b3a34/icon.png -------------------------------------------------------------------------------- /knexfile.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | const config = require('./src/config'); 4 | 5 | _.forOwn(config, (value, key) => config[key] = value.database); 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "delay": "0", 3 | "execMap": { 4 | "ts": "./node_modules/.bin/ts-node" 5 | }, 6 | "events": { 7 | "start": "./node_modules/.bin/tslint -c ./tslint.json 'src/**/*.ts' --format stylish --force", 8 | "restart": "osascript -e 'display notification \"restarting server\" with title \"node.js application\"'" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-graphql-typescript-boilerplate", 3 | "version": "1.0.0-rc.3", 4 | "description": "A Node.Js boilerplate written in TypeScript with express and GraphQL", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "install:typings": "./node_modules/.bin/typings install", 8 | "install:dev": "npm install && npm run install:typings", 9 | "install:clean": "npm prune && ./node_modules/.bin/typings prune", 10 | "serve": "./node_modules/.bin/nodemon --watch 'src/**/*.ts'", 11 | "serve:debug": "./node_modules/.bin/nodemon --watch 'src/**/*.ts' --debug=6666", 12 | "lint": "./node_modules/.bin/gulp lint", 13 | "test": "NODE_ENV=test ./node_modules/.bin/gulp test", 14 | "test:pretty": "NODE_ENV=test ./node_modules/.bin/gulp test:pretty", 15 | "build": "./node_modules/.bin/gulp build", 16 | "clean": "./node_modules/.bin/gulp clean", 17 | "zip": "./node_modules/.bin/gulp zip", 18 | "docs": "npm run docs:generate && npm run docs:server", 19 | "docs:generate": "./node_modules/.bin/gulp docs", 20 | "docs:server": "./node_modules/.bin/http-server ./docs --cors", 21 | "db:migrate": "./node_modules/.bin/knex migrate:latest", 22 | "db:migrate:rollback": "./node_modules/.bin/knex migrate:rollback", 23 | "db:seed": "./node_modules/.bin/knex seed:run", 24 | "start": "node ./src/index.js" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://w3tecch@github.com/w3tecch/express-graphql-typescript-boilerplate.git" 29 | }, 30 | "engines": { 31 | "node": "^6.9.2" 32 | }, 33 | "keywords": [ 34 | "NodeJS", 35 | "TypeScript", 36 | "express", 37 | "GraphQL", 38 | "boilerplate", 39 | "skeleton", 40 | "starter-kit", 41 | "w3tec.ch" 42 | ], 43 | "author": "w3tec.ch ", 44 | "contributors": [ 45 | { 46 | "name": "David Weber", 47 | "email": "david.weber@w3tec.ch", 48 | "url": "https://github.com/dweber019" 49 | }, 50 | { 51 | "name": "Gery Hirschfeld", 52 | "email": "gery.hirschfeld@w3tec.ch", 53 | "url": "https://github.com/hirsch88" 54 | } 55 | ], 56 | "license": "MIT", 57 | "bugs": { 58 | "url": "https://github.com/w3tecch/express-graphql-typescript-boilerplate/issues" 59 | }, 60 | "homepage": "https://github.com/w3tecch/express-graphql-typescript-boilerplate#readme", 61 | "dependencies": { 62 | "@types/bluebird": "^3.5.18", 63 | "@types/body-parser": "^1.16.7", 64 | "@types/chalk": "^2.2.0", 65 | "@types/commander": "^2.11.0", 66 | "@types/cors": "^2.8.1", 67 | "@types/dotenv": "^4.0.2", 68 | "@types/express": "^4.0.39", 69 | "@types/faker": "^4.1.2", 70 | "@types/helmet": "^0.0.37", 71 | "@types/lodash": "^4.14.80", 72 | "@types/morgan": "^1.7.35", 73 | "@types/reflect-metadata": "0.1.0", 74 | "@types/request": "^2.0.8", 75 | "@types/serve-favicon": "^2.2.29", 76 | "@types/supertest": "^2.0.4", 77 | "@types/uuid": "^3.4.3", 78 | "@types/winston": "^2.3.7", 79 | "body-parser": "^1.15.2", 80 | "cors": "^2.8.1", 81 | "dataloader": "^1.3.0", 82 | "debug": "^3.1.0", 83 | "express": "^4.14.0", 84 | "express-graphql": "^0.6.1", 85 | "faker": "^4.1.0", 86 | "graphql": "^0.13.2", 87 | "helmet": "^3.4.0", 88 | "knex": "^0.14.4", 89 | "lodash": "^4.17.4", 90 | "morgan": "^1.7.0", 91 | "mysql": "^2.13.0", 92 | "ts-node": "^5.0.1", 93 | "uuid": "^3.0.1", 94 | "winston": "^2.3.0" 95 | }, 96 | "devDependencies": { 97 | "gulp": "^3.9.1", 98 | "gulp-clean": "^0.4.0", 99 | "gulp-concat": "^2.6.1", 100 | "gulp-copy": "^1.0.0", 101 | "gulp-filter": "^5.1.0", 102 | "gulp-jasmine": "^3.0.0", 103 | "gulp-json-editor": "^2.2.1", 104 | "gulp-load-plugins": "^1.4.0", 105 | "gulp-notify": "^3.2.0", 106 | "gulp-plumber": "^1.1.0", 107 | "gulp-rename": "^1.2.2", 108 | "gulp-sourcemaps": "^2.6.4", 109 | "gulp-tslint": "^8.1.3", 110 | "gulp-typedoc": "^2.0.2", 111 | "gulp-typescript": "^4.0.1", 112 | "gulp-zip": "^4.1.0", 113 | "http-server": "^0.11.1", 114 | "jasmine": "^3.1.0", 115 | "jasmine-spec-reporter": "^4.2.1", 116 | "nodemon": "^1.11.0", 117 | "path": "^0.12.7", 118 | "request": "^2.79.0", 119 | "require-dir": "^1.0.0", 120 | "run-sequence": "^2.2.1", 121 | "sqlite3": "^4.0.0", 122 | "tslint": "^5.9.1", 123 | "typedoc": "^0.11.1", 124 | "typescript": "^2.2.1", 125 | "typings": "^2.1.0" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/RootValue.ts: -------------------------------------------------------------------------------- 1 | export class RootValue { 2 | 3 | public hello(): string { 4 | return 'Hello world!'; 5 | } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export = { 2 | /** 3 | * Development Environment 4 | * ------------------------------------------ 5 | * 6 | * This is the local development environment, which is used by the developoers 7 | */ 8 | development: { 9 | database: { 10 | connection: 'mysql://root@localhost:3306/my-database-dev', 11 | client: 'mysql', 12 | migrations: { 13 | directory: './src/database/migrations', 14 | tableName: 'version' 15 | }, 16 | seeds: { 17 | directory: './src/database/seeds' 18 | } 19 | }, 20 | server: { 21 | host: 'localhost', 22 | port: process.env.PORT || '3000', 23 | graphiql: true 24 | }, 25 | logger: { 26 | debug: 'app*', 27 | console: { 28 | level: 'error' 29 | } 30 | } 31 | }, 32 | /** 33 | * Test Environment 34 | * ------------------------------------------ 35 | * 36 | * This environment is used by the unit, migration and database test. 37 | */ 38 | test: { 39 | database: { 40 | connection: 'mysql://root:root@localhost:3306/my-database-test', 41 | client: 'mysql', 42 | migrations: { 43 | directory: './src/database/migrations', 44 | tableName: 'version' 45 | }, 46 | seeds: { 47 | directory: './src/database/seeds' 48 | } 49 | }, 50 | server: { 51 | host: 'localhost', 52 | port: process.env.PORT || '3000', 53 | graphiql: false 54 | }, 55 | logger: { 56 | debug: '', 57 | console: { 58 | level: 'none' 59 | } 60 | } 61 | }, 62 | /** 63 | * Production Environment 64 | * ------------------------------------------ 65 | * 66 | * This configuration will be used by the cloud servers. You are abel to override 67 | * them with the local cloud environment variable to make it even more configurable. 68 | */ 69 | production: { 70 | database: { 71 | connection: 'mysql://root:root@localhost:3306/my-database-prod', 72 | client: 'mysql', 73 | migrations: { 74 | directory: './src/database/migrations', 75 | tableName: 'version' 76 | }, 77 | seeds: { 78 | directory: './src/database/seeds' 79 | } 80 | }, 81 | server: { 82 | host: 'localhost', 83 | port: process.env.PORT || '3000', 84 | graphiql: false 85 | }, 86 | logger: { 87 | debug: '', 88 | console: { 89 | level: 'debug' 90 | } 91 | } 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /src/context/Context.ts: -------------------------------------------------------------------------------- 1 | import * as Express from 'express'; 2 | 3 | import { DataLoadersContext } from './DataloadersContext'; 4 | import { ServicesContext } from './ServicesContext'; 5 | 6 | 7 | export class Context { 8 | 9 | /** 10 | * We use this property to store the resolve arguments 11 | * from the root query or mutation, so that we can access 12 | * them later in a type resolver 13 | */ 14 | private args: A; 15 | 16 | constructor( 17 | private request: Express.Request, 18 | private repsonse: Express.Response, 19 | private dataLoaders: DataLoadersContext, 20 | private services: ServicesContext 21 | ) { } 22 | 23 | public get Args(): A { 24 | return this.args; 25 | } 26 | 27 | public get Response(): Express.Response { 28 | return this.repsonse; 29 | } 30 | 31 | public get Request(): Express.Request { 32 | return this.request; 33 | } 34 | 35 | public get DataLoaders(): DataLoadersContext { 36 | return this.dataLoaders; 37 | } 38 | 39 | public get Services(): ServicesContext { 40 | return this.services; 41 | } 42 | 43 | public getLanguage(): string[] { 44 | return this.request.acceptsLanguages(); 45 | } 46 | 47 | public hasUserRoles(roles: string[]): boolean { 48 | // TODO: Here you should check if the user as the needed roles for the requested query 49 | return true; 50 | } 51 | 52 | public setResolveArgument(args: A): void { 53 | this.args = args; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/context/DataloadersContext.ts: -------------------------------------------------------------------------------- 1 | import * as DataLoader from 'dataloader'; 2 | 3 | import { AuthorService, BookService } from '../services'; 4 | 5 | import { Logger } from '../core/Logger'; 6 | const log = Logger('app:context:DataLoadersContext'); 7 | 8 | 9 | export class DataLoadersContext { 10 | 11 | static instance: DataLoadersContext; 12 | 13 | private authorDataLaoder: DataLoader; 14 | private bookDataLaoder: DataLoader; 15 | 16 | static getInstance(): DataLoadersContext { 17 | if (!DataLoadersContext.instance) { 18 | DataLoadersContext.instance = new DataLoadersContext(); 19 | } 20 | return DataLoadersContext.instance; 21 | } 22 | 23 | public get AuthorDataLoader(): DataLoader { 24 | return this.authorDataLaoder; 25 | } 26 | 27 | public get BookDataLoader(): DataLoader { 28 | return this.bookDataLaoder; 29 | } 30 | 31 | public setAuthorDataLoader(authorService: AuthorService): DataLoadersContext { 32 | this.authorDataLaoder = new DataLoader(async (ids: number[]) => { 33 | const authors = await authorService.findByIds(ids); 34 | return authors.map(a => a.toJson()); 35 | }); 36 | log.debug('setAuthorDataLoader'); 37 | return this; 38 | } 39 | 40 | public setBookDataLoader(bookService: BookService): DataLoadersContext { 41 | this.bookDataLaoder = new DataLoader(async (ids: number[]) => { 42 | const books = await bookService.findByIds(ids); 43 | return books.map(b => b.toJson()); 44 | }); 45 | log.debug('setBookDataLoader'); 46 | return this; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/context/ServicesContext.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthorService, 3 | BookService 4 | } from '../services'; 5 | 6 | import { Logger } from '../core/Logger'; 7 | const log = Logger('app:context:ServicesContext'); 8 | 9 | 10 | export class ServicesContext { 11 | 12 | static instance: ServicesContext; 13 | 14 | private authorService: AuthorService; 15 | private bookService: BookService; 16 | 17 | static getInstance(): ServicesContext { 18 | if (!ServicesContext.instance) { 19 | ServicesContext.instance = new ServicesContext(); 20 | } 21 | return ServicesContext.instance; 22 | } 23 | 24 | public get AuthorService(): AuthorService { 25 | return this.authorService; 26 | } 27 | 28 | public get BookService(): BookService { 29 | return this.bookService; 30 | } 31 | 32 | public setAuthorService(authorService: AuthorService): ServicesContext { 33 | this.authorService = authorService; 34 | log.debug('setAuthorService'); 35 | return this; 36 | } 37 | 38 | public setBookService(bookService: BookService): ServicesContext { 39 | this.bookService = bookService; 40 | log.debug('setBookService'); 41 | return this; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Context'; 2 | export * from './DataloadersContext'; 3 | export * from './ServicesContext'; 4 | -------------------------------------------------------------------------------- /src/core/Database.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | import { Environment, Logger } from './'; 4 | 5 | 6 | const log = Logger('app:database'); 7 | log.debug('Connecting to database %s', Environment.getConfig().database.connection.split('@')[1]); 8 | 9 | export const DB: Knex = Knex({ 10 | client: Environment.getConfig().database.client, 11 | connection: Environment.getConfig().database.connection, 12 | pool: { min: 0, max: 7 }, 13 | migrations: { 14 | tableName: 'migrations' 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/core/Environment.ts: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | 3 | 4 | export class Environment { 5 | 6 | static getName(): string { 7 | return process.env.NODE_ENV || 'development'; 8 | } 9 | 10 | static isTest(): boolean { 11 | return this.getName() === 'test'; 12 | } 13 | 14 | static isDevelopment(): boolean { 15 | return this.getName() === 'development'; 16 | } 17 | 18 | static isProduction(): boolean { 19 | return this.getName() === 'production'; 20 | } 21 | 22 | static getConfig(): config.Configuration { 23 | return config[this.getName()]; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/core/GraphQLErrorHandling.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid'; 2 | import { 3 | GraphQLType, 4 | GraphQLSchema, 5 | TypeMap, 6 | GraphQLObjectType, 7 | GraphQLFieldDefinitionMap, 8 | GraphQLFieldDefinition, 9 | GraphQLFieldResolveFn 10 | } from 'graphql'; 11 | 12 | import { Environment } from './'; 13 | import { IsException } from '../exceptions'; 14 | 15 | 16 | // Mark field/type/schema 17 | export const Processed = Symbol(); 18 | 19 | export class GraphQLErrorHandling { 20 | 21 | public static watch(schema: GraphQLSchema): void { 22 | this.maskSchema(schema); 23 | } 24 | 25 | private static maskSchema(schema: GraphQLSchema): void { 26 | const types: TypeMap = schema.getTypeMap(); 27 | for (const typeName in types) { 28 | if (!Object.hasOwnProperty.call(types, typeName)) { 29 | continue; 30 | } 31 | this.maskType(types[typeName]); 32 | } 33 | } 34 | 35 | private static maskType(type: GraphQLType): void { 36 | const objectType: GraphQLObjectType = type; 37 | if (objectType[Processed] || !objectType.getFields) { 38 | return; 39 | } 40 | 41 | const fields: GraphQLFieldDefinitionMap = objectType.getFields(); 42 | for (const fieldName in fields) { 43 | if (!Object.hasOwnProperty.call(fields, fieldName)) { 44 | continue; 45 | } 46 | this.maskField(fields[fieldName]); 47 | } 48 | } 49 | 50 | private static maskField(field: GraphQLFieldDefinition): void { 51 | const resolveFn: GraphQLFieldResolveFn = field.resolve; 52 | if (field[Processed] || !resolveFn) { 53 | return; 54 | } 55 | 56 | field[Processed] = true; 57 | field.resolve = async (...args) => { 58 | try { 59 | const out = resolveFn.call(this, ...args); 60 | return await Promise.resolve(out); 61 | } catch (error) { 62 | throw this.handler(error); 63 | } 64 | }; 65 | } 66 | 67 | private static handler(error: any): Error { 68 | if (error[IsException]) { 69 | return new Error(error.toString()); 70 | } 71 | const errId = uuid.v4(); 72 | error.message = `${error.message}: ${errId}`; 73 | if (!Environment.isTest()) { 74 | console.error(error && error.stack || error); 75 | } 76 | error.message = `InternalError:${errId}`; 77 | return error; 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/core/Logger.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | 3 | import { Environment } from './'; 4 | 5 | 6 | /** 7 | * Configures the winston logger. There are also file and remote transports available 8 | */ 9 | const logger = new winston.Logger({ 10 | transports: [ 11 | new winston.transports.Console({ 12 | level: Environment.getConfig().logger.console.level, 13 | timestamp: Environment.isProduction(), 14 | handleExceptions: Environment.isProduction(), 15 | json: Environment.isProduction(), 16 | colorize: !Environment.isProduction() 17 | }) 18 | ], 19 | exitOnError: false 20 | }); 21 | 22 | const stream = (streamFunction) => ({ 23 | 'stream': streamFunction 24 | }); 25 | 26 | const write = (writeFunction) => ({ 27 | write: (message: string) => writeFunction(message) 28 | }); 29 | 30 | /** 31 | * Winston logger stream for the morgan plugin 32 | */ 33 | export const winstonStream = stream(write(logger.info)); 34 | 35 | // Configure the debug module 36 | process.env.DEBUG = Environment.getConfig().logger.debug; 37 | // imports debug moduel 38 | import * as Debug from 'debug'; 39 | const debug = Debug('app:response'); 40 | 41 | /** 42 | * Debug stream for the morgan plugin 43 | */ 44 | export const debugStream = stream(write(debug)); 45 | 46 | /** 47 | * Exports a wrapper for all the loggers we use in this configuration 48 | */ 49 | const format = (scope: string, message: string): string => `[${scope}] ${message}`; 50 | 51 | const parse = (args: any[]) => (args.length > 0) ? args : ''; 52 | 53 | export const Logger = (scope: string) => { 54 | const scopeDebug = Debug(scope); 55 | return { 56 | debug: (message: string, ...args: any[]) => { 57 | if (Environment.isProduction()) { 58 | logger.debug(format(scope, message), parse(args)); 59 | } 60 | scopeDebug(message, parse(args)); 61 | }, 62 | verbose: (message: string, ...args: any[]) => logger.verbose(format(scope, message), parse(args)), 63 | silly: (message: string, ...args: any[]) => logger.silly(format(scope, message), parse(args)), 64 | info: (message: string, ...args: any[]) => logger.info(format(scope, message), parse(args)), 65 | warn: (message: string, ...args: any[]) => logger.warn(format(scope, message), parse(args)), 66 | error: (message: string, ...args: any[]) => logger.error(format(scope, message), parse(args)) 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/core/Server.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as http from 'http'; 3 | 4 | import { Environment, Logger } from './'; 5 | 6 | const log = Logger('app:core:server'); 7 | 8 | 9 | export class Server { 10 | 11 | static init(): express.Application { 12 | return express(); 13 | } 14 | 15 | static run(app: express.Application, port: string): http.Server { 16 | const server = app.listen(this.normalizePort(port)); 17 | server.on('listening', () => this.onListening(server)); 18 | server.on('error', (error) => this.onError(server, error)); 19 | log.debug('Server was started on environment %s', Environment.getName()); 20 | return server; 21 | } 22 | 23 | static normalizePort(port: string): number | string | boolean { 24 | const parsedPort = parseInt(port, 10); 25 | if (isNaN(parsedPort)) { // named pipe 26 | return port; 27 | } 28 | if (parsedPort >= 0) { // port number 29 | return parsedPort; 30 | } 31 | return false; 32 | } 33 | 34 | static onListening(server: http.Server): void { 35 | log.debug(`Listening on ${this.bind(server.address())}`); 36 | } 37 | 38 | static onError(server: http.Server, error: Error): void { 39 | if (error['syscall'] !== 'listen') { 40 | throw error; 41 | } 42 | const addr = server.address(); 43 | // handle specific listen errors with friendly messages 44 | switch (error['code']) { 45 | case 'EACCES': 46 | log.error(`${this.bind(addr)} requires elevated privileges`); 47 | process.exit(1); 48 | break; 49 | case 'EADDRINUSE': 50 | log.error(`${this.bind(addr)} is already in use`); 51 | process.exit(1); 52 | break; 53 | default: 54 | throw error; 55 | } 56 | } 57 | 58 | private static bind(addr: string | any): string { 59 | return typeof addr === 'string' 60 | ? `pipe ${addr}` 61 | : `port ${addr.port}`; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/core/Tables.ts: -------------------------------------------------------------------------------- 1 | export class Tables { 2 | static Authors = 'authors'; 3 | static Books = 'books'; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/Utils.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '../exceptions'; 2 | 3 | 4 | export class Utils { 5 | 6 | static isEmpty(list: T[]): boolean { 7 | return !Utils.hasResults(list); 8 | }; 9 | 10 | static hasResults(list: T[]): boolean { 11 | return (typeof list === 'object' && !!list && list.length) ? list.length > 0 : false; 12 | }; 13 | 14 | static assertResult(result: T, idOrKey: number | string): void { 15 | if (result === null) { 16 | throw new NotFoundException(`${idOrKey}`); 17 | } 18 | } 19 | 20 | static assertResults(list: T[], idOrKey: number | string | number[]): void { 21 | if (!Utils.hasResults(list)) { 22 | throw new NotFoundException(`${idOrKey}`); 23 | } 24 | } 25 | 26 | static single(list: T[]): T { 27 | return Utils.hasResults(list) ? list[0] : null; 28 | } 29 | 30 | static isPositve(number: number): boolean { 31 | return number >= 0; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The order is important! 3 | */ 4 | export * from './Environment'; 5 | export * from './Logger'; 6 | export * from './Server'; 7 | export * from './GraphQLErrorHandling'; 8 | export * from './Database'; 9 | -------------------------------------------------------------------------------- /src/database/factories/AuthorFactory.ts: -------------------------------------------------------------------------------- 1 | import { name } from 'faker'; 2 | 3 | import { AuthorModel } from '../../models/AuthorModel'; 4 | 5 | 6 | export const makeAuthor = (): AuthorModel => { 7 | return (new AuthorModel()) 8 | .setFirstName(name.firstName()) 9 | .setLastName(name.lastName()); 10 | }; 11 | -------------------------------------------------------------------------------- /src/database/factories/BookFactory.ts: -------------------------------------------------------------------------------- 1 | import { lorem, random, date } from 'faker'; 2 | 3 | import { BookModel } from '../../models/BookModel'; 4 | 5 | 6 | export const makeBook = (authorId: number): BookModel => { 7 | return (new BookModel()) 8 | .setTitle(lorem.word()) 9 | .setDescription(lorem.sentences(2)) 10 | .setPrice(random.number(120) + (random.number(99) / 100)) 11 | .setPublishedAt(date.past()) 12 | .setAuthorId(authorId); 13 | }; 14 | -------------------------------------------------------------------------------- /src/database/migrations/20170220183349_create_authors_table.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | 4 | exports.up = (db: Knex): Promise => { 5 | return Promise.all([ 6 | db.schema.createTable('authors', (table: Knex.CreateTableBuilder) => { 7 | table.increments('id').primary(); 8 | table.string('first_name').notNullable(); 9 | table.string('last_name').notNullable(); 10 | table.timestamp('updated_at').defaultTo(db.fn.now()); 11 | table.timestamp('created_at').defaultTo(db.fn.now()); 12 | }) 13 | ]); 14 | }; 15 | 16 | exports.down = (db: Knex): Promise => { 17 | return Promise.all([ 18 | db.schema.dropTable('authors') 19 | ]); 20 | }; 21 | -------------------------------------------------------------------------------- /src/database/migrations/20170221195948_create_books_table.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | 4 | exports.up = (db: Knex): Promise => { 5 | return Promise.all([ 6 | db.schema.createTable('books', (table: Knex.CreateTableBuilder) => { 7 | table.increments('id').primary(); 8 | table.string('title').notNullable(); 9 | table.string('description'); 10 | table.decimal('price', 6, 2).notNullable(); 11 | table.dateTime('published_at').notNullable(); 12 | table.integer('author_id') 13 | .unsigned() 14 | .references('id') 15 | .inTable('authors') 16 | .onDelete('CASCADE') 17 | .onUpdate('CASCADE'); 18 | table.timestamp('updated_at').defaultTo(db.fn.now()); 19 | table.timestamp('created_at').defaultTo(db.fn.now()); 20 | }) 21 | ]); 22 | }; 23 | 24 | exports.down = (db: Knex): Promise => { 25 | return Promise.all([ 26 | db.schema.dropTable('books') 27 | ]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/database/seeds/20170220183349_authors.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | import * as _ from 'lodash'; 3 | 4 | import { Tables } from '../../core/Tables'; 5 | import { makeAuthor } from '../factories/AuthorFactory'; 6 | 7 | 8 | exports.seed = (db: Knex) => { 9 | 10 | // generate fake authors 11 | let entries = _.times(10, () => makeAuthor().toDatabaseObject()); 12 | 13 | // Inserts seed entries 14 | return db(Tables.Authors).insert(entries); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/database/seeds/20170221195948_book.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | import * as _ from 'lodash'; 3 | 4 | import { Tables } from '../../core/Tables'; 5 | import { makeBook } from '../factories/BookFactory'; 6 | 7 | 8 | exports.seed = async (db: Knex) => { 9 | 10 | // Deletes ALL existing entries 11 | const authors = await db.select('id').from(Tables.Authors); 12 | const authorIds = authors.map(author => author.id); 13 | 14 | let entries = []; 15 | _.forEach(authorIds, (authorId: number) => { 16 | entries = _.concat(entries, _.times(10, () => makeBook(authorId).toDatabaseObject())); 17 | }); 18 | 19 | // Inserts seed entries 20 | return await db(Tables.Books).insert(entries); 21 | }; 22 | -------------------------------------------------------------------------------- /src/exceptions/Exception.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Annotaion '@exception' to give the exception a name and a key 3 | */ 4 | export const exception = (constructor: Exception) => { 5 | return class extends constructor { 6 | name = constructor.name; 7 | }; 8 | }; 9 | 10 | // Used to identify UserErrors 11 | export const IsException = Symbol(); 12 | 13 | // UserErrors will be sent to the user 14 | export class Exception extends Error { 15 | 16 | static Seperator = ':'; 17 | static Name = 'UnkownException'; 18 | 19 | static hasName(error: string): boolean; 20 | static hasName(error: Error): boolean; 21 | static hasName(error: any): boolean { 22 | let message = error; 23 | if (error.message) { 24 | message = error.message; 25 | } 26 | const reg = new RegExp('^[a-zA-Z]+:'); 27 | return reg.test(message); 28 | } 29 | 30 | static getName(message: string): string { 31 | if (Exception.hasName(message)) { 32 | return message.split(Exception.Seperator)[0]; 33 | } 34 | return Exception.Name; 35 | } 36 | 37 | static getMessage(message: string): string { 38 | if (Exception.hasName(message)) { 39 | return message.split(Exception.Seperator)[1]; 40 | } 41 | return message; 42 | } 43 | 44 | constructor(...args: any[]) { 45 | super(args[0]); 46 | this.name = Exception.Name; 47 | this.message = args[0]; 48 | this[IsException] = true; 49 | Error.captureStackTrace(this); 50 | } 51 | 52 | public toString(): string { 53 | return `${this.constructor.name}:${this.message}`; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/exceptions/FieldException.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from './Exception'; 2 | 3 | 4 | export class FieldException extends Exception { 5 | 6 | constructor(message: string) { 7 | super(message); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/exceptions/NotFoundException.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from './Exception'; 2 | 3 | 4 | export class NotFoundException extends Exception { 5 | 6 | constructor(id?: number | string) { 7 | super(`Entity with identifier ${id} does not exist`); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/exceptions/ValidationException.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from './Exception'; 2 | 3 | 4 | export class ValidationException extends Exception { 5 | 6 | constructor(message?: string) { 7 | super(message); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Exception'; 2 | export * from './FieldException'; 3 | export * from './NotFoundException'; 4 | export * from './ValidationException'; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * express-graphql-typescript-boilerplate 3 | * 4 | * @author Gery Hirscheld<@hirsch88> 5 | * 6 | * @description 7 | * This is a boilerplate for Node.js app written in TypeScript. We used the framework Express.js 8 | * as a basic layer and on that we setup the awesome GrapQL library. 9 | * 10 | */ 11 | 12 | // Core elements to get the server started 13 | import { 14 | Environment, 15 | Server, 16 | winstonStream, 17 | debugStream 18 | } from './core'; 19 | 20 | // Import all express libs 21 | import * as helmet from 'helmet'; 22 | import * as morgan from 'morgan'; 23 | import * as cors from 'cors'; 24 | 25 | // Import our middlewares to add to the express chain 26 | import { oauth } from './middlewares'; 27 | 28 | // Import all routes 29 | import { DefaultRoutes, GraphQLRoutes } from './routes'; 30 | 31 | // Create a new express app 32 | const app = Server.init(); 33 | 34 | // Helmet helps you secure your Express apps by setting various HTTP headers 35 | app.use(helmet()); 36 | app.use(helmet.noCache()); 37 | app.use(helmet.hsts({ 38 | maxAge: 31536000, 39 | includeSubdomains: true 40 | })); 41 | 42 | // Enable cors for all routes and origins 43 | app.use(cors()); 44 | 45 | // Adds winston logger to the express framework 46 | app.use(morgan('dev', debugStream)); 47 | app.use(morgan('combined', winstonStream)); 48 | 49 | // Our custom oauth middleware 50 | app.use(oauth({})); 51 | 52 | // Map routes to the express application 53 | DefaultRoutes.map(app); 54 | GraphQLRoutes.map(app); 55 | 56 | // Starts the server and listens for common errors 57 | Server.run(app, Environment.getConfig().server.port); 58 | -------------------------------------------------------------------------------- /src/middlewares/OAuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | import { Logger } from '../core'; 4 | 5 | 6 | const log = Logger('app:middleware:oauth'); 7 | 8 | export interface IOauthMiddlewareOptions { 9 | } 10 | 11 | const oauthMiddleware = (options: IOauthMiddlewareOptions) => (req: express.Request, res: express.Response, next: () => void) => { 12 | // To oauth-token logic here 13 | log.debug('oauthMiddleware was passed'); 14 | next(); 15 | }; 16 | 17 | export const oauth = (options: IOauthMiddlewareOptions) => { 18 | // Possibility to handle options to configure this middleware 19 | log.debug('oauthMiddleware was registered'); 20 | return oauthMiddleware(options); 21 | }; 22 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './OAuthMiddleware'; 2 | -------------------------------------------------------------------------------- /src/models/AbstactModel.ts: -------------------------------------------------------------------------------- 1 | export interface AbstactModel { 2 | toJson(): Attributes; 3 | toDatabaseObject(): RawAttributes; 4 | } 5 | -------------------------------------------------------------------------------- /src/models/AuthorModel.ts: -------------------------------------------------------------------------------- 1 | import { models } from 'models'; 2 | import { AbstactModel } from './AbstactModel'; 3 | 4 | 5 | export class AuthorModel implements AbstactModel { 6 | 7 | private id?: number; 8 | private firstName: string; 9 | private lastName: string; 10 | private updatedAt?: Date; 11 | private createdAt?: Date; 12 | 13 | constructor(attributes?: models.author.Attributes | models.author.RawAttributes, isRaw: boolean = true) { 14 | if (attributes) { 15 | if (isRaw) { 16 | this.mapDatabaseObject(attributes); 17 | } else { 18 | this.mapJson(attributes); 19 | } 20 | } 21 | } 22 | 23 | public get Id(): number { 24 | return this.id; 25 | }; 26 | 27 | public get FirstName(): string { 28 | return this.firstName; 29 | }; 30 | 31 | public get LastName(): string { 32 | return this.lastName; 33 | }; 34 | 35 | public get UpdatedAt(): Date { 36 | return this.updatedAt; 37 | }; 38 | 39 | public get CreatedAt(): Date { 40 | return this.createdAt; 41 | }; 42 | 43 | public setId(id: number): AuthorModel { 44 | this.id = id; 45 | return this; 46 | }; 47 | 48 | public setFirstName(firstName: string): AuthorModel { 49 | this.firstName = firstName; 50 | return this; 51 | }; 52 | public setLastName(lastName: string): AuthorModel { 53 | this.lastName = lastName; 54 | return this; 55 | }; 56 | 57 | public setUpdatedAt(updatedAt: Date): AuthorModel { 58 | this.updatedAt = updatedAt; 59 | return this; 60 | }; 61 | 62 | public setCreatedAt(createdAt: Date): AuthorModel { 63 | this.createdAt = createdAt; 64 | return this; 65 | }; 66 | 67 | public mapJson(attributes: models.author.Attributes): AuthorModel { 68 | if (attributes !== undefined) { 69 | this.setId(attributes.id); 70 | this.setFirstName(attributes.firstName); 71 | this.setLastName(attributes.lastName); 72 | this.setCreatedAt(attributes.createdAt); 73 | this.setUpdatedAt(attributes.updatedAt); 74 | } 75 | return this; 76 | } 77 | 78 | public mapDatabaseObject(attributes: models.author.RawAttributes): AuthorModel { 79 | if (attributes !== undefined) { 80 | this.setId(attributes.id); 81 | this.setFirstName(attributes.first_name); 82 | this.setLastName(attributes.last_name); 83 | this.setCreatedAt(attributes.created_at); 84 | this.setUpdatedAt(attributes.updated_at); 85 | } 86 | return this; 87 | } 88 | 89 | public validate(): boolean { 90 | return !!this.firstName && !!this.lastName; 91 | } 92 | 93 | public toJson(): Author { 94 | return new Author(this); 95 | } 96 | 97 | public toDatabaseObject(): RawAuthor { 98 | return new RawAuthor(this); 99 | } 100 | 101 | public merge(model: AuthorModel): AuthorModel { 102 | this.setFirstName(model.FirstName || this.FirstName); 103 | this.setLastName(model.LastName || this.LastName); 104 | return this; 105 | } 106 | 107 | } 108 | 109 | export class Author implements models.author.Attributes { 110 | public id?: number; 111 | public firstName: string; 112 | public lastName: string; 113 | public updatedAt?: Date; 114 | public createdAt?: Date; 115 | 116 | constructor(builder: AuthorModel) { 117 | this.id = builder.Id; 118 | this.firstName = builder.FirstName; 119 | this.lastName = builder.LastName; 120 | this.updatedAt = builder.UpdatedAt; 121 | this.createdAt = builder.CreatedAt; 122 | } 123 | } 124 | 125 | export class RawAuthor implements models.author.RawAttributes { 126 | public id?: number; 127 | public first_name: string; 128 | public last_name: string; 129 | public updated_at?: Date; 130 | public created_at?: Date; 131 | 132 | constructor(builder: AuthorModel) { 133 | this.id = builder.Id; 134 | this.first_name = builder.FirstName; 135 | this.last_name = builder.LastName; 136 | this.updated_at = builder.UpdatedAt; 137 | this.created_at = builder.CreatedAt; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/models/BookModel.ts: -------------------------------------------------------------------------------- 1 | import { models } from 'models'; 2 | import { AbstactModel } from './AbstactModel'; 3 | 4 | 5 | export class BookModel implements AbstactModel { 6 | 7 | private id?: number; 8 | private title?: string; 9 | private description?: string; 10 | private price?: number; 11 | private publishedAt?: Date; 12 | private authorId?: number; 13 | private updatedAt?: Date; 14 | private createdAt?: Date; 15 | 16 | constructor(attributes?: models.book.Attributes | models.book.RawAttributes, isRaw: boolean = true) { 17 | if (attributes) { 18 | if (isRaw) { 19 | this.mapDatabaseObject(attributes); 20 | } else { 21 | this.mapJson(attributes); 22 | } 23 | } 24 | } 25 | 26 | public get Id(): number { 27 | return this.id; 28 | }; 29 | 30 | public get Title(): string { 31 | return this.title; 32 | }; 33 | 34 | public get Description(): string { 35 | return this.description; 36 | }; 37 | 38 | public get Price(): number { 39 | return this.price; 40 | }; 41 | 42 | public get PublishedAt(): Date { 43 | return this.publishedAt; 44 | }; 45 | 46 | public get AuthorId(): number { 47 | return this.authorId; 48 | }; 49 | 50 | public get UpdatedAt(): Date { 51 | return this.updatedAt; 52 | }; 53 | 54 | public get CreatedAt(): Date { 55 | return this.createdAt; 56 | }; 57 | 58 | public setId(id: number): BookModel { 59 | this.id = id; 60 | return this; 61 | }; 62 | 63 | public setTitle(title: string): BookModel { 64 | this.title = title; 65 | return this; 66 | }; 67 | 68 | public setDescription(description: string): BookModel { 69 | this.description = description; 70 | return this; 71 | }; 72 | 73 | public setPrice(price: number): BookModel { 74 | this.price = price; 75 | return this; 76 | }; 77 | 78 | public setAuthorId(authorId: number): BookModel { 79 | this.authorId = authorId; 80 | return this; 81 | }; 82 | 83 | public setPublishedAt(publishedAt: Date): BookModel { 84 | this.publishedAt = publishedAt; 85 | return this; 86 | }; 87 | 88 | public setUpdatedAt(updatedAt: Date): BookModel { 89 | this.updatedAt = updatedAt; 90 | return this; 91 | }; 92 | 93 | public setCreatedAt(createdAt: Date): BookModel { 94 | this.createdAt = createdAt; 95 | return this; 96 | }; 97 | 98 | public mapJson(attributes: models.book.Attributes): BookModel { 99 | if (attributes !== undefined) { 100 | this.setId(attributes.id); 101 | this.setTitle(attributes.title); 102 | this.setDescription(attributes.description); 103 | this.setPrice(attributes.price); 104 | this.setAuthorId(attributes.authorId); 105 | this.setPublishedAt(attributes.publishedAt); 106 | this.setCreatedAt(attributes.createdAt); 107 | this.setUpdatedAt(attributes.updatedAt); 108 | } 109 | return this; 110 | } 111 | 112 | public mapDatabaseObject(attributes: models.book.RawAttributes): BookModel { 113 | if (attributes !== undefined) { 114 | this.setId(attributes.id); 115 | this.setTitle(attributes.title); 116 | this.setDescription(attributes.description); 117 | this.setPrice(attributes.price); 118 | this.setAuthorId(attributes.author_id); 119 | this.setPublishedAt(attributes.published_at); 120 | this.setCreatedAt(attributes.created_at); 121 | this.setUpdatedAt(attributes.updated_at); 122 | } 123 | return this; 124 | } 125 | 126 | public validate(): void { 127 | // TODO Check id all required attributes ar given 128 | } 129 | 130 | public toJson(): Book { 131 | return new Book(this); 132 | } 133 | 134 | public toDatabaseObject(): RawBook { 135 | return new RawBook(this); 136 | } 137 | 138 | } 139 | 140 | export class Book implements models.book.Attributes { 141 | public id?: number; 142 | public title: string; 143 | public authorId: number; 144 | public description?: string; 145 | public price?: number; 146 | public publishedAt?: Date; 147 | public updatedAt?: Date; 148 | public createdAt?: Date; 149 | 150 | constructor(builder: BookModel) { 151 | this.id = builder.Id; 152 | this.title = builder.Title; 153 | this.description = builder.Description; 154 | this.price = builder.Price; 155 | this.publishedAt = builder.PublishedAt; 156 | this.authorId = builder.AuthorId; 157 | this.updatedAt = builder.UpdatedAt; 158 | this.createdAt = builder.CreatedAt; 159 | } 160 | } 161 | 162 | export class RawBook implements models.book.RawAttributes { 163 | public id?: number; 164 | public title: string; 165 | public author_id: number; 166 | public description?: string; 167 | public price?: number; 168 | public published_at?: Date; 169 | public updated_at?: Date; 170 | public created_at?: Date; 171 | 172 | constructor(builder: BookModel) { 173 | this.id = builder.Id; 174 | this.title = builder.Title; 175 | this.description = builder.Description; 176 | this.price = builder.Price; 177 | this.published_at = builder.PublishedAt; 178 | this.author_id = builder.AuthorId; 179 | this.updated_at = builder.UpdatedAt; 180 | this.created_at = builder.CreatedAt; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthorModel'; 2 | export * from './BookModel'; 3 | -------------------------------------------------------------------------------- /src/repositories/AbstractRepository.ts: -------------------------------------------------------------------------------- 1 | export class AbstractRepository { 2 | 3 | constructor( 4 | protected db: DB 5 | ) { } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/repositories/AuthorRepository.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | import { models } from 'models'; 4 | import { Tables } from '../core/Tables'; 5 | import { AbstractRepository } from './AbstractRepository'; 6 | import { Utils } from '../core/Utils'; 7 | 8 | 9 | export class AuthorRepository extends AbstractRepository { 10 | 11 | public async findAll(options: common.PageinationArguments): Promise { 12 | return this.db 13 | .select() 14 | .from(Tables.Authors) 15 | .limit(options.limit) 16 | .offset(options.offset); 17 | } 18 | 19 | public async findById(id: number): Promise { 20 | const results = await this.db 21 | .select() 22 | .from(Tables.Authors) 23 | .where('id', id); 24 | return Utils.single(results); 25 | } 26 | 27 | public async findByIds(ids: number[]): Promise { 28 | return this.db 29 | .select() 30 | .from(Tables.Authors) 31 | .whereIn('id', ids); 32 | } 33 | 34 | public async search(text: string): Promise { 35 | return this.db 36 | .select() 37 | .from(Tables.Authors) 38 | .where('last_name', 'like', `%${text}%`) 39 | .orderBy('updated_at', 'DESC'); 40 | } 41 | 42 | public async create(author: models.author.RawAttributes): Promise { 43 | const ids = await this.db 44 | .insert(author) 45 | .into(Tables.Authors); 46 | return Utils.single(ids); 47 | } 48 | 49 | public async update(author: models.author.RawAttributes): Promise { 50 | await this.db 51 | .update(author) 52 | .into(Tables.Authors) 53 | .where('id', author.id); 54 | } 55 | 56 | public async delete(id: number): Promise { 57 | await this.db 58 | .delete() 59 | .from(Tables.Authors) 60 | .where('id', id); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/repositories/BookRepository.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | import { models } from 'models'; 4 | import { Tables } from '../core/Tables'; 5 | import { AbstractRepository } from './AbstractRepository'; 6 | 7 | 8 | export class BookRepository extends AbstractRepository { 9 | 10 | public async findAll(options: common.PageinationArguments): Promise { 11 | return await this.db 12 | .select() 13 | .from(Tables.Books) 14 | .limit(options.limit) 15 | .offset(options.offset); 16 | } 17 | 18 | public async findById(id: number): Promise { 19 | return this.db 20 | .select() 21 | .from(Tables.Books) 22 | .where('id', id); 23 | } 24 | 25 | public async findByAuthorId(id: number): Promise { 26 | return this.db 27 | .select() 28 | .from(Tables.Books) 29 | .where('author_id', id); 30 | } 31 | 32 | public async findByIds(ids: number[]): Promise { 33 | return this.db 34 | .select() 35 | .from(Tables.Books) 36 | .whereIn('id', ids); 37 | } 38 | 39 | public async search(text: string): Promise { 40 | return this.db 41 | .select() 42 | .from(Tables.Books) 43 | .where('title', 'like', `%${text}%`) 44 | .orderBy('updated_at', 'DESC'); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthorRepository'; 2 | export * from './BookRepository'; 3 | -------------------------------------------------------------------------------- /src/routes/DefaultRoutes.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | 4 | export class DefaultRoutes { 5 | 6 | static map(app: express.Application): void { 7 | app.get('/', (req: express.Request, res: express.Response) => { 8 | const pkg = require('../../package.json'); 9 | res.json({ 10 | name: pkg.name, 11 | version: pkg.version, 12 | description: pkg.description 13 | }); 14 | }); 15 | } 16 | 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/routes/GraphQLRoutes.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as GraphQLHTTP from 'express-graphql'; 3 | 4 | import { Environment, DB } from '../core'; 5 | import { Exception } from '../exceptions'; 6 | import { Schema } from '../schemas'; 7 | import { RootValue } from '../RootValue'; 8 | import { 9 | Context, 10 | DataLoadersContext, 11 | ServicesContext 12 | } from '../context'; 13 | import { 14 | AuthorRepository, 15 | BookRepository 16 | } from '../repositories'; 17 | import { 18 | BookService, 19 | AuthorService 20 | } from '../services'; 21 | 22 | 23 | export class GraphQLRoutes { 24 | 25 | static map(app: express.Application): void { 26 | GraphQLRoutes.buildContext(); 27 | 28 | // Add GraphQL to express route 29 | app.use('/graphql', (req: express.Request, res: express.Response) => { 30 | // Creates a GraphQLHTTP per request 31 | GraphQLHTTP({ 32 | schema: Schema.get(), 33 | rootValue: new RootValue(), 34 | context: new Context( 35 | req, res, 36 | DataLoadersContext.getInstance(), 37 | ServicesContext.getInstance() 38 | ), 39 | graphiql: Environment.getConfig().server.graphiql, 40 | formatError: exception => ({ 41 | name: Exception.getName(exception.message), 42 | message: Exception.getMessage(exception.message), 43 | path: exception.path 44 | }) 45 | })(req, res); 46 | }); 47 | } 48 | 49 | private static buildContext(): void { 50 | ServicesContext.getInstance() 51 | .setBookService(new BookService(new BookRepository(DB))) 52 | .setAuthorService(new AuthorService(new AuthorRepository(DB))); 53 | 54 | DataLoadersContext.getInstance() 55 | .setAuthorDataLoader(ServicesContext.getInstance().AuthorService) 56 | .setBookDataLoader(ServicesContext.getInstance().BookService); 57 | } 58 | 59 | } 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DefaultRoutes'; 2 | export * from './GraphQLRoutes'; 3 | -------------------------------------------------------------------------------- /src/schemas/arguments/LimitArgument.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLArgumentConfig, GraphQLInt } from 'graphql'; 2 | 3 | import { Utils } from '../../core/Utils'; 4 | import { ValidationException } from '../../exceptions'; 5 | 6 | 7 | export class LimitArgument implements GraphQLArgumentConfig { 8 | 9 | public type = GraphQLInt; 10 | public description = 'This is the max amount of data that should be send to the client'; 11 | public defaultValue = 100; 12 | 13 | static validate(limit: number): void { 14 | if (!Utils.isPositve(limit)) { 15 | throw new ValidationException('Limit must be positive'); 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/schemas/arguments/OffsetArgument.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLArgumentConfig, GraphQLInt } from 'graphql'; 2 | 3 | import { Utils } from '../../core/Utils'; 4 | import { ValidationException } from '../../exceptions'; 5 | 6 | 7 | export class OffsetArgument implements GraphQLArgumentConfig { 8 | 9 | public type = GraphQLInt; 10 | public description = 'To do'; 11 | public defaultValue = 0; 12 | 13 | static validate(offset: number): void { 14 | if (!Utils.isPositve(offset)) { 15 | throw new ValidationException('Offset must be positive'); 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/schemas/arguments/TextArgument.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLArgumentConfig, GraphQLString, GraphQLNonNull } from 'graphql'; 2 | 3 | import { ValidationException } from '../../exceptions'; 4 | 5 | 6 | export interface ITextArgument { 7 | text: string; 8 | } 9 | 10 | export class TextArgument implements GraphQLArgumentConfig { 11 | 12 | public type = new GraphQLNonNull(GraphQLString); 13 | public description = 'This argument is used for the search query'; 14 | 15 | static validate(text: string): void { 16 | if (text.length < 3) { 17 | throw new ValidationException('The text argument must have at least 3 characters'); 18 | } 19 | if (text.indexOf('%') >= 0) { 20 | throw new ValidationException('% is not a valid search character'); 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/schemas/arguments/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LimitArgument'; 2 | export * from './OffsetArgument'; 3 | export * from './TextArgument'; 4 | -------------------------------------------------------------------------------- /src/schemas/fields/AbstractField.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../../context'; 2 | 3 | 4 | export interface IGraphQLField { 5 | allow: string[]; 6 | before(context: Context, args: A, source: S): Promise; 7 | after(result: R, context: Context, args: A, source: S): Promise; 8 | execute(source: S, args: A, context: Context): Promise; 9 | } 10 | 11 | 12 | export class AbstractField { 13 | 14 | /** 15 | * Here you can add your needed permisson 16 | * roles. This will be checked at the resolve 17 | * method. 18 | * 19 | * @type {string[]} 20 | * @memberOf AbstractQuery 21 | */ 22 | public allow: string[] = []; 23 | 24 | /** 25 | * This is our before hook. Here you are able 26 | * to alter the args object before the actual resolver(execute) 27 | * will be called. 28 | * 29 | * @template A 30 | * @template S 31 | * @param {Context} context 32 | * @param {A} args 33 | * @param {S} [source] 34 | * @returns {Promise} 35 | * 36 | * @memberOf AbstractQuery 37 | */ 38 | public before(context: Context, args: A, source: S): Promise { 39 | return Promise.resolve(args); 40 | } 41 | 42 | /** 43 | * This our after hook. It will be called ater the actual resolver(execute). 44 | * There you are able to alter the result before it is send to the client. 45 | * 46 | * @template R 47 | * @template A 48 | * @template S 49 | * @param {R} result 50 | * @param {Context} context 51 | * @param {A} [args] 52 | * @param {S} [source] 53 | * @returns {Promise} 54 | * 55 | * @memberOf AbstractQuery 56 | */ 57 | public after(result: R, context: Context, args: A, source: S): Promise { 58 | return Promise.resolve(result); 59 | } 60 | 61 | /** 62 | * This our resolver, which should gather the needed data; 63 | * 64 | * @template R 65 | * @param {any} source 66 | * @param {any} args 67 | * @param {Context} context 68 | * @returns {Promise} 69 | * 70 | * @memberOf AbstractQuery 71 | */ 72 | public execute(source: S, args: A, context: Context): Promise { 73 | return undefined; 74 | } 75 | 76 | /** 77 | * This will be called by graphQL and they need to have it not as a 78 | * member fucntion of this class. We use this hook to add some more logic 79 | * to it, like permission checking and before and after hooks to alter some data. 80 | * 81 | * 82 | * @memberOf AbstractQuery 83 | */ 84 | public resolve = async (source: S, args: A, context: Context): Promise => { 85 | //first check roles 86 | if (!context.hasUserRoles(this.allow)) { 87 | context.Response.send(401); 88 | return Promise.reject('401 Unauthorized'); 89 | } 90 | 91 | //go throw before 92 | args = await this.before(context, args, source); 93 | 94 | //run execute 95 | let result = await this.execute(source, args, context); 96 | 97 | //call after 98 | await this.after(result, context, args, source); 99 | 100 | //return the resolved result 101 | return result; 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/schemas/fields/AuthorField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { Context } from '../../context'; 6 | import { Book } from '../../models'; 7 | 8 | import { AbstractField, IGraphQLField } from './AbstractField'; 9 | import { AuthorType } from '../types'; 10 | 11 | 12 | export class AuthorField extends AbstractField implements GraphQLFieldDefinition, IGraphQLField { 13 | 14 | public log = Logger('app:schemas:author:AuthorField'); 15 | 16 | public type = AuthorType; 17 | public name = 'author'; 18 | public description = 'The author of this book'; 19 | public args; 20 | 21 | public execute(source: Book, args: any, context: Context): Promise 22 | public execute(source: any, args: any, context: Context): Promise { 23 | this.log.debug('Resolve auhtor %s of the book ' + source.id, source.authorId); 24 | 25 | // Repo way 26 | // return context.getRepositories().getAuthorRepository().findAuthorById(source.authorId); 27 | 28 | // DataLoader 29 | return context.DataLoaders.AuthorDataLoader.load(source.authorId); 30 | 31 | // Benchmark with 1000 Authors and per Author 10 Books 32 | // With Loaders => ca. 2s 33 | // Without Loaders => ca. 4s 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/schemas/fields/BooksField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLList } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { Context } from '../../context'; 6 | 7 | import { AbstractField, IGraphQLField } from './AbstractField'; 8 | import { BookType } from '../types'; 9 | import { Author } from '../../models'; 10 | 11 | 12 | export class BooksField extends AbstractField implements GraphQLFieldDefinition, IGraphQLField { 13 | 14 | public log = Logger('app:schemas:book:BooksField'); 15 | 16 | public type = new GraphQLList(BookType); 17 | public name = 'books'; 18 | public description = 'The books of an author'; 19 | public args; 20 | 21 | public execute(source: Author, args: any, context: Context): Promise 22 | public execute(source: any, args: any, context: Context): Promise { 23 | this.log.debug('Resolve books of auhtor %s ' + source.id); 24 | return context.Services.BookService.findByAuthorId(source.id); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/schemas/fields/CreatedAtField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition } from 'graphql'; 2 | 3 | import { DateType } from '../types/DateType'; 4 | 5 | 6 | export class CreatedAtField implements GraphQLFieldDefinition { 7 | 8 | public type = DateType; 9 | public name = 'created at'; 10 | public description = 'This is the date when the object was created'; 11 | public args; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/schemas/fields/DescriptionField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLString } from 'graphql'; 2 | 3 | 4 | export class DescriptionField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLString; 7 | public name = 'description'; 8 | public description = 'The description'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/FirstNameField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLString } from 'graphql'; 2 | 3 | 4 | export class FirstNameField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLString; 7 | public name = 'first name'; 8 | public description = 'The first name'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/IdField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLID } from 'graphql'; 2 | 3 | 4 | export class IdField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLID; 7 | public name = 'id'; 8 | public description = 'The ID'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/LastNameField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLString } from 'graphql'; 2 | 3 | 4 | export class LastNameField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLString; 7 | public name = 'last name'; 8 | public description = 'The last name'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/PriceField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLFloat } from 'graphql'; 2 | 3 | 4 | export class PriceField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLFloat; 7 | public name = 'price'; 8 | public description = 'The price'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/PublishedAtField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition } from 'graphql'; 2 | 3 | import { DateType } from '../types/DateType'; 4 | 5 | 6 | export class PublishedAtField implements GraphQLFieldDefinition { 7 | 8 | public type = DateType; 9 | public name = 'published at'; 10 | public description = 'This is the date when the object was published'; 11 | public args; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/schemas/fields/TitleField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLString } from 'graphql'; 2 | 3 | 4 | export class TitleField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLString; 7 | public name = 'title'; 8 | public description = 'The title'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/TypeField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition, GraphQLString } from 'graphql'; 2 | 3 | 4 | export class TypeField implements GraphQLFieldDefinition { 5 | 6 | public type = GraphQLString; 7 | public name = 'type'; 8 | public description = 'The items type'; 9 | public args; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/fields/UpdatedAtField.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldDefinition } from 'graphql'; 2 | 3 | import { DateType } from '../types/DateType'; 4 | 5 | 6 | export class UpdatedAtField implements GraphQLFieldDefinition { 7 | 8 | public type = DateType; 9 | public name = 'updated at'; 10 | public description = 'This is the date when the object was updated'; 11 | public args; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/schemas/fields/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IdField'; 2 | export * from './DescriptionField'; 3 | export * from './TitleField'; 4 | export * from './TypeField'; 5 | export * from './PriceField'; 6 | export * from './FirstNameField'; 7 | export * from './LastNameField'; 8 | export * from './PublishedAtField'; 9 | export * from './UpdatedAtField'; 10 | export * from './CreatedAtField'; 11 | export * from './AuthorField'; 12 | export * from './BooksField'; 13 | -------------------------------------------------------------------------------- /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLSchema } from 'graphql'; 2 | 3 | import { GraphQLErrorHandling } from '../core'; 4 | import { 5 | FindAllAuthorsQuery, 6 | FindAuthorByIdQuery, 7 | FindAllBooksQuery, 8 | FindBookByIdQuery, 9 | SearchQuery 10 | } from './queries'; 11 | import { 12 | CreateAuthorMutation, 13 | DeleteAuthorMutation, 14 | UpdateAuthorMutation 15 | } from './mutations'; 16 | 17 | export class Schema { 18 | 19 | private static instance: Schema; 20 | 21 | private rootQuery: GraphQLObjectType = new GraphQLObjectType({ 22 | name: 'Query', 23 | fields: { 24 | search: new SearchQuery(), 25 | findAllAuthors: new FindAllAuthorsQuery(), 26 | findAuthorById: new FindAuthorByIdQuery(), 27 | findAllBooks: new FindAllBooksQuery(), 28 | findBookById: new FindBookByIdQuery() 29 | } 30 | }); 31 | 32 | private rootMutation: GraphQLObjectType = new GraphQLObjectType({ 33 | name: 'Mutation', 34 | fields: { 35 | createAuthor: new CreateAuthorMutation(), 36 | updateAuthor: new UpdateAuthorMutation(), 37 | deleteAuthor: new DeleteAuthorMutation() 38 | } 39 | }); 40 | 41 | private schema: GraphQLSchema = new GraphQLSchema({ 42 | query: this.rootQuery, 43 | mutation: this.rootMutation 44 | }); 45 | 46 | static get(): GraphQLSchema { 47 | if (!Schema.instance) { 48 | Schema.instance = new Schema(); 49 | GraphQLErrorHandling.watch(Schema.instance.schema); 50 | } 51 | return Schema.instance.schema; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/schemas/mutations/AbstractMutation.ts: -------------------------------------------------------------------------------- 1 | import { AbstractQuery, IGraphQLQuery } from '../queries/AbstractQuery'; 2 | 3 | export interface IGraphQLMutation extends IGraphQLQuery { 4 | } 5 | 6 | export class AbstractMutation extends AbstractQuery { 7 | } 8 | -------------------------------------------------------------------------------- /src/schemas/mutations/CreateAuthorMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldConfig, GraphQLNonNull, GraphQLString } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { RootValue } from '../../RootValue'; 5 | import { Logger } from '../../core'; 6 | import { Context } from '../../context'; 7 | import { ValidationException } from '../../exceptions'; 8 | import { AuthorType } from '../types'; 9 | import { AuthorModel } from '../../models'; 10 | import { AbstractMutation, IGraphQLMutation } from './AbstractMutation'; 11 | 12 | 13 | export interface ICreateAuthorMutationArguments { 14 | firstName: string; 15 | lastName: string; 16 | } 17 | 18 | export class CreateAuthorMutation extends AbstractMutation implements GraphQLFieldConfig, IGraphQLMutation { 19 | 20 | public log = Logger('app:schemas:author:CreateAuthorMutation'); 21 | 22 | public type = AuthorType; 23 | public allow = ['admin']; 24 | public args = { 25 | firstName: { type: new GraphQLNonNull(GraphQLString) }, 26 | lastName: { type: new GraphQLNonNull(GraphQLString) } 27 | }; 28 | 29 | public before(context: Context, args: ICreateAuthorMutationArguments): Promise { 30 | this.log.debug('hook before args', args); 31 | const authorModel = new AuthorModel() 32 | .setFirstName(args.firstName) 33 | .setLastName(args.lastName); 34 | 35 | if (authorModel.validate()) { 36 | return Promise.resolve(args); 37 | } else { 38 | throw new ValidationException('Invalid author'); 39 | } 40 | } 41 | 42 | public async execute( 43 | root: RootValue, 44 | args: ICreateAuthorMutationArguments, 45 | context: Context 46 | ): Promise { 47 | this.log.debug('resolve createAuthor()'); 48 | const authorModel = new AuthorModel() 49 | .setFirstName(args.firstName) 50 | .setLastName(args.lastName); 51 | const author = await context.Services.AuthorService.create(authorModel); 52 | return author.toJson(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/schemas/mutations/DeleteAuthorMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldConfig, GraphQLNonNull, GraphQLID } from 'graphql'; 2 | 3 | import { Logger } from '../../core'; 4 | import { RootValue } from '../../RootValue'; 5 | import { Context } from '../../context'; 6 | import { AuthorType } from '../types'; 7 | import { AbstractMutation, IGraphQLMutation } from './AbstractMutation'; 8 | 9 | 10 | export interface IDeleteAuthorMutationArguments { 11 | id: number; 12 | } 13 | 14 | export class DeleteAuthorMutation extends AbstractMutation implements GraphQLFieldConfig, IGraphQLMutation { 15 | 16 | public log = Logger('app:schemas:author:DeleteAuthorMutation'); 17 | 18 | public type = AuthorType; 19 | public allow = ['admin']; 20 | public args = { 21 | id: { type: new GraphQLNonNull(GraphQLID) } 22 | }; 23 | 24 | public execute(root: RootValue, args: IDeleteAuthorMutationArguments, context: Context): Promise { 25 | this.log.debug('resolve deleteAuthor(%s)', args.id); 26 | return context.Services.AuthorService.delete(args.id); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/schemas/mutations/UpdateAuthorMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldConfig, GraphQLNonNull, GraphQLString, GraphQLID } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { RootValue } from '../../RootValue'; 6 | import { Context } from '../../context'; 7 | import { AuthorModel } from '../../models'; 8 | import { AuthorType } from '../types'; 9 | import { AbstractMutation, IGraphQLMutation } from './AbstractMutation'; 10 | 11 | 12 | export interface IUpdateAuthorMutationArguments { 13 | id: number; 14 | firstName: string; 15 | lastName: string; 16 | } 17 | 18 | export class UpdateAuthorMutation extends AbstractMutation implements GraphQLFieldConfig, IGraphQLMutation { 19 | 20 | public log = Logger('app:schemas:author:UpdateAuthorMutation'); 21 | 22 | public type = AuthorType; 23 | public allow = ['admin']; 24 | public args = { 25 | id: { type: new GraphQLNonNull(GraphQLID) }, 26 | firstName: { type: new GraphQLNonNull(GraphQLString) }, 27 | lastName: { type: new GraphQLNonNull(GraphQLString) } 28 | }; 29 | 30 | public async execute( 31 | root: RootValue, 32 | args: IUpdateAuthorMutationArguments, 33 | context: Context 34 | ): Promise { 35 | this.log.debug('resolve updateAuthor(%s)', args.id); 36 | const authorModel = new AuthorModel() 37 | .setId(args.id) 38 | .setFirstName(args.firstName) 39 | .setLastName(args.lastName); 40 | const author = await context.Services.AuthorService.update(authorModel); 41 | return author.toJson(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/schemas/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CreateAuthorMutation'; 2 | export * from './DeleteAuthorMutation'; 3 | export * from './UpdateAuthorMutation'; 4 | -------------------------------------------------------------------------------- /src/schemas/queries/AbstractQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from 'graphql'; 2 | 3 | import { RootValue } from '../../RootValue'; 4 | import { Context } from '../../context'; 5 | 6 | 7 | export interface IGraphQLQuery { 8 | allow: string[]; 9 | before(context: Context, args: A, source?: S): Promise; 10 | after(result: R, context: Context, args: A, source?: S): Promise; 11 | execute(root: RootValue, args: A, context: Context, info: GraphQLResolveInfo): Promise; 12 | } 13 | 14 | 15 | export class AbstractQuery { 16 | 17 | /** 18 | * Here you can add your needed permisson 19 | * roles. This will be checked at the resolve 20 | * method. 21 | * 22 | * @type {string[]} 23 | * @memberOf AbstractQuery 24 | */ 25 | public allow: string[] = []; 26 | 27 | /** 28 | * This is our before hook. Here you are able 29 | * to alter the args object before the actual resolver(execute) 30 | * will be called. 31 | * 32 | * @template A 33 | * @template S 34 | * @param {Context} context 35 | * @param {A} args 36 | * @param {S} [source] 37 | * @returns {Promise} 38 | * 39 | * @memberOf AbstractQuery 40 | */ 41 | public before(context: Context, args: A, source?: S): Promise { 42 | return Promise.resolve(args); 43 | } 44 | 45 | /** 46 | * This our after hook. It will be called ater the actual resolver(execute). 47 | * There you are able to alter the result before it is send to the client. 48 | * 49 | * @template R 50 | * @template A 51 | * @template S 52 | * @param {R} result 53 | * @param {Context} context 54 | * @param {A} [args] 55 | * @param {S} [source] 56 | * @returns {Promise} 57 | * 58 | * @memberOf AbstractQuery 59 | */ 60 | public after(result: R, context: Context, args?: A, source?: S): Promise { 61 | return Promise.resolve(result); 62 | } 63 | 64 | /** 65 | * This our resolver, which should gather the needed data; 66 | * 67 | * @template R 68 | * @param {any} root 69 | * @param {any} args 70 | * @param {Context} context 71 | * @returns {Promise} 72 | * 73 | * @memberOf AbstractQuery 74 | */ 75 | public execute(root: RootValue, args: A, context: Context, info: GraphQLResolveInfo): Promise { 76 | return undefined; 77 | } 78 | 79 | /** 80 | * This will be called by graphQL and they need to have it not as a 81 | * member fucntion of this class. We use this hook to add some more logic 82 | * to it, like permission checking and before and after hooks to alter some data. 83 | * 84 | * 85 | * @memberOf AbstractQuery 86 | */ 87 | public resolve = async (root: RootValue, args: A, context: Context, info: GraphQLResolveInfo): Promise => { 88 | //store the root query arguments 89 | context.setResolveArgument(args); 90 | 91 | //first check roles 92 | if (!context.hasUserRoles(this.allow)) { 93 | context.Response.send(401); 94 | return Promise.reject('401 Unauthorized'); 95 | } 96 | 97 | //go throw before 98 | args = await this.before(context, args); 99 | 100 | //run execute 101 | let result = await this.execute(root, args, context, info); 102 | 103 | //call after 104 | await this.after(result, context, args); 105 | 106 | //return the resolved result 107 | return result; 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/schemas/queries/FindAllAuthorsQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLList, GraphQLFieldConfig } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { RootValue } from '../../RootValue'; 6 | import { Context } from '../../context'; 7 | import { AuthorType } from '../types'; 8 | import { LimitArgument, OffsetArgument } from '../arguments'; 9 | import { AbstractQuery, IGraphQLQuery } from './AbstractQuery'; 10 | 11 | 12 | export class FindAllAuthorsQuery extends AbstractQuery implements GraphQLFieldConfig, IGraphQLQuery { 13 | 14 | public log = Logger('app:schemas:author:FindAllAuthorsQuery'); 15 | 16 | public type = new GraphQLList(AuthorType); 17 | public allow = ['admin']; 18 | public args = { 19 | limit: new LimitArgument(), 20 | offset: new OffsetArgument() 21 | }; 22 | 23 | public before(context: Context, args: common.PageinationArguments): Promise { 24 | this.log.debug('hook before args', args); 25 | LimitArgument.validate(args.limit); 26 | OffsetArgument.validate(args.limit); 27 | return Promise.resolve(args); 28 | } 29 | 30 | public async execute(root: RootValue, args: common.PageinationArguments, context: Context): Promise { 31 | this.log.debug('resolve findAllAuthors()'); 32 | const authors = await context.Services.AuthorService.findAll({ 33 | limit: args.limit, 34 | offset: args.offset 35 | }); 36 | return authors.map(author => author.toJson()); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/schemas/queries/FindAllBooksQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLList, GraphQLFieldConfig } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { RootValue } from '../../RootValue'; 6 | import { Context } from '../../context'; 7 | import { BookType } from '../types'; 8 | import { LimitArgument, OffsetArgument } from '../arguments'; 9 | import { AbstractQuery, IGraphQLQuery } from './AbstractQuery'; 10 | 11 | 12 | export class FindAllBooksQuery extends AbstractQuery implements GraphQLFieldConfig, IGraphQLQuery { 13 | 14 | public log = Logger('app:schemas:book:FindAllBooksQuery'); 15 | 16 | public type = new GraphQLList(BookType); 17 | public allow = ['admin']; 18 | public args = { 19 | limit: new LimitArgument(), 20 | offset: new OffsetArgument() 21 | }; 22 | 23 | public before(context: Context, args: common.PageinationArguments): Promise { 24 | this.log.debug('hook before args', args); 25 | LimitArgument.validate(args.limit); 26 | OffsetArgument.validate(args.limit); 27 | return Promise.resolve(args); 28 | } 29 | 30 | public async execute(root: RootValue, args: common.PageinationArguments, context: Context): Promise { 31 | this.log.debug('resolve findAllBooks()'); 32 | const books = await context.Services.BookService.findAll({ 33 | limit: args.limit, 34 | offset: args.offset 35 | }); 36 | return books.map(book => book.toJson()); 37 | } 38 | 39 | public after(result: models.book.Attributes, context: Context): Promise { 40 | this.log.debug('hook after args', context.Args); 41 | return Promise.resolve(result); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/schemas/queries/FindAuthorByIdQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLID, GraphQLFieldConfig, GraphQLNonNull } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { RootValue } from '../../RootValue'; 6 | import { Context } from '../../context'; 7 | import { AuthorType } from '../types'; 8 | import { AbstractQuery, IGraphQLQuery } from './AbstractQuery'; 9 | 10 | 11 | export class FindAuthorByIdQuery extends AbstractQuery implements GraphQLFieldConfig, IGraphQLQuery { 12 | 13 | public log = Logger('app:schemas:author:FindAuthorByIdQuery'); 14 | 15 | public type = AuthorType; 16 | public allow = ['admin']; 17 | public args = { 18 | id: { type: new GraphQLNonNull(GraphQLID) } 19 | }; 20 | 21 | public before(context: Context, args: arguments.ID): Promise { 22 | this.log.debug('hook before args', args); 23 | return Promise.resolve(args); 24 | } 25 | 26 | public async execute(root: RootValue, args: arguments.ID, context: Context): Promise { 27 | this.log.debug('resolve findAuthorById(%s)', args.id); 28 | const author = await context.Services.AuthorService.findById(args.id); 29 | return author.toJson(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/schemas/queries/FindBookByIdQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLID, GraphQLFieldConfig, GraphQLNonNull } from 'graphql'; 2 | 3 | import { models } from 'models'; 4 | import { Logger } from '../../core'; 5 | import { RootValue } from '../../RootValue'; 6 | import { Context } from '../../context'; 7 | import { BookType } from '../types'; 8 | import { AbstractQuery, IGraphQLQuery } from './AbstractQuery'; 9 | 10 | 11 | export class FindBookByIdQuery extends AbstractQuery implements GraphQLFieldConfig, IGraphQLQuery { 12 | 13 | public log = Logger('app:schemas:book:FindBookByIdQuery'); 14 | 15 | public type = BookType; 16 | public allow = ['admin']; 17 | public args = { 18 | id: { type: new GraphQLNonNull(GraphQLID) } 19 | }; 20 | 21 | public before(context: Context, args: arguments.ID): Promise { 22 | this.log.debug('hook before args', args); 23 | return Promise.resolve(args); 24 | } 25 | 26 | public async execute(root: RootValue, args: arguments.ID, context: Context): Promise { 27 | this.log.debug('resolve findBookById(%s)', args.id); 28 | const book = await context.Services.BookService.findById(args.id); 29 | return book.toJson(); 30 | } 31 | 32 | public after(result: models.book.Attributes, context: Context, args: arguments.ID): Promise { 33 | this.log.debug('hook after args', args); 34 | return Promise.resolve(result); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/schemas/queries/SearchQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLList, GraphQLFieldConfig, GraphQLResolveInfo } from 'graphql'; 2 | import * as _ from 'lodash'; 3 | 4 | import { RootValue } from '../../RootValue'; 5 | import { Logger } from '../../core'; 6 | import { Context } from '../../context'; 7 | import { SearchType } from '../types'; 8 | import { TextArgument, ITextArgument } from '../arguments'; 9 | import { AbstractQuery, IGraphQLQuery } from './AbstractQuery'; 10 | 11 | 12 | /** 13 | * @example 14 | * query search($text: String!) { 15 | * search(text: $text) { 16 | * __typename 17 | * ... on Author { 18 | * id 19 | * firstName 20 | * lastName 21 | * } 22 | * ... on Book { 23 | * title 24 | * } 25 | * } 26 | * } 27 | */ 28 | export class SearchQuery extends AbstractQuery implements GraphQLFieldConfig, IGraphQLQuery { 29 | 30 | public log = Logger('app:schemas:search:SearchQuery'); 31 | 32 | public type = new GraphQLList(SearchType); 33 | public allow = ['admin']; 34 | public args = { 35 | text: new TextArgument() 36 | }; 37 | 38 | public before(context: Context, args: ITextArgument): Promise { 39 | TextArgument.validate(args.text); 40 | return Promise.resolve(args); 41 | } 42 | 43 | public async execute(root: RootValue, args: ITextArgument, context: Context, info: GraphQLResolveInfo): Promise { 44 | this.log.debug('resolve search()', args.text); 45 | const [authors, books] = await Promise.all([ 46 | context.Services.AuthorService.search(args.text), 47 | context.Services.BookService.search(args.text) 48 | ]); 49 | const results = _.union( 50 | authors.map(author => author.toJson()), 51 | books.map(book => book.toJson()) 52 | ); 53 | return _.sortBy(results, 'updatedAt').reverse(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/schemas/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FindAllAuthorsQuery'; 2 | export * from './FindAllBooksQuery'; 3 | export * from './FindAuthorByIdQuery'; 4 | export * from './FindBookByIdQuery'; 5 | export * from './SearchQuery'; 6 | -------------------------------------------------------------------------------- /src/schemas/types/AuthorType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { 4 | IdField, 5 | FirstNameField, 6 | LastNameField, 7 | UpdatedAtField, 8 | CreatedAtField, 9 | BooksField 10 | } from '../fields'; 11 | 12 | 13 | export const AuthorType = new GraphQLObjectType({ 14 | name: 'Author', 15 | description: 'A single author.', 16 | fields: () => ({ 17 | id: new IdField(), 18 | firstName: new FirstNameField(), 19 | lastName: new LastNameField(), 20 | books: new BooksField(), 21 | updatedAt: new UpdatedAtField(), 22 | createdAt: new CreatedAtField() 23 | }) 24 | }); 25 | -------------------------------------------------------------------------------- /src/schemas/types/BookType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import { 4 | IdField, 5 | TitleField, 6 | DescriptionField, 7 | PriceField, 8 | PublishedAtField, 9 | UpdatedAtField, 10 | CreatedAtField, 11 | AuthorField 12 | } from '../fields'; 13 | 14 | 15 | export const BookType = new GraphQLObjectType({ 16 | name: 'Book', 17 | description: 'A single book.', 18 | fields: () => ({ 19 | id: new IdField(), 20 | title: new TitleField(), 21 | description: new DescriptionField(), 22 | price: new PriceField(), 23 | author: new AuthorField(), 24 | publishedAt: new PublishedAtField(), 25 | updatedAt: new UpdatedAtField(), 26 | createdAt: new CreatedAtField() 27 | }) 28 | }); 29 | -------------------------------------------------------------------------------- /src/schemas/types/DateType.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLScalarType, 3 | GraphQLError, 4 | StringValue 5 | } from 'graphql'; 6 | import { Kind } from 'graphql/language'; 7 | 8 | import { FieldException } from '../../exceptions'; 9 | 10 | export const FIELD_ERROR_NO_DATE = 'Field error: value is not an instance of Date'; 11 | export const FIELD_ERROR_INVALID_DATE = 'Field error: value is an invalid Date'; 12 | 13 | export const GRAPHQL_ERROR_NO_STRING = (ast: StringValue | any) => 'Query error: Can only parse strings to dates but got a: ' + ast.kind; 14 | export const GRAPHQL_ERROR_INVALID_DATE = 'Query error: Invalid date'; 15 | export const GRAPHQL_ERROR_INVALID_FORMAT = 'Query error: Invalid date format, only accepts: YYYY-MM-DDTHH:MM:SS.SSSZ'; 16 | 17 | 18 | const serializeDate = (value: Date | any) => { 19 | if (!(value instanceof Date)) { 20 | throw new FieldException(FIELD_ERROR_NO_DATE); 21 | } 22 | if (isNaN(value.getTime())) { 23 | throw new FieldException(FIELD_ERROR_INVALID_DATE); 24 | } 25 | return value.toJSON(); 26 | }; 27 | 28 | const parseValue = (value: string) => { 29 | if (typeof value !== 'string') { 30 | throw new FieldException(FIELD_ERROR_NO_DATE); 31 | } 32 | const date = new Date(value); 33 | if (isNaN(date.getTime())) { 34 | throw new FieldException(FIELD_ERROR_INVALID_DATE); 35 | } 36 | return date; 37 | }; 38 | 39 | export const DateType = new GraphQLScalarType({ 40 | name: 'Date', 41 | description: 'Represents a Date object', 42 | serialize: serializeDate, 43 | parseValue: parseValue, 44 | parseLiteral(ast: StringValue | any): Date { 45 | if (ast.kind !== Kind.STRING) { 46 | throw new GraphQLError(GRAPHQL_ERROR_NO_STRING(ast), [ast]); 47 | } 48 | let result = new Date(ast.value); 49 | if (isNaN(result.getTime())) { 50 | throw new GraphQLError(GRAPHQL_ERROR_INVALID_DATE, [ast]); 51 | } 52 | if (ast.value !== result.toJSON()) { 53 | throw new GraphQLError(GRAPHQL_ERROR_INVALID_FORMAT, [ast]); 54 | } 55 | return result; 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /src/schemas/types/SearchType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLUnionType } from 'graphql'; 2 | 3 | import { Book, Author } from '../../models'; 4 | import { BookType, AuthorType } from '../types'; 5 | 6 | 7 | export const SearchType = new GraphQLUnionType({ 8 | name: 'SearchItem', 9 | types: [BookType, AuthorType], 10 | resolveType: (value: Book | Author) => { 11 | if (value instanceof Book) { 12 | return BookType; 13 | } 14 | if (value instanceof Author) { 15 | return AuthorType; 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/schemas/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BookType'; 2 | export * from './AuthorType'; 3 | export * from './SearchType'; 4 | -------------------------------------------------------------------------------- /src/services/AuthorService.ts: -------------------------------------------------------------------------------- 1 | import { AuthorRepository } from '../repositories'; 2 | import { AuthorModel } from '../models/AuthorModel'; 3 | import { Logger } from '../core/Logger'; 4 | import { NotFoundException } from '../exceptions'; 5 | 6 | export class AuthorService { 7 | 8 | private log = Logger('app:service:AuthorService'); 9 | 10 | constructor(private authorRepository: AuthorRepository) { 11 | } 12 | 13 | public async findAll(options: common.PageinationArguments): Promise { 14 | this.log.debug('findAll called'); 15 | const results = await this.authorRepository.findAll(options); 16 | return results.map((result) => new AuthorModel(result)); 17 | } 18 | 19 | public async findByIds(ids: number[]): Promise { 20 | this.log.debug('findByIds called with ids=', ids); 21 | const results = await this.authorRepository.findByIds(ids); 22 | return results.map((result) => new AuthorModel(result)); 23 | } 24 | 25 | public async findById(id: number): Promise { 26 | this.log.debug('findById called with id=', id); 27 | const result = await this.authorRepository.findById(id); 28 | if (result === null) { 29 | throw new NotFoundException(id); 30 | } 31 | return new AuthorModel(result); 32 | } 33 | 34 | public async search(text: string): Promise { 35 | this.log.debug('search called with text=', text); 36 | const results = await this.authorRepository.search(text); 37 | return results.map((result) => new AuthorModel(result)); 38 | } 39 | 40 | public async create(authorModel: AuthorModel): Promise { 41 | this.log.debug('create called with =', authorModel); 42 | const id = await this.authorRepository.create(authorModel.toDatabaseObject()); 43 | return this.findById(id); 44 | } 45 | 46 | public async update(newAuthorModel: AuthorModel): Promise { 47 | const authorModel = await this.findById(newAuthorModel.Id); 48 | authorModel.merge(newAuthorModel); 49 | await this.authorRepository.update(authorModel.toDatabaseObject()); 50 | return this.findById(newAuthorModel.Id); 51 | } 52 | 53 | public async delete(id: number): Promise { 54 | this.log.debug('delete called with id=', id); 55 | return this.authorRepository.delete(id); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/services/BookService.ts: -------------------------------------------------------------------------------- 1 | import { BookRepository } from '../repositories'; 2 | import { BookModel } from '../models/BookModel'; 3 | import { Logger } from '../core/Logger'; 4 | import { NotFoundException } from '../exceptions'; 5 | 6 | 7 | export class BookService { 8 | 9 | private log = Logger('app:service:BookService'); 10 | 11 | constructor(private bookRepository: BookRepository) { 12 | } 13 | 14 | public async findAll(options: common.PageinationArguments): Promise { 15 | this.log.debug('findAll called'); 16 | const results = await this.bookRepository.findAll(options); 17 | return results.map((result) => new BookModel(result)); 18 | } 19 | 20 | public async findByIds(ids: number[]): Promise { 21 | this.log.debug('findByIds called with ids=', ids); 22 | const results = await this.bookRepository.findByIds(ids); 23 | return results.map((result) => new BookModel(result)); 24 | } 25 | 26 | public async findById(id: number): Promise { 27 | this.log.debug('findById called with id=', id); 28 | const result = await this.bookRepository.findById(id); 29 | if (result === null) { 30 | throw new NotFoundException(id); 31 | } 32 | return new BookModel(result); 33 | } 34 | 35 | public async findByAuthorId(authorId: number): Promise { 36 | this.log.debug('findByAuthorId called with authorId=', authorId); 37 | const results = await this.bookRepository.findByAuthorId(authorId); 38 | return results.map((result) => new BookModel(result)); 39 | } 40 | 41 | public async search(text: string): Promise { 42 | this.log.debug('search called with text=', text); 43 | const results = await this.bookRepository.search(text); 44 | return results.map((result) => new BookModel(result)); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthorService'; 2 | export * from './BookService'; 3 | -------------------------------------------------------------------------------- /test/mocks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3tecch/express-graphql-typescript-boilerplate/28b2e84ee0063d6cc464a45bd05be2bc914b3a34/test/mocks/.gitkeep -------------------------------------------------------------------------------- /test/unit/context/context.spec.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../../../src/context/Context'; 2 | 3 | 4 | describe('Context', () => { 5 | describe('Response', () => { 6 | it('should return the response object', () => { 7 | const res: any = 1; 8 | const context = new Context(undefined, res, undefined, undefined); 9 | expect(context.Response).toBe(1); 10 | }); 11 | }); 12 | describe('Request', () => { 13 | it('should return the request object', () => { 14 | const req: any = 1; 15 | const context = new Context(req, undefined, undefined, undefined); 16 | expect(context.Request).toBe(1); 17 | }); 18 | }); 19 | describe('Services', () => { 20 | it('should return the repositories object', () => { 21 | const services: any = 1; 22 | const context = new Context(undefined, undefined, undefined, services); 23 | expect(context.Services).toBe(1); 24 | }); 25 | }); 26 | describe('DataLoaders', () => { 27 | it('should return the dataLoaders object', () => { 28 | const dataLoaders: any = 1; 29 | const context = new Context(undefined, undefined, dataLoaders, undefined); 30 | expect(context.DataLoaders).toBe(1); 31 | }); 32 | }); 33 | describe('hasUserRoles', () => { 34 | it('should return the dataLoaders object', () => { 35 | const req: any = { 36 | acceptsLanguages: () => 'de' 37 | }; 38 | const context = new Context(req, undefined, undefined, undefined); 39 | expect(context.getLanguage()).toBe('de'); 40 | }); 41 | }); 42 | describe('hasUserRoles', () => { 43 | it('should return the request object', () => { 44 | const context = new Context(undefined, undefined, undefined, undefined); 45 | expect(context.hasUserRoles([])).toBeTruthy(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/core/GraphQLErrorHandling.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLSchema, 4 | GraphQLString 5 | } from 'graphql'; 6 | 7 | import { GraphQLErrorHandling, Processed } from '../../../src/core/GraphQLErrorHandling'; 8 | import { Exception } from '../../../src/exceptions'; 9 | 10 | 11 | describe('Core:GraphQLErrorHandling', () => { 12 | 13 | let schema; 14 | beforeEach(() => { 15 | // A simple graphql schema to run tests 16 | schema = new GraphQLSchema({ 17 | query: new GraphQLObjectType({ 18 | name: 'RootQueryType', 19 | fields: { 20 | throwError: { 21 | type: GraphQLString, 22 | resolve(): void { throw new Error('secret error'); } 23 | }, 24 | throwInPromise: { 25 | type: GraphQLString, 26 | resolve(): Promise { 27 | return new Promise(() => { 28 | throw new Error('secret error'); 29 | }); 30 | } 31 | }, 32 | throwUserError: { 33 | type: GraphQLString, 34 | resolve(): void { throw new Exception('custom error'); } 35 | }, 36 | rejectPromise: { 37 | type: GraphQLString, 38 | resolve(): Promise { 39 | return new Promise((resolve, reject) => { 40 | reject(new Error('secret error')); 41 | }); 42 | } 43 | } 44 | } 45 | }) 46 | }); 47 | }); 48 | 49 | describe('User Error', () => { 50 | it('should extend Error type', () => { 51 | const msg = 'hello world'; 52 | const err = new Exception(msg); 53 | expect(err instanceof Error); 54 | expect(err instanceof Exception); 55 | expect(err.message).toBe(msg); 56 | }); 57 | }); 58 | 59 | describe('handlingErrors', () => { 60 | it('should mask errors in fields', async (done) => { 61 | GraphQLErrorHandling.watch(schema); 62 | 63 | const field = schema.getTypeMap().RootQueryType.getFields().throwError; 64 | expect(field[Processed]).toEqual(true); 65 | 66 | try { 67 | await field.resolve(); 68 | fail('Should throw a normal error'); 69 | } catch (e) { 70 | expect(e.message).toContain('InternalError:'); 71 | } 72 | done(); 73 | }); 74 | 75 | it('should mask errors in types', async (done) => { 76 | GraphQLErrorHandling.watch(schema); 77 | const fields = schema.getTypeMap().RootQueryType.getFields(); 78 | 79 | for (const fieldName in fields) { 80 | if (!fields.hasOwnProperty(fieldName)) { 81 | continue; 82 | } 83 | 84 | const field = fields[fieldName]; 85 | expect(field[Processed]).toEqual(true); 86 | 87 | let resolveErr = null; 88 | try { 89 | await field.resolve(); 90 | } catch (e) { 91 | resolveErr = e; 92 | } 93 | 94 | if (fieldName === 'throwUserError') { 95 | expect(resolveErr.message).toContain('Exception'); 96 | expect(resolveErr.message).toContain('custom error'); 97 | } else { 98 | expect(resolveErr.message).toContain('InternalError:'); 99 | } 100 | } 101 | done(); 102 | }); 103 | 104 | it('should mask errors in schema', async (done) => { 105 | GraphQLErrorHandling.watch(schema); 106 | const fields = schema.getTypeMap().RootQueryType.getFields(); 107 | 108 | for (const fieldName in fields) { 109 | if (!fields.hasOwnProperty(fieldName)) { 110 | continue; 111 | } 112 | 113 | const field = fields[fieldName]; 114 | expect(field[Processed]).toEqual(true); 115 | 116 | let resolveErr = null; 117 | try { 118 | await field.resolve(); 119 | } catch (e) { 120 | resolveErr = e; 121 | } 122 | 123 | if (fieldName === 'throwUserError') { 124 | expect(resolveErr.message).toContain('Exception'); 125 | expect(resolveErr.message).toContain('custom error'); 126 | } else { 127 | expect(resolveErr.message).toContain('InternalError:'); 128 | } 129 | } 130 | done(); 131 | }); 132 | }); 133 | 134 | }); 135 | -------------------------------------------------------------------------------- /test/unit/core/Utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from '../../../src/core/Utils'; 2 | 3 | 4 | describe('Utils', () => { 5 | describe('hasResults', () => { 6 | it('should return true if the lenght is bigger than 1', () => { 7 | expect(Utils.hasResults([1])).toBeTruthy(); 8 | }); 9 | it('should return false if the lenght is 0', () => { 10 | expect(Utils.hasResults([])).toBeFalsy(); 11 | expect(Utils.hasResults(null)).toBeFalsy(); 12 | expect(Utils.hasResults(undefined)).toBeFalsy(); 13 | }); 14 | }); 15 | describe('assertResult', () => { 16 | it('should do nothing if a result is given', () => { 17 | Utils.assertResult({}, 1); 18 | expect(true); 19 | }); 20 | it('should throw an error if no result is given', () => { 21 | try { 22 | Utils.assertResult(null, 1); 23 | } catch (e) { 24 | expect(e); 25 | } 26 | }); 27 | }); 28 | describe('assertResults', () => { 29 | it('should do nothing if a result is given', () => { 30 | Utils.assertResults([1], 1); 31 | expect(true); 32 | }); 33 | it('should throw an error if no result is given', () => { 34 | try { 35 | Utils.assertResults([], 1); 36 | } catch (e) { 37 | expect(e); 38 | } 39 | }); 40 | }); 41 | describe('single', () => { 42 | it('should return the first elment of an array', () => { 43 | let a = [1, 2]; 44 | expect(Utils.single(a)).toBe(1); 45 | }); 46 | it('should return null if the array is empty', () => { 47 | expect(Utils.single([])).toBe(null); 48 | expect(Utils.single(null)).toBe(null); 49 | expect(Utils.single(undefined)).toBe(null); 50 | }); 51 | }); 52 | describe('isPositve', () => { 53 | it('should return true if the given number is bigger or equal zero', () => { 54 | expect(Utils.isPositve(0)).toBeTruthy(); 55 | expect(Utils.isPositve(1)).toBeTruthy(); 56 | }); 57 | it('should return false if the given number is below zero', () => { 58 | expect(Utils.isPositve(-1)).toBeFalsy(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/unit/core/bootstrap.spec.ts: -------------------------------------------------------------------------------- 1 | // import { init, run } from '../../../src/core/bootstrap'; 2 | 3 | // describe('Core:Bootstrap', () => { 4 | // describe('init', () => { 5 | // it('Should return a defined object', () => { 6 | // expect(init()).toBeDefined(); 7 | // }); 8 | // }); 9 | // describe('run', () => { 10 | // const appMock = { 11 | // listen: (port) => port 12 | // }; 13 | // let listenSpy; 14 | // beforeEach(() => { 15 | // listenSpy = spyOn(appMock, 'listen'); 16 | // }); 17 | // it('Should return a named pipe', () => { 18 | // const namedPipe = 'testPort'; 19 | // run(appMock, namedPipe); 20 | // expect(listenSpy).toHaveBeenCalledWith(namedPipe); 21 | // }); 22 | // it('Should return undefined as undefined', () => { 23 | // const port = 'undefined'; 24 | // run(appMock, port); 25 | // expect(listenSpy).toHaveBeenCalledWith(port); 26 | // }); 27 | // it('Should return null as null', () => { 28 | // const port = 'null'; 29 | // run(appMock, port); 30 | // expect(listenSpy).toHaveBeenCalledWith(port); 31 | // }); 32 | // it('Should return the correct port number if the prot is >= 0', () => { 33 | // const port = 0; 34 | // run(appMock, port); 35 | // expect(listenSpy).toHaveBeenCalledWith(port); 36 | // }); 37 | // it('Should return false if there was a negative port number', () => { 38 | // const port = -1; 39 | // run(appMock, port); 40 | // expect(listenSpy).toHaveBeenCalledWith(false); 41 | // }); 42 | // }); 43 | // }); 44 | -------------------------------------------------------------------------------- /test/unit/core/environment.spec.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '../../../src/core/Environment'; 2 | 3 | 4 | describe('Core:Environment', () => { 5 | describe('getConfig', () => { 6 | it('Should return a config object', () => { 7 | expect(Environment.getConfig()).toBeDefined(); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/unit/core/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger, debugStream, winstonStream } from '../../../src/core/Logger'; 2 | 3 | const log = Logger('test'); 4 | 5 | 6 | describe('Core:Logger', () => { 7 | describe('debugStream', () => { 8 | it('Should has a write property', () => { 9 | expect(debugStream.stream.write).toBeDefined(); 10 | }); 11 | it('Should not throw any error if calling the write method', () => { 12 | expect(debugStream.stream.write()).toBeUndefined(); 13 | }); 14 | }); 15 | describe('winstonStream', () => { 16 | it('Should has a write property', () => { 17 | expect(winstonStream.stream.write).toBeDefined(); 18 | }); 19 | it('Should not throw any error if calling the write method', () => { 20 | expect(winstonStream.stream.write()).toBeUndefined(); 21 | }); 22 | }); 23 | describe('log', () => { 24 | it('Should have a debug method', () => { 25 | expect(log.debug).toBeDefined(); 26 | }); 27 | it('Should have a verbose method', () => { 28 | expect(log.verbose).toBeDefined(); 29 | }); 30 | it('Should have a info method', () => { 31 | expect(log.info).toBeDefined(); 32 | }); 33 | it('Should have a warn method', () => { 34 | expect(log.warn).toBeDefined(); 35 | }); 36 | it('Should have a error method', () => { 37 | expect(log.error).toBeDefined(); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/unit/core/server.spec.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '../../../src/core'; 2 | 3 | 4 | describe('Core:Server', () => { 5 | describe('listenTo', () => { 6 | let serverMock; 7 | beforeEach(() => { 8 | serverMock = { 9 | error: () => void 0, 10 | listening: () => void 0, 11 | on: (channel, fn) => { 12 | switch (channel) { 13 | case 'listening': 14 | serverMock.listening = fn; 15 | break; 16 | case 'error': 17 | serverMock.error = fn; 18 | break; 19 | } 20 | } 21 | }; 22 | }); 23 | describe('listening', () => { 24 | let app; 25 | beforeEach(() => { 26 | app = { 27 | listen: () => serverMock 28 | }; 29 | }); 30 | it('Should register a subscriber for the listening channel', () => { 31 | const spy = spyOn(serverMock, 'on'); 32 | Server.run(app, undefined); 33 | expect(spy).toHaveBeenCalled(); 34 | expect(spy.calls.allArgs()[0][0]).toBe('listening'); 35 | }); 36 | it('Should call the onListening function', () => { 37 | const spy = spyOn(Server, 'onListening'); 38 | Server.run(app, undefined); 39 | serverMock.listening(); 40 | expect(spy).toHaveBeenCalled(); 41 | }); 42 | }); 43 | describe('error', () => { 44 | let app; 45 | beforeEach(() => { 46 | app = { 47 | listen: () => serverMock 48 | }; 49 | }); 50 | it('Should register a subscriber for the error channel', () => { 51 | const spy = spyOn(serverMock, 'on'); 52 | Server.run(app, undefined); 53 | expect(spy).toHaveBeenCalled(); 54 | expect(spy.calls.allArgs()[1][0]).toBe('error'); 55 | }); 56 | it('Should call the onError function', () => { 57 | const spy = spyOn(Server, 'onError'); 58 | Server.run(app, undefined); 59 | serverMock.error(new Error('Test')); 60 | expect(spy).toHaveBeenCalled(); 61 | }); 62 | }); 63 | }); 64 | describe('onListening', () => { 65 | let serverMock; 66 | beforeEach(() => { 67 | serverMock = { 68 | address: () => 'address' 69 | }; 70 | }); 71 | it('Should call the listening subscriber with the address as a string', () => { 72 | const spy = spyOn(serverMock, 'address').and.returnValue('address'); 73 | Server.onListening(serverMock); 74 | expect(spy).toHaveBeenCalled(); 75 | }); 76 | it('Should call the listening subscriber with the address as a Object', () => { 77 | const spy = spyOn(serverMock, 'address').and.returnValue({ port: 3000 }); 78 | Server.onListening(serverMock); 79 | expect(spy).toHaveBeenCalled(); 80 | }); 81 | }); 82 | describe('onError', () => { 83 | let serverMock; 84 | beforeEach(() => { 85 | serverMock = { 86 | address: () => 'address' 87 | }; 88 | }); 89 | it('Should throw a normal error', () => { 90 | const msg = 'test'; 91 | try { 92 | Server.onError(serverMock, new Error(msg)); 93 | fail('Should have thrown an error'); 94 | } catch (error) { 95 | expect(error.message).toBe(msg); 96 | } 97 | }); 98 | it('Should throw a normal error even if syscall property is defined with "listen"', () => { 99 | const msg = 'test'; 100 | let error = new Error(msg); 101 | error['syscall'] = 'listen'; 102 | error['code'] = 'OTHER'; 103 | try { 104 | Server.onError(serverMock, error); 105 | fail('Should have thrown an error'); 106 | } catch (error) { 107 | expect(error.message).toBe(msg); 108 | } 109 | }); 110 | it('Should throw a normal error even if syscall property is defined with "listen"', () => { 111 | const msg = 'test'; 112 | let error = new Error(msg); 113 | error['syscall'] = 'listen'; 114 | error['code'] = 'EACCES'; 115 | const spy = spyOn(process, 'exit'); 116 | Server.onError(serverMock, error); 117 | expect(spy).toHaveBeenCalledWith(1); 118 | }); 119 | it('Should throw a normal error even if syscall property is defined with "listen"', () => { 120 | const msg = 'test'; 121 | let error = new Error(msg); 122 | error['syscall'] = 'listen'; 123 | error['code'] = 'EADDRINUSE'; 124 | const spy = spyOn(process, 'exit'); 125 | Server.onError(serverMock, error); 126 | expect(spy).toHaveBeenCalledWith(1); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /test/unit/errors/Exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '../../../src/exceptions'; 2 | 3 | 4 | describe('Exception', () => { 5 | describe('hasName', () => { 6 | it('should return true if the given error has an error code in his message', () => { 7 | expect(Exception.hasName('AbZ:')).toBeTruthy(); 8 | expect(Exception.hasName(new Error('AbZ:a_z:'))).toBeTruthy(); 9 | }); 10 | it('should return false if the given error has no error code in his message', () => { 11 | expect(Exception.hasName('Test')).toBeFalsy(); 12 | expect(Exception.hasName(new Error('Test'))).toBeFalsy(); 13 | }); 14 | }); 15 | describe('getName', () => { 16 | it('should return the error code of the given message', () => { 17 | expect(Exception.getName('AbZ:')).toBe('AbZ'); 18 | }); 19 | it('should return 000 for an unknown error', () => { 20 | expect(Exception.getName('Test')).toBe('UnkownException'); 21 | }); 22 | }); 23 | describe('getMessage', () => { 24 | it('should return the message without the error code', () => { 25 | expect(Exception.getMessage('AbZ:message')).toBe('message'); 26 | expect(Exception.getMessage('message')).toBe('message'); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": false, 9 | "experimentalDecorators": true 10 | }, 11 | "filesGlob": [ 12 | "typings/**/*.d.ts", 13 | "typings_custom/**/*.d.ts" 14 | ], 15 | "exclude": [ 16 | "node_modules" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true 6 | ], 7 | "curly": true, 8 | "eofline": true, 9 | "forin": true, 10 | "indent": [ 11 | true, 12 | "spaces" 13 | ], 14 | "label-position": true, 15 | "max-line-length": [ 16 | true, 17 | 160 18 | ], 19 | "member-ordering": [ 20 | true, 21 | "public-before-private", 22 | "static-before-instance", 23 | "variables-before-functions" 24 | ], 25 | "no-arg": true, 26 | "no-bitwise": true, 27 | "no-console": [ 28 | true, 29 | "debug", 30 | "info", 31 | "time", 32 | "timeEnd", 33 | "trace" 34 | ], 35 | "no-construct": true, 36 | "no-debugger": true, 37 | "no-duplicate-variable": true, 38 | "no-empty": false, 39 | "no-eval": true, 40 | "no-inferrable-types": [ 41 | true, 42 | "ignore-params" 43 | ], 44 | "no-shadowed-variable": true, 45 | "no-string-literal": false, 46 | "no-switch-case-fall-through": true, 47 | "no-trailing-whitespace": true, 48 | "no-unused-expression": false, 49 | "no-use-before-declare": true, 50 | "no-var-keyword": false, 51 | "one-line": [ 52 | true, 53 | "check-open-brace", 54 | "check-catch", 55 | "check-else", 56 | "check-whitespace" 57 | ], 58 | "quotemark": [ 59 | true, 60 | "single" 61 | ], 62 | "radix": true, 63 | "semicolon": [ 64 | true, 65 | "always" 66 | ], 67 | "triple-equals": [ 68 | true, 69 | "allow-null-check" 70 | ], 71 | "typedef": [ 72 | true, 73 | "call-signature", 74 | "parameter" 75 | ], 76 | "variable-name": false, 77 | "trailing-comma": [ 78 | true, 79 | { 80 | "multiline": "never", 81 | "singleline": "never" 82 | } 83 | ], 84 | "whitespace": [ 85 | true, 86 | "check-branch", 87 | "check-decl", 88 | "check-operator", 89 | "check-separator", 90 | "check-type" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalDependencies": { 3 | "express-graphql": "registry:dt/express-graphql#0.0.0+20160626095004", 4 | "express-serve-static-core": "registry:dt/express-serve-static-core#4.0.0+20161012061536", 5 | "faker": "registry:dt/faker#3.1.0+20161119044246", 6 | "jasmine": "registry:dt/jasmine#2.5.0+20170117213604", 7 | "morgan": "registry:dt/morgan#1.7.0+20160524142355", 8 | "winston": "registry:dt/winston#0.0.0+20161108133445" 9 | }, 10 | "dependencies": { 11 | "bluebird": "registry:dt/bluebird#3.0.0+20161229155504", 12 | "body-parser": "registry:npm/body-parser#1.15.2+20161116154925", 13 | "cors": "registry:npm/cors#2.7.0+20160902012746", 14 | "debug": "registry:npm/debug#2.0.0+20160723033700", 15 | "express": "registry:dt/express#4.0.0+20170118060322", 16 | "graphql": "registry:npm/graphql#0.5.0+20160602041655", 17 | "helmet": "registry:dt/helmet#0.0.0+20161005184000", 18 | "knex": "registry:dt/knex#0.0.0+20170202190133", 19 | "mime": "registry:npm/mime#1.3.0+20160723033700", 20 | "lodash": "registry:npm/lodash#4.0.0+20161015015725", 21 | "request": "registry:dt/request#0.0.0+20170215005641", 22 | "serve-static": "registry:npm/serve-static#1.11.1+20161113104247" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /typings_custom/arguments.d.ts: -------------------------------------------------------------------------------- 1 | declare module arguments { 2 | 3 | interface ID { 4 | id: number; 5 | } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /typings_custom/cls.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'continuation-local-storage' { 2 | export const createNamespace: (name: string) => Object; 3 | } 4 | -------------------------------------------------------------------------------- /typings_custom/common.d.ts: -------------------------------------------------------------------------------- 1 | declare module common { 2 | 3 | interface PageinationArguments { 4 | limit: number; 5 | offset: number; 6 | } 7 | 8 | } 9 | -------------------------------------------------------------------------------- /typings_custom/config.d.ts: -------------------------------------------------------------------------------- 1 | declare module config { 2 | 3 | interface Environments { 4 | development: Configuration; 5 | test: Configuration; 6 | production: Configuration; 7 | } 8 | 9 | interface Configuration { 10 | database: ConfigurationDatabase; 11 | server: ConfigurationServer; 12 | logger: ConfigurationLogger; 13 | } 14 | 15 | interface ConfigurationDatabase { 16 | client: string; 17 | connection?: string; 18 | } 19 | 20 | interface ConfigurationServer { 21 | host: string; 22 | port: string; 23 | graphiql: boolean; 24 | } 25 | 26 | interface ConfigurationLogger { 27 | host?: string; 28 | port?: string; 29 | file?: ConfigurationLoggerConsole; 30 | console: ConfigurationLoggerConsole; 31 | debug: string; 32 | } 33 | 34 | interface ConfigurationLoggerConsole { 35 | level: string; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /typings_custom/models/author.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'models' { 2 | 3 | export namespace models { 4 | namespace author { 5 | 6 | interface Attributes { 7 | id?: number; 8 | firstName?: string; 9 | lastName?: string; 10 | updatedAt?: Date; 11 | createdAt?: Date; 12 | } 13 | 14 | interface RawAttributes { 15 | id?: number; 16 | first_name?: string; 17 | last_name?: string; 18 | updated_at?: Date; 19 | created_at?: Date; 20 | } 21 | 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /typings_custom/models/book.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'models' { 2 | 3 | export namespace models { 4 | namespace book { 5 | 6 | interface Attributes { 7 | id?: number; 8 | title?: string; 9 | description?: string; 10 | price?: number; 11 | publishedAt?: Date; 12 | authorId?: number; 13 | updatedAt?: Date; 14 | createdAt?: Date; 15 | } 16 | 17 | interface RawAttributes { 18 | id?: number; 19 | title?: string; 20 | description?: string; 21 | price?: number; 22 | published_at?: Date; 23 | author_id?: number; 24 | updated_at?: Date; 25 | created_at?: Date; 26 | } 27 | 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = (wallaby) => { 2 | process.env.NODE_ENV = 'test'; 3 | // View test statistics (for locally running wallaby): http://wallabyjs.com/app/#/tests 4 | return { 5 | 6 | env: { 7 | // to specify system wide node as test runner, e.g. node 6 instead of node 4 from wallaby 8 | // add "runner: '/usr/local/bin/node'" containing the correct path to your node 9 | type: 'node' 10 | }, 11 | 12 | files: [ 13 | { pattern: 'src/**/*.ts' }, 14 | { pattern: 'test/unit/**/*.ts' }, 15 | { pattern: '!src/index.ts' }, 16 | { pattern: '!src/**/*.d.ts' }, 17 | { pattern: '!test/**/*.spec.ts' } 18 | ], 19 | 20 | tests: [ 21 | { pattern: 'test/unit/**/*.spec.ts' } 22 | ], 23 | 24 | debug: true, 25 | 26 | testFramework: 'jasmine', 27 | 28 | compilers: { 29 | '**/*.ts': wallaby.compilers.typeScript({ 30 | typescript: require('typescript'), 31 | module: 'commonjs' 32 | }) 33 | }, 34 | 35 | workers: { 36 | recycle: true, 37 | initial: 1, 38 | regular: 1 39 | } 40 | 41 | }; 42 | }; 43 | --------------------------------------------------------------------------------