apollographql / apollo-client

:rocket:  A fully-featured, production ready caching GraphQL client for every UI framework and GraphQL server.
https://apollographql.com/client
MIT License
19.38k stars 2.66k forks source link

Can't modify response with afterware #2534

Closed beornborn closed 5 years ago

beornborn commented 7 years ago

I am trying to modify response data following this guide https://github.com/apollographql/apollo-client/blob/master/Upgrade.md#afterware-data-manipulation

Apollo client config

//@flow
import ApolloClient from 'apollo-client'
import { HttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { ApolloLink } from 'apollo-link'

const httpLink = new HttpLink({ uri: "https://swapi.apis.guru" })
const addStatusLink = new ApolloLink((operation, forward) => {
  return forward(operation).map((response) => {
    response.data.status = 'SUCCESS'
    console.log('afterware', JSON.stringify(response))
    return response;
  })
})

const link = addStatusLink.concat(httpLink)

const cache = new InMemoryCache()

export const apolloClient = new ApolloClient({
  link,
  cache: cache.restore(window.__APOLLO_STATE__),
})

saga

//@flow
import { takeEvery } from 'redux-saga'
import { SAGA_GET_STAR_WARS } from '~/app/reducers/Saga'
import { getLuke } from '~/app/api/StarWars'
import { apolloClient } from '~/app/Apollo'

export function* perform(_a: Object): Generator<*, *, *> {
  const response = yield apolloClient.query({
    query: getLuke,
    variables: {id: 'cGVvcGxlOjE='}
  })
  console.log('saga', JSON.stringify(response))
}

export function* watch(): Generator<*, *, *> {
  yield* takeEvery(SAGA_GET_STAR_WARS, perform)
}

export default watch

console:

afterware {"data":{"person":{"name":"Luke Skywalker","__typename":"Person"},"status":"SUCCESS"}}

saga {"data":{"person":{"name":"Luke Skywalker","__typename":"Person"}},"loading":false,"networkStatus":7,"stale":false}

problem: status key is present in afterware but absent in saga

minimal example to reproduce https://github.com/beornborn/apollo-client-2-response-issue

Version

How can I pass custom data in response?

jbaxleyiii commented 7 years ago

@beornborn hmm I would have expected that to work. However, does your query have a status field? If not it may be stripped by apollo-client's store interactions on queries

beornborn commented 7 years ago

@jbaxleyiii Query doesn't have status field.

Previously I added custom data with own wrapper like this

export function* apolloCallWrapper(callback: Function): Object {
  try {
    const response = yield call(callback)
    console.log('response', response)
    if (response.errors) {
      return { status: 'Fail', error: response.errors.map(e => e.message).join(', ') }
    } else {
      return { status: 'Success', data: response.data }
    }
  } catch (err) {
    const errorClasses = err.graphQLErrors.map(x => x.name)
    const error = err.message.replace('GraphQL error: ', '')
    if (errorClasses.includes('UnauthorizedError')) {
      yield logout()
    }
    return { status: 'Fail', error }
  }
}

and in saga

const result = yield apolloCallWrapper(() =>
   apolloClient.query({query: someQuery}),
)

Can I achieve this with afterware? Or not because apollo-client will strip all my custom data?

riking commented 6 years ago

Fields not asked for in the query will be stripped by the caching layer. Try this:

const s = response.data.person.name = new String(response.data.person.name);
s.extra = "data";

You can do the same thing with new Number().

beornborn commented 6 years ago

@riking looks like a dirty hack. Why should I put status about request in some person's name value? Also I need universal solution to pass status for every request, and these requests have different keys in data key.

If there is no obvious and simple way to modify response data at this point, I'd rather continue using apolloCallWrapper. It's simple, not much typing and I can do whatever I want there with no restrictions.

Hunter21007 commented 6 years ago

Hi all,

i have a question about extensions.

My server puts some additional metadata to the response and i can see it on the afterware. But it gets stripped underway.

Is it possible work around it without hacking?

Thanks

ash0080 commented 6 years ago

@beornborn Agree, so many restrictions like a typescript addict! I have a simulated issue here

I am using a forked gotgl to handle the Query and Mutation now, as a reference

hwillson commented 6 years ago

As suspected in https://github.com/apollographql/apollo-client/issues/2534#issuecomment-343951913, I've confirmed that the additional data being added to the response is being stripped by the cache. To verify this, set the default fetchPolicy to no-cache in the repro:

export const apolloClient = new ApolloClient({
  link,
  cache: cache.restore(window.__APOLLO_STATE__),
  defaultOptions: {
    query: {
      fetchPolicy: 'no-cache',
    },
  },
});

The console will now show that saga has the previously missing status:

afterware {"data":{"person":{"name":"Luke Skywalker","__typename":"Person"},"status":"SUCCESS"}}
saga {"data":{"person":{"name":"Luke Skywalker","__typename":"Person"},"status":"SUCCESS"},"loading":false,"networkStatus":7,"stale":false}

We should probably allow any form of response updates, or at least update the Afterware (data manipulation) example we show in the docs to make this limitation clear. Thanks!

MicahRamirez commented 6 years ago

@hwillson Any idea of the performance hit for using 'no-cache' in the fetch policy? We've been using InMemoryCache from apollo-cache-inmemory.

This work around would allow me to add IDs set in the response header to the query results. The IDs are sent to our analytics service which will help us track down bugs in our microservices.

It seems like the other option is to add the IDs to every query/mutation so that the cache doesn't throw it out, which seems sub optimal.

chucksellick commented 6 years ago

@MicahRamirez I have this exact same use case. I've tried a ton of stuff and absolutely no joy! The best option I've thought of is to simply throw an Error, including my correlation ID in the error message - then catch the error at a higher level and parse out the ID using a regex. Not at all an ideal solution :(

MicahRamirez commented 6 years ago

@chucksellick Are you unable to set the fetch policy to no cache as shown above? That solution will work for me, for now. From hwillson's comments the current behavior is not intended, so the work around should be sufficient for the short term at least for us.

I am about to be on vacation for about 14 days, but when I get back @hwillson what kind of help are you looking for?

rafaelsales commented 6 years ago

@beornborn Did you find a solution for this?

  const networkLink = split(
    ({ query }) => {
      const { kind, operation } = getMainDefinition(query);
      return kind === 'OperationDefinition' && operation === 'subscription';
    },
    webSocketLink,
    httpLink,
  )

  const parseResponse = new ApolloLink((operation, forward) => {
    return forward(operation).map((response) => {
      response.data.parsed = transformData(response.data)
      return response
    })
  })

  const link = parseResponse.concat(networkLink)

  return new ApolloClient({
    link: link,
    cache: new InMemoryCache({ addTypename: true }),
    defaultOptions: {
      query: {
        fetchPolicy: 'no-cache', // This was an attempt to fix this issue, but it didn't work
        errorPolicy: 'all',
      },
    },
  })

Somewhere between the afterware and the Query children component the parsed field is removed

rafaelsales commented 6 years ago

@hwillson the no-cache didn't work for me :(

beornborn commented 6 years ago

@rafaelsales I use workaround over graphql

    const response = yield apolloCallWrapper(() =>
      apolloClient.mutate({
        mutation: UpdateMember,
        variables: preparedFormData,
      }),
    )
const apolloCallWrapper = function* apolloCallWrapper(callback: Function): Object {
  try {
    const response = yield call(callback)
    console.log('response', response)
    if (response.errors) {
      return { status: 'Fail', error: response.errors.map(e => e.message).join(', ') }
    } else {
      return { status: 'Success', data: response.data }
    }
  } catch (err) {
    const errorInfo = `
      ${err.stack}
      ${JSON.stringify(err.graphQLErrors)}
    `
    bugsnagNotify('Response Error', errorInfo)
    console.log(err.stack, err.graphQLErrors)
    const errorClasses = err.graphQLErrors.map(x => x.name)
    const error = err.message.replace('GraphQL error: ', '')
    if (errorClasses.includes('UnauthorizedError')) {
      yield logout()
    }
    return { status: 'Fail', error }
  }
}

export default apolloCallWrapper
krabbypattified commented 6 years ago

I'm experiencing this issue too, but I don't think it has to do with no-cache.

FilterToSchema seems to be the source of my problems. Even the documentation says:

FilterToSchema: Given a schema and document, remove all fields, variables and fragments for types that don’t exist in that schema.

I applied my own transformResponse transform but it seems that FilterToSchema is applied after everything else because it still gets stripped away. (In my specific case, it seems that my call to mergeSchemas calls delegateToSchema which applies FilterToSchema by default. I have no control over anything except one of the schemas being merged - I'm using addThirdPartySchema with Gatsby)

I am not an expert on transforms, but perhaps the solution is to create a priority mechanism to control the order of the transforms applied?

@jbaxleyiii Any thoughts on this?

efoken commented 5 years ago

Any progress on this?

I'm having the same issue here when using cache. I wanna handle pagination with the WooCommerce REST API like so:

const authRestLink = new ApolloLink((operation, forward) => {
  operation.setContext(({ headers }) => ({
    headers: {
      ...headers,
      authorization: `Basic ${btoa(`${CLIENT_KEY}:${SECRET_KEY}`)}`,
    },
  }));
  return forward(operation).map((result) => {
    const { restResponses } = operation.getContext();
    const wpRestResponse = restResponses.find(res => res.headers.has('x-wp-total'));

    if (wpRestResponse) {
      result.data.headers = {
        xWpTotal: wpRestResponse.headers.get('x-wp-total'),
        xWpTotalpages: wpRestResponse.headers.get('x-wp-totalpages'),
      };
    }
    return result;
  });
});

When I use no-cache as fetch policy, it works fine. Otherwise the data.headers key gets stripped out.

francescovenica commented 5 years ago

I have the same problem, I'm trying to append the field's types to the result, what I did at the end (this is NOT a solution but an hack) is to append a simple query like this: __type(name: "User") {name}

and then replace data.__type with what I need using formatResponse

cy6eria commented 5 years ago

I think I have found a solution for this problem. At first, you have to define a local scheme. You can read more about it here.

For the provided case it should looks like this:

new ApolloClient({
  link: addStatusLink.concat(authLink.concat(link)),
  cache,
  typeDefs: gql`
    type LocalStatus {
      id: String!
      success: String!
    }
  `,
  resolvers,
});

Then you have to modify your query:

const yourQuery = gql`
    query someQueryWithStatus {
        someRealDataQuery {
            id
            name
        }
        status @client {
            id
            success   
        }
    }
`;

Finally, you have to modify your transformations:

const addStatusLink = new ApolloLink((operation, forward) => {
  return forward(operation).map((response) => {
    response.data.status = {
      id: 1,
      success: 'SUCCESS',
      __typename: 'LocalStatus',
    };

    return response;
  })
})

I tried this, it works for me with latest versions of packages:

"apollo-cache-inmemory": "^1.6.2",
"apollo-client": "^2.6.3",
"apollo-link": "^1.2.12",

Hope It'll help someone. I spent hours for this solution 🤓

cy6eria commented 5 years ago

@hwillson hi. Looks like it could be solved without any hacks.

lucasmafra commented 5 years ago

Hi folks

After spending hours struggling with this I came to a solution that worked well for my needs (I'm integrating my React app to a WordPress API using apollo-link-rest).

@cy6eria your solution doesn't work unless you set fetch-policy to no-cache. Otherwise, Apollo will return the headers from cache for every request, which is bad because if you're trying to use the response headers to do pagination, for example, it will mess up everything.

So, here is what I did:

1) Modify query to ask for result and headers:

const getPosts = gql`
  query posts($maxPosts: String!, $page: Int!) {
    posts(maxPosts: $maxPosts, page: $page) @rest(type: "PostsPayload", path: "/wp-json/wp/v2/posts?_embed&per_page={args.maxPosts}&page={args.page}") {
       result @type(name: "Posts") {
         id
         title
         excerpt
         date_gmt
         slug
         _embedded
       }
       headers @type(name: "Header") {
         x_wp_totalpages        
       }
    }
  }
`;

2) Modify your ApolloLink to use a custom responseTransformer:

export const blogApiLink = new RestLink({  
  uri: process.env.BLOG_API_URI,
  responseTransformer: async response => {
    const result = await response.json();
    return {
      result: result,
      headers: {
        x_wp_totalpages: response.headers.get('x-wp-totalpages') // add whichever headers you want
      }
    };
  }
});

...and that's it!

@efoken I have a very similar use case to yours, maybe this solution can work for you as well :smiley:

cy6eria commented 5 years ago

@lucasmafra Hi! I use this technic for my project and it works well for the default fetch-policy (cache-first). I haven't checked exact your case. In my case I need to transform tree data to plain list.

lucasmafra commented 5 years ago

Hi @cy6eria! Good to know it's working for you! I'm curious, have you ensured that the headers are being retrieved correctly for different requests? I'm saying that because:

1) AFAIK the @client directive tells Apollo to get data from cache instead of from the server

2) if someone need to integrate with a REST API (which I think is one of the most common use cases for this thread), the headers must be nested (I mean, inside the query with @rest directive), instead of flat. To illustrate better, let's consider this code:

const getPosts = gql`
  query posts {
    posts @rest(type: "PostsPayload", path: "/wp-json/wp/v2/posts") {       
         id
         title   
       }
    headers @type(name: "Header") {
        x_wp_totalpages        
   }   
}
`;

The snippet above will have an undesired behavior with cache enabled, because the headers are not inside the query with @rest directive, so Apollo does not associate the headers with that particular request. As a result, when you make the first request to the server it works fine, but in the second request Apollo will override the headers in cache with the new value returned from the server, which can be bad depending on your use case.

So, to avoid that, you might need to put the headers in a nested query like this:

const getPosts = gql`
  query posts {
    posts @rest(type: "PostsPayload", path: "/wp-json/wp/v2/posts") {   
         result {    
           id
           title   
         }
         headers @type(name: "Header") {
           x_wp_totalpages        
        }   
   }
}
`;
jbaxleyiii commented 5 years ago

Thanks for reporting this. There hasn't been any activity here in quite some time, so we'll close this issue for now. If this is still a problem (using a modern version of Apollo Client), please let us know. Thanks!

NickTomlin commented 4 years ago

I'm still seeing this with the latest version of apollo client (3.1.11 at time of writing). extensions written in an afterware are not available on data unless I set a fetchPolicy of no-cache.

I'm finding it difficult to reproduce this in a sandbox because access to extensions seems like it's been abstracted away in the plugin lifecycle; my own use case is working with an internal API served by graphql-java so sharing that is difficult as well. If anyone has pointers on how to spin up a server or (or mock a server response) with extensions let me know!

riking commented 4 years ago

/reopen

vitaliikotliar commented 3 years ago

@cy6eria Your solution is brilliant!

mohity777 commented 2 years ago

Does anybody know how to add a common variable to all the requests of apollo client. Suppose u have a lot of queries that take same variable with same value. Instead of defining and passing them on each query can we set a request interceptor to handle this ?