diff --git a/.env.example b/.env.example index a682af6b..79e94b5b 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,13 @@ DB_DATABASE="my_database" DB_SYNCHRONIZE=false DB_LOGGING=false +# +# GraphQL +# +GRAPHQL_ENABLED=true +GRAPHQL_ROUTE="/graphql" +GRAPHQL_EDITOR=true + # # Swagger # diff --git a/.env.test b/.env.test index f08dec70..eafcd105 100644 --- a/.env.test +++ b/.env.test @@ -21,15 +21,16 @@ AUTH_ROUTE="http://localhost:3333/tokeninfo" # # DATABASE # -DB_TYPE="mysql" -DB_HOST="localhost" -DB_PORT=3306 -DB_USERNAME="root" -DB_PASSWORD="" -DB_DATABASE="my_database" -DB_SYNCHRONIZE=false +DB_TYPE="sqlite" +DB_DATABASE="./mydb.sql" DB_LOGGING=false +# +# GraphQL +# +GRAPHQL_ENABLED=true +GRAPHQL_ROUTE="/graphql" + # # Swagger # diff --git a/.gitignore b/.gitignore index 032e2e7e..73268fb2 100755 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ test/**/*.js test/**/*.js.map coverage/ !test/preprocessor.js +mydb.sql diff --git a/README.md b/README.md index ca0259f7..c638d300 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ [Jest](https://facebook.github.io/jest/), [Swagger](http://swagger.io/), [validatejs](https://validatejs.org/), +[GraphQL](http://graphql.org/), +[DataLoaders](https://github.com/facebook/dataloader), by [w3tech](https://github.com/w3tecch) ## Why @@ -44,6 +46,8 @@ Try it!! We are happy to hear your feedback or any kind of new features. - **Easy event dispatching** thanks to [event-dispatch](https://github.com/pleerock/event-dispatch). - **Fast Database Building** with simple migration from [TypeORM](https://github.com/typeorm/typeorm). - **Easy Data Seeding** with our own factories. +- **GraphQL** provides as a awesome query language for our api [GraphQL](http://graphql.org/). +- **DataLoaders** helps with performance thanks to caching and batching [DataLoaders](https://github.com/facebook/dataloader). ### Comming soon @@ -128,6 +132,7 @@ All script are defined in the package.json file, but the most important ones are ### Tests - Run the unit tests using `npm start test` (There is also a vscode task for this called `test`). +- Run the integration tests using `npm start test:integration`. - Run the e2e tests using `npm start test:e2e` and don't forget to start your application and your [Auth0 Mock Server](https://github.com/hirsch88/auth0-mock-server). ### Running in dev mode @@ -165,9 +170,11 @@ The swagger and the monitor route can be altered in the `.env` file. | Route | Description | | -------------- | ----------- | | **/api** | Shows us the name, description and the version of the package.json | +| **/graphql** | Route to the graphql editor or your query/mutations requests | | **/swagger** | This is the Swagger UI with our API documentation | | **/monitor** | Shows a small monitor page for the server | | **/api/users** | Example entity endpoint | +| **/api/pets** | Example entity endpoint | ## Project Structure @@ -187,6 +194,9 @@ The swagger and the monitor route can be altered in the `.env` file. | **src/api/services/** | Service layer | | **src/api/subscribers/** | Event subscribers | | **src/api/validators/** | Custom validators, which can be used in the request classes | +| **src/api/queries/** | GraphQL queries | +| **src/api/mutations/** | GraphQL mutations | +| **src/api/types/** | GraphQL types | | **src/api/** swagger.json | Swagger documentation | | **src/auth/** | Authentication checkers and services | | **src/core/** | The core features like logger and env variables | @@ -199,9 +209,12 @@ The swagger and the monitor route can be altered in the `.env` file. | **src/types/** *.d.ts | Custom type definitions and files that aren't on DefinitelyTyped | | **test** | Tests | | **test/e2e/** *.test.ts | End-2-End tests (like e2e) | +| **test/integration/** *.test.ts | Integration test with SQLite3 | | **test/unit/** *.test.ts | Unit tests | | .env.example | Environment configurations | +| .env.test | Test environment configurations | | ormconfig.json | TypeORM configuration for the database. Used by seeds and the migration. (generated file) | +| mydb.sql | SQLite database for integration tests. Ignored by git and only available after integration tests | ## Logging @@ -378,6 +391,9 @@ npm start db.seed | [Auth0 API Documentation](https://auth0.com/docs/api/management/v2) | Authentification service | | [Jest](http://facebook.github.io/jest/) | Delightful JavaScript Testing Library for unit and e2e tests | | [swagger Documentation](http://swagger.io/) | API Tool to describe and document your api. | +| [SQLite Documentation](https://www.sitepoint.com/getting-started-sqlite3-basic-commands/) | Getting Started with SQLite3 – Basic Commands. | +| [GraphQL Documentation](http://graphql.org/graphql-js/) | A query language for your API. | +| [DataLoader Documentation](https://github.com/facebook/dataloader) | DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching. | ## Related Projects diff --git a/package-scripts.js b/package-scripts.js index 749c6e23..a232686e 100644 --- a/package-scripts.js +++ b/package-scripts.js @@ -6,77 +6,57 @@ const { series, crossEnv, concurrent, rimraf, runInNewWindow } = require('nps-ut module.exports = { scripts: { - default: { - script: 'nps start' - }, + default: 'nps start', /** * Starts the builded app from the dist directory */ - start: { - script: 'node dist/app.js' - }, + start: 'node dist/app.js', /** * Serves the current app and watches for changes to restart it */ - serve: { - script: series( - 'nps banner.serve', - 'nodemon --watch src --watch .env' - ) - }, + serve: series( + 'nps banner.serve', + 'nodemon --watch src --watch .env' + ), /** * Setup's the development environment and the database */ - setup: { - script: series( - 'yarn install', - 'nps db.migrate', - 'nps db.seed' - ) - }, + setup: series( + 'yarn install', + 'nps db.migrate', + 'nps db.seed' + ), /** * Builds the app into the dist directory */ - build: { - script: series( - 'nps banner.build', - 'nps lint', - 'nps clean.dist', - 'nps transpile', - 'nps copy' - ) - }, + build: series( + 'nps banner.build', + 'nps lint', + 'nps clean.dist', + 'nps transpile', + 'nps copy' + ), /** * Database scripts */ db: { - migrate: { - script: series( - 'nps banner.migrate', - 'nps db.config', - runFast('./node_modules/typeorm/cli.js migrations:run') - ) - }, - revert: { - script: series( - 'nps banner.revert', - 'nps db.config', - runFast('./node_modules/typeorm/cli.js migrations:revert') - ) - }, - seed: { - script: series( - 'nps banner.seed', - 'nps db.config', - runFast('./src/lib/seeds/') - ) - }, - config: { - script: runFast('./src/lib/ormconfig.ts') - }, - drop: { - script: runFast('./node_modules/typeorm/cli.js schema:drop') - } + migrate: series( + 'nps banner.migrate', + 'nps db.config', + runFast('./node_modules/typeorm/cli.js migrations:run') + ), + revert: series( + 'nps banner.revert', + 'nps db.config', + runFast('./node_modules/typeorm/cli.js migrations:revert') + ), + seed: series( + 'nps banner.seed', + 'nps db.config', + runFast('./src/lib/seeds/') + ), + config: runFast('./src/lib/ormconfig.ts'), + drop: runFast('./node_modules/typeorm/cli.js schema:drop') }, /** * These run various kinds of tests. Default is unit. @@ -84,25 +64,25 @@ module.exports = { test: { default: 'nps test.unit', unit: { - default: { - script: series( - 'nps banner.test', - 'nps test.unit.pretest', - 'nps test.unit.run' - ) - }, - pretest: { - script: 'tslint -c ./tslint.json -t stylish ./test/unit/**/*.ts' - }, - run: { - script: 'cross-env NODE_ENV=test jest --testPathPattern=unit' - }, - verbose: { - script: 'nps "test --verbose"' - }, - coverage: { - script: 'nps "test --coverage"' - } + default: series( + 'nps banner.test', + 'nps test.unit.pretest', + 'nps test.unit.run' + ), + pretest: 'tslint -c ./tslint.json -t stylish ./test/unit/**/*.ts', + run: 'cross-env NODE_ENV=test jest --testPathPattern=unit', + verbose: 'nps "test --verbose"', + coverage: 'nps "test --coverage"' + }, + integration: { + default: series( + 'nps banner.test', + 'nps test.integration.pretest', + 'nps test.integration.run' + ), + pretest: 'tslint -c ./tslint.json -t stylish ./test/integration/**/*.ts', + verbose: 'nps "test.integration --verbose"', + run: 'cross-env NODE_ENV=test jest --testPathPattern=integration -i', }, e2e: { default: { @@ -128,51 +108,37 @@ module.exports = { /** * Runs TSLint over your project */ - lint: { - script: `tslint -c ./tslint.json -p tsconfig.json src/**/*.ts --format stylish` - }, + lint: `tslint -c ./tslint.json -p tsconfig.json src/**/*.ts --format stylish`, /** * Transpile your app into javascript */ - transpile: { - script: `tsc` - }, + transpile: `tsc`, /** * Clean files and folders */ clean: { - default: { - script: series( - `nps banner.clean`, - `nps clean.dist` - ) - }, - dist: { - script: rimraf('./dist') - } + default: series( + `nps banner.clean`, + `nps clean.dist` + ), + dist: rimraf('./dist') }, /** * Copies static files to the build folder */ copy: { - default: { - script: series( - `nps copy.swagger`, - `nps copy.public` - ) - }, - swagger: { - script: copy( - './src/api/swagger.json', - './dist' - ) - }, - public: { - script: copy( - './src/public/*', - './dist' - ) - } + default: series( + `nps copy.swagger`, + `nps copy.public` + ), + swagger: copy( + './src/api/swagger.json', + './dist' + ), + public: copy( + './src/public/*', + './dist' + ) }, /** * This creates pretty banner to the terminal diff --git a/package.json b/package.json index 7d979a98..d3967c50 100644 --- a/package.json +++ b/package.json @@ -59,14 +59,17 @@ "compression": "^1.7.1", "copyfiles": "^1.2.0", "cors": "^2.8.4", + "dataloader": "^1.3.0", "dotenv": "^4.0.0", "event-dispatch": "^0.4.1", "express": "^4.16.2", "express-basic-auth": "^1.1.3", + "express-graphql": "^0.6.11", "express-status-monitor": "^1.0.1", "faker": "^4.1.0", "figlet": "^1.2.0", "glob": "^7.1.2", + "graphql": "^0.11.7", "helmet": "^3.9.0", "jsonfile": "^4.0.0", "lodash": "^4.17.4", @@ -113,6 +116,7 @@ "mock-express-request": "^0.2.0", "mock-express-response": "^0.2.1", "nock": "^9.1.0", + "sqlite3": "^3.1.13", "ts-jest": "^21.1.4" } } diff --git a/src/api/middlewares/CompressionMiddleware.ts b/src/api/middlewares/CompressionMiddleware.ts index c508e675..a487e207 100644 --- a/src/api/middlewares/CompressionMiddleware.ts +++ b/src/api/middlewares/CompressionMiddleware.ts @@ -5,7 +5,7 @@ import { ExpressMiddlewareInterface, Middleware } from 'routing-controllers'; @Middleware({ type: 'before' }) -export class SecurityMiddleware implements ExpressMiddlewareInterface { +export class CompressionMiddleware implements ExpressMiddlewareInterface { public use(req: express.Request, res: express.Response, next: express.NextFunction): any { return compression()(req, res, next); diff --git a/src/api/models/Pet.ts b/src/api/models/Pet.ts index 7556ff5d..8d353834 100644 --- a/src/api/models/Pet.ts +++ b/src/api/models/Pet.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; import { IsNotEmpty } from 'class-validator'; import { User } from './User'; @@ -17,7 +17,13 @@ export class Pet { @Column() public age: number; + @Column({ + nullable: true, + }) + public userId: number; + @ManyToOne(type => User, user => user.pets) + @JoinColumn({ name: 'userId' }) public user: User; public toString(): string { diff --git a/src/api/mutations/CreatePetMutation.ts b/src/api/mutations/CreatePetMutation.ts new file mode 100644 index 00000000..8c92d32e --- /dev/null +++ b/src/api/mutations/CreatePetMutation.ts @@ -0,0 +1,32 @@ +import { + GraphQLFieldConfig, + GraphQLNonNull, + GraphQLString, + GraphQLInt +} from 'graphql'; +import { plainToClass } from 'class-transformer'; +import { AbstractGraphQLMutation } from '../../lib/graphql/AbstractGraphQLMutation'; +import { PetType } from '../types/PetType'; +import { PetService } from '../services/PetService'; +import { GraphQLContext, Mutation } from '../../lib/graphql'; +import { Pet } from '../models/Pet'; + +interface CreatePetMutationArguments { + name: string; + age: number; +} + +@Mutation() +export class CreatePetMutation extends AbstractGraphQLMutation, Pet, CreatePetMutationArguments> implements GraphQLFieldConfig { + public type = PetType; + public args = { + name: { type: new GraphQLNonNull(GraphQLString) }, + age: { type: new GraphQLNonNull(GraphQLInt) }, + }; + + public async run(root: any, args: CreatePetMutationArguments, context: GraphQLContext): Promise { + const petService = context.container.get(PetService); + const pet = await petService.create(plainToClass(Pet, args)); + return pet; + } +} diff --git a/src/api/queries/GetPetsQuery.ts b/src/api/queries/GetPetsQuery.ts new file mode 100644 index 00000000..375137a0 --- /dev/null +++ b/src/api/queries/GetPetsQuery.ts @@ -0,0 +1,22 @@ +import { GraphQLFieldConfig, GraphQLList } from 'graphql'; +import { Query, AbstractGraphQLQuery, GraphQLContext } from './../../lib/graphql'; +import { PetService } from '../services/PetService'; +import { PetType } from './../types/PetType'; +import { Logger } from '../../core/Logger'; +import { Pet } from '../models/Pet'; + +@Query() +export class GetPetsQuery extends AbstractGraphQLQuery, Pet[], any> implements GraphQLFieldConfig { + public type = new GraphQLList(PetType); + public allow = []; + public args = {}; + + private log = new Logger(__filename); + + public async run(root: any, args: any, context: GraphQLContext): Promise { + const pets = await context.container.get(PetService).find(); + this.log.info(`Found ${pets.length} pets`); + return pets; + } + +} diff --git a/src/api/queries/GetUsersQuery.ts b/src/api/queries/GetUsersQuery.ts new file mode 100644 index 00000000..fec2f681 --- /dev/null +++ b/src/api/queries/GetUsersQuery.ts @@ -0,0 +1,22 @@ +import { GraphQLFieldConfig, GraphQLList } from 'graphql'; +import { Query, AbstractGraphQLQuery, GraphQLContext } from './../../lib/graphql'; +import { UserService } from '../services/UserService'; +import { UserType } from './../types/UserType'; +import { User } from '../models/User'; +import { Logger } from '../../core/Logger'; + +@Query() +export class GetUsersQuery extends AbstractGraphQLQuery, User[], any> implements GraphQLFieldConfig { + public type = new GraphQLList(UserType); + public allow = []; + public args = {}; + + private log = new Logger(__filename); + + public async run(root: any, args: any, context: GraphQLContext): Promise { + const users = await context.container.get(UserService).find(); + this.log.info(`Found ${users.length} users`); + return users; + } + +} diff --git a/src/api/repositories/PetRepository.ts b/src/api/repositories/PetRepository.ts index 77e031ae..44673f0c 100644 --- a/src/api/repositories/PetRepository.ts +++ b/src/api/repositories/PetRepository.ts @@ -2,6 +2,16 @@ import { Repository, EntityRepository } from 'typeorm'; import { Pet } from '../models/Pet'; @EntityRepository(Pet) -export class PetRepository extends Repository { +export class PetRepository extends Repository { + + /** + * Find by userId is used for our data-loader to get all needed pets in one query. + */ + public findByUserIds(ids: string[]): Promise { + return this.createQueryBuilder() + .select() + .where(`pet.userId IN (${ids.map(id => `'${id}'`).join(', ')})`) + .getMany(); + } } diff --git a/src/api/services/PetService.ts b/src/api/services/PetService.ts index 9a063be9..c381975c 100644 --- a/src/api/services/PetService.ts +++ b/src/api/services/PetService.ts @@ -5,6 +5,7 @@ import { Pet } from '../models/Pet'; import { events } from '../subscribers/events'; import { EventDispatcher, EventDispatcherInterface } from '../../decorators/EventDispatcher'; import { Logger, LoggerInterface } from '../../decorators/Logger'; +import { User } from '../models/User'; @Service() @@ -21,6 +22,15 @@ export class PetService { return this.petRepository.find(); } + public findByUser(user: User): Promise { + this.log.info('Find all pets of the user', user.toString()); + return this.petRepository.find({ + where: { + userId: user.id, + }, + }); + } + public findOne(id: string): Promise { this.log.info('Find all pets'); return this.petRepository.findOne({ id }); diff --git a/src/api/types/PetType.ts b/src/api/types/PetType.ts new file mode 100644 index 00000000..ced8148e --- /dev/null +++ b/src/api/types/PetType.ts @@ -0,0 +1,44 @@ +import { + GraphQLID, + GraphQLString, + GraphQLInt, + GraphQLObjectType, + GraphQLFieldConfigMap, +} from 'graphql'; +import { OwnerType } from './UserType'; +import { Pet } from '../models/Pet'; +import { GraphQLContext } from '../../lib/graphql'; + +const PetFields: GraphQLFieldConfigMap = { + id: { + type: GraphQLID, + description: 'The ID', + }, + name: { + type: GraphQLString, + description: 'The name of the pet.', + }, + age: { + type: GraphQLInt, + description: 'The age of the pet in years.', + }, +}; + +export const PetOfUserType = new GraphQLObjectType({ + name: 'PetOfUser', + description: 'A users pet', + fields: () => ({ ...PetFields, ...{} }), +}); + +export const PetType = new GraphQLObjectType({ + name: 'Pet', + description: 'A single pet.', + fields: () => ({ ...PetFields, ...{ + owner: { + type: OwnerType, + description: 'The owner of the pet', + resolve: (pet: Pet, args: any, context: GraphQLContext) => + context.dataLoaders.users.load(pet.userId), + }, + } }), +}); diff --git a/src/api/types/UserType.ts b/src/api/types/UserType.ts new file mode 100644 index 00000000..45bccc4e --- /dev/null +++ b/src/api/types/UserType.ts @@ -0,0 +1,51 @@ +import { + GraphQLID, + GraphQLString, + GraphQLObjectType, + GraphQLFieldConfigMap, + GraphQLList, +} from 'graphql'; +import { GraphQLContext } from '../../lib/graphql'; +import { PetOfUserType } from './PetType'; +import { User } from '../models/User'; + +const UserFields: GraphQLFieldConfigMap = { + id: { + type: GraphQLID, + description: 'The ID', + }, + firstName: { + type: GraphQLString, + description: 'The first name of the user.', + }, + lastName: { + type: GraphQLString, + description: 'The last name of the user.', + }, + email: { + type: GraphQLString, + description: 'The email of this user.', + }, +}; + +export const UserType = new GraphQLObjectType({ + name: 'User', + description: 'A single user.', + fields: () => ({ ...UserFields, ...{ + pets: { + type: new GraphQLList(PetOfUserType), + description: 'The pets of a user', + resolve: async (user: User, args: any, context: GraphQLContext) => + // We use data-loaders to save db queries + context.dataLoaders.petByUserIds.loadMany([user.id]), + // This would be the case with a normal service, but not very fast + // context.container.get(PetService).findByUser(user), + }, + } }), +}); + +export const OwnerType = new GraphQLObjectType({ + name: 'Owner', + description: 'The owner of a pet', + fields: () => ({ ...UserFields, ...{} }), +}); diff --git a/src/app.ts b/src/app.ts index 86846c6d..d93e7b64 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ import { monitorLoader } from './loaders/monitorLoader'; import { homeLoader } from './loaders/homeLoader'; import { publicLoader } from './loaders/publicLoader'; import { iocLoader } from './loaders/iocLoader'; +import { graphqlLoader } from './loaders/graphqlLoader'; import { eventDispatchLoader } from './loaders/eventDispatchLoader'; @@ -38,6 +39,7 @@ bootstrapMicroframework({ monitorLoader, homeLoader, publicLoader, + graphqlLoader, ], }) .then(() => banner(log)) diff --git a/src/core/banner.ts b/src/core/banner.ts index ef494f10..a336cc3e 100644 --- a/src/core/banner.ts +++ b/src/core/banner.ts @@ -13,6 +13,9 @@ export function banner(log: Logger): void { log.info(`Version : ${env.app.version}`); log.info(``); log.info(`API Info : ${env.app.route}${env.app.routePrefix}`); + if (env.graphql.enabled) { + log.info(`GraphQL : ${env.app.route}${env.graphql.route}`); + } if (env.swagger.enabled) { log.info(`Swagger : ${env.app.route}${env.swagger.route}`); } diff --git a/src/core/env.ts b/src/core/env.ts index 73a6f444..3bf68160 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -13,6 +13,8 @@ dotenv.config({ path: path.join(process.cwd(), `.env${((process.env.NODE_ENV === export const env = { node: process.env.NODE_ENV || 'development', isProduction: process.env.NODE_ENV === 'production', + isTest: process.env.NODE_ENV === 'test', + isDevelopment: process.env.NODE_ENV === 'development', app: { name: getOsEnv('APP_NAME'), version: (pkg as any).version, @@ -29,6 +31,8 @@ export const env = { controllers: [path.join(__dirname, '..', 'api/**/*Controller{.js,.ts}')], middlewares: [path.join(__dirname, '..', 'api/**/*Middleware{.js,.ts}')], interceptors: [path.join(__dirname, '..', 'api/**/*Interceptor{.js,.ts}')], + queries: [path.join(__dirname, '..', 'api/**/*Query{.js,.ts}')], + mutations: [path.join(__dirname, '..', 'api/**/*Mutation{.js,.ts}')], }, }, log: { @@ -49,6 +53,11 @@ export const env = { synchronize: toBool(getOsEnv('DB_SYNCHRONIZE')), logging: toBool(getOsEnv('DB_LOGGING')), }, + graphql: { + enabled: toBool(getOsEnv('GRAPHQL_ENABLED')), + route: getOsEnv('GRAPHQL_ROUTE'), + editor: toBool(getOsEnv('GRAPHQL_EDITOR')), + }, swagger: { enabled: toBool(getOsEnv('SWAGGER_ENABLED')), route: getOsEnv('SWAGGER_ROUTE'), diff --git a/src/lib/graphql/AbstractGraphQLHooks.ts b/src/lib/graphql/AbstractGraphQLHooks.ts new file mode 100644 index 00000000..915cc12a --- /dev/null +++ b/src/lib/graphql/AbstractGraphQLHooks.ts @@ -0,0 +1,29 @@ +import { UserError } from './graphql-error-handling'; + +export abstract class AbstractGraphQLHooks { + + /** + * This is our before hook. Here we are able + * to alter the args object before the actual resolver(execute) + * will be called. + */ + public before(context: TContext, args: TArgs, source?: S): Promise | TArgs { + return args; + } + + /** + * This is our after hook. It will be called ater the actual resolver(execute). + * There you are able to alter the result before it is send to the client. + */ + public after(result: TResult, context: TContext, args?: TArgs, source?: S): Promise | TResult { + return result; + } + + /** + * This is our resolver, which should gather the needed data; + */ + public run(rootOrSource: S, args: TArgs, context: TContext): Promise | TResult { + throw new UserError('Query not implemented!'); + } + +} diff --git a/src/lib/graphql/AbstractGraphQLMutation.ts b/src/lib/graphql/AbstractGraphQLMutation.ts new file mode 100644 index 00000000..2f8564c1 --- /dev/null +++ b/src/lib/graphql/AbstractGraphQLMutation.ts @@ -0,0 +1,5 @@ +import { AbstractGraphQLQuery } from './AbstractGraphQLQuery'; + + +export abstract class AbstractGraphQLMutation extends AbstractGraphQLQuery { +} diff --git a/src/lib/graphql/AbstractGraphQLQuery.ts b/src/lib/graphql/AbstractGraphQLQuery.ts new file mode 100644 index 00000000..1a34e77b --- /dev/null +++ b/src/lib/graphql/AbstractGraphQLQuery.ts @@ -0,0 +1,20 @@ +import { AbstractGraphQLHooks } from './AbstractGraphQLHooks'; + + +export abstract class AbstractGraphQLQuery extends AbstractGraphQLHooks { + + /** + * This will be called by graphQL and they need it as a property function. + * We use this hook to add some more logic to it, + * like permission checking, before- and after hooks to alter some data. + */ + public resolve = async (root: S, args: TArgs, context: TContext): Promise => { + // We need to store the query arguments in the context so they can be accessed by subsequent resolvers + (context as any).resolveArgs = args; + args = await this.before(context, args); + let result = await this.run(root, args, context); + result = await this.after(result, context, args); + return result as TResult; + } + +} diff --git a/src/lib/graphql/GraphQLContext.ts b/src/lib/graphql/GraphQLContext.ts new file mode 100644 index 00000000..e8bb0250 --- /dev/null +++ b/src/lib/graphql/GraphQLContext.ts @@ -0,0 +1,16 @@ +import * as express from 'express'; +import * as DataLoader from 'dataloader'; +import { Container } from 'typedi'; + +export interface GraphQLContext { + container: typeof Container; + request: express.Request; + response: express.Response; + dataLoaders: GraphQLContextDataLoader; + resolveArgs?: TResolveArgs; + data?: TData; +} + +export interface GraphQLContextDataLoader { + [key: string]: DataLoader; +} diff --git a/src/lib/graphql/MetadataArgsStorage.ts b/src/lib/graphql/MetadataArgsStorage.ts new file mode 100644 index 00000000..e7862a7d --- /dev/null +++ b/src/lib/graphql/MetadataArgsStorage.ts @@ -0,0 +1,52 @@ +import { QueryMetadataArgs } from './QueryMetadataArgs'; +import { MutationMetadataArgs } from './MutationMetadataArgs'; + +/** + * Storage all metadatas read from decorators. + */ +export class MetadataArgsStorage { + + // ------------------------------------------------------------------------- + // Properties + // ------------------------------------------------------------------------- + + /** + * Registered controller metadata args. + */ + public queries: QueryMetadataArgs[] = []; + + /** + * Registered middleware metadata args. + */ + public mutations: MutationMetadataArgs[] = []; + + + // ------------------------------------------------------------------------- + // Public Methods + // ------------------------------------------------------------------------- + /** + * Filters registered queries by a given classes. + */ + public filterQueryMetadatasForClasses(classes: Array<() => void>): MutationMetadataArgs[] { + return this.queries.filter(ctrl => { + return classes.filter(cls => ctrl.target === cls).length > 0; + }); + } + /** + * Filters registered mutations by a given classes. + */ + public filterMutationMetadatasForClasses(classes: Array<() => void>): MutationMetadataArgs[] { + return this.mutations.filter(ctrl => { + return classes.filter(cls => ctrl.target === cls).length > 0; + }); + } + + /** + * Removes all saved metadata. + */ + public reset(): void { + this.queries = []; + this.mutations = []; + } + +} diff --git a/src/lib/graphql/Mutation.ts b/src/lib/graphql/Mutation.ts new file mode 100644 index 00000000..9cdf203a --- /dev/null +++ b/src/lib/graphql/Mutation.ts @@ -0,0 +1,10 @@ +import { getMetadataArgsStorage } from './index'; + + +export function Mutation(): any { + return (object: () => void) => { + getMetadataArgsStorage().mutations.push({ + target: object, + }); + }; +} diff --git a/src/lib/graphql/MutationMetadataArgs.ts b/src/lib/graphql/MutationMetadataArgs.ts new file mode 100644 index 00000000..7331f0da --- /dev/null +++ b/src/lib/graphql/MutationMetadataArgs.ts @@ -0,0 +1,6 @@ +export interface MutationMetadataArgs { + /** + * Indicates object which is used by this controller. + */ + target: () => void; +} diff --git a/src/lib/graphql/Query.ts b/src/lib/graphql/Query.ts new file mode 100644 index 00000000..0a6386c0 --- /dev/null +++ b/src/lib/graphql/Query.ts @@ -0,0 +1,10 @@ +import { getMetadataArgsStorage } from './index'; + + +export function Query(): any { + return (object: () => void) => { + getMetadataArgsStorage().queries.push({ + target: object, + }); + }; +} diff --git a/src/lib/graphql/QueryMetadataArgs.ts b/src/lib/graphql/QueryMetadataArgs.ts new file mode 100644 index 00000000..86ea5929 --- /dev/null +++ b/src/lib/graphql/QueryMetadataArgs.ts @@ -0,0 +1,6 @@ +export interface QueryMetadataArgs { + /** + * Indicates object which is used by this controller. + */ + target: () => void; +} diff --git a/src/lib/graphql/dataloader.ts b/src/lib/graphql/dataloader.ts new file mode 100644 index 00000000..7482ebd0 --- /dev/null +++ b/src/lib/graphql/dataloader.ts @@ -0,0 +1,21 @@ +export interface Identifiable { + id?: number | number; +} + +export function ensureInputOrder(ids: number[] | string[], result: T[], key: string): T[] { + // For the dataloader batching to work, the results must be in the same order and of the + // same length as the ids. See: https://github.com/facebook/dataloader#batch-function + const orderedResult: T[] = []; + for (const id of ids) { + const item = result.find(t => t[key] === id); + if (item) { + orderedResult.push(item); + } else { + /* tslint:disable */ + // @ts-ignore + orderedResult.push(null); + /* tslint:enable */ + } + } + return orderedResult; +} diff --git a/src/lib/graphql/graphql-error-handling.ts b/src/lib/graphql/graphql-error-handling.ts new file mode 100644 index 00000000..a8d2edff --- /dev/null +++ b/src/lib/graphql/graphql-error-handling.ts @@ -0,0 +1,125 @@ +// This feature is a copy from https://github.com/kadirahq/graphql-errors +import * as uuid from 'uuid'; +import { GraphQLObjectType, GraphQLSchema } from 'graphql'; + +import { env } from '../../core/env'; +import { Logger } from '../../core/Logger'; +const logger = new Logger('app:errors'); + + +// Mark field/type/schema +export const Processed = Symbol(); + +// Used to identify UserErrors +export const IsUserError = Symbol(); + +// UserErrors will be sent to the user +export class UserError extends Error { + constructor(...args: any[]) { + super(args[0]); + this.name = 'Error'; + this.message = args[0]; + this[IsUserError] = true; + Error.captureStackTrace(this); + } +} + +// Modifies errors before sending to the user +export let defaultHandler = (err?) => { + if (err[IsUserError]) { + return err; + } + const errId = uuid.v4(); + err.message = `${err.message}: ${errId}`; + if (!env.isTest) { + console.error(err && err.stack || err); + } + if (env.isProduction) { + logger.error(err); + } + err.message = `500: Internal Error: ${errId}`; + return err; +}; + +const maskField = (field, fn) => { + const resolveFn = field.resolve; + if (field[Processed] || !resolveFn) { + return; + } + + field[Processed] = true; + field.resolve = async (...args) => { + try { + const out = resolveFn.call(undefined, ...args); + return await Promise.resolve(out); + } catch (e) { + throw fn(e); + } + }; + + // save the original resolve function + field.resolve._resolveFn = resolveFn; +}; + +const maskType = (type, fn) => { + if (type[Processed] || !type.getFields) { + return; + } + + const fields = type.getFields(); + for (const fieldName in fields) { + if (!Object.hasOwnProperty.call(fields, fieldName)) { + continue; + } + maskField(fields[fieldName], fn); + } +}; + +const maskSchema = (schema, fn) => { + const types = schema.getTypeMap(); + for (const typeName in types) { + if (!Object.hasOwnProperty.call(types, typeName)) { + continue; + } + maskType(types[typeName], fn); + } +}; + +// Changes the default error handler function +export const setDefaultHandler = (handlerFn) => { + defaultHandler = handlerFn; +}; + +// Masks graphql schemas, types or individual fields +export const handlingErrors = (thing, fn = defaultHandler) => { + if (thing instanceof GraphQLSchema) { + maskSchema(thing, fn); + } else if (thing instanceof GraphQLObjectType) { + maskType(thing, fn); + } else { + maskField(thing, fn); + } +}; + +export const getErrorCode = (message: string): string => { + if (hasErrorCode(message)) { + return message.substring(0, 3); + } + return '500'; // unkown error code +}; + +export const getErrorMessage = (message: string): string => { + if (hasErrorCode(message)) { + return message.substring(5); + } + return message; +}; + +export const hasErrorCode = (error: any): boolean => { + let message = error; + if (error.message) { + message = error.message; + } + const reg = new RegExp('^[0-9]{3}: '); + return reg.test(message); +}; diff --git a/src/lib/graphql/importClassesFromDirectories.ts b/src/lib/graphql/importClassesFromDirectories.ts new file mode 100644 index 00000000..299194ef --- /dev/null +++ b/src/lib/graphql/importClassesFromDirectories.ts @@ -0,0 +1,34 @@ +import * as path from 'path'; + +/** + * Loads all exported classes from the given directory. + */ +export function importClassesFromDirectories(directories: string[], formats: string[] = ['.js', '.ts']): Array<() => void> { + + const loadFileClasses = (exported: any, allLoaded: Array<() => void>) => { + if (exported instanceof Function) { + allLoaded.push(exported); + } else if (exported instanceof Array) { + exported.forEach((i: any) => loadFileClasses(i, allLoaded)); + } else if (exported instanceof Object || typeof exported === 'object') { + Object.keys(exported).forEach(key => loadFileClasses(exported[key], allLoaded)); + } + + return allLoaded; + }; + + const allFiles = directories.reduce((allDirs, dir) => { + return allDirs.concat(require('glob').sync(path.normalize(dir))); + }, [] as string[]); + + const dirs = allFiles + .filter(file => { + const dtsExtension = file.substring(file.length - 5, file.length); + return formats.indexOf(path.extname(file)) !== -1 && dtsExtension !== '.d.ts'; + }) + .map(file => { + return require(file); + }); + + return loadFileClasses(dirs, []); +} diff --git a/src/lib/graphql/index.ts b/src/lib/graphql/index.ts new file mode 100644 index 00000000..766c8e44 --- /dev/null +++ b/src/lib/graphql/index.ts @@ -0,0 +1,210 @@ +import * as express from 'express'; +import * as GraphQLHTTP from 'express-graphql'; +import * as DataLoader from 'dataloader'; +import { GraphQLSchema, GraphQLObjectType } from 'graphql'; +import { Container as container, ObjectType } from 'typedi'; + +import { GraphQLContext, GraphQLContextDataLoader } from './GraphQLContext'; +import { MetadataArgsStorage } from './MetadataArgsStorage'; +import { importClassesFromDirectories } from './importClassesFromDirectories'; +import { handlingErrors, getErrorCode, getErrorMessage } from './graphql-error-handling'; +import { ensureInputOrder } from './dataloader'; +import { Repository, getCustomRepository, getRepository } from 'typeorm'; + +// ------------------------------------------------------------------------- +// Main exports +// ------------------------------------------------------------------------- + +export * from './Query'; +export * from './Mutation'; + +export * from './AbstractGraphQLHooks'; +export * from './AbstractGraphQLQuery'; +export * from './GraphQLContext'; +export * from './graphql-error-handling'; + +// ------------------------------------------------------------------------- +// Main Functions +// ------------------------------------------------------------------------- + +/** + * Creates a new dataloader with the typorm repository + */ +export function createDataLoader(obj: ObjectType, method?: string, key?: string): DataLoader { + let repository; + try { + repository = getCustomRepository>(obj); + } catch (errorRepo) { + try { + repository = getRepository(obj); + } catch (errorModel) { + throw new Error('Could not create a dataloader, because obj is nether model or repository!'); + } + } + + return new DataLoader(async (ids: number[]) => { + let items = []; + if (method) { + items = await repository[method](ids); + } else { + items = await repository.findByIds(ids); + } + + return ensureInputOrder(ids, items, key || 'id'); + }); +} + +/** + * Defines the options to create a GraphQLServer + */ +export interface GraphQLServerOptions { + queries: string[]; + mutations: string[]; + route?: string; + dataLoaders?: GraphQLContextDataLoader; + editorEnabled?: boolean; + contextData?: TData; +} + +/** + * Create GraphQL Server and bind it to the gieven express app + */ +export function createGraphQLServer(expressApp: express.Application, options: GraphQLServerOptions): void { + // collect queries & mutaions for our graphql schema + const schema = createSchema({ + queries: options.queries, + mutations: options.mutations, + }); + + // Handles internal errors and prints the stack to the console + handlingErrors(schema); + + // Add graphql layer to the express app + expressApp.use(options.route || '/graphql', (request: express.Request, response: express.Response) => { + + // Build GraphQLContext + const context: GraphQLContext = { + container, + request, + response, + dataLoaders: options.dataLoaders || {}, + resolveArgs: {}, + data: options.contextData, + }; + + // Setup GraphQL Server + GraphQLHTTP({ + schema, + context, + graphiql: options.editorEnabled || true, + formatError: error => ({ + code: getErrorCode(error.message), + message: getErrorMessage(error.message), + path: error.path, + }), + })(request, response); + }); +} + +/** + * Gets metadata args storage. + * Metadata args storage follows the best practices and stores metadata in a global variable. + */ +export function getMetadataArgsStorage(): MetadataArgsStorage { + if (!(global as any).graphqlMetadataArgsStorage) { + (global as any).graphqlMetadataArgsStorage = new MetadataArgsStorage(); + } + + return (global as any).graphqlMetadataArgsStorage; +} + +/** + * Create query name out of the class name + */ +export function createQueryName(name: string): string { + return lowercaseFirstLetter(removeSuffix(name, 'Query')); +} + +/** + * Create mutation name out of the class name + */ +export function createMutationName(name: string): string { + return lowercaseFirstLetter(removeSuffix(name, 'Mutation')); +} + +/** + * Removes the suffix + */ +export function removeSuffix(value: string, suffix: string): string { + return value.slice(0, value.length - suffix.length); +} + +/** + * LowerCase first letter + */ +export function lowercaseFirstLetter(s: string): string { + return s.charAt(0).toLowerCase() + s.slice(1); +} + +/** + * GraphQL schema options for building it + */ +export interface GraphQLSchemaOptions { + queries: string[]; + mutations: string[]; +} + +/** + * Create schema out of the @Query and @Mutation + */ +export function createSchema(options: GraphQLSchemaOptions): GraphQLSchema { + + // import all queries + let queryClasses: Array<() => void> = []; + if (options && options.queries && options.queries.length) { + queryClasses = (options.queries as any[]).filter(query => query instanceof Function); + const queryDirs = (options.queries as any[]).filter(query => typeof query === 'string'); + queryClasses.push(...importClassesFromDirectories(queryDirs)); + } + + const queries = {}; + queryClasses.forEach(queryClass => { + queries[createQueryName(queryClass.name)] = new queryClass(); + }); + + const RootQuery = new GraphQLObjectType({ + name: 'Query', + fields: queries, + }); + + // import all mutations + let mutationClasses: Array<() => void> = []; + if (options && options.mutations && options.mutations.length) { + mutationClasses = (options.mutations as any[]).filter(mutation => mutation instanceof Function); + const mutationDirs = (options.mutations as any[]).filter(mutation => typeof mutation === 'string'); + mutationClasses.push(...importClassesFromDirectories(mutationDirs)); + } + + const mutations = {}; + mutationClasses.forEach(mutationClass => { + mutations[createMutationName(mutationClass.name)] = new mutationClass(); + }); + + const RootMutation: GraphQLObjectType = new GraphQLObjectType({ + name: 'Mutation', + fields: mutations, + }); + + const schemaOptions: any = {}; + + if (queryClasses && queryClasses.length) { + schemaOptions.query = RootQuery; + } + + if (mutationClasses && mutationClasses.length) { + schemaOptions.mutation = RootMutation; + } + + return new GraphQLSchema(schemaOptions); + +} diff --git a/src/loaders/graphqlLoader.ts b/src/loaders/graphqlLoader.ts new file mode 100644 index 00000000..b53e9ca9 --- /dev/null +++ b/src/loaders/graphqlLoader.ts @@ -0,0 +1,26 @@ +import { MicroframeworkSettings, MicroframeworkLoader } from 'microframework'; +import { createGraphQLServer, createDataLoader } from '../lib/graphql'; +import { env } from '../core/env'; +import { PetRepository } from './../api/repositories/PetRepository'; +import { Pet } from './../api/models/Pet'; +import { UserRepository } from './../api/repositories/UserRepository'; + + +export const graphqlLoader: MicroframeworkLoader = (settings: MicroframeworkSettings | undefined) => { + if (settings && env.graphql.enabled) { + const expressApp = settings.getData('express_app'); + + createGraphQLServer(expressApp, { + route: env.graphql.route, + editorEnabled: env.graphql.editor, + queries: env.app.dirs.queries, + mutations: env.app.dirs.mutations, + dataLoaders: { + users: createDataLoader(UserRepository), + pets: createDataLoader(Pet), + petByUserIds: createDataLoader(PetRepository, 'findByUserIds', 'userId'), + }, + }); + + } +}; diff --git a/src/types/auth0.d.ts b/src/types/auth0.d.ts deleted file mode 100644 index 317ab01e..00000000 --- a/src/types/auth0.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * auth0 - * ---------------------------------------- - * - * Type definitions for the auth0 responses. - */ - -declare namespace auth0 { - - interface User { - user_id: string; - email: string; - email_verified: boolean; - picture: string; - created_at: Date; - updated_at: Date; - clientID?: string; - nickname?: string; - name?: string; - global_client_id?: string; - identities?: UserIdentities[]; - } - - interface UserIdentities { - user_id: string; - provider: string; - connection: string; - isSocial: boolean; - } - - interface Body { - client_id: string; - client_secret: string; - audience: string; - grant_type: string; - } - -} - -export as namespace auth0; -export = auth0; diff --git a/test/integration/PetService.test.ts b/test/integration/PetService.test.ts new file mode 100644 index 00000000..e84c384d --- /dev/null +++ b/test/integration/PetService.test.ts @@ -0,0 +1,30 @@ +import { Container } from 'typedi'; +import { createConnection, useContainer, Connection } from 'typeorm'; + +import { Pet } from '../../src/api/models/Pet'; +import { PetService } from './../../src/api/services/PetService'; +import { createDatabaseConnection, synchronizeDatabase, closeDatabase } from './utils/database'; + +describe('PetService', () => { + + let connection: Connection; + beforeAll(async () => connection = await createDatabaseConnection()); + beforeEach(() => synchronizeDatabase(connection)); + afterAll(() => closeDatabase(connection)); + + test('should create a new pet in the database', async (done) => { + const pet = new Pet(); + pet.name = 'test'; + pet.age = 1; + const service = Container.get(PetService); + const resultCreate = await service.create(pet); + expect(resultCreate.name).toBe(pet.name); + expect(resultCreate.age).toBe(pet.age); + + const resultFind = await service.findOne(resultCreate.id); + expect(resultFind.name).toBe(pet.name); + expect(resultFind.age).toBe(pet.age); + done(); + }); + +}); diff --git a/test/integration/utils/database.ts b/test/integration/utils/database.ts new file mode 100644 index 00000000..f3f3a569 --- /dev/null +++ b/test/integration/utils/database.ts @@ -0,0 +1,22 @@ +import { Container } from 'typedi'; +import { createConnection, useContainer, Connection } from 'typeorm'; +import { env } from '../../../src/core/env'; + +export const createDatabaseConnection = async (): Promise => { + useContainer(Container); + const connection = await createConnection({ + type: env.db.type as any, // See createConnection options for valid types + database: env.db.database, + logging: env.db.logging, + entities: env.app.dirs.entities, + }); + return connection; +}; + +export const synchronizeDatabase = (connection: Connection) => { + return connection.synchronize(true); +}; + +export const closeDatabase = (connection: Connection) => { + return connection.close(); +}; diff --git a/yarn.lock b/yarn.lock index 9472fbd1..a8b5fe90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -140,7 +140,7 @@ accepts@1.3.3: mime-types "~2.1.11" negotiator "0.6.1" -accepts@^1.3.4, accepts@~1.3.4: +accepts@^1.3.0, accepts@^1.3.4, accepts@~1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f" dependencies: @@ -940,7 +940,7 @@ content-type-parser@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.2.tgz#caabe80623e63638b2502fd4c7f12ff4ce2352e7" -content-type@^1.0.4, content-type@~1.0.4: +content-type@^1.0.2, content-type@^1.0.4, content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -1110,6 +1110,10 @@ dashify@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.2.2.tgz#6a07415a01c91faf4a32e38d9dfba71f61cb20fe" +dataloader@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.3.0.tgz#6fec5be4b30a712e4afd30b86b4334566b97673b" + date-fns@^1.23.0: version "1.29.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6" @@ -1388,6 +1392,15 @@ express-basic-auth@^1.1.3: dependencies: basic-auth "^1.1.0" +express-graphql@^0.6.11: + version "0.6.11" + resolved "https://registry.yarnpkg.com/express-graphql/-/express-graphql-0.6.11.tgz#3dce78d0643e78e7e3606646ce162025ba0585ab" + dependencies: + accepts "^1.3.0" + content-type "^1.0.2" + http-errors "^1.3.0" + raw-body "^2.1.0" + express-status-monitor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/express-status-monitor/-/express-status-monitor-1.0.1.tgz#311288347b7aabfeaec0a01547e55c77652bb298" @@ -1736,6 +1749,12 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" +graphql@^0.11.7: + version "0.11.7" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.11.7.tgz#e5abaa9cb7b7cccb84e9f0836bf4370d268750c6" + dependencies: + iterall "1.1.3" + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -1898,7 +1917,7 @@ html-encoding-sniffer@^1.0.1: dependencies: whatwg-encoding "^1.0.1" -http-errors@1.6.2, http-errors@~1.6.2: +http-errors@1.6.2, http-errors@^1.3.0, http-errors@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" dependencies: @@ -2210,6 +2229,10 @@ istanbul-reports@^1.1.3: dependencies: handlebars "^4.0.3" +iterall@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.1.3.tgz#1cbbff96204056dde6656e2ed2e2226d0e6d72c9" + jest-changed-files@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-21.2.0.tgz#5dbeecad42f5d88b482334902ce1cba6d9798d29" @@ -2900,6 +2923,10 @@ nan@^2.3.0: version "2.8.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a" +nan@~2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -2949,7 +2976,7 @@ node-notifier@^5.0.2: shellwords "^0.1.0" which "^1.2.12" -node-pre-gyp@^0.6.39: +node-pre-gyp@^0.6.39, node-pre-gyp@~0.6.38: version "0.6.39" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649" dependencies: @@ -3426,7 +3453,7 @@ range-parser@^1.2.0, range-parser@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" -raw-body@2.3.2: +raw-body@2.3.2, raw-body@^2.1.0: version "2.3.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" dependencies: @@ -3893,6 +3920,13 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +sqlite3@^3.1.13: + version "3.1.13" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-3.1.13.tgz#d990a05627392768de6278bafd1a31fdfe907dd9" + dependencies: + nan "~2.7.0" + node-pre-gyp "~0.6.38" + sqlstring@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.0.tgz#525b8a4fd26d6f71aa61e822a6caf976d31ad2a8"