Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[apollo-datasource-rest] Add option to disable GET cache #6650

Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -10,6 +10,8 @@ The version headers in this history reflect the versions of Apollo Server itself

## vNEXT

- [apollo-datasource-rest] Add option to disable GET cache [PR #6650](https://github.com/apollographql/apollo-server/pull/6650)

## v3.10.1

- ⚠️ **SECURITY**: The default landing page contained HTML to display a sample `curl` command which is made visible if the full landing page bundle could not be fetched from Apollo's CDN. The server's URL is directly interpolated into this command inside the browser from `window.location.href`. On some older browsers such as IE11, this value is not URI-encoded. On such browsers, opening a malicious URL pointing at an Apollo Router could cause execution of attacker-controlled JavaScript. In this release, the fallback page does not display a `curl` command. More details are available at the [security advisory](https://github.com/apollographql/apollo-server/security/advisories/GHSA-2fvv-qxrq-7jq6).
Expand Down
82 changes: 81 additions & 1 deletion docs/source/data/data-sources.mdx
Expand Up @@ -96,6 +96,14 @@ By default, data source implementations use Apollo Server's in-memory cache to s

When you initialize Apollo Server, you can provide its constructor a _different_ cache object that implements the [`KeyValueCache` interface](https://github.com/apollographql/apollo-utils/tree/main/packages/keyValueCache#keyvaluecache-interface). This enables you to back your cache with shared stores like Memcached or Redis.

```js title="server.js"
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new MyCustomKeyValueCache()
});
```

### Using an external cache backend

When running multiple instances of your server, you should use a shared cache backend. This enables one server instance to use the cached result from _another_ instance.
Expand All @@ -104,7 +112,7 @@ Apollo Server supports using [Memcached](https://memcached.org/), [Redis](https:

You can also choose to implement your own cache backend. For more information, see [Implementing your own cache backend](../performance/cache-backends#implementing-your-own-cache-backend).

## `RESTDataSource` reference
## `RESTDataSource`

The `RESTDataSource` abstract class helps you fetch data from REST APIs. Your server defines a separate subclass of `RESTDataSource` for each REST API it communicates with.

Expand All @@ -116,6 +124,78 @@ npm install apollo-datasource-rest

You then extend the `RESTDataSource` class and implement whatever data-fetching methods your resolvers need. These methods can use built-in convenience methods (like `get` and `post`) to perform HTTP requests, helping you add query parameters, parse JSON results, and handle errors.

### API Reference
To see the all the properties and functions that can be overridden, the [source code](https://github.com/apollographql/apollo-server/tree/main/packages/apollo-datasource-rest) is always the best option.

#### Constructor Parameters
##### `httpFetch`

Optional constructor option which allows overriding the `fetch` implementation used when calling data sources.

#### 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.

##### `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.

### Example

Here's an example `RESTDataSource` subclass that defines two data-fetching methods, `getMovie` and `getMostViewedMovies`:
Expand Down
74 changes: 74 additions & 0 deletions packages/apollo-datasource-rest/README.md
Expand Up @@ -41,6 +41,80 @@ class MoviesAPI extends RESTDataSource {
}
```

## API Reference
View the source code to see the all the properties and functions that can be overridden and their specific parameters. This section lists the usage and default behavior of the `RESTDatasource` class.

### Constructor Parameters
#### `httpFetch`

Optional constructor option which allows overriding the `fetch` implementation used when calling data sources.

### 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.

#### `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.


## Examples
### HTTP Methods

The `get` method on the [RESTDataSource](https://github.com/apollographql/apollo-server/tree/main/packages/apollo-datasource-rest) makes an HTTP `GET` request. Similarly, there are methods built-in to allow for POST, PUT, PATCH, and DELETE requests.
Expand Down
26 changes: 16 additions & 10 deletions packages/apollo-datasource-rest/src/RESTDataSource.ts
Expand Up @@ -49,6 +49,8 @@ export abstract class RESTDataSource<TContext = any> extends DataSource {
httpCache!: HTTPCache;
context!: TContext;
memoizedResults = new Map<string, Promise<any>>();
baseURL?: string;
requestCacheEnabled: boolean = true;

constructor(private httpFetch?: typeof fetch) {
super();
Expand All @@ -59,8 +61,6 @@ export abstract class RESTDataSource<TContext = any> extends DataSource {
this.httpCache = new HTTPCache(config.cache, this.httpFetch);
}

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 @@ -266,15 +266,21 @@ export abstract class RESTDataSource<TContext = any> extends DataSource {
});
};

if (request.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
Expand Up @@ -5,7 +5,11 @@ import {
AuthenticationError,
ForbiddenError,
} from 'apollo-server-errors';
import { RESTDataSource, RequestOptions } from '../RESTDataSource';
import {
RESTDataSource,
RequestOptions,
CacheOptions,
} from '../RESTDataSource';

import { HTTPCache } from '../HTTPCache';
import { MapKeyValueCache } from './MapKeyValueCache';
Expand Down Expand Up @@ -502,8 +506,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 @@ -631,6 +635,26 @@ describe('RESTDataSource', () => {
'https://api.example.com/foo/1?api_key=secret',
);
});

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}`);
}
})();

dataSource.httpCache = httpCache;

fetch.mockJSONResponseOnce();
fetch.mockJSONResponseOnce();

await Promise.all([dataSource.getFoo(1), dataSource.getFoo(1)]);

expect(fetch).toBeCalledTimes(2);
});
});

describe('error handling', () => {
Expand Down Expand Up @@ -779,4 +803,67 @@ 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,
};
}
})();

dataSource.httpCache = httpCache;

fetch.mockJSONResponseOnce();
await dataSource.getFoo(1);

// Call a second time which should be cached
fetch.mockJSONResponseOnce();
await dataSource.getFoo(1);

expect(fetch).toBeCalledTimes(1);
});

it('allows setting a short TTL for the 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}`);
}

// Set a short TTL for every request
override cacheOptionsFor(): CacheOptions | undefined {
return {
ttl: 1,
};
}
})();

dataSource.httpCache = httpCache;

fetch.mockJSONResponseOnce();
await dataSource.getFoo(1);

// Sleep for a little to expire cache
await new Promise((r) => setTimeout(r, 2000));

// Call a second time which should be invalid now
fetch.mockJSONResponseOnce();
await dataSource.getFoo(1);

expect(fetch).toBeCalledTimes(2);
});
});
});