Skip to content

Apollo Federation/Gateway doesn't support Cache Control #356

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

Closed
RichiCoder1 opened this issue Aug 24, 2019 · 16 comments · Fixed by #870
Closed

Apollo Federation/Gateway doesn't support Cache Control #356

RichiCoder1 opened this issue Aug 24, 2019 · 16 comments · Fixed by #870

Comments

@RichiCoder1
Copy link

It doesn't look like @apollo/[email protected] and @apollo/[email protected] support @cacheControl directives or cache control hints.

Related: apollographql/apollo-server#3181

Expected

Using static or dynamic cacheControl hints will result in cache-control http headers.

Actual

No headers are sent, and the hints are empty when cacheControl: true in ApolloServer.

Code Example:

import { gql } from 'apollo-server'

const typeDefs = gql`
    type Test @key(fields: "id") @cacheControl(maxAge: 60, scope: PRIVATE) {
        id: ID!
     }
`;

export { typeDefs }

Error: UnhandledPromiseRejectionWarning: GraphQLSchemaValidationError: Unknown directive "cacheControl".

@RichiCoder1
Copy link
Author

RichiCoder1 commented Aug 24, 2019

I think a broad statement that could be made of this is that federation doesn't support any custom directives and any origin headers or extensions are dropped in the process of executing queries.

@shaneu
Copy link

shaneu commented Nov 12, 2019

It does support custom directives, but you need to use mergeSchemas from graphq-tools in the following manner

    const schema = mergeSchemas({
      schemaDirectives: {
        auth: AuthzDirectiveVisitor,
      },
      schemas: [schemas],
    });

which feels like a bit of a hack/workaround but I'm not sure of another option to include custom directives

@RichiCoder1
Copy link
Author

@shaneu To clarify, this issue is about using the Federated Schema tools, not schema stitching.

@shaneu
Copy link

shaneu commented Nov 12, 2019

@RichiCoder1 understood, I'm saying I've done that workaround above to implement custom directives using federation, I should have included the whole snipped

    const federatedSchemas = buildFederatedSchema([{resolvers, typeDefs}]);

    const schema = mergeSchemas({
      schemaDirectives: {
        auth: AuthzDirectiveVisitor,
      },
      schemas: [federatedSchemas],
    });

    new ApolloServer({
        schema,
        ...rest
     })

@RichiCoder1
Copy link
Author

Ah, I see! That's good know and great workaround until this is natively supported.

@donedgardo
Copy link

donedgardo commented Nov 14, 2019

@RichiCoder1 @shaneu
Where can i find the schemaDirectives for apollo cacheControl to merge them to my federated Schema?

@donedgardo
Copy link

@RichiCoder1 did you get to implement cache on your federated services?

@RichiCoder1
Copy link
Author

@donedgardo This issue came out of some exploratory work, so I haven't tackled fixing this personally.

@youfoundron
Copy link

Also running into this issue. Anyone find a workaround?

@ctpaulson
Copy link

Here is a simple workaround that worked for us. Include the schema for the cacheControl directive when you call buildFederatedSchema(). We just imported it from its own file that included this:

const typeDefs = gql`
  directive @cacheControl(
    maxAge: Int,
    scope: CacheControlScope
  ) on OBJECT | FIELD_DEFINITION

  enum CacheControlScope {
    PUBLIC
    PRIVATE
  }
`;

const resolvers = {

};

Should work as long as you have apollo-cache-control installed.

@srdone
Copy link

srdone commented Dec 13, 2019

In reference to @ctpaulson's comment above, we also had to do some additional work in the gateway to ensure that the resulting headers were combined appropriately:

First, we create a new datasource that collects the headers:

'use strict';

const { RemoteGraphQLDataSource } = require('@apollo/gateway');

/**
 * CachedDataSource
 *
 * Intended to be used in an `ApolloGateway`, in conjunction with
 * the `CacheControlHeaderPlugin`. Takes all responses and their
 * `Cache-Control` headers and adds them to a `cacheControl` array
 * on the context.
 *
 * When used in conjunction with the `CacheControlHeaderPlugin`,
 * the `cacheControl` array of headers will be used to calculate the overall
 * `Cache-Control` header that should be set on the final response.
 */
class CachedDataSource extends RemoteGraphQLDataSource {
  async didReceiveResponse (response, _request, _context) {
    const cacheControl = response.headers.get('Cache-Control');

    if (!_context.cacheControl || !Array.isArray(_context.cacheControl)) {
      _context.cacheControl = [];
    }

    _context.cacheControl.push(cacheControl);

    return super.didReceiveResponse(response, _request, _context);
  }
}

module.exports = {
  CachedDataSource
}

And we also have a special plugin to calculate the overall cache-control value and set the headers:

'use strict';

/**
 * The regext for finding the max-age value
 */
const CacheHeaderRegex = /^max-age=([0-9]+), public$/;

/**
 * Calculates the minimum max-age between the headers, retruns a new header
 *
 * @param {string[]} cacheControl - array of cache-control headers
 */
const calculateCacheHeader = (cacheControl = []) => {
  const maxAge = cacheControl.map((h) => CacheHeaderRegex.exec(h))
    .map((matches) => matches || [])
    .map((matches) => matches[1] || 0) // eslint-disable-line no-magic-numbers
    .reduce((acc, val) => Math.min(acc, val), +Infinity);

  return maxAge ? `max-age=${maxAge}, public` : 'no-cache';
};

/**
 * CacheControlHeaderPlugin
 *
 * Intended for use in conjunction with `CachedDataSource`.
 *
 * When used in an `ApolloGateway` with the `CachedDataSource`,
 * takes the generated array of `cacheControl` headers on the context
 * and calculates the minimum max-age of all the headers and then
 * sets that max-age on the response.
 *
 * This is intended to be used with federated schemas that return
 * `Cache-Control` headers, as supported by Apollo CacheControl.
 */
