├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── deno.json ├── img └── DenoStoreDemo.gif ├── mod.ts ├── src ├── denostore.ts ├── types.ts └── utils.ts └── tests ├── integration.test.ts ├── integrationExpire.test.ts └── schema ├── expireResolver.ts ├── resolver.ts └── typeDefs.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dump.rdb -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "proseWrap": "preserve", 7 | "trailingComma": "es5", 8 | "semi": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **DenoStore** 2 | 3 | DenoStore brings modular and low latency caching of GraphQL queries to a Deno/Oak server. 4 | 5 | [![Tests Passing](https://img.shields.io/badge/tests-passing-green)](https://github.com/oslabs-beta/DenoStore) 6 | [![Custom badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fdeno-visualizer.danopia.net%2Fshields%2Flatest-version%2Fx%2Fdenostore%2Fmod.ts)](https://deno.land/x/denostore) 7 | [![License](https://img.shields.io/badge/license-MIT-orange)](https://github.com/oslabs-beta/DenoStore/blob/main/LICENSE.md) 8 | [![Contributions](https://img.shields.io/badge/contributions-welcome-blue)]() 9 | 10 | **DenoStore Query Demo** 11 | 12 | ![](img/DenoStoreDemo.gif) 13 | 14 | ## Table of Contents 15 | 16 | - [Description](#description) 17 | - [Features](#features) 18 | - [Installation](#installation) 19 | - [Getting Started](#getting-started) 20 | - [Server Setup](#server-setup) 21 | - [Caching](#caching) 22 | - [Expiration](#expiration) 23 | - [Further Documentation](#documentation) 24 | - [Contributions](#contributions) 25 | - [Developers](#developers) 26 | - [License](#license) 27 | 28 | ## Description 29 | 30 | When implementing caching of GraphQL queries there are a few main issues to consider: 31 | 32 | - Cache becoming stale/cache invalidation 33 | - More unique queries and results compared to REST due to granularity of GraphQL 34 | - Lack of built-in caching support (especially for Deno) 35 | 36 | DenoStore was built to address the above challenges and empowers users with a caching tool that is modular, efficient and quick to implement. 37 | 38 | ## Features 39 | 40 | - Seamlessly embeds caching functionality at query resolver level, giving implementing user modular decision making power to cache specific queries and not others 41 | - Caches resolver results rather than query results - so subsequent queries with different fields and formats can still receive existing cached values 42 | - Leverages _[Redis](https://redis.io/)_ as an in-memory low latency server-side cache 43 | - Integrates with _[Oak](https://oakserver.github.io/oak/)_ middleware framework to handle GraphQL queries with error handling 44 | - Provides global and resolver level expiration controls 45 | - Makes _GraphQL Playground IDE_ available for constructing and sending queries during development 46 | - Supports all GraphQL query options (e.g. arguments, directives, variables, fragments) 47 | 48 | ## Installation 49 | 50 | ### Redis 51 | 52 | DenoStore uses Redis data store for caching 53 | 54 | - If you do not yet have Redis installed, please follow the instructions for your operation system here: https://redis.io/docs/getting-started/installation/ 55 | - After installing, start the Redis server by running `redis-server` 56 | - You can test that your Redis server is running by connecting with the Redis CLI: 57 | 58 | ```sh 59 | redis-cli 60 | 127.0.0.1:6379> ping 61 | PONG 62 | ``` 63 | 64 | - To stop your Redis server: 65 | `redis-cli shutdown` 66 | 67 | - To restart your Redis server: 68 | `redis-server restart` 69 | 70 | - Redis uses port `6379` by default 71 | 72 | ### DenoStore 73 | 74 | DenoStore is hosted as a third-party module at https://deno.land/x/denostore and will be installed the first time you import it and run your server. It is recommended to specify the latest DenoStore version so Deno does not use a previously cached version. 75 | 76 | ```ts 77 | import { DenoStore } from 'https://deno.land/x/denostore@/mod.ts'; 78 | ``` 79 | 80 | ### Oak 81 | 82 | DenoStore uses the popular middleware framework Oak https://deno.land/x/oak to set up routes for handling GraphQL queries and optionally using the _GraphQL Playground IDE_. Like DenoStore, Oak will be installed directly from deno.land the first time you run your server unless you already have it cached. 83 | 84 | **Using v10.2.0 is highly recommended** 85 | 86 | ```ts 87 | import { Application } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 88 | ``` 89 | 90 | ## Getting Started 91 | 92 | Implementing DenoStore takes only a few steps and since it is modular you can implement caching to your query resolvers incrementally if desired. 93 | 94 | ### Server Setup 95 | 96 | To set up your server: 97 | 98 | - Import _Oak_, _DenoStore_ class and your _schema_ 99 | - Create a new instance of DenoStore with your desired configuration 100 | - Add the route to handle GraphQL queries ('/graphql' by default) 101 | 102 | Below is a simple example of configuring DenoStore for your server file, but there are several configuration options. Please refer to the [docs](http://denostore.io/docs) for more details. 103 | 104 | ```ts 105 | // imports 106 | import { Application } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 107 | import { DenoStore } from 'https://deno.land/x/denostore@/mod.ts'; 108 | import { typeDefs, resolvers } from './yourSchema.ts'; 109 | 110 | const PORT = 3000; 111 | 112 | const app = new Application(); 113 | 114 | // configure DenoStore instance 115 | const ds = new DenoStore({ 116 | route: '/graphql', 117 | usePlayground: true, 118 | schema: { typeDefs, resolvers }, 119 | redisPort: 6379, 120 | }); 121 | 122 | // add dedicated route 123 | app.use(ds.routes(), ds.allowedMethods()); 124 | ``` 125 | 126 | ### Caching 127 | 128 | **How do I set up caching?** 129 | 130 | After your DenoStore instance is configured in your server, all GraphQL resolvers have access to that DenoStore instance and its methods through the `ds` property in each resolver's `context` object argument. Your schemas do not require any DenoStore imports. 131 | 132 | **Accessing DenoStore methods using `ds` from `context`** 133 | ```ts 134 | oneRocket: async ( 135 | _parent: any, 136 | args: any, 137 | // destructuring ds off context 138 | { ds }: any, 139 | info: any 140 | ) 141 | ``` 142 | 143 | Alternatively, you can access ds from context without destructuring (e.g. `context.ds.cache`) 144 | 145 | 146 | #### Cache Implementation Example 147 | 148 | Here is an example of a query resolver before and after adding the cache method from DenoStore. This is a simple query to pull information for a particular rocket from the SpaceX API. 149 | 150 | **No DenoStore** 151 | 152 | ```ts 153 | Query: { 154 | oneRocket: async ( 155 | _parent: any, 156 | args: any, 157 | context: any, 158 | info: any 159 | ) => { 160 | const results = await fetch( 161 | `https://api.spacexdata.com/v3/rockets/${args.id}` 162 | ) 163 | .then(res => res.json()) 164 | .catch(err => console.log(err)) 165 | 166 | return results; 167 | }, 168 | ``` 169 | 170 | **DenoStore Caching** 171 | 172 | ```ts 173 | Query: { 174 | oneRocket: async ( 175 | _parent: any, 176 | args: any, 177 | { ds }: any, 178 | info: any 179 | ) => { 180 | return await ds.cache({ info }, async () => { 181 | const results = await fetch( 182 | `https://api.spacexdata.com/v3/rockets/${args.id}` 183 | ) 184 | .then(res => res.json()) 185 | .catch(err => console.log(err)) 186 | 187 | return results; 188 | }); 189 | }, 190 | ``` 191 | 192 | As you can see, it only takes a few lines of code to add modular caching exactly how and where you need it. 193 | 194 | **Cache Method** 195 | 196 | ```ts 197 | ds.cache({ info }, callback); 198 | ``` 199 | 200 | `cache` is an asynchronous method that takes two arguments: 201 | 202 | - An object where **info** is the only required property. The GraphQL resolver's info argument must be passed as a property in this object as DenoStore parses the info AST for query information 203 | - A callback function with your data store call to execute if the results are not in the cache 204 | 205 | ### Expiration 206 | 207 | Expiration time for cached results can be set for each resolver and/or as a global default. 208 | 209 | #### Setting expiration in the cache method 210 | 211 | You can easily pass in cache expiration time in seconds as a value to the `ex` property to the cache method's first argument object: 212 | 213 | ```ts 214 | // cached value will expire in 5 seconds 215 | ds.cache({ info, ex: 5 }, callback); 216 | ``` 217 | 218 | #### Setting global expiration in DenoStore config 219 | 220 | You can also add the `defaultEx` property with value expiration time in seconds when configuring the `ds` instance on your server. 221 | 222 | ```ts 223 | // configure DenoStore instance 224 | const ds = new DenoStore({ 225 | route: '/graphql', 226 | usePlayground: true, 227 | schema: { typeDefs, resolvers }, 228 | redisPort: 6379, 229 | // default expiration set globally to 5 seconds 230 | defaultEx: 5, 231 | }); 232 | ``` 233 | 234 | When determining expiration for a cached value, DenoStore will always prioritize expiration time in the following order: 235 | 236 | 1. `ex` property in resolver `cache` method 237 | 2. `defaultEx` property in DenoStore configuration 238 | 3. If no resolver or global expiration is set, cached values will **default to no expiration**. However, in the next section we discuss ways to clear the cache 239 | 240 | ### Clearing Cache 241 | 242 | #### DenoStore Clear Method 243 | 244 | There may be times when you want to clear the cache in resolver logic such as when you perform a mutation. In these cases you can invoke the DenoStore `clear` method. 245 | 246 | ```ts 247 | Mutation: { 248 | cancelTrip: async ( 249 | _parent: any, 250 | args: launchId, 251 | { ds }: any 252 | ) => { 253 | const result = await dataSources.userAPI.cancelTrip({ launchId }); 254 | if (!result) 255 | return { 256 | success: false, 257 | message: 'failed to cancel trip', 258 | }; 259 | 260 | // clear/invalidate cache after successful mutation 261 | await ds.clear(); 262 | 263 | return result; 264 | }, 265 | ``` 266 | 267 | #### Clearing with redis-cli 268 | 269 | You can also clear the Redis cache at any time using the redis command line interface. 270 | 271 | Clear keys from all databases on Redis instance 272 | 273 | ```sh 274 | redis-cli flushall 275 | ``` 276 | 277 | Clear keys from all databases without blocking your server 278 | 279 | ```sh 280 | redis-cli flushall async 281 | ``` 282 | 283 | Clear keys from currently selected database (if using same Redis client for other purposes aside from DenoStore) 284 | 285 | ```sh 286 | redis-cli flushdb 287 | ``` 288 | 289 | ## Contributions 290 | 291 | We welcome contributions to DenoStore as they are key to growing the Deno ecosystem and community. 292 | 293 | ### Start Contributing 294 | 295 | 1. Fork and clone the repository 296 | 2. Ensure [Deno](https://deno.land/manual/getting_started/installation) and [Redis](https://redis.io/docs/getting-started/) are installed on your machine 297 | 3. Redis server must be [running](#installation) to use DenoStore 298 | 4. Checkout feature/issue branch off of _main_ branch 299 | 300 | ### Running Testing 301 | 302 | 1. Make sure Redis server is [running](#installation) on port _6379_ when testing 303 | 2. To run all tests run `deno test tests/ --allow-net` 304 | 3. If tests pass you can submit a PR to the DenoStore _main_ branch 305 | 306 | ## Developers 307 | 308 | - [Jake Van Vorhis](https://github.com/jakedoublev) 309 | - [James Kim](https://github.com/Jamesmjkim) 310 | - [Jessica Wachtel](https://github.com/JessicaWachtel) 311 | - [Scott Tatsuno](https://github.com/sktatsuno) 312 | - [TX Ho](https://github.com/lawauditswe) 313 | 314 | ## License 315 | 316 | This product is licensed under the MIT License - see the LICENSE.md file for details. 317 | 318 | This is an open source product. 319 | 320 | This product is accelerated by [OS Labs](https://opensourcelabs.io/). 321 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "files": { 4 | "include": ["src/"], 5 | "exclude": [] 6 | }, 7 | "options": { 8 | "useTabs": false, 9 | "lineWidth": 80, 10 | "indentWidth": 2, 11 | "singleQuote": true, 12 | "proseWrap": "preserve" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /img/DenoStoreDemo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/DenoStore/ada3e097295c18d3543499246cdd2cbb6d35221f/img/DenoStoreDemo.gif -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import DenoStore from './src/denostore.ts'; 2 | 3 | export { DenoStore }; 4 | -------------------------------------------------------------------------------- /src/denostore.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 2 | import { renderPlaygroundPage } from 'https://deno.land/x/oak_graphql@0.6.3/graphql-playground-html/render-playground-html.ts'; 3 | import { graphql } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts'; 4 | import { connect } from 'https://deno.land/x/redis@v0.25.2/mod.ts'; 5 | import { makeExecutableSchema } from 'https://deno.land/x/graphql_tools@0.0.2/mod.ts'; 6 | import { buildCacheKey } from './utils.ts'; 7 | 8 | import type { 9 | Redis, 10 | GraphQLSchema, 11 | DenoStoreArgs, 12 | Middleware, 13 | Context, 14 | defaultExArg, 15 | redisClientArg, 16 | redisPortArg, 17 | userSchemaArg, 18 | cacheArgs, 19 | cacheCallbackArg, 20 | optsVariable, 21 | } from './types.ts'; 22 | 23 | export default class DenoStore { 24 | #usePlayground: boolean; 25 | #redisClient!: Redis; 26 | #schema!: GraphQLSchema; 27 | #router: Router; 28 | #route: string; 29 | #defaultEx: defaultExArg; 30 | 31 | constructor(args: DenoStoreArgs) { 32 | const { 33 | schema, 34 | usePlayground = false, 35 | redisClient, 36 | redisPort, 37 | route = '/graphql', 38 | defaultEx, 39 | } = args; 40 | 41 | this.#setSchemaProperty(schema); 42 | this.#setRedisClientProperty(redisClient, redisPort); 43 | this.#usePlayground = usePlayground; 44 | this.#router = new Router(); 45 | this.#route = route; 46 | this.#defaultEx = defaultEx; 47 | } 48 | 49 | async #setRedisClientProperty( 50 | redisClient: redisClientArg, 51 | redisPort: redisPortArg 52 | ): Promise { 53 | if (redisClient) { 54 | this.#redisClient = redisClient; 55 | } else { 56 | this.#redisClient = await connect({ 57 | hostname: 'localhost', 58 | port: redisPort, 59 | }); 60 | } 61 | } 62 | 63 | #setSchemaProperty(schema: userSchemaArg): void { 64 | if ('typeDefs' in schema || 'resolvers' in schema) { 65 | this.#schema = makeExecutableSchema({ 66 | typeDefs: schema.typeDefs, 67 | resolvers: schema.resolvers || {}, 68 | }); 69 | } else { 70 | this.#schema = schema; 71 | } 72 | } 73 | 74 | /** 75 | ** Caching method to be invoked in the field resolver of queries user wants cached 76 | ** Creates cache key by accessing resolver 'info' AST for query information 77 | ** Accepts optional expire time in seconds argument 78 | ** Retrieves cached value using created cache key 79 | ** If cache key does not exist for a query, invokes provided callback and sets cache with results 80 | */ 81 | async cache({ info, ex }: cacheArgs, callback: cacheCallbackArg) { 82 | const cacheKey = buildCacheKey(info); 83 | const cacheValue = await this.#redisClient.get(cacheKey); 84 | 85 | // cache hit: return cached data 86 | if (cacheValue) { 87 | return JSON.parse(cacheValue); 88 | } 89 | 90 | // cache miss: invoke provided callback to fetch results 91 | const results = await callback(); 92 | if (results === null || results === undefined) { 93 | console.error( 94 | '%cError: result of callback provided to DenoStore cache function cannot be undefined or null', 95 | 'font-weight: bold; color: white; background-color: red;' 96 | ); 97 | throw new Error('Error: Query error. See server console.'); 98 | } 99 | 100 | // redis caching options 101 | let opts: optsVariable; 102 | 103 | // if positive expire argument specified, set expire time in options 104 | if (ex) { 105 | if (ex > 0) opts = { ex: ex }; 106 | // else set default expire time in options if provided 107 | } else if (this.#defaultEx) { 108 | opts = { ex: this.#defaultEx }; 109 | } 110 | 111 | // set results in cache with options if specified 112 | if (opts) { 113 | await this.#redisClient.set(cacheKey, JSON.stringify(results), opts); 114 | /** 115 | * If negative expire argument provided or no expire specified, cache results with no expiration 116 | * Uses negative number to indicate no expiration to avoid adding unnecessary expire flag argument 117 | * while still fulfilling Redis type checks 118 | */ 119 | } else { 120 | await this.#redisClient.set(cacheKey, JSON.stringify(results)); 121 | } 122 | 123 | return results; 124 | } 125 | 126 | /** 127 | * Removes all keys and values from redis instance 128 | */ 129 | async clear(): Promise { 130 | await this.#redisClient.flushall(); 131 | } 132 | 133 | routes(): Middleware { 134 | // render Playground IDE if enabled 135 | if (this.#usePlayground) { 136 | this.#router.get(this.#route, (ctx: Context): void => { 137 | const { request, response } = ctx; 138 | try { 139 | const playground = renderPlaygroundPage({ 140 | endpoint: request.url.origin + this.#route, 141 | }); 142 | response.status = 200; 143 | response.body = playground; 144 | return; 145 | } catch (err) { 146 | console.log( 147 | `%cError: ${err}`, 148 | 'font-weight: bold; color: white; background-color: red;' 149 | ); 150 | response.status = 500; 151 | response.body = 'Problem rendering GraphQL Playground IDE'; 152 | throw err; 153 | } 154 | }); 155 | } 156 | 157 | // where GraphQL queries are handled 158 | this.#router.post(this.#route, async (ctx: Context): Promise => { 159 | const { response, request } = ctx; 160 | try { 161 | const { query, variables } = await request.body().value; 162 | 163 | // resolve GraphQL query 164 | const graphqlResults = await graphql({ 165 | schema: this.#schema, 166 | source: query, 167 | // pass DenoStore instance through context to use methods in resolvers 168 | contextValue: { ds : this }, 169 | variableValues: variables, 170 | }); 171 | 172 | // if errors delete results data 173 | if (graphqlResults.errors) delete graphqlResults.data; 174 | // respond with resolved query results 175 | response.status = graphqlResults.errors ? 500 : 200; 176 | response.body = graphqlResults; 177 | return; 178 | } catch (err) { 179 | console.error( 180 | `%cError: error finding query on provided route. 181 | \nReceived error: ${err}`, 182 | 'font-weight: bold; color: white; background-color: red;' 183 | ); 184 | throw err; 185 | } 186 | }); 187 | 188 | return this.#router.routes(); 189 | } 190 | 191 | allowedMethods(): Middleware { 192 | return this.#router.allowedMethods(); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Redis, SetOpts } from 'https://deno.land/x/redis@v0.25.3/mod.ts'; 2 | import type { 3 | GraphQLSchema, 4 | GraphQLResolveInfo, 5 | FieldNode, 6 | ArgumentNode, 7 | DocumentNode, 8 | DefinitionNode, 9 | Source, 10 | } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts'; 11 | import type { 12 | Middleware, 13 | Context, 14 | } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 15 | import type { IResolvers } from 'https://deno.land/x/graphql_tools@0.0.2/utils/interfaces.ts'; 16 | 17 | /** 18 | ** DenoStore Args 19 | ** Require either redis client instance or redis port and not both 20 | */ 21 | interface BaseArgs { 22 | schema: userSchemaArg; 23 | route?: string; 24 | usePlayground?: boolean; 25 | defaultEx?: number | undefined; 26 | } 27 | 28 | interface RedisClientArgs extends BaseArgs { 29 | redisClient: Redis; 30 | redisPort?: never; 31 | } 32 | 33 | interface RedisPortArgs extends BaseArgs { 34 | redisPort: number; 35 | redisClient?: never; 36 | } 37 | 38 | export type DenoStoreArgs = RedisClientArgs | RedisPortArgs; 39 | 40 | export type defaultExArg = number | undefined; 41 | 42 | export type redisClientArg = Redis | undefined; 43 | 44 | export type redisPortArg = number | undefined; 45 | 46 | export type userSchemaArg = GraphQLSchema | ExecutableSchemaArgs; 47 | 48 | /** 49 | * backend call to your data store - results will be cached by cache 50 | */ 51 | export type cacheCallbackArg = { (): Promise<{}> | {} }; 52 | 53 | export type optsVariable = SetOpts | undefined; 54 | 55 | export interface cacheArgs { 56 | /** 57 | ** resolver info object must be passed to cache function for access to query 58 | */ 59 | info: GraphQLResolveInfo; 60 | /** 61 | ** expire time in seconds 62 | ** use -1 to specify no expiration 63 | */ 64 | ex?: number; 65 | } 66 | 67 | export type ITypedefDS = 68 | | string 69 | | Source 70 | | DocumentNode 71 | | GraphQLSchema 72 | | DefinitionNode 73 | | Array 74 | | (() => ITypedefDS); 75 | 76 | export interface ExecutableSchemaArgs { 77 | typeDefs: ITypedefDS; // type definitions used to make schema 78 | resolvers?: IResolvers | Array>; // resolvers for the type definitions 79 | } 80 | 81 | export type Maybe = null | undefined | T; 82 | export interface CacheKeyObj { 83 | readonly name: string; 84 | readonly arguments?: ReadonlyArray; 85 | readonly variables?: Maybe<{ [key: string]: any }>; 86 | } 87 | 88 | export type { 89 | Redis, 90 | GraphQLSchema, 91 | GraphQLResolveInfo, 92 | Middleware, 93 | Context, 94 | FieldNode, 95 | SetOpts, 96 | }; 97 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { FieldNode, CacheKeyObj, GraphQLResolveInfo } from './types.ts'; 2 | 3 | const buildCacheKey = (info: GraphQLResolveInfo): string => { 4 | if (info.operation.operation === 'mutation') { 5 | console.error( 6 | '%cDenoStore cache function does not allow caching of mutations.', 7 | 'font-weight: bold; color: white; background-color: red;' 8 | ); 9 | throw new Error('Query error. See server console.'); 10 | } 11 | const node: FieldNode = info.fieldNodes[0]; 12 | const cacheKeyObj: CacheKeyObj = { 13 | name: node.name.value, 14 | arguments: node.arguments, 15 | variables: info.variableValues, 16 | }; 17 | 18 | return JSON.stringify(cacheKeyObj); 19 | }; 20 | 21 | export { buildCacheKey }; 22 | -------------------------------------------------------------------------------- /tests/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'https://deno.land/std@0.134.0/testing/asserts.ts'; 2 | import { superoak } from 'https://deno.land/x/superoak@4.7.0/mod.ts'; 3 | import { connect } from 'https://deno.land/x/redis@v0.25.2/mod.ts'; 4 | import { Application } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 5 | import { makeExecutableSchema } from 'https://deno.land/x/graphql_tools@0.0.2/mod.ts'; 6 | import { DenoStore } from '../mod.ts'; 7 | import { typeDefs } from './schema/typeDefs.ts'; 8 | import { resolvers } from './schema/resolver.ts'; 9 | 10 | /** 11 | test application invocation worked 12 | test DenoStore function returned instance of DenoStore imported class 13 | test optional arguments working properly in DenoStore function 14 | test routes working 15 | test speed reduction of repeated query (aka cache worked) 16 | 17 | mock schemas 18 | mock queries 19 | */ 20 | 21 | Deno.test('DenoStore started for standard setup', async (t) => { 22 | const redisClient = await connect({ 23 | hostname: 'localhost', 24 | port: 6379, 25 | }); 26 | const ds = new DenoStore({ 27 | route: '/graphql', 28 | usePlayground: false, 29 | schema: { typeDefs, resolvers }, 30 | redisClient, 31 | }); 32 | 33 | const app = new Application(); 34 | app.use(ds.routes(), ds.allowedMethods()); 35 | 36 | let firstCallTime: number; 37 | 38 | await t.step('Accept query and respond with correct query', async () => { 39 | const testQuery = 40 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 41 | 42 | const request = await superoak(app, true); 43 | const start = Date.now(); 44 | await request 45 | .post('/graphql') 46 | .type('json') 47 | .send({ query: testQuery }) 48 | .expect( 49 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 50 | ); 51 | firstCallTime = Date.now() - start; 52 | }); 53 | await t.step('Same exact query (Testing Caching ability)', async (t) => { 54 | const testQuery = 55 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 56 | 57 | const request = await superoak(app, true); 58 | const start = Date.now(); 59 | await request 60 | .post('/graphql') 61 | .type('json') 62 | .send({ query: testQuery }) 63 | .expect( 64 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 65 | ); 66 | 67 | await t.step('Speed of query is faster than first call', () => { 68 | const secondCallTime: number = Date.now() - start; 69 | assert(firstCallTime > secondCallTime); 70 | }); 71 | }); 72 | 73 | await t.step( 74 | 'Same query, but with different fields (Testing Caching ability)', 75 | async (t) => { 76 | const testQuery = 77 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n country\n}\n}\n'; 78 | 79 | const request = await superoak(app, true); 80 | const start = Date.now(); 81 | await request 82 | .post('/graphql') 83 | .type('json') 84 | .send({ query: testQuery }) 85 | .expect( 86 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket","country":"United States"}}}' 87 | ); 88 | 89 | await t.step( 90 | 'Speed of query with different fields is faster than first call', 91 | () => { 92 | const thirdCallTime: number = Date.now() - start; 93 | assert(firstCallTime > thirdCallTime); 94 | } 95 | ); 96 | } 97 | ); 98 | 99 | await redisClient.flushdb(); 100 | await redisClient.close(); 101 | 102 | // console.log(Deno.resources()); 103 | }); 104 | 105 | Deno.test({ 106 | name: 'DenoStore started using redis port', 107 | fn: async (t) => { 108 | const ds = new DenoStore({ 109 | route: '/graphql', 110 | usePlayground: false, 111 | schema: { typeDefs, resolvers }, 112 | redisPort: 6379, 113 | }); 114 | 115 | const app = new Application(); 116 | app.use(ds.routes(), ds.allowedMethods()); 117 | 118 | let firstCallTime: number; 119 | 120 | await t.step('Accept query and respond with correct query', async () => { 121 | const testQuery = 122 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 123 | 124 | const request = await superoak(app, true); 125 | const start = Date.now(); 126 | await request 127 | .post('/graphql') 128 | .type('json') 129 | .send({ query: testQuery }) 130 | .expect( 131 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 132 | ); 133 | firstCallTime = Date.now() - start; 134 | }); 135 | await t.step('Same exact query (Testing Caching ability)', async (t) => { 136 | const testQuery = 137 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 138 | 139 | const request = await superoak(app, true); 140 | const start = Date.now(); 141 | await request 142 | .post('/graphql') 143 | .type('json') 144 | .send({ query: testQuery }) 145 | .expect( 146 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 147 | ); 148 | 149 | await t.step('Speed of query is faster than first call', () => { 150 | const secondCallTime: number = Date.now() - start; 151 | assert(firstCallTime > secondCallTime); 152 | }); 153 | }); 154 | await t.step( 155 | 'Same query, but with different fields (Testing Caching ability)', 156 | async (t) => { 157 | const testQuery = 158 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n country\n}\n}\n'; 159 | 160 | const request = await superoak(app, true); 161 | const start = Date.now(); 162 | await request 163 | .post('/graphql') 164 | .type('json') 165 | .send({ query: testQuery }) 166 | .expect( 167 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket","country":"United States"}}}' 168 | ); 169 | 170 | await t.step( 171 | 'Speed of query with different fields is faster than first call', 172 | () => { 173 | const thirdCallTime: number = Date.now() - start; 174 | assert(firstCallTime > thirdCallTime); 175 | } 176 | ); 177 | } 178 | ); 179 | await ds.clear(); 180 | }, 181 | sanitizeResources: false, 182 | sanitizeOps: false, 183 | }); 184 | 185 | Deno.test('DenoStore started passing in schema', async (t) => { 186 | const redisClient = await connect({ 187 | hostname: 'localhost', 188 | port: 6379, 189 | }); 190 | 191 | const schema = makeExecutableSchema({ typeDefs, resolvers }); 192 | 193 | const ds = new DenoStore({ 194 | route: '/graphql', 195 | usePlayground: false, 196 | schema, 197 | redisClient, 198 | }); 199 | 200 | const app = new Application(); 201 | app.use(ds.routes(), ds.allowedMethods()); 202 | 203 | let firstCallTime: number; 204 | 205 | await t.step('Accept query and respond with correct query', async () => { 206 | const testQuery = 207 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 208 | 209 | const request = await superoak(app, true); 210 | const start = Date.now(); 211 | await request 212 | .post('/graphql') 213 | .type('json') 214 | .send({ query: testQuery }) 215 | .expect( 216 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 217 | ); 218 | firstCallTime = Date.now() - start; 219 | }); 220 | await t.step('Same exact query (Testing Caching ability)', async (t) => { 221 | const testQuery = 222 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 223 | 224 | const request = await superoak(app, true); 225 | const start = Date.now(); 226 | await request 227 | .post('/graphql') 228 | .type('json') 229 | .send({ query: testQuery }) 230 | .expect( 231 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 232 | ); 233 | 234 | await t.step('Speed of query is faster than first call', () => { 235 | const secondCallTime: number = Date.now() - start; 236 | assert(firstCallTime > secondCallTime); 237 | }); 238 | }); 239 | await t.step( 240 | 'Same query, but with different fields (Testing Caching ability)', 241 | async (t) => { 242 | const testQuery = 243 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n country\n}\n}\n'; 244 | 245 | const request = await superoak(app, true); 246 | const start = Date.now(); 247 | await request 248 | .post('/graphql') 249 | .type('json') 250 | .send({ query: testQuery }) 251 | .expect( 252 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket","country":"United States"}}}' 253 | ); 254 | 255 | await t.step( 256 | 'Speed of query with different fields is faster than first call', 257 | () => { 258 | const thirdCallTime: number = Date.now() - start; 259 | assert(firstCallTime > thirdCallTime); 260 | } 261 | ); 262 | } 263 | ); 264 | 265 | await redisClient.flushdb(); 266 | await redisClient.close(); 267 | }); 268 | -------------------------------------------------------------------------------- /tests/integrationExpire.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from 'https://deno.land/std@0.134.0/testing/asserts.ts'; 2 | import { superoak } from 'https://deno.land/x/superoak@4.7.0/mod.ts'; 3 | import { connect } from 'https://deno.land/x/redis@v0.25.2/mod.ts'; 4 | import { Application } from 'https://deno.land/x/oak@v10.2.0/mod.ts'; 5 | import { DenoStore } from '../mod.ts'; 6 | import { typeDefs } from './schema/typeDefs.ts'; 7 | import { resolvers } from './schema/expireResolver.ts'; 8 | import { delay } from 'https://deno.land/std@0.137.0/async/mod.ts'; 9 | 10 | Deno.test('Expiration testing', async (t) => { 11 | const redisClient = await connect({ 12 | hostname: 'localhost', 13 | port: 6379, 14 | }); 15 | const ds = new DenoStore({ 16 | route: '/graphql', 17 | usePlayground: false, 18 | schema: { typeDefs, resolvers }, 19 | redisClient, 20 | defaultEx: 10, 21 | }); 22 | 23 | const app = new Application(); 24 | app.use(ds.routes(), ds.allowedMethods()); 25 | 26 | await t.step('Accept query and respond with correct query', async () => { 27 | const testQuery = 28 | '{\n oneRocket(id: "falcon9") {\n rocket_name\n rocket_type\n }\n}\n'; 29 | 30 | const request = await superoak(app, true); 31 | await request 32 | .post('/graphql') 33 | .type('json') 34 | .send({ query: testQuery }) 35 | .expect( 36 | '{"data":{"oneRocket":{"rocket_name":"Falcon 9","rocket_type":"rocket"}}}' 37 | ); 38 | }); 39 | 40 | await t.step('Confirm result has been cached', async () => { 41 | const keys = await redisClient.keys('*'); 42 | assertEquals(keys.length, 1); 43 | }); 44 | 45 | await t.step('Confirm resolver level expiration working', async () => { 46 | // delay 5 seconds 47 | await delay(5000); 48 | const keysAfterExpire = await redisClient.keys('*'); 49 | assertEquals(keysAfterExpire.length, 0); 50 | }); 51 | 52 | await t.step('Accept query and confirm result has been cached', async () => { 53 | const testQuery = 54 | '{\n rockets {\n rocket_name\n rocket_type\n }\n}\n'; 55 | 56 | const request = await superoak(app, true); 57 | await request.post('/graphql').type('json').send({ query: testQuery }); 58 | 59 | const keys = await redisClient.keys('*'); 60 | assertEquals(keys.length, 1); 61 | }); 62 | 63 | await t.step('Confirm default expiration working', async () => { 64 | // delay 10 seconds 65 | await delay(10000); 66 | const keysAfterExpire = await redisClient.keys('*'); 67 | assertEquals(keysAfterExpire.length, 0); 68 | }); 69 | 70 | await redisClient.flushdb(); 71 | await redisClient.close(); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/schema/expireResolver.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | export const resolvers = { 3 | Query: { 4 | rockets: async (_p: any, _a: any, { ds }: any, info: any) => { 5 | return await ds.cache({ info }, async () => { 6 | return await fetch(`https://api.spacexdata.com/v3/rockets`).then( 7 | (res) => res.json() 8 | ); 9 | }); 10 | }, 11 | oneRocket: async (_p: any, { id }: any, { ds }: any, info: any) => { 12 | return await ds.cache({ info, ex: 5 }, async () => { 13 | return await fetch(`https://api.spacexdata.com/v3/rockets/${id}`).then( 14 | (res) => res.json() 15 | ); 16 | }); 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /tests/schema/resolver.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | export const resolvers = { 3 | Query: { 4 | rockets: async (_p: any, _a: any, { ds }: any, info: any) => { 5 | return await ds.cache({ info, ex: -1 }, async () => { 6 | return await fetch(`https://api.spacexdata.com/v3/rockets`).then( 7 | (res) => res.json() 8 | ); 9 | }); 10 | }, 11 | oneRocket: async (_p: any, { id }: any, { ds }: any, info: any) => { 12 | return await ds.cache({ info, ex: -1 }, async () => { 13 | return await fetch(`https://api.spacexdata.com/v3/rockets/${id}`).then( 14 | (res) => res.json() 15 | ); 16 | }); 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /tests/schema/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'https://deno.land/x/graphql_tag@0.0.1/mod.ts'; 2 | 3 | export const typeDefs = gql` 4 | type RocketType { 5 | id: Int 6 | active: Boolean 7 | stages: Int 8 | first_flight: String 9 | country: String 10 | height: HeightType 11 | diameter: DiameterType 12 | wikipedia: String 13 | description: String 14 | rocket_id: String 15 | rocket_name: String 16 | rocket_type: String 17 | } 18 | 19 | type HeightType { 20 | meters: Float 21 | feet: Float 22 | } 23 | 24 | type DiameterType { 25 | meters: Float 26 | feet: Float 27 | } 28 | 29 | type Query { 30 | rockets: [RocketType!]! 31 | oneRocket(id: String): RocketType 32 | } 33 | `; 34 | --------------------------------------------------------------------------------