Skip to content

Commit

Permalink
Add option to disable GET request cache (#3)
Browse files Browse the repository at this point in the history
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.

This change adds a new class property requestCacheEnabled (which defaults to
true to match current behavior) which allows users to disable the cache. You might
want to do this if your API is not actually cacheable or your data changes over time.

Separately it documents this feature exists and adds more info about how to set a
TTL for the entire HTTP cache.

Fixes #6603 (for ASv4)
  • Loading branch information
smyrick committed Aug 19, 2022
1 parent 4344037 commit d2e600c
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/heavy-lies-retire.md
@@ -0,0 +1,5 @@
---
'@apollo/datasource-rest': minor
---

Add option to disable GET request cache
1 change: 1 addition & 0 deletions .prettierignore
Expand Up @@ -4,5 +4,6 @@
*.md

dist/
coverage/

.volta
73 changes: 73 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 16 additions & 10 deletions src/RESTDataSource.ts
Expand Up @@ -57,13 +57,13 @@ export interface DataSourceConfig {
export abstract class RESTDataSource {
httpCache: HTTPCache;
memoizedResults = new Map<string, Promise<any>>();
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.
Expand Down Expand Up @@ -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();
}
}
Expand Down
80 changes: 78 additions & 2 deletions src/__tests__/RESTDataSource.test.ts
@@ -1,6 +1,7 @@
// import fetch, { Request } from 'node-fetch';
import {
AuthenticationError,
CacheOptions,
DataSourceConfig,
ForbiddenError,
RequestOptions,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
});

0 comments on commit d2e600c

Please sign in to comment.