urql icon indicating copy to clipboard operation
urql copied to clipboard

RFC: setContextExchange

Open sarink opened this issue 3 years ago • 5 comments

Summary

A simple exchange to set the operation context, should accept a function, or async function, which returns the new context. Coming from apollo where this was built-in and really handy, I miss it.

I originally wanted this for use with third party auth libraries (auth0, in my case), but I think there's a ton of general flexibility with it as well.

The authExchange API is really great and well-designed, however, a lot of third party auth services already take care of all of that, and just give you a token which you need to set in the headers. Urql makes it pretty difficult to set an async fetch option (as discussed in plenty of other issues).

Proposed Solution

import { Operation, Exchange } from 'urql';
import { fromPromise, map, mergeMap, pipe } from 'wonka';

type Context = Operation['context'];
type ContextSetter = (operation: Operation) => Context | Promise<Context>;

export const setContextExchange =
  (contextSetter: ContextSetter): Exchange =>
  ({ forward }) =>
  (ops$) =>
    pipe(
      ops$,
      mergeMap((operation) =>
        pipe(
          fromPromise(Promise.resolve(contextSetter(operation))),
          map((newContext) => ({ ...operation, context: newContext }))
        )
      ),
      forward
    );

Pretty straightforward, but unless you're familiar with both wonka and how exchanges work, it is very confusing. Would be nicer to just import it from urql instead :)

sarink avatar Apr 19 '22 02:04 sarink

We probably don't want to always accept promises and also allow for synchronous calls so this can be used in front of a cache exchange as well 🤔 Other than that, fully agree that we can make this happen 👍

This doesn't do a map of incoming operations but does show you what I mean in terms of supporting promises and non-promise maps ❤️ https://github.com/FormidableLabs/urql/blob/dbcc738279806e29bb712297932b4cfcbcb95c46/packages/storybook-addon/src/exchange.ts#L10

Other than that, I'm happy to accept a mapOperation exchange PR to @urql/core ✌️

kitten avatar May 24 '22 18:05 kitten

@kitten this does already work with promises and non-promises (notice the signature type ContextSetter = (operation: Operation) => Context | Promise<Context>;)

Instead of checking 'then' in result and conditionally using fromPromise, which is potentially (theoretically) dangerous, it uses Promise.resolve to always convert the value to a Promise first.

Is there a downside to just always using fromPromise?

sarink avatar May 28 '22 16:05 sarink

Yea, if you always use fromPromise then it's very likely that you'll at least introduce a micro-tick delay when calling Promise.resolve which means that the result won't ever be available synchronously.

The check 'then' in x is indeed a little tricky, however, thanks to the types we know and can assume that there'll only be two different return types, which imho makes it pretty safe ✌️

kitten avatar May 28 '22 16:05 kitten

Interesting point, hadn't thought of that. Out of curiosity, what sort of side effects could be seen by adding an async function (which introduces a micro-tick delay on every operation) in the exchange pipeline?

For reference, and any future readers, here's an example of how we're using it:

export const UrqlProvider: React.FC = (props) => {
  const { getAccessTokenSilently } = useAuth0();

  const client = createClient({
    url: 'http://localhost:3000/graphql',
    exchanges: [
      dedupExchange,
      cacheExchange,
      setContextExchange(async (operation) => {
        const origFetchOptions =
          typeof operation.context.fetchOptions === 'function'
            ? operation.context.fetchOptions()
            : operation.context.fetchOptions ?? {};

        const authToken = await getAccessTokenSilently();

        const fetchOptions = {
          ...origFetchOptions,
          headers: { ...origFetchOptions.headers, authorization: authToken },
        };
        return { ...operation.context, fetchOptions };
      }),
      fetchExchange,
    ],
  });

  return <Provider value={client}>{props.children}</Provider>;
};

I'll work on getting a PR up sometime! Does the naming mapOperation imply you'd like it to be more generic and return the entire operation (rather than just the operation.context object)?

sarink avatar May 28 '22 16:05 sarink

There'll be no side effects of it's right in front of the fetchExchange admittedly, but I'm more thinking of people who'd like to use it in front of the cacheExchange. We currently impose and assume that all cached operations resolve synchronously and a delay would break that assumption

kitten avatar May 28 '22 20:05 kitten