diff --git a/.changeset/heavy-lies-retire.md b/.changeset/heavy-lies-retire.md new file mode 100644 index 0000000..a78e9ce --- /dev/null +++ b/.changeset/heavy-lies-retire.md @@ -0,0 +1,5 @@ +--- +'@apollo/datasource-rest': minor +--- + +Add option to disable GET request cache diff --git a/.prettierignore b/.prettierignore index 0e6da06..224f82d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,5 +4,6 @@ *.md dist/ +coverage/ .volta \ No newline at end of file diff --git a/README.md b/README.md index dfb9b4b..1a2030c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,79 @@ class MoviesAPI extends RESTDataSource { } ``` +### API Reference +To see the all the properties and functions that can be overridden, the [source code](https://github.com/apollographql/datasource-rest/tree/main/src/RESTDataSource.ts) is always the best option. + +#### Properties +##### `baseURL` +Optional value to use for all the REST calls. If it is set in your class implementation, this base URL is used as the prefix for all calls. If it is not set, then the value passed to the REST call is exactly the value used. + +```js title="baseURL.js" +class MoviesAPI extends RESTDataSource { + constructor() { + super(); + this.baseURL = 'https://movies-api.example.com/'; + } + // GET + async getMovie(id) { + return this.get( + `movies/${encodeURIComponent(id)}` // path + ); + } +} +``` + +##### `requestCacheEnabled` +By default, `RESTDataSource` caches all outgoing GET **requests** in a separate memoized cache from the regular response cache. It makes the assumption that all responses from HTTP GET calls are cacheable by their URL. +If a request is made with the same cache key (URL by default) but with an HTTP method other than GET, the cached request is then cleared. + +If you would like to disable the GET request cache, set the `requestCacheEnabled` property to `false`. You might want to do this if your API is not actually cacheable or your data changes over time. + +```js title="requestCacheEnabled.js" +class MoviesAPI extends RESTDataSource { + constructor() { + super(); + // Defaults to true + this.requestCacheEnabled = false; + } + // Outgoing requests are never cached, however the response cache is still enabled + async getMovie(id) { + return this.get( + `https://movies-api.example.com/movies/${encodeURIComponent(id)}` // path + ); + } +} +``` + +#### Methods + +##### `cacheKeyFor` +By default, `RESTDatasource` uses the full request URL as the cache key. Override this method to remove query parameters or compute a custom cache key. + +For example, you could use this to use header fields as part of the cache key. Even though we do validate header fields and don't serve responses from cache when they don't match, new responses overwrite old ones with different header fields. + +##### `willSendRequest` +This method is invoked just before the fetch call is made. If a `Promise` is returned from this method it will wait until the promise is completed to continue executing the request. + +##### `cacheOptionsFor` +Allows setting the `CacheOptions` to be used for each request/response in the HTTPCache. This is separate from the request-only cache. You can use this to set the TTL. + +```javascript +override cacheOptionsFor() { + return { + ttl: 1 + } +} +``` + +##### `didReceiveResponse` +By default, this method checks if the response was returned successfully and parses the response into the result object. If the response had an error, it detects which type of HTTP error and throws the error result. + +If you override this behavior, be sure to implement the proper error handling. + +##### `didEncounterError` +By default, this method just throws the `error` it was given. If you override this method, you can choose to either perform some additional logic and still throw, or to swallow the error by not throwing the error result. + ### HTTP Methods The `get` method on the [`RESTDataSource`](https://github.com/apollographql/datasource-rest/tree/main/src/RESTDataSource.ts) makes an HTTP `GET` request. Similarly, there are methods built-in to allow for `POST`, `PUT`, `PATCH`, and `DELETE` requests. diff --git a/package-lock.json b/package-lock.json index f804213..c8f72b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apollo/datasource-rest", - "version": "3.0.0", + "version": "4.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@apollo/datasource-rest", - "version": "3.0.0", + "version": "4.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/RESTDataSource.ts b/src/RESTDataSource.ts index abeddf5..afdd2a6 100644 --- a/src/RESTDataSource.ts +++ b/src/RESTDataSource.ts @@ -57,13 +57,13 @@ export interface DataSourceConfig { export abstract class RESTDataSource { httpCache: HTTPCache; memoizedResults = new Map>(); + baseURL?: string; + requestCacheEnabled: boolean = true; constructor(config?: DataSourceConfig) { this.httpCache = new HTTPCache(config?.cache, config?.fetch); } - baseURL?: string; - // By default, we use the full request URL as the cache key. // You can override this to remove query parameters or compute a cache key in any way that makes sense. // For example, you could use this to take Vary header fields into account. @@ -258,15 +258,21 @@ export abstract class RESTDataSource { }); }; - if (modifiedRequest.method === 'GET') { - let promise = this.memoizedResults.get(cacheKey); - if (promise) return promise; - - promise = performRequest(); - this.memoizedResults.set(cacheKey, promise); - return promise; + // Cache GET requests based on the calculated cache key + // Disabling the request cache does not disable the response cache + if (this.requestCacheEnabled) { + if (request.method === 'GET') { + let promise = this.memoizedResults.get(cacheKey); + if (promise) return promise; + + promise = performRequest(); + this.memoizedResults.set(cacheKey, promise); + return promise; + } else { + this.memoizedResults.delete(cacheKey); + return performRequest(); + } } else { - this.memoizedResults.delete(cacheKey); return performRequest(); } } diff --git a/src/__tests__/RESTDataSource.test.ts b/src/__tests__/RESTDataSource.test.ts index 334fb5e..3a04107 100644 --- a/src/__tests__/RESTDataSource.test.ts +++ b/src/__tests__/RESTDataSource.test.ts @@ -1,6 +1,7 @@ // import fetch, { Request } from 'node-fetch'; import { AuthenticationError, + CacheOptions, DataSourceConfig, ForbiddenError, RequestOptions, @@ -492,8 +493,8 @@ describe('RESTDataSource', () => { }); }); - describe('memoization', () => { - it('deduplicates requests with the same cache key', async () => { + describe('memoization/request cache', () => { + it('de-duplicates requests with the same cache key', async () => { const dataSource = new (class extends RESTDataSource { override baseURL = 'https://api.example.com'; @@ -585,6 +586,23 @@ describe('RESTDataSource', () => { dataSource.getFoo(1, 'anotherSecret'), ]); }); + + it('allows disabling the GET cache', async () => { + const dataSource = new (class extends RESTDataSource { + override baseURL = 'https://api.example.com'; + override requestCacheEnabled = false; + + getFoo(id: number) { + return this.get(`foo/${id}`); + } + })(); + + nock(apiUrl).get('/foo/1').reply(200); + nock(apiUrl).get('/foo/1').reply(200); + + // Expect two calls to pass + await Promise.all([dataSource.getFoo(1), dataSource.getFoo(1)]); + }); }); describe('error handling', () => { @@ -722,5 +740,63 @@ describe('RESTDataSource', () => { ); }); }); + + describe('http cache', () => { + it('allows setting cache options for each request', async () => { + const dataSource = new (class extends RESTDataSource { + override baseURL = 'https://api.example.com'; + override requestCacheEnabled = false; + + getFoo(id: number) { + return this.get(`foo/${id}`); + } + + // Set a long TTL for every request + override cacheOptionsFor(): CacheOptions | undefined { + return { + ttl: 1000000, + }; + } + })(); + + nock(apiUrl).get('/foo/1').reply(200); + await dataSource.getFoo(1); + + // Call a second time which should be cached + await dataSource.getFoo(1); + }); + + it('allows setting a short TTL for the cache', async () => { + // nock depends on process.nextTick + jest.useFakeTimers({ doNotFake: ['nextTick'] }); + + const dataSource = new (class extends RESTDataSource { + override baseURL = 'https://api.example.com'; + override requestCacheEnabled = false; + + getFoo(id: number) { + return this.get(`foo/${id}`); + } + + // Set a short TTL for every request + override cacheOptionsFor(): CacheOptions | undefined { + return { + ttl: 1, + }; + } + })(); + + nock(apiUrl).get('/foo/1').reply(200); + await dataSource.getFoo(1); + + // expire the cache (note: 999ms, just shy of the 1s ttl, will reliably fail this test) + jest.advanceTimersByTime(1000); + + // Call a second time which should be invalid now + await expect(dataSource.getFoo(1)).rejects.toThrow(); + + jest.useRealTimers(); + }); + }); }); });