├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .nycrc ├── CONTRIBUTING.md ├── README.md ├── example-consuming-client ├── .env ├── README.md ├── build.sh ├── package-lock.json ├── package.json ├── src │ ├── api.ts │ ├── index.ts │ └── schema.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── ApiBlueprint.ts ├── InMemoryCache.ts ├── Logger.ts ├── OpenApi.ts ├── RedisCache.ts ├── Route.ts ├── Router.ts ├── describeRouteVariables.ts ├── index.ts ├── traverseAndBuildOptimizedQuery.ts └── types.ts ├── test ├── InMemoryCache.test.js ├── Logger.test.js ├── RedisCache.test.js ├── Route.test.js ├── Router.test.js ├── schema.example.graphql └── traverseAndBuildOptimizedQuery.test.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | test -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "import" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/typescript" 12 | ], 13 | "rules": { 14 | "import/no-relative-parent-imports": "error", 15 | "import/no-dynamic-require": "error", 16 | "import/first": "error", 17 | "no-empty": "error", 18 | "quotes": ["error", "single"], 19 | "semi": ["error", "always"], 20 | "comma-dangle": ["error", "only-multiline"], 21 | "no-trailing-spaces": "error", 22 | "max-len": [ 23 | "warn", 24 | { 25 | "code": 110, 26 | "tabWidth": 2, 27 | "comments": 110, 28 | "ignoreComments": false, 29 | "ignoreTrailingComments": true, 30 | "ignoreUrls": true, 31 | "ignoreStrings": true, 32 | "ignoreTemplateLiterals": true, 33 | "ignoreRegExpLiterals": true 34 | } 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '...' 18 | 3. Scroll down to '...' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Issue Link 2 | 3 | If this feature / bugfix has a corresponding ticket, link here: 4 | 5 | [Issue: ${issueNumber}](https://github.com/Econify/graphql-rest-router/issues/${issueNumber}) 6 | 7 | Otherwise delete this section. 8 | 9 | ## Description 10 | 11 | - If feature: 12 | - Overview of assigned task including architecture, code changes required and acceptance criteria. 13 | 14 | - If bugfix: 15 | - Explanation of the problem's behavior & repro steps, the code culprit and the solution. 16 | 17 | ## PR Requirements 18 | 19 | Before requesting review this criteria must be met: 20 | 21 | - [ ] Overall test coverage >= current master? 22 | - [ ] Documentation included -- README.md updated, docs, etc. (if any behavioral changes)? 23 | - [ ] Backwards compatibility (if breaking change)? 24 | 25 | ## Screenshots 26 | 27 | Include screenshots or video screen captures showing relevant information when applicable 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | build 3 | .DS_Store 4 | node_modules 5 | *.swp 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "exclude": ["build", "example-consuming-client", "test"], 4 | "all": true 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to the GraphQl Rest Router contributing guide 2 | 3 | Thank you for your interest in our project and its continued health! 4 | 5 | This document's purpose is to explain our conventions, expectations and procedure for contributing to this code base. 6 | 7 | ## Issues 8 | ### Creating a new issue 9 | If you have a feature request, see a discrepency in our documentation or have found a bug, we strongly encourage you to help us better this library! 10 | 11 | Before you create a ticket, navigate to [issues](https://github.com/Econify/graphql-rest-router/issues) page and ensure that there is not an existing ticket already filed. 12 | 13 | If none are found, please create a new feature or bug ticket from our templates -- ensuring that all the following areas are covered with enough detail to action. 14 | 15 | Please note: 16 | * Please be respectful of all community members and keep this environment collaborative, safe and suitable for all. 17 | 18 | * We will read and consider each issue filed but we cannot accomodate all requests. 19 | 20 | ### Solving an issue 21 | To address an existing [issue](https://github.com/Econify/graphql-rest-router/issues) or open a pr for a new item, please follow standard open source procedure. 22 | 23 | Rules: 24 | 25 | 1. Prove all applicable changes are working as expected with meaningful unit tests. 26 | 27 | 2. Update the [README.md](https://github.com/Econify/graphql-rest-router/blob/master/README.md) with each change in functionality. 28 | 29 | Steps: 30 | 1. Fork the repository. 31 | 32 | 2. Create a working branch and start with your changes! 33 | 34 | 3. Commit the changes once you are happy with them and self review. 35 | 36 | 4. Create a pull request and fill out all fields in the pull request template. 37 | 38 | 5. Link the pull request to the issue with a link in the title. For example: 39 | `Bugfix: Fix casing found Issue: [#${issueNumber}](https://github.com/Econify/graphql-rest-router/issues/${issueNumber})`. 40 | 41 | 6. Add repo contributors as reviewers. 42 | 43 | 7. At our earliest convenience, the team will review the proposed changes. It is highly likely we will pose questions and request more information or even changes. 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Rest Router 2 | 3 | GraphQL Rest Router allows you to expose an internal GraphQL API as a REST API without exposing the entire schema with a simple DSL. 4 | 5 | ```js 6 | import GraphQLRestRouter from 'graphql-rest-router'; 7 | const clientSchema = fs.readFileSync('./clientSchema.gql', 'utf-8'); 8 | 9 | const options = { 10 | defaultCacheTimeInMS: 10 11 | }; 12 | 13 | const api = new GraphQLRestRouter('http://graphqlurl.com', clientSchema, options); 14 | 15 | api.mount('SearchUsers').at('/users'); 16 | api.mount('GetUserById').at('/users/:id'); 17 | api.mount('CreateUser').at('/users').as('post').withOption('cacheTimeInMs', 0); 18 | 19 | api.listen(3000, () => { 20 | console.log('GraphQL Rest Router is listening!'); 21 | }); 22 | ``` 23 | 24 | ## Table of Contents 25 | 26 | - [Overview / Introduction](#overview) 27 | - [Internal GraphQL Usage](#internal-graphql-api) 28 | - [External GraphQL Usage](#external-graphql-api) 29 | - [Documentation](#documentation) 30 | - [Getting Started (Recommended)](#getting-started) 31 | - [Creating Endpoints (Recommended)](#creating-endpoints) 32 | - [Proxies / Authentication](#proxies-and-authentication) 33 | - [Advanced Options](#advanced-configuration-of-graphql-rest-router) 34 | - [Caching / Redis](#caching) 35 | - [Swagger / OpenAPI / Documentation](#swagger--open-api) 36 | - [Express.js Usage](#usage-with-express) 37 | - [KOA Usage](#usage-with-koa) 38 | - [Examples](#code-examples) 39 | - [Upgrading From Alpha](#upgrading-from-alpha) 40 | 41 | ## Overview 42 | 43 | GraphQL has gained adoption as a replacement to the conventional REST API and for good reason. Development Time/Time to Market are signficantly shortened when no longer requiring every application to build and maintain its own API. 44 | 45 | ### Internal GraphQL API 46 | 47 | While GraphQL has gained traction recently, the majority of GraphQL endpoints are used internally and not distributed or endorsed for public use. Items such as authentication, permissions, priveleges and sometimes even performance on certain keys are not seen as primary concerns as the API is deemed "internal". Because of this, any exposure of your GraphQL server to the public internet exposes you to risk (e.g. DDOS by allowing unknown users to create their own non-performant queries, accidental exposure of sensitive data, etc). 48 | 49 | GraphQL Rest Router attempts to solve these problems by allowing you to leverage GraphQL upstream for all of your data resolution, but exposes predefined queries downstream to the public in the form of cacheable RESTful urls. 50 | 51 | Instead of exposing your server by having your client or web application (e.g. React, Angular, etc...) perform api calls directly to `http://yourgraphqlserver.com/?query={ getUserById(1) { firstName, lastName }` that could then be altered to `http://yourgraphqlserver.com/?query={ getUserById(1) { firstName, lastName, socialSecurityNumber }`, GraphQL Rest Router allows you to predefine a query and expose it as a restful route, such as `http://yourserver/api/users/1`. This ensures end users are only able to execute safe, performant, and tested queries. 52 | 53 | ```js 54 | import GraphQLRestRouter from 'graphql-rest-router'; 55 | const schema = ` 56 | query GetUserById($id: ID!) { 57 | getUserById(1) { 58 | firstName 59 | lastName 60 | } 61 | } 62 | 63 | query SearchUsers($page: Int) { 64 | search(page: $page) { 65 | id 66 | firstName 67 | lastName 68 | } 69 | } 70 | `; 71 | 72 | const api = new GraphQLRestRouter('http://yourgraphqlserver.com', schema); 73 | 74 | api.mount('GetUserById').at('/users/:id'); 75 | api.mount('SearchUsers').at('/users').withOption('cacheTimeInMs', 0); 76 | 77 | api.listen(3000); 78 | ``` 79 | 80 | See [Usage with Express](#usage-with-express) and read [Getting Started](#getting-started) to see all available options. 81 | 82 | ### External GraphQL API 83 | 84 | When dealing with a publicly exposed GraphQL server that implements users and priveleges, the main benefit GraphQL Rest Router client provides is caching. While implementing individual caches at a content-level with push-expiration in the GraphQL server is optimal, building these systems is laborous and isn't always prioritized in an MVP product. GraphQL Rest Router's client allows you to expose a GraphQL query as a REST endpoint with built-in cache management that is compatible with all CDNs and cache management layers (e.g. CloudFlare, Akamai, Varnish, etc). 85 | 86 | One line of GraphQL Rest Router code allows you to take 87 | 88 | ```gql 89 | query UserById($id: ID!) { 90 | getUserById(id: $id) { 91 | id 92 | email 93 | firstName 94 | lastName 95 | } 96 | } 97 | ``` 98 | 99 | and expose it as `http://www.youapiurl.com/user/:id` with a single line of code: 100 | 101 | ```js 102 | api.mount('UserById').at('/user/:id'); 103 | ``` 104 | 105 | ## Documentation 106 | 107 | GraphQL Rest Router is available via NPM as `graphql-rest-router` (`npm install graphql-rest-router`) 108 | 109 | ### Getting Started 110 | 111 | Get started by installing GraphQL Rest Router as a production dependency in your application with: `npm install --save graphql-rest-router`. 112 | 113 | To instantiate a bare bones GraphQL Rest Router instance you'll need both the location of your GraphQL Server and the client schema you'll be using. It is advised that you create one `.gql` file per application that holds all of the application's respective queries. 114 | 115 | GraphQL Rest Router leverages [Operation Names](https://graphql.org/learn/queries/#operation-name) and [Variables](https://graphql.org/learn/queries/#variables) as a way to transform your provided schema into a REST endpoint. **Make sure that your queries and mutations are all utilizing operation names or they will not be mountable.** 116 | 117 | For Example: 118 | 119 | ```gql 120 | # THIS 121 | query GetFirstUser { 122 | getUser(id: 1) { 123 | id 124 | firstName 125 | lastName 126 | } 127 | } 128 | 129 | # NOT THIS 130 | { 131 | getUser(id: 1) { 132 | id 133 | firstName 134 | lastName 135 | } 136 | } 137 | 138 | # THIS 139 | query GetUser($id: ID!) { 140 | getUser(id: $id) { 141 | id 142 | firstName 143 | lastName 144 | } 145 | } 146 | ``` 147 | 148 | Once you have your schema and your endpoint, usage is straight-forward: 149 | 150 | ```js 151 | import GraphQLRestRouter from 'graphql-rest-router'; 152 | 153 | const schema = fs.readFileSync(`${__dirname}/schema.gql`, 'utf-8'); 154 | const endpoint = 'http://mygraphqlserver.com:9000'; 155 | 156 | const api = new GraphQLRestRouter(endpoint, schema); 157 | 158 | api.mount('GetFirstUser').at('/users/first'); 159 | api.mount('GetUser').at('/users/:id'); 160 | 161 | api.listen(3000); 162 | ``` 163 | 164 | ### Creating Endpoints 165 | 166 | Once GraphQL Rest Router has been configured, setting up endpoints to proxy queries is simple. Make sure that the schema you've provided is utilizing [Operation Names](https://graphql.org/learn/queries/#operation-name) and `mount(OperationName)` to have GraphQL Rest Router automatically scan your schema for the desired operation and create a RESTful endpoint for it. If you attempt to mount a non-named query or a query that does not exist within your provided schema, GraphQL Rest Router will throw an exception. 167 | 168 | ```js 169 | const api = new GraphQLRestRouter(endpoint, schema); 170 | 171 | api.mount('OperationName'); // Mounts "query OperationName" as "GET /OperationName" 172 | ``` 173 | 174 | #### HTTP Methods 175 | 176 | By default, mounted queries are GET requests. If you'd like to change that you may specify any HTTP method using `.as()` on a route. 177 | 178 | Example: 179 | 180 | ```js 181 | const api = new GraphQLRestRouter(endpoint, schema); 182 | 183 | api.mount('GetUserById'); // GET /GetUserById 184 | api.mount('UpdateUser').as('put'); // PUT /UpdateUser 185 | api.mount('CreateUser').as('post'); // POST /CreateUser 186 | ``` 187 | 188 | #### Variables 189 | 190 | GraphQL Rest Router will read your provided schema to determine which variables are required and optional. If you are unsure how to create a named operation with variables, the [official GraphQL documentation](https://graphql.org/learn/queries/#variables) has examples. When mounted as a GET endpoint, the variables will be expected as query parameters, while all other methods will check the body for the required variables. 191 | 192 | In order to reduce unnecessary load on the GraphQL server, GraphQL Rest Router validates the variables you've provided before sending a request to the GraphQL server. 193 | 194 | Example Schema: 195 | 196 | ```gql 197 | # If GetUserById is mounted: 198 | # 199 | # - A GET request to /GetUserById will require you to pass in a query parameter of id or it will error. 200 | # 201 | # Example: 202 | # URL: /GetUserById?id=1 203 | # Method: GET 204 | # 205 | # - A POST request to /GetUserByID will require you to pass in a body that conatins a JSON object with the key id. 206 | # 207 | # Example: 208 | # Url: /GetUserById 209 | # Method: POST 210 | # Headers: { Content-Type: application/json } 211 | # Body: { "id": 1 } 212 | # 213 | query GetUserById($id: Int!) { 214 | getUserById(id: $id) { 215 | firstName 216 | lastName 217 | } 218 | } 219 | 220 | # If SearchUsers is mounted: 221 | # 222 | # - A GET request to /SearchUsers will require you to pass in a query parameter of searchTerm or it will error. Optionally you # may pass in page and resultsPerPage as well (/SearchUsers?searchTerm=pesto&page=1&resultsPerPage=10) 223 | # 224 | # Example: 225 | # URL: /SearchUsers?id=1 226 | # Method: GET 227 | # 228 | # - A POST request to /SearchUsers will require you to pass in a body that conatins a JSON object with the key searchTerm and # the optional parameters of page and resultsPerPage. 229 | # 230 | # Example: 231 | # Url: /GetUserById 232 | # Method: POST 233 | # Headers: { Content-Type: application/json } 234 | # Body: { "searchTerm": "pesto", page: 1 } 235 | # 236 | query SearchUsers($page: Int = 1, $resultsPerPage: Int, $searchTerm: String!) { 237 | searchUsers(resultsPerPage: $resultsPerPage, page: $page, query: $searchTerm) { 238 | email 239 | firstName 240 | lastName 241 | } 242 | } 243 | `; 244 | ``` 245 | 246 | #### Custom Paths 247 | 248 | If no path is provided to a mounted route, it will be made available exactly as it is typed in the operation name: 249 | 250 | ```js 251 | api.mount('GetUserById'); // Made available at /GetUserById 252 | ``` 253 | 254 | It is possible to change/customize this mounting path by using `.at(pathname)` on a route. 255 | 256 | ```js 257 | api.mount('GetUserById').at('/user'); // A call to '/user?id=42' will execute a 'GetUserById' operation on your GraphQL Server with an id of 42 258 | ``` 259 | 260 | It is also possible to describe a required variable in the path using a syntax similar to that of express routes 261 | 262 | ```js 263 | api.mount('GetUserById').at('/user/:id'); // A call to /user/42 will execute a 'GetUserById'operation on your GraphQL server with an id of 42 264 | ``` 265 | 266 | #### Schemaless Mount 267 | 268 | A schema is optional with GraphQL Rest Router. You may inline a query on call to `mount()` instead. 269 | 270 | Example: 271 | 272 | ```js 273 | const api = new GraphQLRestRouter(endpoint); 274 | 275 | // Simple inline query 276 | api.mount('{ users { displayName } }').at('/usernames'); // GET /usernames 277 | 278 | // With a path parameter 279 | api.mount('query GetUserByID($id: ID!) { displayName }').at('/user/:id'); // GET /user/:id 280 | ``` 281 | 282 | ### Proxies and Authentication 283 | 284 | If the server that you are running GraphQL Rest Router on requires a proxy to connect to the GraphQL server or credentials to connect, you may pass them directly into GraphQL Rest Router during instantiation or on a per route basis to limit them to specific routes. See [Advanced Configuration of GraphQL Rest Router](#Advanced-Configuration-of-GraphQL-Rest-Router) for implementation 285 | 286 | ### Advanced Configuration of GraphQL Rest Router 287 | 288 | GraphQL Rest Router takes an optional third parameter during initialization that allows you to control default cache, headers, authentication and proxies. 289 | 290 | ```js 291 | const options = { 292 | defaultCacheTimeInMs: 3000, 293 | }; 294 | 295 | new GraphQLRestRouter(endpoint, schema, options); 296 | ``` 297 | 298 | A list of options and their default values is below: 299 | 300 | | Property | Type | Default | Description | 301 | | --- | --- | --- | --- | 302 | | defaultCacheTimeInMs | number | 0 | If a cache engine has been provided use this as a default value for all routes and endpoints. If a route level cache time has been provided this value will be ignored | 303 | | defaultTimeoutInMs | number | 10000 | The amount of time to allow for a request to the GraphQL to wait before timing out an endpoint | 304 | | cacheKeyIncludedHeaders | string[] | [] | HTTP Headers that are used in the creation of the cache key for requests. This allows users to identify unique requests by specific headers. If these headers specified here differ between requests, they will be considered unique requests. | 305 | | optimizeQueryRequest | boolean | false | When set to true, GraphQL Rest Router will split up the provided schema into the smallest fragment necessary to complete each request to the GraphQL server as opposed to sending the originally provided schema with each request| 306 | | headers | object | {} | Any headers provided here will be sent with each request to GraphQL. If headers are also set at the route level, they will be combined with these headers (Route Headers take priority over Global Headers) | 307 | | passThroughHeaders | string[] | [] | An array of strings that indicate which headers to pass through from the request to GraphQL Rest Router to the GraphQL Server. (Example: ['x-context-jwt']) | 308 | | auth | [AxiosBasicCredentials](https://github.com/axios/axios/blob/76f09afc03fbcf392d31ce88448246bcd4f91f8c/index.d.ts#L9-L12) | null | If the GraphQL server is protected with basic auth provide the basic auth credentials here to allow GraphQL Rest Router to connect. (Example: { username: 'pesto', password: 'foobar' } | 309 | | proxy | [AxiosProxyConfig](https://github.com/axios/axios/blob/76f09afc03fbcf392d31ce88448246bcd4f91f8c/index.d.ts#L14-L22) | null | If a proxy is required to communicate with your GraphQL server from the server that GraphQL Rest Router is running on, provide it here. | 310 | | cacheEngine | [ICacheEngine](https://github.com/Econify/graphql-rest-router/blob/29cc328f23b8dd579a6f4af242266460e95e7d69/src/types.ts#L87-L90) | null | Either a cache engine that [ships default](#Caching) with GraphQL Rest Router or adheres to the [ICacheEngine interface](#Custom-Cache-Engine) | 311 | | logger | [ILogger](https://github.com/Econify/graphql-rest-router/blob/29cc328f23b8dd579a6f4af242266460e95e7d69/src/types.ts#L101-L107) | null | A logger object that implements info, warn, error, and debug methods | 312 | | defaultLogLevel | number | 0 | Default logger level for the logger object | 313 | 314 | Routes can be individually configured using the `withOptions` or `withOption` methods. See more usage examples below. 315 | 316 | ```js 317 | import GraphQLRestRouter, { LogLevels, InMemoryCache } from 'graphql-rest-router'; 318 | 319 | const api = new GraphQLRestRouter('http://localhost:1227', schema); 320 | 321 | // Set individual option 322 | api.mount('CreateUser').withOption('cacheEngine', new InMemoryCache()); 323 | 324 | // Set two options with one function call 325 | api.mount('GetUser').withOptions({ 326 | logger: console, 327 | logLevel: LogLevels.DEBUG, 328 | }); 329 | ``` 330 | 331 | ### Request & Response Transformations 332 | 333 | GraphQL Rest Router allows the developer to add transformations on incoming requests or outgoing responses. By default, the regular axios transformers are used. 334 | 335 | If the shape of data coming from GraphQL is not what your consuming application needs, transformation logic can be encapsulated inside of the REST layer in the form of these callbacks. 336 | 337 | ```js 338 | import GraphQLRestRouter from 'graphql-rest-router'; 339 | 340 | const api = new GraphQLRestRouter('http://localhost:1227', schema); 341 | 342 | api.mount('GetImages').withOption('transformResponse', (response) => { 343 | const { data, errors } = response; 344 | 345 | return { 346 | data: { 347 | // Turn images array into image URL map 348 | images: data.images?.reduce((acc, img) => { 349 | acc[img.url] = img; 350 | }, {}), 351 | } 352 | errors, 353 | }; 354 | })); 355 | ``` 356 | 357 | You can also modify the outgoing request. These transformers should return the stringified request, but also allow you to modify the request headers. 358 | 359 | ```js 360 | import GraphQLRestRouter from 'graphql-rest-router'; 361 | 362 | const api = new GraphQLRestRouter('http://localhost:1227', schema); 363 | 364 | api.mount('GetImages').at('/images').withOption('transformRequest', (request, headers) => { 365 | headers['X-My-Header'] = 'MyValue'; 366 | return request; 367 | }); 368 | ``` 369 | 370 | ### Logging 371 | 372 | GraphQL Rest Router supports robust logging of incoming requests and errors. On instantiation, a logger of your choice can be injected with configurable log levels. The logger object must implement [ILogger](https://github.com/Econify/graphql-rest-router/blob/29cc328f23b8dd579a6f4af242266460e95e7d69/src/types.ts#L101-L107), and log levels must be one of the following [ILogLevels](https://github.com/Econify/graphql-rest-router/blob/f83881d30bdb329a306ebb94fdf577fb065f2e6e/src/types.ts#L107-L113). 373 | 374 | ```js 375 | import GraphQLRestRouter, { LogLevels } from 'graphql-rest-router'; 376 | 377 | const api = new GraphQLRestRouter('http://localhost:1227', schema, { 378 | logger: console, 379 | defaultLogLevel: LogLevels.ERROR // Log only errors 380 | }); 381 | 382 | api.mount('CreateUser').withOption('logLevel', LogLevels.DEBUG); // Log everything 383 | api.mount('GetUser').withOption('logLevel', LogLevels.SILENCE); // No logs 384 | ``` 385 | 386 | ### Caching 387 | 388 | GraphQL Rest Router includes two cache interfaces and supports any number of custom or third party caching interfaces, as long as they implement [ICacheEngine](https://github.com/Econify/graphql-rest-router/blob/29cc328f23b8dd579a6f4af242266460e95e7d69/src/types.ts#L87-L90) 389 | 390 | *Important note about cache key creation*: by default, GraphQL Rest Router does not differentiate cache keys based on their HTTP headers. If an upstream GraphQL service returns different responses based on headers, the GraphQL rest router instance would be required to include them in the `cacheKeyIncludedHeaders` global configuration option. 391 | 392 | For example, if your application supports `Authorization` headers, you must include that header in the `cacheKeyIncludedHeaders` field. The cache layer will then not serve User A's result to User B. Alternatively, you can disable the cache on authorized routes. 393 | 394 | #### In Memory Cache 395 | 396 | InMemoryCache stores your cached response data on your server in memory. This can be used in development or with very low throughput, however it is strongly discouraged to use this in production. 397 | 398 | ```js 399 | import GraphQLRestRouter, { InMemoryCache } from 'graphql-rest-router'; 400 | 401 | const api = new GraphQLRestRouter('http://localhost:1227', schema, { 402 | cacheEngine: new InMemoryCache(), 403 | defaultCacheTimeInMs: 300, 404 | }); 405 | 406 | api.mount('CreateUser').withOption('cacheTimeInMs', 0); // Disable the cache on this route 407 | ``` 408 | 409 | Note: By default the InMemoryCache TTL is 10 milliseconds. This is configurable via the constructor. E.g. `new InMemoryCache(5000)` will expire entries every 5 seconds instead of every 10 milliseconds. 410 | 411 | #### Redis Cache 412 | 413 | RedisCache stores your cached route data in an external Redis instance. The RedisCache class constructor accepts the [ClientOpts](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/f4b63e02370940350887eaa82ac976dc2ecbf313/types/redis/index.d.ts#L39) object type provided for connection configuration. 414 | 415 | ```js 416 | import GraphQLRestRouter, { RedisCache } from 'graphql-rest-router'; 417 | 418 | const api = new GraphQLRestRouter('http://localhost:1227', schema, { 419 | cacheEngine: new RedisCache({ host: 'localhost', port: 6379 }), 420 | defaultCacheTimeInMs: 300000, // 5 minutes 421 | }); 422 | 423 | api.mount('CreateUser').withOption('cacheTimeInMs', 0); // Disable the cache on this route 424 | api.mount('GetUser').withOption('cacheTimeInMs', 500); // Override 5 minute cache 425 | ``` 426 | 427 | #### Custom Cache Engine 428 | 429 | You may implement a custom cache engine as long as it adheres to the [ICacheEngine](https://github.com/Econify/graphql-rest-router/blob/af05660d53ee74df10ccc85c9fdc958eec09ff71/src/types.ts#L94-L97) interface. 430 | 431 | Simply said, provide an object that contains `get` and `set` functions. See `InMemoryCache.ts` or `RedisCache.ts` as examples. 432 | 433 | ```js 434 | import GraphQLRestRouter from 'graphql-rest-router'; 435 | import CustomCache from ...; 436 | 437 | const api = new GraphQLRestRouter('http://localhost:1227', schema, { 438 | cacheEngine: new CustomCache(), 439 | defaultCacheTimeInMs: 300, 440 | }); 441 | 442 | api.mount('CreateUser'); 443 | ``` 444 | 445 | ### Swagger / Open API 446 | 447 | As GraphQL Rest Router exposes your API with new routes that aren't covered by GraphQL's internal documentation or introspection queries, GraphQL Rest Router ships with support for Swagger (Open Api V2), Open API (V3) and API Blueprint (planned). When mounting a documentation on GraphQL Rest Router, it will automatically inspect all queries in the schema you provided and run an introspection query on your GraphQL server to dynamically assemble and document the types / endpoints. 448 | 449 | #### Open API (Preferred) 450 | 451 | ```js 452 | const { OpenApi } = require('graphql-rest-router'); 453 | 454 | const documentation = new OpenApi.V3({ 455 | title: 'My REST API', // REQUIRED! 456 | version: '1.0.0', // REQUIRED! 457 | 458 | host: 'http://localhost:1227', 459 | basePath: '/api', 460 | }); 461 | 462 | const api = new GraphQLRestRouter('http://yourgraphqlendpoint', schema); 463 | 464 | api.mount(documentation).at('/docs/openapi'); 465 | ``` 466 | 467 | #### Swagger 468 | 469 | ```js 470 | const { OpenApi } = require('graphql-rest-router'); 471 | 472 | const swaggerDocumentation = new OpenApi.V2({ 473 | title: 'My REST API', // REQUIRED! 474 | version: '1.0.0', // REQUIRED! 475 | 476 | host: 'http://localhost:1227', 477 | basePath: '/api', 478 | }); 479 | 480 | const api = new GraphQLRestRouter('http://yourgraphqlendpoint', schema); 481 | 482 | api.mount(swaggerDocumentation).at('/docs/swagger'); 483 | ``` 484 | 485 | #### API Blueprint 486 | 487 | Not supported yet, please create a PR! 488 | 489 | ### Usage with Web Frameworks 490 | 491 | Currently GraphQL Rest Router only supports Express out of the box. Please submit a PR or an Issue if you would like to see GraphQL Rest Router support additional frameworks. 492 | 493 | #### Usage with Express 494 | 495 | It is common to leverage GraphQL Rest Router client on a server that is already delivering a website as opposed to standing up a new server. To integrate with an existing express server, simply export GraphQL Rest Router as express using `.asExpressRouter()` instead of starting up a new server using `.listen(port)`. 496 | 497 | For Example: 498 | 499 | ```js 500 | // api.js 501 | const api = new GraphQLRestRouter(endpoint, schema); 502 | 503 | api.mount('GetUserById').at('/users/:id'); 504 | 505 | export default api.asExpressRouter(); 506 | 507 | // server.js 508 | import express from 'express'; 509 | import api from './api'; 510 | 511 | const app = express(); 512 | 513 | app.get('/status', (req, res) => ...); 514 | app.get('/', (req, res) => ...); 515 | app.use('/api', api); // Mounts GraphQL Rest Router ON :3000/api/* (e.g. :3000/api/users/4) 516 | 517 | app.listen(3000); 518 | ``` 519 | 520 | #### Usage with KOA 521 | 522 | As of the time of this writing, a KOA extension for GraphQL Rest Router is not available. Feel free to submit a PR. 523 | 524 | ### Code Examples 525 | 526 | See the [example client](/example-consuming-client) in this repo for code examples. 527 | 528 | ## Upgrading from Alpha 529 | 530 | There is one breaking change with the release of `1.0.0-beta.0`: Transform response callbacks now receive parsed data as opposed to the stringified version. Therefore, any callback passed in this way must no longer parse prior to processing. 531 | 532 | Chained route methods such as `disableCache()` or `transformResponse()` have been deprecated. Please use `withOption()` or `withOptions()` instead. Support for chained route methods will be removed in a future version. 533 | 534 | For example: 535 | 536 | ```js 537 | // not this 538 | api.mount('GetUserById').at('/users/:id').disableCache().transformResponse(cb); 539 | 540 | // this 541 | api.mount('GetUserById').at('/users/:id').withOptions({ 542 | cacheTimeInMs: 0, 543 | transformResponse: cb, 544 | }); 545 | ``` 546 | 547 | ## Like this package? 548 | 549 | Check out Econify's other GraphQL package, [graphql-request-profiler](https://www.github.com/Econify/graphql-request-profiler), an easy to use performance analysis and visualization tool for tracing API resolver execution time. 550 | -------------------------------------------------------------------------------- /example-consuming-client/.env: -------------------------------------------------------------------------------- 1 | # This has no sensitive information. Checking into git despite my better judgement 2 | 3 | LOG_LEVEL=debug 4 | NODE_ENV=development 5 | 6 | ENDPOINT=https://rickandmortyapi.com/graphql -------------------------------------------------------------------------------- /example-consuming-client/README.md: -------------------------------------------------------------------------------- 1 | # Client for testing rest router 2 | 3 | ## Overview 4 | 5 | This is a test application for graphql rest router. 6 | Currently it uses an open source live Rick and Morty api but 7 | in a future iteration we will write a small api and containerize the whole test. 8 | 9 | ## Usage 10 | 11 | From parent directory, simply run: `npm run live-test` 12 | 13 | OR 14 | 15 | From this directory, simply run: `bash build.sh` 16 | 17 | The server will be exposed on `localhost:4000` and can be hit at any of the paths found in `example-consuming-client/src/api` -- some examples being: 18 | 19 | ```txt 20 | http://localhost:4000/api/characters 21 | http://localhost:4000/api/characters/:id 22 | http://localhost:4000/api/episodes 23 | http://localhost:4000/api/episodes/:id 24 | http://localhost:4000/api/locations 25 | http://localhost:4000/api/locations/:id 26 | ``` 27 | 28 | ## Future TODOs 29 | 30 | * Remove Rick and Morty api and add small api for reliability 31 | * Containerize both this example app and the above mentioned api so testing can be done with `docker-compose up` 32 | * Ensure all major features from `graphql-rest-router` are being utilized in this example app 33 | -------------------------------------------------------------------------------- /example-consuming-client/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Set current shell location to here so can be 4 | # run via parent npm run live-test or from current directory 5 | cd "$(dirname "$0")" || exit 1 6 | 7 | # Build graphql rest router 8 | cd ../ 9 | 10 | echo "Building rest router" 11 | 12 | # npm install only if graphql-rest-routers node_modules are not found 13 | if [ -d "node_modules" ] 14 | then 15 | echo "Node modules exist." 16 | else 17 | echo "Node modules do not exist in parent. Installing all dependencies." 18 | npm ci 19 | fi 20 | 21 | npm run build 22 | 23 | echo 'Completed build' 24 | 25 | # build this example application 26 | cd example-consuming-client || exit 1 27 | 28 | if [ -d "node_modules" ] 29 | then 30 | echo "Node modules exist. Just installing rest router." 31 | else 32 | echo "Node modules does not exist. Installing all dependencies." 33 | npm ci 34 | fi 35 | 36 | npm install ../build 37 | 38 | npm run start 39 | -------------------------------------------------------------------------------- /example-consuming-client/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-consuming-client", 3 | "version": "0.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "example-consuming-client", 9 | "version": "0.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.17.1", 13 | "graphql-rest-router": "file:../build", 14 | "module-alias": "^2.2.2", 15 | "tslib": "^2.0.3" 16 | }, 17 | "devDependencies": { 18 | "@types/body-parser": "^1.19.0", 19 | "@types/express": "^4.17.8", 20 | "@types/node": "^14.11.2", 21 | "dotenv-cli": "^4.0.0", 22 | "tsc-watch": "^4.2.9", 23 | "typescript": "^4.0.3" 24 | } 25 | }, 26 | "../build": { 27 | "name": "graphql-rest-router", 28 | "version": "1.0.0-beta.0", 29 | "license": "ISC", 30 | "dependencies": { 31 | "axios": "^0.21.4", 32 | "express": "^4.17.1", 33 | "graphql": "^14.0.2", 34 | "redis": "^3.1.2", 35 | "tslib": "^2.1.0" 36 | }, 37 | "devDependencies": { 38 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 39 | "@types/chai": "^4.1.7", 40 | "@types/express": "^4.17.1", 41 | "@types/jest": "^26.0.20", 42 | "@types/mocha": "^5.2.5", 43 | "@types/node": "^10.12.18", 44 | "@types/redis": "^2.8.32", 45 | "@types/sinon": "^7.0.2", 46 | "@typescript-eslint/eslint-plugin": "^4.6.1", 47 | "@typescript-eslint/parser": "^4.6.1", 48 | "chai": "^4.2.0", 49 | "eslint": "^7.12.1", 50 | "eslint-plugin-import": "^2.22.1", 51 | "faker": "^5.5.3", 52 | "husky": "^4.2.3", 53 | "lint-staged": "^10.5.1", 54 | "mocha": "^9.1.1", 55 | "nyc": "^15.1.0", 56 | "sinon": "^7.2.2", 57 | "ts-node": "^7.0.1", 58 | "typescript": "4.0.3" 59 | } 60 | }, 61 | "node_modules/@types/body-parser": { 62 | "version": "1.19.1", 63 | "integrity": "sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==", 64 | "dev": true, 65 | "dependencies": { 66 | "@types/connect": "*", 67 | "@types/node": "*" 68 | } 69 | }, 70 | "node_modules/@types/connect": { 71 | "version": "3.4.35", 72 | "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", 73 | "dev": true, 74 | "dependencies": { 75 | "@types/node": "*" 76 | } 77 | }, 78 | "node_modules/@types/express": { 79 | "version": "4.17.13", 80 | "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", 81 | "dev": true, 82 | "dependencies": { 83 | "@types/body-parser": "*", 84 | "@types/express-serve-static-core": "^4.17.18", 85 | "@types/qs": "*", 86 | "@types/serve-static": "*" 87 | } 88 | }, 89 | "node_modules/@types/express-serve-static-core": { 90 | "version": "4.17.24", 91 | "integrity": "sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==", 92 | "dev": true, 93 | "dependencies": { 94 | "@types/node": "*", 95 | "@types/qs": "*", 96 | "@types/range-parser": "*" 97 | } 98 | }, 99 | "node_modules/@types/mime": { 100 | "version": "1.3.2", 101 | "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", 102 | "dev": true 103 | }, 104 | "node_modules/@types/node": { 105 | "version": "14.17.20", 106 | "integrity": "sha512-gI5Sl30tmhXsqkNvopFydP7ASc4c2cLfGNQrVKN3X90ADFWFsPEsotm/8JHSUJQKTHbwowAHtcJPeyVhtKv0TQ==", 107 | "dev": true 108 | }, 109 | "node_modules/@types/qs": { 110 | "version": "6.9.7", 111 | "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", 112 | "dev": true 113 | }, 114 | "node_modules/@types/range-parser": { 115 | "version": "1.2.4", 116 | "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", 117 | "dev": true 118 | }, 119 | "node_modules/@types/serve-static": { 120 | "version": "1.13.10", 121 | "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", 122 | "dev": true, 123 | "dependencies": { 124 | "@types/mime": "^1", 125 | "@types/node": "*" 126 | } 127 | }, 128 | "node_modules/accepts": { 129 | "version": "1.3.7", 130 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", 131 | "dependencies": { 132 | "mime-types": "~2.1.24", 133 | "negotiator": "0.6.2" 134 | }, 135 | "engines": { 136 | "node": ">= 0.6" 137 | } 138 | }, 139 | "node_modules/ansi-regex": { 140 | "version": "5.0.1", 141 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 142 | "dev": true, 143 | "engines": { 144 | "node": ">=8" 145 | } 146 | }, 147 | "node_modules/array-flatten": { 148 | "version": "1.1.1", 149 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 150 | }, 151 | "node_modules/body-parser": { 152 | "version": "1.19.0", 153 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", 154 | "dependencies": { 155 | "bytes": "3.1.0", 156 | "content-type": "~1.0.4", 157 | "debug": "2.6.9", 158 | "depd": "~1.1.2", 159 | "http-errors": "1.7.2", 160 | "iconv-lite": "0.4.24", 161 | "on-finished": "~2.3.0", 162 | "qs": "6.7.0", 163 | "raw-body": "2.4.0", 164 | "type-is": "~1.6.17" 165 | }, 166 | "engines": { 167 | "node": ">= 0.8" 168 | } 169 | }, 170 | "node_modules/bytes": { 171 | "version": "3.1.0", 172 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", 173 | "engines": { 174 | "node": ">= 0.8" 175 | } 176 | }, 177 | "node_modules/content-disposition": { 178 | "version": "0.5.3", 179 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", 180 | "dependencies": { 181 | "safe-buffer": "5.1.2" 182 | }, 183 | "engines": { 184 | "node": ">= 0.6" 185 | } 186 | }, 187 | "node_modules/content-type": { 188 | "version": "1.0.4", 189 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", 190 | "engines": { 191 | "node": ">= 0.6" 192 | } 193 | }, 194 | "node_modules/cookie": { 195 | "version": "0.4.0", 196 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", 197 | "engines": { 198 | "node": ">= 0.6" 199 | } 200 | }, 201 | "node_modules/cookie-signature": { 202 | "version": "1.0.6", 203 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 204 | }, 205 | "node_modules/cross-spawn": { 206 | "version": "7.0.3", 207 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 208 | "dev": true, 209 | "dependencies": { 210 | "path-key": "^3.1.0", 211 | "shebang-command": "^2.0.0", 212 | "which": "^2.0.1" 213 | }, 214 | "engines": { 215 | "node": ">= 8" 216 | } 217 | }, 218 | "node_modules/debug": { 219 | "version": "2.6.9", 220 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 221 | "dependencies": { 222 | "ms": "2.0.0" 223 | } 224 | }, 225 | "node_modules/depd": { 226 | "version": "1.1.2", 227 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", 228 | "engines": { 229 | "node": ">= 0.6" 230 | } 231 | }, 232 | "node_modules/destroy": { 233 | "version": "1.0.4", 234 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 235 | }, 236 | "node_modules/dotenv": { 237 | "version": "8.6.0", 238 | "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", 239 | "dev": true, 240 | "engines": { 241 | "node": ">=10" 242 | } 243 | }, 244 | "node_modules/dotenv-cli": { 245 | "version": "4.0.0", 246 | "integrity": "sha512-ByKEec+ashePEXthZaA1fif9XDtcaRnkN7eGdBDx3HHRjwZ/rA1go83Cbs4yRrx3JshsCf96FjAyIA2M672+CQ==", 247 | "dev": true, 248 | "dependencies": { 249 | "cross-spawn": "^7.0.1", 250 | "dotenv": "^8.1.0", 251 | "dotenv-expand": "^5.1.0", 252 | "minimist": "^1.1.3" 253 | }, 254 | "bin": { 255 | "dotenv": "cli.js" 256 | } 257 | }, 258 | "node_modules/dotenv-expand": { 259 | "version": "5.1.0", 260 | "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", 261 | "dev": true 262 | }, 263 | "node_modules/duplexer": { 264 | "version": "0.1.2", 265 | "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 266 | "dev": true 267 | }, 268 | "node_modules/ee-first": { 269 | "version": "1.1.1", 270 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 271 | }, 272 | "node_modules/encodeurl": { 273 | "version": "1.0.2", 274 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", 275 | "engines": { 276 | "node": ">= 0.8" 277 | } 278 | }, 279 | "node_modules/escape-html": { 280 | "version": "1.0.3", 281 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 282 | }, 283 | "node_modules/etag": { 284 | "version": "1.8.1", 285 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", 286 | "engines": { 287 | "node": ">= 0.6" 288 | } 289 | }, 290 | "node_modules/event-stream": { 291 | "version": "3.3.4", 292 | "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", 293 | "dev": true, 294 | "dependencies": { 295 | "duplexer": "~0.1.1", 296 | "from": "~0", 297 | "map-stream": "~0.1.0", 298 | "pause-stream": "0.0.11", 299 | "split": "0.3", 300 | "stream-combiner": "~0.0.4", 301 | "through": "~2.3.1" 302 | } 303 | }, 304 | "node_modules/express": { 305 | "version": "4.17.1", 306 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", 307 | "dependencies": { 308 | "accepts": "~1.3.7", 309 | "array-flatten": "1.1.1", 310 | "body-parser": "1.19.0", 311 | "content-disposition": "0.5.3", 312 | "content-type": "~1.0.4", 313 | "cookie": "0.4.0", 314 | "cookie-signature": "1.0.6", 315 | "debug": "2.6.9", 316 | "depd": "~1.1.2", 317 | "encodeurl": "~1.0.2", 318 | "escape-html": "~1.0.3", 319 | "etag": "~1.8.1", 320 | "finalhandler": "~1.1.2", 321 | "fresh": "0.5.2", 322 | "merge-descriptors": "1.0.1", 323 | "methods": "~1.1.2", 324 | "on-finished": "~2.3.0", 325 | "parseurl": "~1.3.3", 326 | "path-to-regexp": "0.1.7", 327 | "proxy-addr": "~2.0.5", 328 | "qs": "6.7.0", 329 | "range-parser": "~1.2.1", 330 | "safe-buffer": "5.1.2", 331 | "send": "0.17.1", 332 | "serve-static": "1.14.1", 333 | "setprototypeof": "1.1.1", 334 | "statuses": "~1.5.0", 335 | "type-is": "~1.6.18", 336 | "utils-merge": "1.0.1", 337 | "vary": "~1.1.2" 338 | }, 339 | "engines": { 340 | "node": ">= 0.10.0" 341 | } 342 | }, 343 | "node_modules/finalhandler": { 344 | "version": "1.1.2", 345 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", 346 | "dependencies": { 347 | "debug": "2.6.9", 348 | "encodeurl": "~1.0.2", 349 | "escape-html": "~1.0.3", 350 | "on-finished": "~2.3.0", 351 | "parseurl": "~1.3.3", 352 | "statuses": "~1.5.0", 353 | "unpipe": "~1.0.0" 354 | }, 355 | "engines": { 356 | "node": ">= 0.8" 357 | } 358 | }, 359 | "node_modules/forwarded": { 360 | "version": "0.2.0", 361 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 362 | "engines": { 363 | "node": ">= 0.6" 364 | } 365 | }, 366 | "node_modules/fresh": { 367 | "version": "0.5.2", 368 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", 369 | "engines": { 370 | "node": ">= 0.6" 371 | } 372 | }, 373 | "node_modules/from": { 374 | "version": "0.1.7", 375 | "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", 376 | "dev": true 377 | }, 378 | "node_modules/graphql-rest-router": { 379 | "resolved": "../build", 380 | "link": true 381 | }, 382 | "node_modules/http-errors": { 383 | "version": "1.7.2", 384 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", 385 | "dependencies": { 386 | "depd": "~1.1.2", 387 | "inherits": "2.0.3", 388 | "setprototypeof": "1.1.1", 389 | "statuses": ">= 1.5.0 < 2", 390 | "toidentifier": "1.0.0" 391 | }, 392 | "engines": { 393 | "node": ">= 0.6" 394 | } 395 | }, 396 | "node_modules/iconv-lite": { 397 | "version": "0.4.24", 398 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 399 | "dependencies": { 400 | "safer-buffer": ">= 2.1.2 < 3" 401 | }, 402 | "engines": { 403 | "node": ">=0.10.0" 404 | } 405 | }, 406 | "node_modules/inherits": { 407 | "version": "2.0.3", 408 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 409 | }, 410 | "node_modules/ipaddr.js": { 411 | "version": "1.9.1", 412 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 413 | "engines": { 414 | "node": ">= 0.10" 415 | } 416 | }, 417 | "node_modules/isexe": { 418 | "version": "2.0.0", 419 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", 420 | "dev": true 421 | }, 422 | "node_modules/map-stream": { 423 | "version": "0.1.0", 424 | "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", 425 | "dev": true 426 | }, 427 | "node_modules/media-typer": { 428 | "version": "0.3.0", 429 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", 430 | "engines": { 431 | "node": ">= 0.6" 432 | } 433 | }, 434 | "node_modules/merge-descriptors": { 435 | "version": "1.0.1", 436 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 437 | }, 438 | "node_modules/methods": { 439 | "version": "1.1.2", 440 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", 441 | "engines": { 442 | "node": ">= 0.6" 443 | } 444 | }, 445 | "node_modules/mime": { 446 | "version": "1.6.0", 447 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 448 | "bin": { 449 | "mime": "cli.js" 450 | }, 451 | "engines": { 452 | "node": ">=4" 453 | } 454 | }, 455 | "node_modules/mime-db": { 456 | "version": "1.50.0", 457 | "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==", 458 | "engines": { 459 | "node": ">= 0.6" 460 | } 461 | }, 462 | "node_modules/mime-types": { 463 | "version": "2.1.33", 464 | "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==", 465 | "dependencies": { 466 | "mime-db": "1.50.0" 467 | }, 468 | "engines": { 469 | "node": ">= 0.6" 470 | } 471 | }, 472 | "node_modules/minimist": { 473 | "version": "1.2.5", 474 | "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", 475 | "dev": true 476 | }, 477 | "node_modules/module-alias": { 478 | "version": "2.2.2", 479 | "integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==" 480 | }, 481 | "node_modules/ms": { 482 | "version": "2.0.0", 483 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 484 | }, 485 | "node_modules/negotiator": { 486 | "version": "0.6.2", 487 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", 488 | "engines": { 489 | "node": ">= 0.6" 490 | } 491 | }, 492 | "node_modules/node-cleanup": { 493 | "version": "2.1.2", 494 | "integrity": "sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=", 495 | "dev": true 496 | }, 497 | "node_modules/on-finished": { 498 | "version": "2.3.0", 499 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 500 | "dependencies": { 501 | "ee-first": "1.1.1" 502 | }, 503 | "engines": { 504 | "node": ">= 0.8" 505 | } 506 | }, 507 | "node_modules/parseurl": { 508 | "version": "1.3.3", 509 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 510 | "engines": { 511 | "node": ">= 0.8" 512 | } 513 | }, 514 | "node_modules/path-key": { 515 | "version": "3.1.1", 516 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 517 | "dev": true, 518 | "engines": { 519 | "node": ">=8" 520 | } 521 | }, 522 | "node_modules/path-to-regexp": { 523 | "version": "0.1.7", 524 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 525 | }, 526 | "node_modules/pause-stream": { 527 | "version": "0.0.11", 528 | "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", 529 | "dev": true, 530 | "dependencies": { 531 | "through": "~2.3" 532 | } 533 | }, 534 | "node_modules/proxy-addr": { 535 | "version": "2.0.7", 536 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 537 | "dependencies": { 538 | "forwarded": "0.2.0", 539 | "ipaddr.js": "1.9.1" 540 | }, 541 | "engines": { 542 | "node": ">= 0.10" 543 | } 544 | }, 545 | "node_modules/ps-tree": { 546 | "version": "1.2.0", 547 | "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", 548 | "dev": true, 549 | "dependencies": { 550 | "event-stream": "=3.3.4" 551 | }, 552 | "bin": { 553 | "ps-tree": "bin/ps-tree.js" 554 | }, 555 | "engines": { 556 | "node": ">= 0.10" 557 | } 558 | }, 559 | "node_modules/qs": { 560 | "version": "6.7.0", 561 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", 562 | "engines": { 563 | "node": ">=0.6" 564 | } 565 | }, 566 | "node_modules/range-parser": { 567 | "version": "1.2.1", 568 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 569 | "engines": { 570 | "node": ">= 0.6" 571 | } 572 | }, 573 | "node_modules/raw-body": { 574 | "version": "2.4.0", 575 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", 576 | "dependencies": { 577 | "bytes": "3.1.0", 578 | "http-errors": "1.7.2", 579 | "iconv-lite": "0.4.24", 580 | "unpipe": "1.0.0" 581 | }, 582 | "engines": { 583 | "node": ">= 0.8" 584 | } 585 | }, 586 | "node_modules/safe-buffer": { 587 | "version": "5.1.2", 588 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 589 | }, 590 | "node_modules/safer-buffer": { 591 | "version": "2.1.2", 592 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 593 | }, 594 | "node_modules/send": { 595 | "version": "0.17.1", 596 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", 597 | "dependencies": { 598 | "debug": "2.6.9", 599 | "depd": "~1.1.2", 600 | "destroy": "~1.0.4", 601 | "encodeurl": "~1.0.2", 602 | "escape-html": "~1.0.3", 603 | "etag": "~1.8.1", 604 | "fresh": "0.5.2", 605 | "http-errors": "~1.7.2", 606 | "mime": "1.6.0", 607 | "ms": "2.1.1", 608 | "on-finished": "~2.3.0", 609 | "range-parser": "~1.2.1", 610 | "statuses": "~1.5.0" 611 | }, 612 | "engines": { 613 | "node": ">= 0.8.0" 614 | } 615 | }, 616 | "node_modules/send/node_modules/ms": { 617 | "version": "2.1.1", 618 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 619 | }, 620 | "node_modules/serve-static": { 621 | "version": "1.14.1", 622 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", 623 | "dependencies": { 624 | "encodeurl": "~1.0.2", 625 | "escape-html": "~1.0.3", 626 | "parseurl": "~1.3.3", 627 | "send": "0.17.1" 628 | }, 629 | "engines": { 630 | "node": ">= 0.8.0" 631 | } 632 | }, 633 | "node_modules/setprototypeof": { 634 | "version": "1.1.1", 635 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" 636 | }, 637 | "node_modules/shebang-command": { 638 | "version": "2.0.0", 639 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 640 | "dev": true, 641 | "dependencies": { 642 | "shebang-regex": "^3.0.0" 643 | }, 644 | "engines": { 645 | "node": ">=8" 646 | } 647 | }, 648 | "node_modules/shebang-regex": { 649 | "version": "3.0.0", 650 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 651 | "dev": true, 652 | "engines": { 653 | "node": ">=8" 654 | } 655 | }, 656 | "node_modules/split": { 657 | "version": "0.3.3", 658 | "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", 659 | "dev": true, 660 | "dependencies": { 661 | "through": "2" 662 | }, 663 | "engines": { 664 | "node": "*" 665 | } 666 | }, 667 | "node_modules/statuses": { 668 | "version": "1.5.0", 669 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", 670 | "engines": { 671 | "node": ">= 0.6" 672 | } 673 | }, 674 | "node_modules/stream-combiner": { 675 | "version": "0.0.4", 676 | "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", 677 | "dev": true, 678 | "dependencies": { 679 | "duplexer": "~0.1.1" 680 | } 681 | }, 682 | "node_modules/string-argv": { 683 | "version": "0.1.2", 684 | "integrity": "sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA==", 685 | "dev": true, 686 | "engines": { 687 | "node": ">=0.6.19" 688 | } 689 | }, 690 | "node_modules/strip-ansi": { 691 | "version": "6.0.1", 692 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 693 | "dev": true, 694 | "dependencies": { 695 | "ansi-regex": "^5.0.1" 696 | }, 697 | "engines": { 698 | "node": ">=8" 699 | } 700 | }, 701 | "node_modules/through": { 702 | "version": "2.3.8", 703 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", 704 | "dev": true 705 | }, 706 | "node_modules/toidentifier": { 707 | "version": "1.0.0", 708 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", 709 | "engines": { 710 | "node": ">=0.6" 711 | } 712 | }, 713 | "node_modules/tsc-watch": { 714 | "version": "4.5.0", 715 | "integrity": "sha512-aXhN4jY+1YEcn/NwCQ/+fHqU43EqOpW+pS+933EPsVEsrKhvyrodPDIjQsk1a1niFrabAK3RIBrRbAslVefEbQ==", 716 | "dev": true, 717 | "dependencies": { 718 | "cross-spawn": "^7.0.3", 719 | "node-cleanup": "^2.1.2", 720 | "ps-tree": "^1.2.0", 721 | "string-argv": "^0.1.1", 722 | "strip-ansi": "^6.0.0" 723 | }, 724 | "bin": { 725 | "tsc-watch": "index.js" 726 | }, 727 | "engines": { 728 | "node": ">=8.17.0" 729 | }, 730 | "peerDependencies": { 731 | "typescript": "*" 732 | } 733 | }, 734 | "node_modules/tslib": { 735 | "version": "2.3.1", 736 | "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" 737 | }, 738 | "node_modules/type-is": { 739 | "version": "1.6.18", 740 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 741 | "dependencies": { 742 | "media-typer": "0.3.0", 743 | "mime-types": "~2.1.24" 744 | }, 745 | "engines": { 746 | "node": ">= 0.6" 747 | } 748 | }, 749 | "node_modules/typescript": { 750 | "version": "4.4.3", 751 | "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==", 752 | "dev": true, 753 | "bin": { 754 | "tsc": "bin/tsc", 755 | "tsserver": "bin/tsserver" 756 | }, 757 | "engines": { 758 | "node": ">=4.2.0" 759 | } 760 | }, 761 | "node_modules/unpipe": { 762 | "version": "1.0.0", 763 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", 764 | "engines": { 765 | "node": ">= 0.8" 766 | } 767 | }, 768 | "node_modules/utils-merge": { 769 | "version": "1.0.1", 770 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", 771 | "engines": { 772 | "node": ">= 0.4.0" 773 | } 774 | }, 775 | "node_modules/vary": { 776 | "version": "1.1.2", 777 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", 778 | "engines": { 779 | "node": ">= 0.8" 780 | } 781 | }, 782 | "node_modules/which": { 783 | "version": "2.0.2", 784 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 785 | "dev": true, 786 | "dependencies": { 787 | "isexe": "^2.0.0" 788 | }, 789 | "bin": { 790 | "node-which": "bin/node-which" 791 | }, 792 | "engines": { 793 | "node": ">= 8" 794 | } 795 | } 796 | }, 797 | "dependencies": { 798 | "@types/body-parser": { 799 | "version": "1.19.1", 800 | "integrity": "sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==", 801 | "dev": true, 802 | "requires": { 803 | "@types/connect": "*", 804 | "@types/node": "*" 805 | } 806 | }, 807 | "@types/connect": { 808 | "version": "3.4.35", 809 | "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", 810 | "dev": true, 811 | "requires": { 812 | "@types/node": "*" 813 | } 814 | }, 815 | "@types/express": { 816 | "version": "4.17.13", 817 | "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", 818 | "dev": true, 819 | "requires": { 820 | "@types/body-parser": "*", 821 | "@types/express-serve-static-core": "^4.17.18", 822 | "@types/qs": "*", 823 | "@types/serve-static": "*" 824 | } 825 | }, 826 | "@types/express-serve-static-core": { 827 | "version": "4.17.24", 828 | "integrity": "sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==", 829 | "dev": true, 830 | "requires": { 831 | "@types/node": "*", 832 | "@types/qs": "*", 833 | "@types/range-parser": "*" 834 | } 835 | }, 836 | "@types/mime": { 837 | "version": "1.3.2", 838 | "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", 839 | "dev": true 840 | }, 841 | "@types/node": { 842 | "version": "14.17.20", 843 | "integrity": "sha512-gI5Sl30tmhXsqkNvopFydP7ASc4c2cLfGNQrVKN3X90ADFWFsPEsotm/8JHSUJQKTHbwowAHtcJPeyVhtKv0TQ==", 844 | "dev": true 845 | }, 846 | "@types/qs": { 847 | "version": "6.9.7", 848 | "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", 849 | "dev": true 850 | }, 851 | "@types/range-parser": { 852 | "version": "1.2.4", 853 | "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", 854 | "dev": true 855 | }, 856 | "@types/serve-static": { 857 | "version": "1.13.10", 858 | "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", 859 | "dev": true, 860 | "requires": { 861 | "@types/mime": "^1", 862 | "@types/node": "*" 863 | } 864 | }, 865 | "accepts": { 866 | "version": "1.3.7", 867 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", 868 | "requires": { 869 | "mime-types": "~2.1.24", 870 | "negotiator": "0.6.2" 871 | } 872 | }, 873 | "ansi-regex": { 874 | "version": "5.0.1", 875 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 876 | "dev": true 877 | }, 878 | "array-flatten": { 879 | "version": "1.1.1", 880 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 881 | }, 882 | "body-parser": { 883 | "version": "1.19.0", 884 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", 885 | "requires": { 886 | "bytes": "3.1.0", 887 | "content-type": "~1.0.4", 888 | "debug": "2.6.9", 889 | "depd": "~1.1.2", 890 | "http-errors": "1.7.2", 891 | "iconv-lite": "0.4.24", 892 | "on-finished": "~2.3.0", 893 | "qs": "6.7.0", 894 | "raw-body": "2.4.0", 895 | "type-is": "~1.6.17" 896 | } 897 | }, 898 | "bytes": { 899 | "version": "3.1.0", 900 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" 901 | }, 902 | "content-disposition": { 903 | "version": "0.5.3", 904 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", 905 | "requires": { 906 | "safe-buffer": "5.1.2" 907 | } 908 | }, 909 | "content-type": { 910 | "version": "1.0.4", 911 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 912 | }, 913 | "cookie": { 914 | "version": "0.4.0", 915 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" 916 | }, 917 | "cookie-signature": { 918 | "version": "1.0.6", 919 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 920 | }, 921 | "cross-spawn": { 922 | "version": "7.0.3", 923 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 924 | "dev": true, 925 | "requires": { 926 | "path-key": "^3.1.0", 927 | "shebang-command": "^2.0.0", 928 | "which": "^2.0.1" 929 | } 930 | }, 931 | "debug": { 932 | "version": "2.6.9", 933 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 934 | "requires": { 935 | "ms": "2.0.0" 936 | } 937 | }, 938 | "depd": { 939 | "version": "1.1.2", 940 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" 941 | }, 942 | "destroy": { 943 | "version": "1.0.4", 944 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 945 | }, 946 | "dotenv": { 947 | "version": "8.6.0", 948 | "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", 949 | "dev": true 950 | }, 951 | "dotenv-cli": { 952 | "version": "4.0.0", 953 | "integrity": "sha512-ByKEec+ashePEXthZaA1fif9XDtcaRnkN7eGdBDx3HHRjwZ/rA1go83Cbs4yRrx3JshsCf96FjAyIA2M672+CQ==", 954 | "dev": true, 955 | "requires": { 956 | "cross-spawn": "^7.0.1", 957 | "dotenv": "^8.1.0", 958 | "dotenv-expand": "^5.1.0", 959 | "minimist": "^1.1.3" 960 | } 961 | }, 962 | "dotenv-expand": { 963 | "version": "5.1.0", 964 | "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", 965 | "dev": true 966 | }, 967 | "duplexer": { 968 | "version": "0.1.2", 969 | "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", 970 | "dev": true 971 | }, 972 | "ee-first": { 973 | "version": "1.1.1", 974 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 975 | }, 976 | "encodeurl": { 977 | "version": "1.0.2", 978 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" 979 | }, 980 | "escape-html": { 981 | "version": "1.0.3", 982 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 983 | }, 984 | "etag": { 985 | "version": "1.8.1", 986 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" 987 | }, 988 | "event-stream": { 989 | "version": "3.3.4", 990 | "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", 991 | "dev": true, 992 | "requires": { 993 | "duplexer": "~0.1.1", 994 | "from": "~0", 995 | "map-stream": "~0.1.0", 996 | "pause-stream": "0.0.11", 997 | "split": "0.3", 998 | "stream-combiner": "~0.0.4", 999 | "through": "~2.3.1" 1000 | } 1001 | }, 1002 | "express": { 1003 | "version": "4.17.1", 1004 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", 1005 | "requires": { 1006 | "accepts": "~1.3.7", 1007 | "array-flatten": "1.1.1", 1008 | "body-parser": "1.19.0", 1009 | "content-disposition": "0.5.3", 1010 | "content-type": "~1.0.4", 1011 | "cookie": "0.4.0", 1012 | "cookie-signature": "1.0.6", 1013 | "debug": "2.6.9", 1014 | "depd": "~1.1.2", 1015 | "encodeurl": "~1.0.2", 1016 | "escape-html": "~1.0.3", 1017 | "etag": "~1.8.1", 1018 | "finalhandler": "~1.1.2", 1019 | "fresh": "0.5.2", 1020 | "merge-descriptors": "1.0.1", 1021 | "methods": "~1.1.2", 1022 | "on-finished": "~2.3.0", 1023 | "parseurl": "~1.3.3", 1024 | "path-to-regexp": "0.1.7", 1025 | "proxy-addr": "~2.0.5", 1026 | "qs": "6.7.0", 1027 | "range-parser": "~1.2.1", 1028 | "safe-buffer": "5.1.2", 1029 | "send": "0.17.1", 1030 | "serve-static": "1.14.1", 1031 | "setprototypeof": "1.1.1", 1032 | "statuses": "~1.5.0", 1033 | "type-is": "~1.6.18", 1034 | "utils-merge": "1.0.1", 1035 | "vary": "~1.1.2" 1036 | } 1037 | }, 1038 | "finalhandler": { 1039 | "version": "1.1.2", 1040 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", 1041 | "requires": { 1042 | "debug": "2.6.9", 1043 | "encodeurl": "~1.0.2", 1044 | "escape-html": "~1.0.3", 1045 | "on-finished": "~2.3.0", 1046 | "parseurl": "~1.3.3", 1047 | "statuses": "~1.5.0", 1048 | "unpipe": "~1.0.0" 1049 | } 1050 | }, 1051 | "forwarded": { 1052 | "version": "0.2.0", 1053 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 1054 | }, 1055 | "fresh": { 1056 | "version": "0.5.2", 1057 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" 1058 | }, 1059 | "from": { 1060 | "version": "0.1.7", 1061 | "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", 1062 | "dev": true 1063 | }, 1064 | "graphql-rest-router": { 1065 | "version": "file:../build", 1066 | "requires": { 1067 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 1068 | "@types/chai": "^4.1.7", 1069 | "@types/express": "^4.17.1", 1070 | "@types/jest": "^26.0.20", 1071 | "@types/mocha": "^5.2.5", 1072 | "@types/node": "^10.12.18", 1073 | "@types/redis": "^2.8.32", 1074 | "@types/sinon": "^7.0.2", 1075 | "@typescript-eslint/eslint-plugin": "^4.6.1", 1076 | "@typescript-eslint/parser": "^4.6.1", 1077 | "axios": "^0.21.4", 1078 | "chai": "^4.2.0", 1079 | "eslint": "^7.12.1", 1080 | "eslint-plugin-import": "^2.22.1", 1081 | "express": "^4.17.1", 1082 | "faker": "^5.5.3", 1083 | "graphql": "^14.0.2", 1084 | "husky": "^4.2.3", 1085 | "lint-staged": "^10.5.1", 1086 | "mocha": "^9.1.1", 1087 | "nyc": "^15.1.0", 1088 | "redis": "^3.1.2", 1089 | "sinon": "^7.2.2", 1090 | "ts-node": "^7.0.1", 1091 | "tslib": "^2.1.0", 1092 | "typescript": "4.0.3" 1093 | } 1094 | }, 1095 | "http-errors": { 1096 | "version": "1.7.2", 1097 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", 1098 | "requires": { 1099 | "depd": "~1.1.2", 1100 | "inherits": "2.0.3", 1101 | "setprototypeof": "1.1.1", 1102 | "statuses": ">= 1.5.0 < 2", 1103 | "toidentifier": "1.0.0" 1104 | } 1105 | }, 1106 | "iconv-lite": { 1107 | "version": "0.4.24", 1108 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 1109 | "requires": { 1110 | "safer-buffer": ">= 2.1.2 < 3" 1111 | } 1112 | }, 1113 | "inherits": { 1114 | "version": "2.0.3", 1115 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 1116 | }, 1117 | "ipaddr.js": { 1118 | "version": "1.9.1", 1119 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 1120 | }, 1121 | "isexe": { 1122 | "version": "2.0.0", 1123 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", 1124 | "dev": true 1125 | }, 1126 | "map-stream": { 1127 | "version": "0.1.0", 1128 | "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", 1129 | "dev": true 1130 | }, 1131 | "media-typer": { 1132 | "version": "0.3.0", 1133 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 1134 | }, 1135 | "merge-descriptors": { 1136 | "version": "1.0.1", 1137 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 1138 | }, 1139 | "methods": { 1140 | "version": "1.1.2", 1141 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 1142 | }, 1143 | "mime": { 1144 | "version": "1.6.0", 1145 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 1146 | }, 1147 | "mime-db": { 1148 | "version": "1.50.0", 1149 | "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==" 1150 | }, 1151 | "mime-types": { 1152 | "version": "2.1.33", 1153 | "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==", 1154 | "requires": { 1155 | "mime-db": "1.50.0" 1156 | } 1157 | }, 1158 | "minimist": { 1159 | "version": "1.2.5", 1160 | "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", 1161 | "dev": true 1162 | }, 1163 | "module-alias": { 1164 | "version": "2.2.2", 1165 | "integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==" 1166 | }, 1167 | "ms": { 1168 | "version": "2.0.0", 1169 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 1170 | }, 1171 | "negotiator": { 1172 | "version": "0.6.2", 1173 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" 1174 | }, 1175 | "node-cleanup": { 1176 | "version": "2.1.2", 1177 | "integrity": "sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=", 1178 | "dev": true 1179 | }, 1180 | "on-finished": { 1181 | "version": "2.3.0", 1182 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 1183 | "requires": { 1184 | "ee-first": "1.1.1" 1185 | } 1186 | }, 1187 | "parseurl": { 1188 | "version": "1.3.3", 1189 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1190 | }, 1191 | "path-key": { 1192 | "version": "3.1.1", 1193 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 1194 | "dev": true 1195 | }, 1196 | "path-to-regexp": { 1197 | "version": "0.1.7", 1198 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 1199 | }, 1200 | "pause-stream": { 1201 | "version": "0.0.11", 1202 | "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", 1203 | "dev": true, 1204 | "requires": { 1205 | "through": "~2.3" 1206 | } 1207 | }, 1208 | "proxy-addr": { 1209 | "version": "2.0.7", 1210 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 1211 | "requires": { 1212 | "forwarded": "0.2.0", 1213 | "ipaddr.js": "1.9.1" 1214 | } 1215 | }, 1216 | "ps-tree": { 1217 | "version": "1.2.0", 1218 | "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", 1219 | "dev": true, 1220 | "requires": { 1221 | "event-stream": "=3.3.4" 1222 | } 1223 | }, 1224 | "qs": { 1225 | "version": "6.7.0", 1226 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" 1227 | }, 1228 | "range-parser": { 1229 | "version": "1.2.1", 1230 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 1231 | }, 1232 | "raw-body": { 1233 | "version": "2.4.0", 1234 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", 1235 | "requires": { 1236 | "bytes": "3.1.0", 1237 | "http-errors": "1.7.2", 1238 | "iconv-lite": "0.4.24", 1239 | "unpipe": "1.0.0" 1240 | } 1241 | }, 1242 | "safe-buffer": { 1243 | "version": "5.1.2", 1244 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 1245 | }, 1246 | "safer-buffer": { 1247 | "version": "2.1.2", 1248 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1249 | }, 1250 | "send": { 1251 | "version": "0.17.1", 1252 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", 1253 | "requires": { 1254 | "debug": "2.6.9", 1255 | "depd": "~1.1.2", 1256 | "destroy": "~1.0.4", 1257 | "encodeurl": "~1.0.2", 1258 | "escape-html": "~1.0.3", 1259 | "etag": "~1.8.1", 1260 | "fresh": "0.5.2", 1261 | "http-errors": "~1.7.2", 1262 | "mime": "1.6.0", 1263 | "ms": "2.1.1", 1264 | "on-finished": "~2.3.0", 1265 | "range-parser": "~1.2.1", 1266 | "statuses": "~1.5.0" 1267 | }, 1268 | "dependencies": { 1269 | "ms": { 1270 | "version": "2.1.1", 1271 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 1272 | } 1273 | } 1274 | }, 1275 | "serve-static": { 1276 | "version": "1.14.1", 1277 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", 1278 | "requires": { 1279 | "encodeurl": "~1.0.2", 1280 | "escape-html": "~1.0.3", 1281 | "parseurl": "~1.3.3", 1282 | "send": "0.17.1" 1283 | } 1284 | }, 1285 | "setprototypeof": { 1286 | "version": "1.1.1", 1287 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" 1288 | }, 1289 | "shebang-command": { 1290 | "version": "2.0.0", 1291 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1292 | "dev": true, 1293 | "requires": { 1294 | "shebang-regex": "^3.0.0" 1295 | } 1296 | }, 1297 | "shebang-regex": { 1298 | "version": "3.0.0", 1299 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 1300 | "dev": true 1301 | }, 1302 | "split": { 1303 | "version": "0.3.3", 1304 | "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", 1305 | "dev": true, 1306 | "requires": { 1307 | "through": "2" 1308 | } 1309 | }, 1310 | "statuses": { 1311 | "version": "1.5.0", 1312 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" 1313 | }, 1314 | "stream-combiner": { 1315 | "version": "0.0.4", 1316 | "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", 1317 | "dev": true, 1318 | "requires": { 1319 | "duplexer": "~0.1.1" 1320 | } 1321 | }, 1322 | "string-argv": { 1323 | "version": "0.1.2", 1324 | "integrity": "sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA==", 1325 | "dev": true 1326 | }, 1327 | "strip-ansi": { 1328 | "version": "6.0.1", 1329 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1330 | "dev": true, 1331 | "requires": { 1332 | "ansi-regex": "^5.0.1" 1333 | } 1334 | }, 1335 | "through": { 1336 | "version": "2.3.8", 1337 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", 1338 | "dev": true 1339 | }, 1340 | "toidentifier": { 1341 | "version": "1.0.0", 1342 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" 1343 | }, 1344 | "tsc-watch": { 1345 | "version": "4.5.0", 1346 | "integrity": "sha512-aXhN4jY+1YEcn/NwCQ/+fHqU43EqOpW+pS+933EPsVEsrKhvyrodPDIjQsk1a1niFrabAK3RIBrRbAslVefEbQ==", 1347 | "dev": true, 1348 | "requires": { 1349 | "cross-spawn": "^7.0.3", 1350 | "node-cleanup": "^2.1.2", 1351 | "ps-tree": "^1.2.0", 1352 | "string-argv": "^0.1.1", 1353 | "strip-ansi": "^6.0.0" 1354 | } 1355 | }, 1356 | "tslib": { 1357 | "version": "2.3.1", 1358 | "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" 1359 | }, 1360 | "type-is": { 1361 | "version": "1.6.18", 1362 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 1363 | "requires": { 1364 | "media-typer": "0.3.0", 1365 | "mime-types": "~2.1.24" 1366 | } 1367 | }, 1368 | "typescript": { 1369 | "version": "4.4.3", 1370 | "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==", 1371 | "dev": true 1372 | }, 1373 | "unpipe": { 1374 | "version": "1.0.0", 1375 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 1376 | }, 1377 | "utils-merge": { 1378 | "version": "1.0.1", 1379 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" 1380 | }, 1381 | "vary": { 1382 | "version": "1.1.2", 1383 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" 1384 | }, 1385 | "which": { 1386 | "version": "2.0.2", 1387 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1388 | "dev": true, 1389 | "requires": { 1390 | "isexe": "^2.0.0" 1391 | } 1392 | } 1393 | } 1394 | } 1395 | -------------------------------------------------------------------------------- /example-consuming-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-consuming-client", 3 | "version": "0.0.0", 4 | "description": "", 5 | "private": true, 6 | "license": "ISC", 7 | "main": "./build/index.js", 8 | "scripts": { 9 | "clean": "rm -rf ./build", 10 | "build": "tsc", 11 | "start": "tsc-watch --onSuccess \"dotenv -- node ./build/index.js\"" 12 | }, 13 | "dependencies": { 14 | "express": "^4.17.1", 15 | "graphql-rest-router": "file:../build", 16 | "module-alias": "^2.2.2", 17 | "tslib": "^2.0.3" 18 | }, 19 | "devDependencies": { 20 | "@types/body-parser": "^1.19.0", 21 | "@types/express": "^4.17.8", 22 | "@types/node": "^14.11.2", 23 | "dotenv-cli": "^4.0.0", 24 | "tsc-watch": "^4.2.9", 25 | "typescript": "^4.0.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example-consuming-client/src/api.ts: -------------------------------------------------------------------------------- 1 | import GraphQLRestRouter, { InMemoryCache, OpenApi } from 'graphql-rest-router'; 2 | import SCHEMA from './schema'; 3 | 4 | const { ENDPOINT = '' } = process.env; 5 | 6 | const api = new GraphQLRestRouter(ENDPOINT, SCHEMA, { optimizeQueryRequest: true, logger: console }); 7 | 8 | const documentation = new OpenApi.V3({ 9 | title: 'My REST API', // REQUIRED! 10 | version: '1.0.0', // REQUIRED! 11 | host: 'http://localhost:4000', 12 | basePath: '/api', 13 | }); 14 | 15 | api.mount('GetCharacters').at('/characters/').withOptions({ 16 | cacheEngine: new InMemoryCache(), 17 | cacheTimeInMs: 5000, 18 | }); 19 | api.mount('GetCharacterById').at('/characters/:id'); 20 | 21 | api.mount('GetLocations').at('/locations').withOption('transformResponse', (response) => { 22 | const { data, errors } = response; 23 | 24 | return { 25 | data: { 26 | isTransformed: true, 27 | ...data, 28 | errors, 29 | } 30 | }; 31 | }); 32 | 33 | api.mount('GetLocationById').at('/locations/:id'); 34 | 35 | api.mount('GetEpisodes').at('episodes'); 36 | api.mount('GetEpisodeById').at('episodes/:id'); 37 | 38 | api.mount(documentation).at('/docs/openapi'); 39 | 40 | export default api.asExpressRouter(); 41 | -------------------------------------------------------------------------------- /example-consuming-client/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import * as express from 'express'; 3 | 4 | import api from './api'; 5 | 6 | export const createServer = (): void => { 7 | const app = express(); 8 | app.use('/api', api); 9 | // eslint-disable-next-line 10 | app.listen(4000, () => console.log('Server listening on port 4000')); 11 | }; 12 | 13 | /* istanbul ignore next */ 14 | if (require.main === module) { 15 | createServer(); 16 | } 17 | -------------------------------------------------------------------------------- /example-consuming-client/src/schema.ts: -------------------------------------------------------------------------------- 1 | const schema = ` 2 | fragment info on Info { 3 | count 4 | pages 5 | next 6 | prev 7 | } 8 | 9 | fragment location on Location { 10 | id 11 | name 12 | # type 13 | # dimension 14 | # created 15 | } 16 | 17 | fragment character on Character { 18 | id 19 | name 20 | origin { 21 | ...location 22 | } 23 | # status 24 | # species 25 | # type 26 | # gender 27 | # location { 28 | # ...location 29 | # } 30 | # image 31 | # episode { 32 | # ...episode 33 | # } 34 | # created 35 | } 36 | 37 | fragment episode on Episode { 38 | id 39 | name 40 | air_date 41 | episode 42 | created 43 | characters { 44 | ...character 45 | } 46 | } 47 | 48 | # "id": "1" 49 | query GetCharacterById($id: ID!) { 50 | character(id: $id) { 51 | ...character 52 | } 53 | } 54 | 55 | query GetCharacters { 56 | characters { 57 | info { 58 | ...info 59 | } 60 | results { 61 | ...character 62 | } 63 | } 64 | } 65 | 66 | # "id": "1" 67 | query GetLocationById($id: ID!) { 68 | location(id: $id) { 69 | ...location 70 | } 71 | } 72 | 73 | query GetLocations { 74 | locations { 75 | info { 76 | ...info 77 | } 78 | results { 79 | ...location 80 | } 81 | } 82 | } 83 | 84 | # "id": "1" 85 | query GetEpisodeById($id: ID!) { 86 | episode(id: $id) { 87 | ...episode 88 | } 89 | } 90 | 91 | query GetEpisodes { 92 | episodes { 93 | info { 94 | ...info 95 | } 96 | results { 97 | ...episode 98 | } 99 | } 100 | } 101 | `; 102 | 103 | export default schema; 104 | 105 | -------------------------------------------------------------------------------- /example-consuming-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2020"], 4 | "module": "commonjs", 5 | "target": "es2020", 6 | 7 | "outDir": "build", 8 | "rootDir": "src", 9 | "strict": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "inlineSourceMap": true, 13 | "strictNullChecks": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": false, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "allowSyntheticDefaultImports": true, 20 | "experimentalDecorators": true, 21 | "downlevelIteration": true, 22 | "allowUnreachableCode": false, 23 | "allowJs": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ], 27 | "types": ["node", "jest"], 28 | "baseUrl": "./", 29 | "paths": { 30 | "src/*": ["./src/*"] 31 | } 32 | }, 33 | "include": [ 34 | "./src/*.ts", 35 | ], 36 | "exclude": [ 37 | "node_modules", 38 | "build" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-rest-router", 3 | "version": "1.0.0-beta.1", 4 | "description": "", 5 | "main": "src/index.js", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "lint": "eslint . --ext .ts", 9 | "build": "npm run clean && tsc && cp package.json README.md build", 10 | "clean": "rm -rf build", 11 | "live-test": "bash ./example-consuming-client/build.sh", 12 | "test": "nyc mocha -r ts-node/register ./test/*.test.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/Econify/graphql-rest-router.git" 17 | }, 18 | "author": "Stephen Baldwin ", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/Econify/graphql-rest-router/issues" 22 | }, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "npm t" 26 | } 27 | }, 28 | "homepage": "https://github.com/Econify/graphql-rest-router#readme", 29 | "devDependencies": { 30 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 31 | "@types/chai": "^4.1.7", 32 | "@types/express": "^4.17.1", 33 | "@types/jest": "^26.0.20", 34 | "@types/mocha": "^5.2.5", 35 | "@types/node": "^10.12.18", 36 | "@types/redis": "^2.8.32", 37 | "@types/sinon": "^7.0.2", 38 | "@typescript-eslint/eslint-plugin": "^4.6.1", 39 | "@typescript-eslint/parser": "^4.6.1", 40 | "chai": "^4.2.0", 41 | "eslint": "^7.12.1", 42 | "eslint-plugin-import": "^2.22.1", 43 | "faker": "^5.5.3", 44 | "husky": "^4.2.3", 45 | "lint-staged": "^10.5.1", 46 | "mocha": "^9.1.1", 47 | "nyc": "^15.1.0", 48 | "sinon": "^7.2.2", 49 | "ts-node": "^7.0.1", 50 | "typescript": "4.0.3" 51 | }, 52 | "dependencies": { 53 | "axios": "^0.21.4", 54 | "express": "^4.17.1", 55 | "graphql": "^14.0.2", 56 | "redis": "^3.1.2", 57 | "tslib": "^2.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ApiBlueprint.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Router from './Router'; 4 | import { IMountableItem } from './types'; 5 | 6 | export default class ApiBlueprint implements IMountableItem { 7 | public path = '/docs/blueprint'; 8 | public httpMethod = 'get'; 9 | protected router: Router; 10 | 11 | constructor() { 12 | throw new Error('not yet implemented'); 13 | } 14 | 15 | onMount(router: Router): this { 16 | this.router = router; 17 | 18 | return this; 19 | } 20 | 21 | at(path: string): this { 22 | this.path = path; 23 | 24 | return this; 25 | } 26 | 27 | withOptions(options: Record): this { 28 | // This doesn't do anything yet. 29 | // Did not want to comment out at the moment 30 | if (options) { 31 | return this; 32 | } 33 | 34 | return this; 35 | } 36 | 37 | asExpressRoute(): ((req: express.Request, res: express.Response) => void) | never { 38 | throw new Error('Not yet implemented'); 39 | } 40 | 41 | asKoaRoute(): ((req: express.Request, res: express.Response) => void) | never { 42 | throw new Error('not yet implemented'); 43 | } 44 | 45 | asMetal(): ((req: express.Request, res: express.Response) => void) | never { 46 | throw new Error('not yet implemented'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/InMemoryCache.ts: -------------------------------------------------------------------------------- 1 | import { ICacheEngine } from './types'; 2 | 3 | export default class InMemoryCache implements ICacheEngine { 4 | private store: { [key: string]: string } = {}; 5 | private storeCacheExpiration: { [key: string]: number } = {}; 6 | private storeExpirationCheckInMs = 10; 7 | 8 | constructor(storeExpirationCheckInMs?: number) { 9 | if (storeExpirationCheckInMs) { 10 | this.storeExpirationCheckInMs = storeExpirationCheckInMs; 11 | } 12 | 13 | this.monitorStoreForExpiredValues(); 14 | } 15 | 16 | get(key: string): string { 17 | return this.store[key]; 18 | } 19 | 20 | set(key: string, value: string, cacheTimeInMs = 0): void { 21 | this.store[key] = value; 22 | 23 | this.storeCacheExpiration[key] = new Date().getTime() + cacheTimeInMs; 24 | } 25 | 26 | private monitorStoreForExpiredValues(): void { 27 | const { store, storeCacheExpiration } = this; 28 | 29 | setInterval((): void => { 30 | const currentTime = new Date().getTime(); 31 | 32 | Object.keys(storeCacheExpiration).forEach((key: string): void => { 33 | if (storeCacheExpiration[key] < currentTime) { 34 | delete storeCacheExpiration[key]; 35 | delete store[key]; 36 | } 37 | }); 38 | }, this.storeExpirationCheckInMs); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | import { ILogger, ILogLevels, LogLevel } from './types'; 2 | 3 | export const LogLevels: ILogLevels = { 4 | SILENT: -1, 5 | ERROR: 0, 6 | WARN: 1, 7 | INFO: 2, 8 | DEBUG: 3, 9 | }; 10 | 11 | export default class Logger implements ILogger { 12 | private loggerObject?: ILogger; 13 | private logLevel: LogLevel = LogLevels.INFO; 14 | 15 | private shouldLog(level: LogLevel) { 16 | return level <= this.logLevel; 17 | } 18 | 19 | public setLogLevel(level: LogLevel): this { 20 | this.logLevel = level; 21 | return this; 22 | } 23 | 24 | public setLoggerObject(loggerObject: ILogger): this { 25 | this.loggerObject = loggerObject; 26 | return this; 27 | } 28 | 29 | public debug(message: string): void { 30 | if (this.loggerObject && this.shouldLog(LogLevels.DEBUG)) { 31 | this.loggerObject.debug(message); 32 | } 33 | } 34 | 35 | public info(message: string): void { 36 | if (this.loggerObject && this.shouldLog(LogLevels.INFO)) { 37 | this.loggerObject.info(message); 38 | } 39 | } 40 | 41 | public warn(message: string): void { 42 | if (this.loggerObject && this.shouldLog(LogLevels.WARN)) { 43 | this.loggerObject.warn(message); 44 | } 45 | } 46 | 47 | public error(message: string): void { 48 | if (this.loggerObject && this.shouldLog(LogLevels.ERROR)) { 49 | this.loggerObject.error(message); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/OpenApi.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import describeRouteVariables from './describeRouteVariables'; 3 | import Router from './Router'; 4 | import Route from './Route'; 5 | import { IMountableItem, IOperationVariable, IOpenApiOptions, IGlobalConfiguration } from './types'; 6 | 7 | interface IParameter { 8 | name: string; 9 | required: boolean; 10 | in: string; 11 | type?: string; 12 | schema?: IParameterArraySchema | IParameterItemTypeOrRef; 13 | default?: string | boolean | number; 14 | } 15 | 16 | interface IParameterArraySchema { 17 | type: string; 18 | items: IParameterItemTypeOrRef; 19 | } 20 | 21 | interface IParameterItemTypeOrRef { 22 | type?: string; 23 | $ref?: string; 24 | } 25 | 26 | interface IBuildParametersArguments { 27 | variableDefinitions: IOperationVariable[]; 28 | variableLocation: string; 29 | refLocation: string; 30 | } 31 | 32 | const PATH_VARIABLES_REGEX = /:([A-Za-z]+)/g; 33 | 34 | function resolveBodySchemaName(route: Route): string { 35 | return `${route.operationName}Body`; 36 | } 37 | 38 | function translateScalarType(scalarType: string): string { 39 | switch (scalarType) { 40 | case 'Int': 41 | return 'number'; 42 | case 'Boolean': 43 | return 'boolean'; 44 | case 'String': 45 | default: 46 | return 'string'; 47 | } 48 | } 49 | 50 | function buildScalarDefinition(node: any): any { 51 | const scalarType = translateScalarType(node.name); 52 | 53 | const scalarDoc: any = { 54 | type: scalarType, 55 | }; 56 | 57 | if (node.description) { 58 | scalarDoc.description = node.description; 59 | } 60 | 61 | return scalarDoc; 62 | } 63 | 64 | function buildObjectDefinition(node: any): any { 65 | const objectDoc: any = {}; 66 | 67 | objectDoc.type = 'object'; 68 | objectDoc.properties = {}; 69 | 70 | if (node.inputFields) { 71 | node.inputFields.forEach((field: any) => { 72 | const { type: fieldNode } = field; 73 | 74 | objectDoc.properties[field.name] = buildDefinition(fieldNode); 75 | }); 76 | } 77 | 78 | return objectDoc; 79 | } 80 | 81 | function buildBodyDefinition(variables: IOperationVariable[], refLocation: string): any { 82 | const bodyDoc: any = {}; 83 | 84 | bodyDoc.type = 'object'; 85 | bodyDoc.properties = {}; 86 | 87 | variables.forEach((field: IOperationVariable) => { 88 | bodyDoc.properties[field.name] = buildSchemaParameter(field, refLocation); 89 | }); 90 | 91 | return bodyDoc; 92 | } 93 | 94 | function buildDefinition(node: any): any { 95 | switch (node.kind) { 96 | case 'INPUT_OBJECT': 97 | return buildObjectDefinition(node); 98 | case 'ENUM': 99 | return buildEnumDefinition(node); 100 | case 'SCALAR': 101 | default: 102 | return buildScalarDefinition(node); 103 | } 104 | } 105 | 106 | function buildEnumDefinition(node: any): any { 107 | const enumDoc: any = { 108 | type: 'string', 109 | enum: [], 110 | }; 111 | 112 | if (node.description) { 113 | enumDoc.description = node.description; 114 | } 115 | 116 | node.enumValues.forEach((enumValue: any) => { 117 | enumDoc.enum.push(enumValue.name); 118 | }); 119 | 120 | return enumDoc; 121 | } 122 | 123 | function openApiPath(path: string): string { 124 | return path.replace(PATH_VARIABLES_REGEX, '{$1}'); 125 | } 126 | 127 | function buildSchemaParameter( 128 | variableDefinition: IOperationVariable, 129 | refLocation: string 130 | ): IParameterArraySchema | IParameterItemTypeOrRef { 131 | if (variableDefinition.array) { 132 | return { 133 | type: 'array', 134 | items: { 135 | '$ref': `${refLocation}/${variableDefinition.type}` 136 | }, 137 | }; 138 | } 139 | 140 | return { 141 | '$ref': `${refLocation}/${variableDefinition.type}` 142 | }; 143 | } 144 | 145 | // TODO: Return Type and Attempt to get description from graphql 146 | function buildParametersArray( 147 | { variableDefinitions, variableLocation, refLocation } : IBuildParametersArguments 148 | ): IParameter[] { 149 | return variableDefinitions.map( 150 | (variableDefinition: IOperationVariable): IParameter => ({ 151 | name: variableDefinition.name, 152 | required: variableDefinition.required, 153 | in: variableLocation, 154 | schema: buildSchemaParameter(variableDefinition, refLocation), 155 | // default: variableDefinition.defaultValue, 156 | }) 157 | ); 158 | } 159 | 160 | class MountableDocument implements IMountableItem { 161 | public path = '/docs/openapi'; 162 | public httpMethod = 'get'; 163 | 164 | protected router?: Router; 165 | 166 | constructor(protected options: IOpenApiOptions) { 167 | } 168 | 169 | onMount(router: Router): this { 170 | this.router = router; 171 | 172 | return this; 173 | } 174 | 175 | at(path: string): this { 176 | this.path = path; 177 | 178 | return this; 179 | } 180 | 181 | protected async generateDocumentation(): Promise { 182 | return ''; 183 | } 184 | 185 | // Not currently used 186 | withOptions(options: IGlobalConfiguration): this { 187 | return this; 188 | } 189 | 190 | asExpressRoute(): ((req: express.Request, res: express.Response) => void) | never { 191 | const generateDoc = this.generateDocumentation(); 192 | 193 | return async (req: express.Request, res: express.Response) => { 194 | const doc = await generateDoc; 195 | 196 | res 197 | .status(200) 198 | .json(doc); 199 | }; 200 | } 201 | 202 | asKoaRoute(): ((req: express.Request, res: express.Response) => void) | never { 203 | throw new Error('not yet implemented'); 204 | } 205 | 206 | asMetal(): ((req: express.Request, res: express.Response) => void) | never { 207 | throw new Error('not yet implemented'); 208 | } 209 | 210 | protected getRouter(): Router { 211 | const { router } = this; 212 | 213 | if (!router) { 214 | throw new Error(` 215 | Router must be set in order to generate documentation. If you are using router.mount(), the router will automatically be set. 216 | If you are using this outside of a Router instance, please leverage #setRouter() to select a class that adheres to the router 217 | interface. 218 | `); 219 | } 220 | 221 | return router; 222 | } 223 | } 224 | 225 | export class V2 extends MountableDocument { 226 | public path = '/docs/swagger'; 227 | 228 | protected async generateDocumentation(): Promise { 229 | const router = this.getRouter(); 230 | 231 | try { 232 | const { 233 | title, 234 | version, 235 | termsOfService, 236 | license, 237 | host, 238 | basePath, 239 | } = this.options; 240 | 241 | const page: any = { 242 | swagger: '2.0', 243 | info: {}, 244 | paths: {}, 245 | produces: ['application/json'], 246 | definitions: {}, 247 | }; 248 | 249 | page.info.title = title; 250 | page.info.version = version; 251 | 252 | if (termsOfService) { 253 | page.info.termsOfService = termsOfService; 254 | } 255 | 256 | if (license) { 257 | page.info.license = { name: license }; 258 | } 259 | 260 | if (host) { 261 | page.host = host; 262 | } 263 | 264 | if (basePath) { 265 | page.basePath = basePath; 266 | } 267 | 268 | router.routes.forEach((route) => { 269 | const { path, httpMethod } = route; 270 | 271 | const docPath = openApiPath(path); 272 | 273 | if (!page.paths[docPath]) { 274 | page.paths[docPath] = {}; 275 | } 276 | 277 | const routeDoc: any = page.paths[docPath][httpMethod] = {}; 278 | 279 | routeDoc.parameters = []; 280 | routeDoc.consumes = []; 281 | routeDoc.produces = ['application/json']; 282 | routeDoc.responses = { 283 | 200: { 284 | description: 'Server alive. This does not mean that the query was completed succesfully. Check the errors object in the response', 285 | }, 286 | 400: { 287 | description: 'Authentication Required', 288 | }, 289 | 500: { 290 | description: 'Server error.', 291 | } 292 | }; 293 | 294 | if (httpMethod === 'post') { 295 | routeDoc.consumes.push('application/json'); 296 | } 297 | 298 | // TODO: 'push in parameters for header' 299 | 300 | routeDoc.parameters.push( 301 | ...buildParametersArray({ 302 | variableDefinitions: route.queryVariables, 303 | variableLocation: 'query', 304 | refLocation: '#/definitions', 305 | }) 306 | ); 307 | 308 | routeDoc.parameters.push( 309 | ...buildParametersArray({ 310 | variableDefinitions: route.pathVariables, 311 | variableLocation: 'path', 312 | refLocation: '#/definitions', 313 | }) 314 | ); 315 | 316 | routeDoc.parameters.push( 317 | ...buildParametersArray({ 318 | variableDefinitions: route.bodyVariables, 319 | variableLocation: 'body', 320 | refLocation: '#/definitions', 321 | }) 322 | ); 323 | }); 324 | 325 | const introspectedVariableDefinitions: any = await describeRouteVariables(router); 326 | 327 | Object.keys(introspectedVariableDefinitions).forEach((variableName) => { 328 | const variableDefinition: any = introspectedVariableDefinitions[variableName]; 329 | 330 | page.definitions[variableName] = buildDefinition(variableDefinition); 331 | }); 332 | 333 | return page; 334 | } catch (error) { 335 | return { 336 | error: error.message, 337 | }; 338 | } 339 | } 340 | } 341 | 342 | export class V3 extends MountableDocument { 343 | protected async generateDocumentation(): Promise { 344 | const { options } = this; 345 | 346 | const doc: any = {}; 347 | const refLocation = '#/components/schemas'; 348 | 349 | doc.openapi = '3.0.0'; 350 | doc.info = {}; 351 | doc.info.title = options.title; 352 | doc.info.version = options.version; 353 | 354 | doc.paths = {}; 355 | doc.components = {}; 356 | 357 | if (options.license) { 358 | doc.license = {}; 359 | doc.license.name = options.license; 360 | } 361 | 362 | if (options.host) { 363 | doc.servers = []; 364 | 365 | doc.servers.push({ 366 | url: `${options.host}${options.basePath || ''}`, 367 | }); 368 | } 369 | 370 | const router = this.getRouter(); 371 | 372 | router.routes.forEach((route) => { 373 | const { path, httpMethod } = route; 374 | 375 | const docPath = openApiPath(path); 376 | 377 | if (!doc.paths[docPath]) { 378 | doc.paths[docPath] = {}; 379 | } 380 | 381 | const routeDoc: any = doc.paths[docPath][httpMethod] = {}; 382 | 383 | routeDoc.responses = {}; 384 | 385 | routeDoc.responses.default = {}; 386 | routeDoc.responses.default.description = 'OK'; 387 | 388 | routeDoc.parameters = []; 389 | 390 | routeDoc.parameters.push( 391 | ...buildParametersArray({ 392 | variableDefinitions: route.queryVariables, 393 | variableLocation: 'query', 394 | refLocation, 395 | }) 396 | ); 397 | 398 | routeDoc.parameters.push( 399 | ...buildParametersArray({ 400 | variableDefinitions: route.pathVariables, 401 | variableLocation: 'path', 402 | refLocation, 403 | }) 404 | ); 405 | 406 | if (route.bodyVariables.length) { 407 | const schemaName = resolveBodySchemaName(route); 408 | 409 | routeDoc.requestBody = { 410 | content: { 411 | 'application/json': { 412 | schema: { 413 | '$ref': `${refLocation}/${schemaName}` 414 | }, 415 | }, 416 | }, 417 | }; 418 | } 419 | }); 420 | 421 | doc.components = {}; 422 | doc.components.schemas = {}; 423 | 424 | try { 425 | const introspectedVariableDefinitions: any = await describeRouteVariables(router); 426 | 427 | Object.keys(introspectedVariableDefinitions).forEach((variableName) => { 428 | const variableDefinition: any = introspectedVariableDefinitions[variableName]; 429 | 430 | doc.components.schemas[variableName] = buildDefinition(variableDefinition); 431 | }); 432 | 433 | // Build out definitions for each route to contain a "body" schema in case it's used 434 | // for anything besides GET 435 | router.routes.forEach( 436 | (route) => { 437 | const schemaName = resolveBodySchemaName(route); 438 | 439 | doc.components.schemas[schemaName] = 440 | buildBodyDefinition(route.bodyVariables, refLocation); 441 | } 442 | ); 443 | 444 | return doc; 445 | } catch (error) { 446 | return { 447 | error: error.message, 448 | }; 449 | } 450 | 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /src/RedisCache.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import { createClient, RedisClient, ClientOpts } from 'redis'; 3 | import { ICacheEngine } from './types'; 4 | 5 | export default class RedisCache implements ICacheEngine { 6 | private client: RedisClient; 7 | private setFn: (key: string, cacheTimeInSeconds: number, value: string) => Promise; 8 | private getFn: (key: string) => Promise; 9 | 10 | constructor(options?: ClientOpts) { 11 | this.client = createClient(options); 12 | this.setFn = promisify(this.client.setex).bind(this.client); 13 | this.getFn = promisify(this.client.get).bind(this.client); 14 | } 15 | 16 | async get(key: string): Promise { 17 | return this.getFn(key); 18 | } 19 | 20 | async set(key: string, value: string, cacheTimeInMs = 0): Promise { 21 | const cacheTimeInSec = Math.floor(cacheTimeInMs / 1000); 22 | await this.setFn(key, cacheTimeInSec, value); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Route.ts: -------------------------------------------------------------------------------- 1 | import { IncomingHttpHeaders } from 'http'; 2 | import { 3 | OperationDefinitionNode, ListTypeNode, VariableDefinitionNode, 4 | parse, print, getOperationAST, NonNullTypeNode, DocumentNode, 5 | } from 'graphql'; 6 | import axios, { AxiosTransformer, AxiosInstance, AxiosRequestConfig } from 'axios'; 7 | import * as express from 'express'; 8 | 9 | import { createHash } from 'crypto'; 10 | 11 | import { 12 | IMountableItem, IConstructorRouteOptions, IRouteOptions, LogLevel, ILogger, 13 | IOperationVariableMap, IOperationVariable, IResponse, ICacheEngine, 14 | } from './types'; 15 | 16 | import Logger from './Logger'; 17 | 18 | const PATH_VARIABLES_REGEX = /:([A-Za-z]+)/g; 19 | 20 | /* 21 | enum EHTTPMethod { 22 | GET = 'get', 23 | POST = 'post', 24 | PUT = 'put', 25 | } 26 | */ 27 | 28 | function optionsDeprecationWarning(methodName: string) { 29 | /* Use console.warn instead of Logger instance 30 | * so it always logs the warning regardless of logger configuration */ 31 | console.warn(`Deprecated method ${methodName}() called. This function will be removed in a later version, please use withOption() or withOptions() instead.`); 32 | } 33 | 34 | function isVariableArray(node: VariableDefinitionNode | NonNullTypeNode): boolean { 35 | if (node.type.kind === 'NonNullType') { 36 | return isVariableArray(node.type); 37 | } 38 | 39 | return node.type.kind === 'ListType'; 40 | } 41 | 42 | function translateVariableType(node: VariableDefinitionNode | ListTypeNode | NonNullTypeNode): string { 43 | if (node.type.kind === 'NonNullType' || node.type.kind === 'ListType') { 44 | return translateVariableType(node.type); 45 | } 46 | 47 | return node.type.name.value; 48 | } 49 | 50 | function cleanPath(path: string): string { 51 | if (path[0] === '/') { 52 | return path; 53 | } 54 | 55 | return `/${path}`; 56 | } 57 | 58 | function getDefaultValue(node: VariableDefinitionNode): string | boolean | number | null | undefined { 59 | if ( 60 | !node.defaultValue || 61 | node.defaultValue.kind === 'Variable' || 62 | node.defaultValue.kind === 'ListValue' || 63 | node.defaultValue.kind === 'ObjectValue' 64 | ) { 65 | // TODO: implement different kinds of variables 66 | return undefined; 67 | } 68 | 69 | if (node.defaultValue.kind === 'NullValue') { 70 | return null; 71 | } 72 | 73 | return node.defaultValue.value; 74 | } 75 | 76 | 77 | // NOTE: 78 | // Consider moving the introspection of the graphql query into the routes so that 79 | // we know for certain which variables are INPUT_VARIABLES and which are enums / strings. 80 | // 81 | // This attempts to typecast all unknown types to JSON but the try catch deoptimizes the parsing of the JSON 82 | // and may affect performance. 83 | function attemptJSONParse(variable: string): string | unknown { 84 | try { 85 | return JSON.parse(variable); 86 | } catch (e) { 87 | return variable; 88 | } 89 | } 90 | 91 | function typecastVariable( 92 | variable: string, 93 | variableDefinition: IOperationVariable 94 | ): string | boolean | number | unknown { 95 | switch (variableDefinition && variableDefinition.type) { 96 | case 'Int': 97 | return parseInt(variable, 10); 98 | case 'Boolean': 99 | return Boolean(variable); 100 | case 'String': 101 | return variable; 102 | default: 103 | return attemptJSONParse(variable); 104 | } 105 | } 106 | 107 | export default class Route implements IMountableItem { 108 | public path!: string; 109 | public httpMethod = 'get'; 110 | 111 | public passThroughHeaders: string[] = []; 112 | public operationVariables!: IOperationVariableMap; 113 | public operationName?: string; 114 | 115 | // TODO: 116 | // The route should be frozen on any type of export 117 | // (such as asExpressRoute) to ensure that users understand 118 | // that changes made after export will not be respected by 119 | // the export and will only be respected on exports made after 120 | // the change 121 | private configurationIsFrozen = false; 122 | 123 | private axios!: AxiosInstance; 124 | private schema!: DocumentNode; 125 | private logger!: Logger; 126 | 127 | private transformRequestFn: AxiosTransformer[] = []; 128 | private transformResponseFn: AxiosTransformer[] = []; 129 | 130 | private staticVariables: Record = {}; 131 | private defaultVariables: Record = {}; 132 | 133 | private cacheTimeInMs = 0; 134 | private cacheEngine?: ICacheEngine; 135 | private cacheKeyIncludedHeaders: Set = new Set(); 136 | 137 | constructor(configuration: IConstructorRouteOptions) { 138 | this.configureRoute(configuration); 139 | } 140 | 141 | private setDefaultTransforms() { 142 | const defaultTransformResponse = Array.isArray(axios.defaults.transformResponse) 143 | && axios.defaults.transformResponse[0]; 144 | const defaultTransformRequest = Array.isArray(axios.defaults.transformRequest) 145 | && axios.defaults.transformRequest[0]; 146 | 147 | if (defaultTransformResponse) { 148 | this.transformResponseFn.push(defaultTransformResponse); 149 | } 150 | 151 | if (defaultTransformRequest) { 152 | this.transformRequestFn.push(defaultTransformRequest); 153 | } 154 | } 155 | 156 | private configureRoute(configuration: IConstructorRouteOptions) { 157 | const { 158 | schema, 159 | operationName, 160 | axios, 161 | 162 | ...options 163 | } = configuration; 164 | 165 | if (!schema) { 166 | throw new Error('A valid schema is required to initialize a Route'); 167 | } 168 | 169 | this.setDefaultTransforms(); 170 | this.schema = typeof schema === 'string' ? parse(schema) : schema; 171 | this.axios = axios; 172 | this.logger = new Logger(); 173 | 174 | this.setOperationName(operationName); 175 | 176 | if (!options.path) { 177 | options.path = operationName; 178 | } 179 | 180 | this.withOptions(options); 181 | } 182 | 183 | private filterHeadersForPassThrough(headers: IncomingHttpHeaders): IncomingHttpHeaders { 184 | const passThroughHeaders: IncomingHttpHeaders = {}; 185 | 186 | this.passThroughHeaders.forEach( 187 | (header: string) => { 188 | // eslint-disable-next-line no-prototype-builtins 189 | if (headers.hasOwnProperty(header)) { 190 | passThroughHeaders[header] = headers[header]; 191 | } 192 | } 193 | ); 194 | 195 | return passThroughHeaders; 196 | } 197 | 198 | private getOperationVariables(operation: OperationDefinitionNode): IOperationVariableMap { 199 | const variableMap: IOperationVariableMap = {}; 200 | 201 | operation.variableDefinitions?.forEach( 202 | (node: VariableDefinitionNode): void => { 203 | const variable: IOperationVariable = { 204 | name: node.variable.name.value, 205 | required: node.type.kind === 'NonNullType', 206 | type: translateVariableType(node), 207 | array: isVariableArray(node), 208 | defaultValue: getDefaultValue(node) 209 | }; 210 | 211 | variableMap[variable.name] = variable; 212 | } 213 | ); 214 | 215 | return variableMap; 216 | } 217 | 218 | private setOperationName(operationName?: string): void { 219 | const operation = getOperationAST(this.schema, operationName); 220 | 221 | if (!operation) { 222 | throw new Error(`The named query "${operationName}" does not exist in the Schema provided`); 223 | } 224 | 225 | this.operationName = operationName; 226 | this.operationVariables = this.getOperationVariables(operation); 227 | } 228 | 229 | private get requiredVariables(): string[] { 230 | return Object.values(this.operationVariables) 231 | .filter( 232 | ({ required, defaultValue }) => required && !defaultValue 233 | ) 234 | .map( 235 | ({ name }) => name 236 | ); 237 | } 238 | 239 | private warnForUsageOfStaticVariables(params: Record): void { 240 | const staticVariablesAsKeys = Object.keys(this.staticVariables); 241 | 242 | const unassignableVariables = Object.keys(params).filter( 243 | param => staticVariablesAsKeys.includes(param) 244 | ); 245 | 246 | if (unassignableVariables.length) { 247 | console.warn(` 248 | ${this.path} received the following restricted variables with 249 | the request that will be ignored: 250 | ${unassignableVariables.join(', ')}. 251 | `); 252 | } 253 | } 254 | 255 | private assembleVariables(params: Record): Record { 256 | const { staticVariables, defaultVariables } = this; 257 | 258 | return { ...defaultVariables, ...params, ...staticVariables }; 259 | } 260 | 261 | private missingVariables(variables: Record): string[] { 262 | const variablesAsKeys = Object.keys(variables); 263 | 264 | return this.requiredVariables 265 | .filter(requiredVariable => !variablesAsKeys.includes(requiredVariable)); 266 | } 267 | 268 | // When an encoded value is passed in, it is decoded automatically but will always 269 | // be a string. 270 | // 271 | // This method will iterate through all variables, check their definition type from the spec 272 | // and typecast them 273 | private typecastVariables(variables: { [key: string]: string }): { [key: string]: unknown } { 274 | const parsedVariables: { [key: string]: unknown } = {}; 275 | 276 | Object.entries(variables).forEach( 277 | ([variableName, value]) => { 278 | const variableDefinition = this.operationVariables[variableName]; 279 | 280 | parsedVariables[variableName] = typecastVariable(value, variableDefinition); 281 | } 282 | ); 283 | 284 | return parsedVariables; 285 | } 286 | 287 | private addPassThroughHeaders(headers: string[] | string) { 288 | if (Array.isArray(headers)) { 289 | this.passThroughHeaders = this.passThroughHeaders.concat(headers.map(value => value.toLowerCase())); 290 | } else { 291 | this.passThroughHeaders.push(headers.toLowerCase()); 292 | } 293 | return this; 294 | } 295 | 296 | private addCacheKeyHeaders(headers: string[] | string) { 297 | if (Array.isArray(headers)) { 298 | headers.forEach(v => this.cacheKeyIncludedHeaders.add(v.toLowerCase())); 299 | } else { 300 | this.cacheKeyIncludedHeaders.add(headers.toLowerCase()); 301 | } 302 | 303 | return this; 304 | } 305 | 306 | asExpressRoute() { 307 | return async (req: express.Request, res: express.Response): Promise => { 308 | const { query, params, body } = req; 309 | 310 | const parsedQueryVariables = this.typecastVariables(query as Record); 311 | const parsedPathVariables = this.typecastVariables(params); 312 | 313 | const providedVariables = { ...parsedQueryVariables, ...parsedPathVariables, ...body }; 314 | 315 | // Assemble variables from query, path and default values 316 | const assembledVariables = this.assembleVariables(providedVariables); 317 | const missingVariables = this.missingVariables(assembledVariables); 318 | 319 | if (missingVariables.length) { 320 | res.json({ 321 | error: 'Missing Variables', 322 | }); 323 | 324 | return; 325 | } 326 | 327 | const { statusCode, body: responseBody } = 328 | await this.makeRequest(assembledVariables, req.headers); 329 | 330 | res 331 | .status(statusCode) 332 | .json(responseBody); 333 | }; 334 | } 335 | 336 | asKoaRoute(): never { 337 | throw new Error('Not available! Submit PR'); 338 | } 339 | 340 | asMetal(): never { 341 | throw new Error('Not available! Submit PR'); 342 | } 343 | 344 | // areVariablesValid(variables: {}) {} 345 | 346 | withOption(option: string, value: unknown): this { 347 | if (!value || !option) { 348 | return this; 349 | } 350 | 351 | switch (option) { 352 | case 'path': 353 | return this.at(value as string); 354 | case 'httpMethod': 355 | case 'method': 356 | return this.as(value as string); 357 | case 'passThroughHeaders': 358 | return this.addPassThroughHeaders(value as string); 359 | case 'logger': 360 | this.logger.setLoggerObject(value as ILogger); 361 | return this; 362 | case 'logLevel': 363 | this.logger.setLogLevel(value as LogLevel); 364 | return this; 365 | case 'cacheKeyIncludedHeaders': 366 | return this.addCacheKeyHeaders(value as string); 367 | case 'transformRequest': 368 | this.transformRequestFn.push(value as AxiosTransformer); 369 | return this; 370 | case 'transformResponse': 371 | this.transformResponseFn.push(value as AxiosTransformer); 372 | return this; 373 | case 'cacheTimeInMs': 374 | this.cacheTimeInMs = value as number; 375 | return this; 376 | case 'cacheEngine': 377 | this.cacheEngine = value as ICacheEngine; 378 | return this; 379 | case 'staticVariables': 380 | this.staticVariables = value as Record; 381 | return this; 382 | case 'defaultVariables': 383 | this.defaultVariables = value as Record; 384 | return this; 385 | default: 386 | throw new Error(`Invalid option: ${option}`); 387 | } 388 | } 389 | 390 | withOptions(options: IRouteOptions): this { 391 | Object.entries(options).forEach(([k, v]) => { 392 | this.withOption(k, v); 393 | }); 394 | 395 | return this; 396 | } 397 | 398 | at(path: string): this { 399 | this.path = cleanPath(path); 400 | 401 | return this; 402 | } 403 | 404 | as(httpMethod: string): this { 405 | // this.httpMethod = EHTTPMethod[EHTTPMethod[httpMethod]]; 406 | this.httpMethod = httpMethod.toLowerCase(); 407 | 408 | return this; 409 | } 410 | 411 | addCacheKeyHeader(header: string): this { 412 | optionsDeprecationWarning('addCacheKeyHeader'); 413 | this.withOption('cacheKeyIncludedHeaders', header); 414 | 415 | return this; 416 | } 417 | 418 | whitelistHeaderForPassThrough(header: string): this { 419 | optionsDeprecationWarning('whitelistHeaderForPassThrough'); 420 | this.withOption('passThroughHeaders', header); 421 | 422 | return this; 423 | } 424 | 425 | setLogLevel(logLevel: LogLevel): this { 426 | optionsDeprecationWarning('setLogLevel'); 427 | this.withOption('logLevel', logLevel); 428 | 429 | return this; 430 | } 431 | 432 | transformRequest(fn: AxiosTransformer): this { 433 | optionsDeprecationWarning('transformRequest'); 434 | this.withOption('transformRequest', fn); 435 | 436 | return this; 437 | } 438 | 439 | transformResponse(fn: AxiosTransformer): this { 440 | optionsDeprecationWarning('transformResponse'); 441 | this.withOption('transformResponse', fn); 442 | 443 | return this; 444 | } 445 | 446 | setCacheTimeInMs(cacheTimeInMs: number): this { 447 | optionsDeprecationWarning('setCacheTimeInMs'); 448 | this.withOption('cacheTimeInMs', cacheTimeInMs); 449 | 450 | return this; 451 | } 452 | 453 | disableCache(): this { 454 | optionsDeprecationWarning('disableCache'); 455 | this.withOption('cacheTimeInMs', 0); 456 | 457 | return this; 458 | } 459 | 460 | get queryVariables(): IOperationVariable[] { 461 | if (this.httpMethod === 'post') { 462 | return []; 463 | } 464 | 465 | return this.nonPathVariables; 466 | } 467 | 468 | get bodyVariables(): IOperationVariable[] { 469 | if (this.httpMethod === 'get') { 470 | return []; 471 | } 472 | 473 | return this.nonPathVariables; 474 | } 475 | 476 | get pathVariables(): IOperationVariable[] { 477 | const matches = this.path.match(PATH_VARIABLES_REGEX); 478 | 479 | if (!matches) { 480 | return []; 481 | } 482 | 483 | const pathVariableNames = matches.map(match => match.substr(1)); 484 | 485 | return Object.values(this.operationVariables).filter( 486 | ({ name }) => pathVariableNames.includes(name) 487 | ); 488 | } 489 | 490 | get nonPathVariables(): IOperationVariable[] { 491 | const pathVariableNames = this.pathVariables.map(({ name }) => name); 492 | 493 | return Object.values(this.operationVariables) 494 | .filter( 495 | ({ name }) => !pathVariableNames.includes(name) 496 | ); 497 | } 498 | 499 | private getRequestFingerprint( 500 | path: string, 501 | variables: Record, 502 | headers: Record, 503 | ) { 504 | const hash = createHash('sha1'); 505 | 506 | hash.update(path); 507 | 508 | Object.entries(variables).forEach(([k, v]) => hash.update(`${k}-${v}`)); 509 | Object.entries(headers).forEach(([k, v]) => { 510 | if (this.cacheKeyIncludedHeaders.has(k)) { 511 | hash.update(`${k}-${v}`); 512 | } 513 | }); 514 | 515 | return hash.digest('hex'); 516 | } 517 | 518 | private async checkCache(fingerprint: string) { 519 | if (this.cacheEngine && this.cacheTimeInMs !== 0) { 520 | const cachedResult = await this.cacheEngine.get(fingerprint); 521 | 522 | return cachedResult ? { 523 | statusCode: 200, 524 | body: JSON.parse(cachedResult), 525 | } : null; 526 | } 527 | } 528 | 529 | private async makeRequest( 530 | variables: Record, 531 | headers: IncomingHttpHeaders = {} 532 | ): Promise { 533 | const { axios, schema, operationName, path } = this; 534 | const headersForPassThrough = this.filterHeadersForPassThrough(headers); 535 | const fingerprint = this.getRequestFingerprint(path, variables, headers); 536 | 537 | this.logger.info(`Incoming request on ${operationName} at ${path}, request variables: ${JSON.stringify(variables)}`); 538 | 539 | const cachedResult = await this.checkCache(fingerprint); 540 | 541 | if (cachedResult) { 542 | this.logger.debug('Cache hit'); 543 | return cachedResult; 544 | } 545 | 546 | const config: AxiosRequestConfig = { 547 | data: { 548 | query: print(schema), 549 | variables, 550 | operationName, 551 | }, 552 | 553 | headers: headersForPassThrough, 554 | }; 555 | 556 | if (this.transformRequestFn.length) { 557 | config.transformRequest = this.transformRequestFn; 558 | } 559 | 560 | if (this.transformResponseFn.length) { 561 | config.transformResponse = this.transformResponseFn; 562 | } 563 | 564 | try { 565 | const { data, status } = await axios(config); 566 | 567 | data.errors?.forEach((error: unknown) => { 568 | this.logger.error(`Error in GraphQL response: ${JSON.stringify(error)}`); 569 | }); 570 | 571 | if (this.cacheEngine && this.cacheTimeInMs !== 0) { 572 | this.logger.debug('Cache miss, setting results'); 573 | this.cacheEngine.set(fingerprint, JSON.stringify(data), this.cacheTimeInMs); 574 | } 575 | 576 | return { body: data, statusCode: status }; 577 | } catch (error) { 578 | this.logger.error(error.stack); 579 | 580 | if (error.response) { 581 | return { 582 | body: error.response.data, 583 | statusCode: error.response.status, 584 | }; 585 | } 586 | 587 | if (error.message.indexOf('timeout') >= 0) { 588 | return { 589 | statusCode: 504, 590 | body: { 591 | error: error.message, 592 | }, 593 | }; 594 | } 595 | 596 | return { 597 | statusCode: 500, 598 | body: { 599 | error: error.message, 600 | }, 601 | }; 602 | } 603 | } 604 | } 605 | -------------------------------------------------------------------------------- /src/Router.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | // TODO: UNDO THESE ^^ 4 | import * as express from 'express'; 5 | import * as bodyParser from 'body-parser'; 6 | import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; 7 | import { parse, DocumentNode, getOperationAST } from 'graphql'; 8 | 9 | import Route from './Route'; 10 | import traverseAndBuildOptimizedQuery from './traverseAndBuildOptimizedQuery'; 11 | import { IGlobalConfiguration, IMountableItem, IConstructorRouteOptions } from './types'; 12 | import { LogLevels } from './Logger'; 13 | 14 | // TODO: Fix this in ts config and change to import 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | const version = require('../package.json').version; 17 | 18 | const DEFAULT_CONFIGURATION: IGlobalConfiguration = { 19 | cacheEngine: undefined, 20 | logger: undefined, 21 | auth: undefined, 22 | proxy: undefined, 23 | defaultLogLevel: LogLevels.ERROR, 24 | defaultTimeoutInMs: 10000, 25 | defaultCacheTimeInMs: 0, 26 | autoDiscoverEndpoints: false, 27 | optimizeQueryRequest: false, 28 | headers: { 'x-graphql-rest-router-version': version }, 29 | }; 30 | 31 | export default class Router { 32 | private schema?: DocumentNode; 33 | private options: IGlobalConfiguration; 34 | 35 | public routes: Route[] = []; 36 | public modules: IMountableItem[] = []; 37 | public axios: AxiosInstance; 38 | 39 | private passThroughHeaders: string[] = []; 40 | 41 | constructor(public endpoint: string, schema?: string, assignedConfiguration?: IGlobalConfiguration) { 42 | const { 43 | auth, 44 | proxy, 45 | defaultTimeoutInMs: timeout, 46 | passThroughHeaders, 47 | ...options 48 | } = { 49 | ...DEFAULT_CONFIGURATION, 50 | ...assignedConfiguration, 51 | 52 | // Default headers should always override 53 | headers: { 54 | ...(assignedConfiguration || {}).headers, 55 | ...DEFAULT_CONFIGURATION.headers, 56 | }, 57 | }; 58 | 59 | const axiosConfig: AxiosRequestConfig = { 60 | baseURL: endpoint, 61 | method: 'post', 62 | headers: options.headers, 63 | timeout, 64 | auth, 65 | proxy, 66 | responseType: 'json', 67 | }; 68 | 69 | this.axios = axios.create(axiosConfig); 70 | 71 | if (schema) { 72 | this.schema = parse(schema); 73 | } 74 | 75 | if (passThroughHeaders) { 76 | this.passThroughHeaders = passThroughHeaders; 77 | } 78 | 79 | this.options = options; 80 | } 81 | 82 | private queryForOperation(operationName: string) { 83 | const { schema, options } = this; 84 | const { optimizeQueryRequest } = options; 85 | 86 | if (!schema) { 87 | console.warn('optimizeQueryRequest has no effect when not using schema'); 88 | 89 | return schema; 90 | } 91 | 92 | if (optimizeQueryRequest) { 93 | try { 94 | return traverseAndBuildOptimizedQuery(schema, operationName); 95 | } catch (e) { 96 | console.error('Failed to build optimized schema', e); 97 | } 98 | } 99 | 100 | return schema; 101 | } 102 | 103 | mount(operationName: string, options?: any): Route; 104 | mount(inlineOperation: string, options?: any): Route; 105 | mount(mountableItem: IMountableItem, options?: any): IMountableItem; 106 | mount(operationOrMountableItem: string | IMountableItem, options?: any): IMountableItem { 107 | if (typeof operationOrMountableItem === 'string') { 108 | const { 109 | schema: defaultSchema, 110 | axios, 111 | options: { logger, defaultLogLevel, cacheEngine, defaultCacheTimeInMs, cacheKeyIncludedHeaders }, 112 | } = this; 113 | const isOperationName = defaultSchema && 114 | Boolean(getOperationAST(defaultSchema, operationOrMountableItem)); 115 | const operationName = isOperationName ? operationOrMountableItem : undefined; 116 | const schema = isOperationName ? 117 | this.queryForOperation(operationOrMountableItem) : 118 | parse(operationOrMountableItem); 119 | 120 | // eslint-disable-next-line no-extra-boolean-cast 121 | const passThroughHeaders = Boolean(options) 122 | ? [...this.passThroughHeaders, ...options.passThroughHeaders] 123 | : [...this.passThroughHeaders]; 124 | 125 | const routeOptions: IConstructorRouteOptions = { 126 | ...options, 127 | operationName, 128 | 129 | axios, 130 | schema, 131 | cacheEngine, 132 | cacheTimeInMs: defaultCacheTimeInMs, 133 | cacheKeyIncludedHeaders, 134 | 135 | logger, 136 | logLevel: defaultLogLevel, 137 | 138 | passThroughHeaders, 139 | }; 140 | 141 | const graphQLRoute = new Route(routeOptions); 142 | 143 | this.routes.push(graphQLRoute); 144 | 145 | return graphQLRoute; 146 | } 147 | 148 | const mountedItem = operationOrMountableItem.withOptions(options); 149 | 150 | if (mountedItem.onMount) { 151 | mountedItem.onMount(this); 152 | } 153 | 154 | this.modules.push(mountedItem); 155 | 156 | return mountedItem; 157 | } 158 | 159 | // TODO: Temporarily using express as metal 160 | listen(port: number, callback?: () => void): void { 161 | const router = express(); 162 | 163 | router.use(this.asExpressRouter()); 164 | 165 | router.listen(port, callback); 166 | } 167 | 168 | asExpressRouter(): express.Router { 169 | const router: any = express.Router(); 170 | 171 | router.use(bodyParser.json()); 172 | 173 | [...this.modules, ...this.routes].forEach((route) => { 174 | const { path, httpMethod } = route; 175 | const routeFn = route.asExpressRoute(); 176 | 177 | router[httpMethod](path, routeFn); 178 | }); 179 | 180 | return router; 181 | } 182 | 183 | asKoaRouter(): never { 184 | throw new Error('Not Implemented'); 185 | } 186 | } 187 | 188 | -------------------------------------------------------------------------------- /src/describeRouteVariables.ts: -------------------------------------------------------------------------------- 1 | import Router from './Router'; 2 | import Route from './Route'; 3 | import { IGlobalConfiguration } from './types'; 4 | 5 | const TYPE_FRAGMENT = ` 6 | fragment TypeFragment on __Type { 7 | ...InputField 8 | inputFields { 9 | name 10 | type { 11 | ...InputField 12 | inputFields { 13 | name 14 | type { 15 | ...InputField 16 | } 17 | } 18 | } 19 | } 20 | } 21 | 22 | fragment InputField on __Type { 23 | kind 24 | name 25 | description 26 | enumValues { 27 | name 28 | description 29 | } 30 | } 31 | `; 32 | 33 | function buildQueryForVariable(variableName: string): string { 34 | return ` 35 | ${variableName}: __type(name: "${variableName}") { 36 | ...TypeFragment 37 | } 38 | `; 39 | } 40 | 41 | function buildIntrospectionQuery(variables: string[]): string { 42 | return ` 43 | query IntrospectionTypeQuery { 44 | ${variables.map(buildQueryForVariable).join('\n')} 45 | } 46 | 47 | ${TYPE_FRAGMENT} 48 | `; 49 | } 50 | 51 | function getAllUsedVariables(routes: Route[]): string[] { 52 | const variables: string[] = ([] as string[]).concat( 53 | ...routes.map( 54 | route => Object.values(route.operationVariables).map(variable => variable.type) 55 | ) 56 | ); 57 | 58 | const uniqueVariables: string[] = [...new Set(variables)]; 59 | 60 | return uniqueVariables; 61 | } 62 | 63 | export default async function describeRouteVariables(router: Router): Promise { 64 | const variables = getAllUsedVariables(router.routes); 65 | const query = buildIntrospectionQuery(variables); 66 | 67 | try { 68 | const response = await router.axios({ 69 | data: { 70 | query, 71 | } 72 | }); 73 | 74 | return response.data.data; 75 | } catch (error) { 76 | console.error(` 77 | There was an issue connecting to GraphQL to generate documentation. 78 | Please ensure your connection string is correct and that any required proxies 79 | have been applied. 80 | `); 81 | 82 | throw error; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Router from './Router'; 2 | import Route from './Route'; 3 | import InMemoryCache from './InMemoryCache'; 4 | import RedisCache from './RedisCache'; 5 | import { LogLevels } from './Logger'; 6 | 7 | import * as OpenApi from './OpenApi'; 8 | import ApiBlueprint from './ApiBlueprint'; 9 | 10 | export * from './types'; 11 | export default Router; 12 | export { Route, OpenApi, LogLevels, ApiBlueprint, InMemoryCache, RedisCache }; 13 | -------------------------------------------------------------------------------- /src/traverseAndBuildOptimizedQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | FieldNode, 4 | FragmentSpreadNode, 5 | InlineFragmentNode, 6 | parse, 7 | print, 8 | SelectionNode, 9 | getOperationAST, 10 | FragmentDefinitionNode, 11 | } from 'graphql'; 12 | 13 | function traverseAndBuildOptimizedQuery( 14 | schema: DocumentNode, 15 | operationName: string 16 | ): DocumentNode { 17 | const resultMap: { [k: string]: string } = {}; 18 | 19 | function getFragmentSchema(fragmentName: string) { 20 | const fragmentSchema = schema.definitions.find((definition) => 21 | definition.kind === 'FragmentDefinition' && definition.name.value === fragmentName); 22 | 23 | return fragmentSchema as FragmentDefinitionNode; 24 | } 25 | 26 | function findFragments(selections: readonly SelectionNode[]) { 27 | selections.forEach((selection: SelectionNode) => { 28 | const { kind } = selection; 29 | 30 | if (kind === 'FragmentSpread') { 31 | const { name: { value } } = selection as FragmentSpreadNode; 32 | 33 | if (!resultMap[value]) { 34 | const fragmentSnippet = getFragmentSchema(value); 35 | 36 | if (fragmentSnippet) { 37 | resultMap[value] = print(fragmentSnippet); 38 | } 39 | 40 | if (fragmentSnippet?.selectionSet?.selections?.length) { 41 | findFragments(fragmentSnippet.selectionSet.selections); 42 | } 43 | } 44 | } 45 | 46 | const { selectionSet } = selection as FieldNode | InlineFragmentNode; 47 | 48 | if (selectionSet?.selections?.length) { 49 | findFragments(selectionSet.selections); 50 | } 51 | }); 52 | } 53 | 54 | const operationAST = getOperationAST(schema, operationName); 55 | 56 | if (!operationAST?.selectionSet?.selections?.length) { 57 | return schema; 58 | } 59 | 60 | findFragments(operationAST.selectionSet.selections); 61 | 62 | const optimizedSchema = `${Object.values(resultMap).join('\n')}\n${print(operationAST)}`; 63 | 64 | return parse(optimizedSchema); 65 | } 66 | 67 | export default traverseAndBuildOptimizedQuery; 68 | 69 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import Router from './Router'; 2 | import { AxiosBasicCredentials, AxiosProxyConfig, AxiosInstance, AxiosTransformer } from 'axios'; 3 | import { DocumentNode } from 'graphql'; 4 | import express from 'express'; 5 | 6 | export interface IGlobalConfiguration { 7 | cacheEngine?: ICacheEngine; 8 | defaultTimeoutInMs?: number; 9 | defaultCacheTimeInMs?: number; 10 | logger?: ILogger; 11 | defaultLogLevel?: LogLevel; 12 | autoDiscoverEndpoints?: boolean; 13 | optimizeQueryRequest?: boolean; 14 | headers?: Record; 15 | passThroughHeaders?: string[]; 16 | cacheKeyIncludedHeaders?: string[]; 17 | auth?: AxiosBasicCredentials; 18 | proxy?: AxiosProxyConfig; 19 | } 20 | 21 | export interface IConstructorRouteOptions { 22 | schema: DocumentNode | string; // GraphQL Document Type 23 | operationName?: string; 24 | axios: AxiosInstance; 25 | logger?: ILogger; 26 | logLevel: LogLevel; 27 | path?: string; 28 | cacheTimeInMs?: number; 29 | cacheEngine?: ICacheEngine; 30 | method?: string; 31 | passThroughHeaders?: string[]; 32 | cacheKeyIncludedHeaders?: string[]; 33 | staticVariables?: Record; 34 | defaultVariables?: Record; 35 | } 36 | 37 | export interface IRouteOptions { 38 | path?: string; 39 | logger?: ILogger; 40 | logLevel?: LogLevel; 41 | cacheTimeInMs?: number; 42 | cacheEngine?: ICacheEngine; 43 | method?: string; 44 | passThroughHeaders?: string[]; 45 | cacheKeyIncludedHeaders?: string[]; 46 | staticVariables?: Record; 47 | defaultVariables?: Record; 48 | transformRequest?: AxiosTransformer; 49 | transformResponse?: AxiosTransformer; 50 | } 51 | 52 | export interface IOperationVariableMap { 53 | [variableName: string]: IOperationVariable; 54 | } 55 | 56 | export interface IOperationVariable { 57 | name: string; 58 | required: boolean; 59 | type: string; 60 | array: boolean; 61 | defaultValue?: string | boolean | number | null; 62 | } 63 | 64 | export interface IResponse { 65 | statusCode: number; 66 | body: Record; 67 | } 68 | 69 | export interface IOpenApiOptions { 70 | title: string; 71 | version: string; 72 | termsOfService?: string; 73 | license?: string; 74 | basePath?: string; 75 | host?: string; 76 | } 77 | 78 | export interface IMountableItem { 79 | path: string; 80 | httpMethod: string; 81 | 82 | at: (path: string) => this; 83 | 84 | asExpressRoute: () => (req: express.Request, res: express.Response) => void; 85 | asKoaRoute: () => void; 86 | asMetal: () => void; 87 | 88 | withOptions: (options: Record) => this; 89 | 90 | onMount?: (router: Router) => this 91 | } 92 | 93 | export interface ICacheEngine { 94 | get: (key: string, setFn?: () => string) => string | null | Promise; 95 | set: (key: string, value: string, cacheTimeInMs?: number) => void | Promise; 96 | } 97 | 98 | export interface ILogger { 99 | error: (message: string) => void; 100 | warn: (message: string) => void; 101 | info: (message: string) => void; 102 | debug: (message: string) => void; 103 | } 104 | 105 | export type LogLevel = -1 | 0 | 1 | 2 | 3; 106 | 107 | export interface ILogLevels { 108 | SILENT: LogLevel, 109 | ERROR: LogLevel, 110 | WARN: LogLevel, 111 | INFO: LogLevel, 112 | DEBUG: LogLevel, 113 | } 114 | 115 | -------------------------------------------------------------------------------- /test/InMemoryCache.test.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker'); 2 | const { assert } = require('chai'); 3 | const { useFakeTimers, restore } = require('sinon'); 4 | 5 | const InMemoryCache = require('../src/InMemoryCache').default; 6 | 7 | 8 | describe('InMemoryCache', () => { 9 | beforeEach(() => { 10 | this.clock = useFakeTimers(); 11 | this.key = faker.lorem.word(); 12 | this.value = faker.lorem.paragraph(); 13 | }); 14 | 15 | afterEach(() => { 16 | this.clock = restore(); 17 | }) 18 | 19 | describe('#constructor', () => { 20 | it('creates cache instance', () => { 21 | const cache = new InMemoryCache(); 22 | 23 | assert.equal(typeof cache, 'object'); 24 | assert.equal(cache.storeExpirationCheckInMs, 10); 25 | }); 26 | 27 | it('accepts expiration interval override', () => { 28 | const cache = new InMemoryCache(5000); 29 | 30 | assert.equal(cache.storeExpirationCheckInMs, 5000); 31 | }); 32 | 33 | it('expires values on interval', () => { 34 | const cacheTime = Math.floor(Math.random() + 19) + 1; 35 | const cache = new InMemoryCache(cacheTime); 36 | 37 | cache.set(this.key, this.value); 38 | this.clock.tick(cacheTime + 1); 39 | 40 | const value = cache.get(this.key); 41 | 42 | assert.equal(value, null); 43 | assert.equal(cache.store[this.key], null) 44 | assert.equal(cache.storeCacheExpiration[this.key], null) 45 | }); 46 | }); 47 | 48 | describe('#set', () => { 49 | it('sets a value in the memory cache', () => { 50 | const cache = new InMemoryCache(); 51 | 52 | cache.set(this.key, this.value); 53 | 54 | assert.equal(cache.store[this.key], this.value); 55 | assert.equal(cache.storeCacheExpiration[this.key], new Date().getTime()); 56 | }); 57 | 58 | it('sets value expiration', () => { 59 | const cache = new InMemoryCache(); 60 | const expirationTimeInMs = 1000; 61 | 62 | cache.set(this.key, this.value, expirationTimeInMs); 63 | 64 | assert.equal(cache.store[this.key], this.value); 65 | assert.equal(cache.storeCacheExpiration[this.key], new Date().getTime() + expirationTimeInMs); 66 | }) 67 | }); 68 | 69 | describe('#get', () => { 70 | it('gets a value from the memory cache', () => { 71 | const cache = new InMemoryCache(); 72 | 73 | cache.store[this.key] = this.value; 74 | const result = cache.get(this.key); 75 | 76 | assert.equal(result, this.value); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/Logger.test.js: -------------------------------------------------------------------------------- 1 | const { assert } = require('chai'); 2 | const { mock } = require('sinon'); 3 | const Logger = require('../src/Logger').default; 4 | const LogLevels = require('../src/Logger').LogLevels; 5 | 6 | const loggerObject = { 7 | warn: () => undefined, 8 | debug: () => undefined, 9 | info: () => undefined, 10 | error: () => undefined 11 | } 12 | const logMessage = 'Test Message'; 13 | const defaultLogLevel = LogLevels.DEBUG; 14 | 15 | describe('Logger', () => { 16 | describe('#constructor', () => { 17 | it('creates logger instance', () => { 18 | const logger = new Logger(); 19 | 20 | assert.equal(typeof logger, 'object'); 21 | }); 22 | }); 23 | 24 | describe('#setLogLevel', () => { 25 | it('sets the log level', () => { 26 | const logger = new Logger(); 27 | const level = Math.floor(Math.random() * 500); 28 | 29 | logger.setLogLevel(level); 30 | 31 | assert.equal(logger.logLevel, level); 32 | }); 33 | }); 34 | 35 | describe('#setLoggerObject', () => { 36 | it('sets the logger object', () => { 37 | const logger = new Logger(); 38 | const logObject = {}; 39 | 40 | logger.setLoggerObject(logObject); 41 | 42 | assert.equal(logger.loggerObject, logObject); 43 | }); 44 | }); 45 | 46 | describe('logging functions', () => { 47 | describe('#info', () => { 48 | let mockLogger; 49 | let logger; 50 | 51 | beforeEach(() => { 52 | mockLogger = mock(loggerObject); 53 | logger = new Logger().setLoggerObject(loggerObject).setLogLevel(defaultLogLevel); 54 | }) 55 | 56 | it('calls logging objects info method', () => { 57 | mockLogger.expects('info').once().withArgs(logMessage); 58 | logger.info(logMessage); 59 | mockLogger.verify(); 60 | }); 61 | 62 | it('no ops with lower log level', () => { 63 | mockLogger.expects('info').never(); 64 | 65 | logger.setLogLevel(LogLevels.ERROR); 66 | logger.info(logMessage); 67 | 68 | mockLogger.verify(); 69 | }); 70 | }); 71 | 72 | describe('#warn', () => { 73 | let mockLogger; 74 | let logger; 75 | 76 | beforeEach(() => { 77 | mockLogger = mock(loggerObject); 78 | logger = new Logger().setLoggerObject(loggerObject).setLogLevel(defaultLogLevel); 79 | }) 80 | 81 | it('calls logging objects warn method', () => { 82 | mockLogger.expects('warn').once().withArgs(logMessage); 83 | logger.warn(logMessage); 84 | mockLogger.verify(); 85 | }); 86 | 87 | it('no ops with lower log level', () => { 88 | mockLogger.expects('warn').never(); 89 | 90 | logger.setLogLevel(LogLevels.ERROR); 91 | logger.warn(logMessage); 92 | 93 | mockLogger.verify(); 94 | }); 95 | }); 96 | 97 | describe('#debug', () => { 98 | let mockLogger; 99 | let logger; 100 | 101 | beforeEach(() => { 102 | mockLogger = mock(loggerObject); 103 | logger = new Logger().setLoggerObject(loggerObject).setLogLevel(defaultLogLevel); 104 | }) 105 | 106 | it('calls logging objects debug method', () => { 107 | mockLogger.expects('debug').once().withArgs(logMessage); 108 | logger.debug(logMessage); 109 | mockLogger.verify(); 110 | }); 111 | 112 | it('no ops with lower log level', () => { 113 | mockLogger.expects('debug').never(); 114 | 115 | logger.setLogLevel(LogLevels.ERROR); 116 | logger.debug(logMessage); 117 | 118 | mockLogger.verify(); 119 | }); 120 | }); 121 | describe('#error', () => { 122 | let mockLogger; 123 | let logger; 124 | 125 | beforeEach(() => { 126 | mockLogger = mock(loggerObject); 127 | logger = new Logger().setLoggerObject(loggerObject).setLogLevel(defaultLogLevel); 128 | }) 129 | 130 | it('calls logging objects error method', () => { 131 | mockLogger.expects('error').once().withArgs(logMessage); 132 | logger.error(logMessage); 133 | mockLogger.verify(); 134 | }); 135 | 136 | it('no ops with lower log level', () => { 137 | mockLogger.expects('error').never(); 138 | 139 | logger.setLogLevel(LogLevels.SILENT); 140 | logger.error(logMessage); 141 | 142 | mockLogger.verify(); 143 | }); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/RedisCache.test.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker'); 2 | const { assert } = require('chai'); 3 | const { stub } = require('sinon'); 4 | 5 | const dependencyModule = require('redis'); 6 | const RedisCache = require('../src/RedisCache').default; 7 | 8 | describe('RedisCache', () => { 9 | before(() => { 10 | stub(dependencyModule, 'createClient').returns({ 11 | setex: (key, _, val, cb) => { 12 | this.redisStore[key] = val; 13 | cb(null, true); 14 | }, 15 | get: (key, cb) => { 16 | return cb(null, this.redisStore[key]) 17 | } 18 | }); 19 | }); 20 | 21 | beforeEach(() => { 22 | this.key = faker.lorem.word(); 23 | this.value = faker.lorem.paragraph(); 24 | this.redisStore = {}; 25 | }); 26 | 27 | describe('#constructor', () => { 28 | it('creates cache instance', () => { 29 | const cache = new RedisCache(); 30 | 31 | assert.equal(typeof cache, 'object'); 32 | }); 33 | }); 34 | 35 | describe('#set', () => { 36 | it('sets a value in the memory cache', async () => { 37 | const cache = new RedisCache(); 38 | 39 | await cache.set(this.key, this.value); 40 | 41 | assert.equal(this.redisStore[this.key], this.value); 42 | }); 43 | }); 44 | 45 | describe('#get', () => { 46 | it('gets a value from the memory cache', async () => { 47 | const cache = new RedisCache(); 48 | 49 | this.redisStore[this.key] = this.value; 50 | 51 | const result = await cache.get(this.key); 52 | assert.equal(result, this.value) 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/Route.test.js: -------------------------------------------------------------------------------- 1 | const { assert } = require('chai'); 2 | const Route = require('../src/Route').default; 3 | const Logger = require('../src/Logger').default; 4 | const fs = require('fs'); 5 | 6 | describe('Route', () => { 7 | const schema = fs.readFileSync(`${__dirname}/schema.example.graphql`, 'utf8'); 8 | 9 | describe('#constructor', () => { 10 | describe('with valid arguments', () => { 11 | describe('when using minimal configuration', () => { 12 | const operationName = 'GetUserById'; 13 | const operationAsPath = `/${operationName}`; 14 | let route; 15 | 16 | beforeEach(() => { 17 | route = new Route({ schema, operationName }); 18 | }); 19 | 20 | it('should set path to the path and operation name', () => { 21 | assert.equal(route.operationName, operationName); 22 | assert.equal(route.path, operationAsPath); 23 | }); 24 | }); 25 | 26 | describe('when using full configuration', () => { 27 | const configuration = { 28 | schema, 29 | operationName: 'GetUserByEmail', 30 | path: '/user/:id', 31 | logger: console, 32 | logLevel: 3, 33 | }; 34 | let route; 35 | 36 | beforeEach(() => { 37 | route = new Route(configuration); 38 | }); 39 | 40 | it('should take operation name from configuration', () => { 41 | assert.equal(route.operationName, configuration.operationName); 42 | }); 43 | 44 | it('should set path correctly', () => { 45 | assert.equal(route.path, configuration.path); 46 | }); 47 | 48 | it('should create a Logger instance', () => { 49 | assert.instanceOf(route.logger, Logger, 'route logger is an instance of Logger'); 50 | }) 51 | }); 52 | 53 | describe('when using chained configuration', () => { 54 | let route; 55 | beforeEach(() => { 56 | route = new Route({ schema, operationName: 'GetUserById', logger: console, logLevel: 3 }); 57 | }); 58 | 59 | it('should allow you to change logging level with .withOption()', () => { 60 | const newLogLevel = -1; 61 | 62 | route.withOption('logLevel', newLogLevel); 63 | 64 | assert.equal(route.logger.logLevel, newLogLevel); 65 | }) 66 | 67 | it('should allow you to change path with .at()', () => { 68 | const path = '/test'; 69 | 70 | route.at(path); 71 | 72 | assert.equal(route.path, path); 73 | }); 74 | 75 | it('should allow you to change all options with .withOptions()', () => { 76 | const method = 'POST'; 77 | 78 | route.withOptions({ method }); 79 | 80 | assert.equal(route.httpMethod, 'post'); 81 | }); 82 | 83 | it('should allow you to change http method with .as()', () => { 84 | const method = 'POST'; 85 | 86 | route.as(method); 87 | 88 | assert.equal(route.httpMethod, 'post'); 89 | }); 90 | 91 | it('should allow you to chain multiple operations', () => { 92 | route.as('POST').at('/test'); 93 | 94 | assert.equal(route.httpMethod, 'post'); 95 | assert.equal(route.path, '/test'); 96 | }); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('#transformResponse', () => { 102 | const operationName = 'GetUserById'; 103 | let route; 104 | 105 | beforeEach(() => { 106 | route = new Route({ schema, operationName }); 107 | }); 108 | 109 | it('should include the default axios transformResponse', () => { 110 | assert.equal(route.transformResponseFn.length, 1) 111 | }); 112 | 113 | it('should append additional transforms when called', () => { 114 | route.withOption('transformResponse', (data) => 'testing transform') 115 | assert.equal(route.transformResponseFn.length, 2); 116 | }); 117 | 118 | it('should return data as JSON if response is stringified JSON', async () => { 119 | const stringifiedJSON = "{\"data\":{\"users\":[{\"id\":1,\"name\":\"Charles Barkley\"}]}}"; 120 | const transformResponse = route.transformResponseFn[0]; 121 | const transitional = { 122 | silentJSONParsing: true, 123 | forcedJSONParsing: true, 124 | clarifyTimeoutError: false 125 | }; 126 | // HACK: transitional default not bound to axios function by default. This is fixed in 127 | // later versions of axios (> 0.24.0), please remove after upgrade. 128 | const data = await transformResponse.bind({ transitional })(stringifiedJSON); 129 | 130 | assert.strictEqual(typeof data, 'object'); 131 | }); 132 | }); 133 | 134 | describe('#transformRequest', () => { 135 | const operationName = 'GetUserById'; 136 | let route; 137 | 138 | beforeEach(() => { 139 | route = new Route({ schema, operationName }); 140 | }); 141 | 142 | it('should include the default axios transformRequest', () => { 143 | assert.equal(route.transformRequestFn.length, 1) 144 | }); 145 | 146 | it('should append additional transforms when called', () => { 147 | route.withOption('transformRequest', (data) => 'testing transform') 148 | assert.equal(route.transformRequestFn.length, 2); 149 | }); 150 | 151 | it('should return request', async () => { 152 | const stringifiedRequest = `{"query":"{users}","operationName":"${operationName}"}`; 153 | const transformRequest = route.transformRequestFn[0]; 154 | const data = await transformRequest(stringifiedRequest); 155 | 156 | assert.strictEqual(data, stringifiedRequest); 157 | }); 158 | }); 159 | 160 | describe('private#setOperationName', () => { 161 | it('throws an error if operation name does not exist in the schema', () => { 162 | assert.throws(() => { 163 | new Route({ schema, operationName: 'FakeQuery' }); 164 | }, Error); 165 | }); 166 | 167 | describe('when given an operation name that exists in the schema', () => { 168 | let route; 169 | let operationName = 'GetUserById'; 170 | 171 | beforeEach(() => { 172 | route = new Route({ schema, operationName }); 173 | }); 174 | 175 | it('should set operation name', () => { 176 | assert.equal(route.operationName, operationName); 177 | }); 178 | 179 | it('should set operation variables', () => { 180 | const name = 'id'; 181 | 182 | const operationVariables = { 183 | [name]: { 184 | name, 185 | required: true, 186 | defaultValue: undefined, 187 | array: false, 188 | type: 'Int', 189 | } 190 | }; 191 | 192 | assert.deepEqual(route.operationVariables, operationVariables); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('#path', () => { 198 | it('should use the operation name as the default path', () => { 199 | const operationName = 'GetUserById'; 200 | const operationAsPath = `/${operationName}`; 201 | 202 | const route = new Route({ schema, operationName }); 203 | assert.equal(route.operationName, operationName); 204 | assert.equal(route.path, operationAsPath); 205 | }); 206 | }); 207 | 208 | describe('#areVariablesValid', () => { 209 | }); 210 | 211 | describe('#asExpressRoute', () => { 212 | }); 213 | 214 | describe('#asKoaRoute', () => { 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /test/Router.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { assert } = require('chai'); 3 | const sinon = require('sinon'); 4 | const { parse } = require('graphql'); 5 | 6 | const Router = require('../src/Router').default; 7 | const Route = require('../src/Route').default; 8 | const { version } = require('../package.json'); 9 | 10 | const schema = fs.readFileSync(`${__dirname}/schema.example.graphql`, 'utf8'); 11 | const endpoint = 'http://foobar.com'; 12 | 13 | describe('Router', () => { 14 | describe('constructor', () => { 15 | describe('by default', () => { 16 | let router; 17 | 18 | beforeEach(() => { 19 | router = new Router(endpoint); 20 | }); 21 | 22 | it('should set cache time to 0', () => { 23 | assert.equal(router.options.defaultCacheTimeInMs, 0); 24 | }); 25 | 26 | it('should not optimize the request be default', () => { 27 | assert.equal(router.options.optimizeQueryRequest, false); 28 | }); 29 | 30 | it('should set a graphql-rest-router header', () => { 31 | assert.deepEqual(router.options.headers, { 'x-graphql-rest-router-version': version }); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('#mount', () => { 37 | describe('schemaless mount', () => { 38 | let router; 39 | let spy; 40 | 41 | beforeEach(() => { 42 | router = new Router(endpoint); 43 | spy = sinon.spy(Route.prototype, 'configureRoute'); 44 | }); 45 | 46 | afterEach(() => { 47 | Route.prototype.configureRoute.restore(); 48 | }); 49 | 50 | function getMountedOperationDetails() { 51 | const { operationName, schema } = Route.prototype.configureRoute.getCall(0).args[0]; 52 | 53 | return { operationName, schema }; 54 | } 55 | 56 | it('should parse nameless inline operation', () => { 57 | const inlineQuery = '{ users { id displayName } }'; 58 | router.mount(inlineQuery); 59 | 60 | const { operationName, schema } = getMountedOperationDetails(); 61 | 62 | assert.equal(operationName, undefined); 63 | assert.deepEqual(schema, parse(inlineQuery)) 64 | }); 65 | }); 66 | 67 | describe('argument overloading', () => { 68 | const operationName = 'GetUserById'; 69 | const defaultLogLevel = 3; 70 | let router; 71 | let spy; 72 | 73 | beforeEach(() => { 74 | router = new Router(endpoint, schema, { logger: console, defaultLogLevel }); 75 | spy = sinon.spy(Route.prototype, 'configureRoute'); 76 | }); 77 | 78 | afterEach(() => { 79 | Route.prototype.configureRoute.restore(); 80 | }); 81 | 82 | function getOperationName() { 83 | const { operationName } = Route.prototype.configureRoute.getCall(0).args[0]; 84 | 85 | return operationName; 86 | } 87 | 88 | function getLogger() { 89 | const { logger, logLevel } = Route.prototype.configureRoute.getCall(0).args[0]; 90 | 91 | return { logger, logLevel }; 92 | } 93 | 94 | it('should combine operation name into the configuration', () => { 95 | router.mount(operationName); 96 | 97 | assert.equal(operationName, getOperationName()); 98 | }); 99 | 100 | // This test doesn't work 101 | // it('should get operation name from configuration if only single argument provided', () => { 102 | // router.mount({ operationName }); 103 | 104 | // assert.equal(operationName, getOperationName()); 105 | // }); 106 | 107 | it('should pass logger object to Route class', () => { 108 | router.mount(operationName); 109 | 110 | const { logger, logLevel } = getLogger(); 111 | 112 | assert.equal(console, logger); 113 | assert.equal(defaultLogLevel, logLevel); 114 | }) 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/schema.example.graphql: -------------------------------------------------------------------------------- 1 | # NOTE: 2 | # Changing this schema by altering queries will break the tests. It is 3 | # preferred that you add new queries vs modify existing queries. 4 | # 5 | # Please make sure to run the test suite and update it accordingly after 6 | # modifying this file 7 | 8 | query GetUserById($id: Int!) { 9 | user: getUserById(id: $id) { 10 | ...UserDetails 11 | } 12 | } 13 | 14 | query GetUserByEmail($email: String!) { 15 | user: getUserByEmail(email: $email) { 16 | ...UserDetails 17 | } 18 | } 19 | 20 | fragment UserDetails on User { 21 | id 22 | email 23 | } 24 | 25 | fragment info on Info { 26 | count 27 | pages 28 | next 29 | prev 30 | } 31 | 32 | fragment location on Location { 33 | id 34 | name 35 | # type 36 | # dimension 37 | # created 38 | } 39 | 40 | fragment character on Character { 41 | id 42 | name 43 | origin { 44 | ...location 45 | } 46 | # status 47 | # species 48 | # type 49 | # gender 50 | # location { 51 | # ...location 52 | # } 53 | # image 54 | # episode { 55 | # ...episode 56 | # } 57 | # created 58 | } 59 | 60 | fragment episode on Episode { 61 | id 62 | name 63 | air_date 64 | episode 65 | created 66 | characters { 67 | ...character 68 | } 69 | } 70 | 71 | # "id": "1" 72 | query GetCharacterById($id: ID!) { 73 | character(id: $id) { 74 | ...character 75 | } 76 | } 77 | 78 | query GetCharacters { 79 | characters { 80 | info { 81 | ...info 82 | } 83 | results { 84 | ...character 85 | } 86 | } 87 | } 88 | 89 | # "id": "1" 90 | query GetLocationById($id: ID!) { 91 | location(id: $id) { 92 | ...location 93 | } 94 | } 95 | 96 | query GetLocations { 97 | locations { 98 | info { 99 | ...info 100 | } 101 | results { 102 | ...location 103 | } 104 | } 105 | } 106 | 107 | # "id": "1" 108 | query GetEpisodeById($id: ID!) { 109 | episode(id: $id) { 110 | ...episode 111 | } 112 | } 113 | 114 | query GetEpisodes { 115 | episodes { 116 | info { 117 | ...info 118 | } 119 | results { 120 | ...episode 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test/traverseAndBuildOptimizedQuery.test.js: -------------------------------------------------------------------------------- 1 | const graphql = require('graphql'); 2 | const { assert } = require('chai'); 3 | const fs = require('fs'); 4 | 5 | const { print, parse } = graphql; 6 | 7 | const traverseAndBuildOptimizedQuery = require('../src/traverseAndBuildOptimizedQuery').default; 8 | const schema = fs.readFileSync(`${__dirname}/schema.example.graphql`, 'utf8'); 9 | 10 | describe('traverseAndBuildOptimizedQuery', () => { 11 | it('returns original full parsed schema if no FragmentDefinitions are found', () => { 12 | const bareSchema = ` 13 | query GetLocations { 14 | locations { 15 | name 16 | } 17 | } 18 | `; 19 | 20 | const parsedSchema = parse(bareSchema); 21 | const result = traverseAndBuildOptimizedQuery(parsedSchema, 'GetLocations'); 22 | assert.equal(print(result), print(parsedSchema)) 23 | }); 24 | 25 | it('properly returns all fragments required for query', () => { 26 | const parsedSchema = parse(schema); 27 | const result = traverseAndBuildOptimizedQuery(parsedSchema, 'GetEpisodes'); 28 | const { definitions } = result; 29 | const expectedFragments = ['info', 'episode', 'location', 'character']; 30 | 31 | expectedFragments.forEach((expectedFragment) => { 32 | const fragmentFound = definitions.some(definition => definition.name.value === expectedFragment); 33 | assert.equal(fragmentFound, true); 34 | }) 35 | 36 | assert.equal(definitions.length, expectedFragments.length + 1); // +1 is the named query definition 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2019"], 4 | "module": "commonjs", 5 | "target": "es2019", 6 | 7 | "outDir": "build/src/", 8 | "rootDir": "src/", 9 | "strict": true, 10 | "moduleResolution": "node", 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitAny": false, 13 | "declaration": true, 14 | "declarationDir": "build/src/", 15 | "allowSyntheticDefaultImports": true, 16 | "allowUnreachableCode": false, 17 | "typeRoots": ["node_modules/@types"], 18 | "types": ["node", "jest"], 19 | "baseUrl": "./", 20 | "paths": { 21 | "src/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | "src" 26 | ], 27 | "exclude": [ 28 | "node_modules", 29 | "example-consuming-client", 30 | "test", 31 | "build" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------