const CacheControlHeaderPlugin = {
  requestDidStart () {
    return {
      willSendResponse ({ response, context }) {
        const cacheHeader = calculateCacheHeader(context.cacheControl);
        response.http.headers.set('Cache-Control', cacheHeader);
      }
    };
  }
};

module.exports = {
  CacheControlHeaderPlugin
}

And then we wire it all up in the configuration:

const gateway = new ApolloGateway({
  ...
  buildService({ url }) {
    return new CachedDataSource({ url });
  }
});

const server = new ApolloServer({
  gateway,
  ...
  subscriptions: false,
  plugins: [
    CacheControlHeaderPlugin
  ],
  persistedQueries: {
    ...
  },
  ...
});

@tmdude9586
Copy link

Any update for when this should get fixed?

@robinboening
Copy link

I am very interested in this feature as well. Is this on the roadmap?

@robinboening
Copy link

My question is already answered by this document: https://github.com/apollographql/apollo-server/blob/c798068227b446d30b7aa306d2ea8008cd731375/ROADMAP.md#apollo-server-3x

  • Support schema directives for modifying execution:
    • Support the @CacheControl directive for both federated and non-federated graphs.

Thanks for writing this up 👍 ❤️

@abernix abernix transferred this issue from apollographql/apollo-server Jan 15, 2021
@eliw00d
Copy link

eliw00d commented May 10, 2021

In reference to @ctpaulson's comment above, we also had to do some additional work in the gateway to ensure that the resulting headers were combined appropriately:

First, we create a new datasource that collects the headers:

'use strict';

const { RemoteGraphQLDataSource } = require('@apollo/gateway');

/**
 * CachedDataSource
 *
 * Intended to be used in an `ApolloGateway`, in conjunction with
 * the `CacheControlHeaderPlugin`. Takes all responses and their
 * `Cache-Control` headers and adds them to a `cacheControl` array
 * on the context.
 *
 * When used in conjunction with the `CacheControlHeaderPlugin`,
 * the `cacheControl` array of headers will be used to calculate the overall
 * `Cache-Control` header that should be set on the final response.
 */
class CachedDataSource extends RemoteGraphQLDataSource {
  async didReceiveResponse (response, _request, _context) {
    const cacheControl = response.headers.get('Cache-Control');

    if (!_context.cacheControl || !Array.isArray(_context.cacheControl)) {
      _context.cacheControl = [];
    }

    _context.cacheControl.push(cacheControl);

    return super.didReceiveResponse(response, _request, _context);
  }
}

module.exports = {
  CachedDataSource
}

And we also have a special plugin to calculate the overall cache-control value and set the headers:

'use strict';

/**
 * The regext for finding the max-age value
 */
const CacheHeaderRegex = /^max-age=([0-9]+), public$/;

/**
 * Calculates the minimum max-age between the headers, retruns a new header
 *
 * @param {string[]} cacheControl - array of cache-control headers
 */
const calculateCacheHeader = (cacheControl = []) => {
  const maxAge = cacheControl.map((h) => CacheHeaderRegex.exec(h))
    .map((matches) => matches || [])
    .map((matches) => matches[1] || 0) // eslint-disable-line no-magic-numbers
    .reduce((acc, val) => Math.min(acc, val), +Infinity);

  return maxAge ? `max-age=${maxAge}, public` : 'no-cache';
};

/**
 * CacheControlHeaderPlugin
 *
 * Intended for use in conjunction with `CachedDataSource`.
 *
 * When used in an `ApolloGateway` with the `CachedDataSource`,
 * takes the generated array of `cacheControl` headers on the context
 * and calculates the minimum max-age of all the headers and then
 * sets that max-age on the response.
 *
 * This is intended to be used with federated schemas that return
 * `Cache-Control` headers, as supported by Apollo CacheControl.
 */
const CacheControlHeaderPlugin = {
  requestDidStart () {
    return {
      willSendResponse ({ response, context }) {
        const cacheHeader = calculateCacheHeader(context.cacheControl);
        response.http.headers.set('Cache-Control', cacheHeader);
      }
    };
  }
};

module.exports = {
  CacheControlHeaderPlugin
}

And then we wire it all up in the configuration:

const gateway = new ApolloGateway({
  ...
  buildService({ url }) {
    return new CachedDataSource({ url });
  }
});

const server = new ApolloServer({
  gateway,
  ...
  subscriptions: false,
  plugins: [
    CacheControlHeaderPlugin
  ],
  persistedQueries: {
    ...
  },
  ...
});

It's actually a lot less complicated than this, since the servers behind the gateway actually do respond with the age and cache-control headers already. In addition to adding the directive to buildFederatedSchema and using the responseCachePlugin, I just did this in the gateway:

new ApolloGateway({
  buildService: ({ url }) =>
    new RemoteGraphQLDataSource({
      didReceiveResponse({ context: { req, res } = {}, response }) {
        const age = response.http?.headers?.get('Age')
        if (age) {
          res?.append('Age', age)
        }
          
        const cacheControl = response.http?.headers?.get('Cache-Control')
        if (cacheControl) {
          res?.append('Cache-Control', cacheControl)
        }
        return response
      }
    })
})

I can't seem to get it to work using response.http?.headers?.append so I had to use the underlying express response.

Everything works after that except for invalidating the cache for mutations, which ended up killing this for us, but just in case it is helpful for anyone else.

@glasser
Copy link
Member

glasser commented Jul 8, 2021

We're hoping to improve this to work by default. See plan at apollographql/apollo-server#5449

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants