├── .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 | [](https://github.com/oslabs-beta/DenoStore)
6 | [](https://deno.land/x/denostore)
7 | [](https://github.com/oslabs-beta/DenoStore/blob/main/LICENSE.md)
8 | []()
9 |
10 | **DenoStore Query Demo**
11 |
12 | 
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 |
--------------------------------------------------------------------------------