Skip to content

Commit

Permalink
Adds caching for services (#6082)
Browse files Browse the repository at this point in the history
* cache() and cacheLatest() working

* Move clients to standalone files

* Move to closure style to properly export required functions

* Comments

* Adds RedisClient

* Simplify logic to remove separate init() function

* Refactor for more generic client usage, no more init()

* Adds redis package

* Moves cache clients to devDependencies

* Simplify memcached options on init

* Use logger for messages

* Server connection string must be included

* Adds docs on service caching

* Add timeout for cache calls

* Adds setup command for cache

* Updates templates with new timeout option

* Updates docs for new createCache() client

* Comment

* Move errors to separate file

* Allow renaming of id/updatedAt fields, catch error if model has no id/updatedAt, catch if no records in cacheLatest

* Allow adding a global prefix to cache key

* Adds docs for global prefix, options, array key syntax

* Move formatting of cache key to a standalone function, allow cache key as an array

* cacheLatest -> cacheFindMany, exports some additional functions, updates types

* Start of tests

* Adds InMemoryClient for cache testing

* Create base cache client class to extend from, rename clients for consistency

* Adds cache tests

* Doc updates

* --rebuild-fixture

* Update templates for cacheFindMany

* yarn constraints --fix

* Updates lock file with constraints fix

* Refactor to use TS abstract class

* Types in template

* Fixes `setup cache` CLI command

* Export client defaults as named exports

* InMemoryCache is a default export now

* Fix link

* Doc updates

* More doc updates

* Adds docs for `setup cache` command

* Adds some complex types to inputs and results

Thanks @dac09!

* Adds test for no records found

* Adds spys to check on client calls

* Fix some type issues
Fix bugs in tests

* Remove specific types for cache and cacheFindMany result

* Handle redis disconnect

* Pass logger to RedisClient

* Use logger instead of console in Memcached config

* Adds reconnect() function

* Attempt reconnect on timeout error

* Adds test for reconnect()

* Remove commented mock code

* Update docs/docs/cli-commands.md

Co-authored-by: Daniel Choudhury <dannychoudhury@gmail.com>

* Update packages/api/src/cache/clients/BaseClient.ts

Co-authored-by: Daniel Choudhury <dannychoudhury@gmail.com>

* Update packages/cli/src/commands/setup/cache/cache.js

Co-authored-by: Daniel Choudhury <dannychoudhury@gmail.com>

* Update docs/docs/services.md

Co-authored-by: Daniel Choudhury <dannychoudhury@gmail.com>

* Moves addPackagesTask to shared location

* Add memjs/redis package based on client choice during setup

* Fix type issue in BaseClient

* Refactor to use async imports
Introduce connect/disconnect

* fix: reconnect -> disconnect tests

* Adds testing helpers for contents of InMemoryCache

* Updates cache templates to include testing check, moves host URL to ENV var

* Move cache server connection string to .env

* Move adding env var code to shared helper

* Export client for testing

* Fix merge conflicts

* Use addEnvVarTask from cli helpers

* Use listr2 instead

* WIP(testing): Add custom matcher to check cached values

* Add contents and clear to InMemoryCache

* Unused imports in deploy helpers

* Fix bugs in the cli helper

* Add partialMatch helper

* Updates `toHaveCached()` to accept an optional argument

Can include the key you expect the value to have so that you can verify both

* Update custom matcher, comments

* Support multiple values in partialMatch array |
Disable parse(stringfy())

* Provide options to toHaveCached()

* fix: Check string values after serializing
| update types

* docs: Update testing docs

* docs: small update

* fix: Remove matcher options
Update docs on strict matching

* Update docs/docs/testing.md

* fix(cli): Fix Listr import in cache setup cli

* Update docs/docs/services.md

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>

* Update docs/docs/services.md

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>

* Apply suggestions from code review

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>
Co-authored-by: Rob Cameron <cannikin@fastmail.com>

* Apply suggestions from code review

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>

* Update docs/docs/services.md

Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>

* Code example changes

Co-authored-by: Daniel Choudhury <dannychoudhury@gmail.com>
Co-authored-by: Dominic Saadi <dominiceliassaadi@gmail.com>
  • Loading branch information
3 people committed Nov 11, 2022
1 parent a13f12c commit 34b0556
Show file tree
Hide file tree
Showing 30 changed files with 1,677 additions and 105 deletions.
15 changes: 15 additions & 0 deletions docs/docs/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,7 @@ yarn redwood setup <category>
| Commands | Description |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `auth` | Set up auth configuration for a provider |
| `cache` | Set up cache configuration for memcached or redis |
| `custom-web-index` | Set up an `index.js` file, so you can customize how Redwood web is mounted in your browser |
| `deploy` | Set up a deployment configuration for a provider |
| `generator` | Copy default Redwood generator templates locally for customization |
Expand Down Expand Up @@ -1728,6 +1729,20 @@ yarn redwood setup graphiql <provider>
| `--expiry, -e` | Token expiry in minutes. Default is 60 |
| `--view, -v` | Print out generated headers to console |
### setup cache
This command creates a setup file in `api/src/lib/cache.{ts|js}` for connecting to a Memcached or Redis server and allows caching in services. See the [**Caching** section of the Services docs](/docs/services#caching) for usage.
```
yarn redwood setup cache <client>
```
| Arguments & Options | Description |
| :------------------ | :----------------------- |
| `client` | Name of the client to configure, `memcached` or `redis` |
| `--force, -f` | Overwrite existing files |
### setup custom-web-index
Redwood automatically mounts your `<App />` to the DOM, but if you want to customize how that happens, you can use this setup command to generate an `index.js` file in `web/src`.
Expand Down
303 changes: 303 additions & 0 deletions docs/docs/services.md

Large diffs are not rendered by default.

216 changes: 216 additions & 0 deletions docs/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -1744,6 +1744,222 @@ Luckily, RedwoodJS has several api testing utilities to make [testing functions
## Testing GraphQL Directives
Please refer to the [Directives documentation](./directives.md) for details on how to write Redwood [Validator](./directives.md#writing-validator-tests) or [Transformer](./directives.md#writing-transformer-tests) Directives tests.
## Testing Caching
If you're using Redwood's [caching](services#caching), we provide a handful of utilities and patterns to help you test this too!
Let's say you have a service where you cache the result of products, and individual products:
```ts
export const listProducts: QueryResolvers['listProducts'] = () => {
// highlight-next-line
return cacheFindMany('products-list', db.product, {
expires: 3600,
})
}

export const product: QueryResolvers['product'] = async ({ id }) => {
// highlight-next-line
return cache(
`cached-product-${id}`,
() =>
db.product.findUnique({
where: { id },
}),
{ expires: 3600 }
)
}
```
With this code, we'll be caching an array of products (from the find many), and individual products that get queried too.
:::tip
It's important to note that when you write scenario or unit tests, it will use the `InMemoryClient`.
The InMemoryClient has a few extra features to help with testing.
1. Allows you to call `cacheClient.clear()` so each of your tests have a fresh cache state
2. Allows you to get all its contents (without cache-keys) with the `cacheClient.contents` getter
:::
There's a few different things you may want to test, but let's start with the basics.
In your test let's import your cache client and clear after each test:
```ts
import type { InMemoryClient } from '@redwoodjs/api/cache'
import { client } from 'src/lib/cache'

// For TypeScript users
const testCacheClient = client as InMemoryClient

describe('products', () => {
// highlight-start
afterEach(() => {
testCacheClient.clear()
})
// highlight-end
//....
})
```
### The `toHaveCached` matcher
We have a custom Jest matcher included in Redwood to make things a little easier. To use it simply add an import to the top of your test file:
```ts
// highlight-next-line
import '@redwoodjs/testing/cache'
// ^^ make `.toHaveCached` available
```
The `toHaveCached` matcher can take three forms:
`expect(testCacheClient)`
1. `.toHaveCached(expectedData)` - check for an exact match of the data, regardless of the key
2. `.toHaveCached('expected-key', expectedData)` - check that the data is cached in the key you supply
3. `.toHaveCached(/key-regex.*/, expectedData)` - check that data is cached in a key that matches the regex supplied
Let's see these in action now:
```ts
scenario('returns a single product', async (scenario: StandardScenario) => {
await product({ id: scenario.product.three.id })

// Pattern 1: Only check that the data is present in the cache
expect(testCacheClient).toHaveCached(scenario.product.three)

// Pattern 2: Check that data is cached, at a specific key
expect(testCacheClient).toHaveCached(
`cached-product-${scenario.product.three.id}`,
scenario.product.three
)

// Pattern 3: Check that data is cached, in a key matching the regex
expect(testCacheClient).toHaveCached(
/cached-.*/,
scenario.product.three
)
```
:::info Serialized Objects in Cache
Remember that the cache only ever contains serialized objects. So if you passed an object like this:
```js
{
id: 5,
published: new Date('12/10/1995')
}

```
The published key will be serialized and stored as a string. To make testing easier for you, we serialize the object you are passing when you use the `toHaveCached` matcher, before we compare it against the value in the cache.
:::
### Partial Matching
It can be a little tedious to check that every key in the object you are looking for matches. This is especially true if you have autogenerated values such as `updatedAt` and `cuid` IDs.
To help with this, we've provided a helper for partial matching!
```ts
// highlight-next-line
import { partialMatch } from '@redwoodjs/testing/cache'

scenario('returns all products', async (scenario: StandardScenario) => {
await products()

// Partial match using the toHaveCached, if you supply a key
expect(testCacheClient).toHaveCached(
/cached-products.*/,
// highlight-next-line
partialMatch([{ name: 'LS50', brand: 'KEF' }])
)

// Or you can use the .contents getter
expect(testCacheClient.contents).toContainEqual(
// check that an array contains an object matching
// highlight-next-line
partialMatch([{ name: 'LS50', brand: 'KEF' }])
)
}

scenario('finds a single product', () = {
await product({id: 5})

// You can also check for a partial match of an object
expect(testCacheClient).toHaveCached(
/cached-.*/,
// highlight-start
partialMatch({
name: 'LS50',
brand: 'KEF'
})
)
// highlight-end
})
```
Partial match is just syntactic sugar—underneath it uses Jest's `expect.objectContaining` and `expect.arrayContaining`.
The `partialMatch` helper takes two forms of arguments:
- If you supply an object, you are expecting a partial match of that object
- If you supply an array of objects, you are expecting an array containing a partial match of each of the objects
:::tip
Note that you cannot use `partialMatch` with toHaveCached without supplying a key!
```ts
// 🛑 Will never pass!
expect(testCacheClient).toHaveCached(partialMatch({name: 'LS50'}))
```
For partial matches, you either have to supply a key to `toHaveCached` or use the `cacheClient.contents` helper.
:::
### Strict Matching
If you'd like stricter checking (i.e. you do not want helpers to automatically serialize/deserialize your _expected_ value), you can use the `.contents` getter in test cache client. Note that the `.contents` helper will still de-serialize the values in your cache (to make it easier to compare), just not the expected value.
For example:
```ts

const expectedValue = {
// Note that this is a date 👇
publishDate: new Date('12/10/1988'),
title: 'A book from the eighties',
id: 1988
}

// ✅ will pass, because we will serialize the publishedDate for you
expect(testCacheClient).toHaveCached(expectedValue)


// 🛑 won't pass, because publishDate in cache is a string, but you supplied a Date object
expect(testCacheClient.contents).toContainEqual(expectedValue)

// ✅ will pass, because you serialized the date
expect(testCacheClient.contents).toContainEqual({
...expectedValue,
publishDate: expectedValue.publishDate.toISOString()
})

// And if you wanted to view the raw contents of the cache
console.log(testCacheClient.storage)
```
This is mainly helpful when you are testing for a very specific value, or have edgecases in how the serialization/deserialization works in the cache.
## Wrapping Up
So that's the world of testing according to Redwood. Did we miss anything? Can we make it even more awesome? Stop by [the community](https://community.redwoodjs.com) and ask questions, or if you've thought of a way to make this doc even better then [open a PR](https://github.com/redwoodjs/redwoodjs.com/pulls).
Expand Down
2 changes: 2 additions & 0 deletions packages/api/cache/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-env es6, commonjs */
module.exports = require('../dist/cache/index')
4 changes: 4 additions & 0 deletions packages/api/cache/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "./index.js",
"types": "../dist/cache/index.d.ts"
}
4 changes: 4 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"files": [
"dist",
"cache",
"logger",
"webhooks"
],
Expand Down Expand Up @@ -55,11 +56,14 @@
"@types/crypto-js": "4.1.1",
"@types/jsonwebtoken": "8.5.9",
"@types/md5": "2.3.2",
"@types/memjs": "1",
"@types/pascalcase": "1.0.1",
"@types/split2": "3.2.1",
"@types/uuid": "8.3.4",
"aws-lambda": "1.0.7",
"jest": "29.3.1",
"memjs": "1.3.0",
"redis": "4.2.0",
"split2": "4.1.0",
"typescript": "4.7.4"
},
Expand Down
30 changes: 30 additions & 0 deletions packages/api/src/cache/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import InMemoryClient from '../clients/InMemoryClient'
import { createCache } from '../index'

describe('cache', () => {
it('adds a missing key to the cache', async () => {
const client = new InMemoryClient()
const { cache } = createCache(client)

const result = await cache('test', () => {
return { foo: 'bar' }
})

expect(result).toEqual({ foo: 'bar' })
expect(client.storage.test.value).toEqual(JSON.stringify({ foo: 'bar' }))
})

it('finds an existing key in the cache', async () => {
const client = new InMemoryClient({
test: { expires: 1977175194415, value: '{"foo":"bar"}' },
})
const { cache } = createCache(client)

const result = await cache('test', () => {
return { bar: 'baz' }
})

// returns existing cached value, not the one that was just set
expect(result).toEqual({ foo: 'bar' })
})
})
Loading

0 comments on commit 34b0556

Please sign in to comment.