Urql: File-upload exchange

Created on 1 Mar 2020  路  9Comments  路  Source: FormidableLabs/urql

Now that we have a monorepo and more room to add custom-exchanges, this spectrum post made me think about publishing an exchange that builds upon the current fetchExchange but additionally allows file-uploads.

Example file-upload exchange

Let's get some opinions on this and then mark it as a Good First Issue or refer to the conclusion here when people ask for this.

Most helpful comment

Hey @kayluhb ,

Thank you for reporting this!
The example has probably been outdated, at this point we're actively working on the new and improved documentation. If anyone feels like it feedback is always welcome, the docs can be ran by cloning urql installing deps, going to packages/site and running yarn start.

I'll most likely take some time to construct the exchange in the following days.

All 9 comments

@BjoernRave I would welcome such an exchange :) I like the idea, that you can easily create your own exchanges, but there is no point in everyone having to reinvent the wheel when it comes to something so common like file uploads.

(Also I didn't get the code from the condesanbox to work, wonka tells me undefined is not a function)

Yes. Yes.

Just as a headsup, I'm getting an error using this example with @urql/[email protected]

```
TypeError: undefined is not a function
(anonymous function)
./utils/exchanges/uploadExchange.js:42
39 | });
40 | };
41 |

42 | const createFetchSource = operation =>

Hey @kayluhb ,

Thank you for reporting this!
The example has probably been outdated, at this point we're actively working on the new and improved documentation. If anyone feels like it feedback is always welcome, the docs can be ran by cloning urql installing deps, going to packages/site and running yarn start.

I'll most likely take some time to construct the exchange in the following days.

Thanks @JoviDeCroock! That helped me down the right path.

For anyone that may need it, here's an updated version of the exchange that was previously in the docs (sans typescript and without support for gets).

import { extractFiles } from 'extract-files';
import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
import { Kind, print } from 'graphql';
import { makeResult, makeErrorResult } from 'urql';

const getOperationName = query => {
  const node = query.definitions.find(
    node$ => node$.kind === Kind.OPERATION_DEFINITION && node$.name,
  );

  return node !== undefined && node.name ? node.name.value : null;
};

const executeFetch = (operation, opts) => {
  const { url, fetch: fetcher } = operation.context;
  let response;

  return (fetcher || fetch)(url, opts)
    .then(res => {
      const { status } = res;
      const statusRangeEnd = opts.redirect === 'manual' ? 400 : 300;
      response = res;

      if (status < 200 || status >= statusRangeEnd) {
        throw new Error(res.statusText);
      } else {
        return res.json();
      }
    })
    .then(result => makeResult(operation, result, response))
    .catch(err => {
      if (err.name !== 'AbortError') {
        return makeErrorResult(operation, err, response);
      }
    });
};

const createFetchSource = operation => {
  if (
    process.env.NODE_ENV !== 'production' &&
    operation.operationName === 'subscription'
  ) {
    throw new Error(
      `Received a subscription operation in the httpExchange.
      You are probably trying to create a subscription. Have you added a subscriptionExchange?`,
    );
  }

  return make(({ next, complete }) => {
    const abortController =
      typeof AbortController !== 'undefined'
        ? new AbortController()
        : undefined;

    const { context } = operation;

    const extraOptions =
      typeof context.fetchOptions === 'function'
        ? context.fetchOptions()
        : context.fetchOptions || {};

    const operationName = getOperationName(operation.query);

    const body = {
      query: print(operation.query),
      variables: operation.variables,
    };

    if (operationName !== null) {
      body.operationName = operationName;
    }

    const fetchOptions = {
      method: 'POST',
      headers: {},
      ...extraOptions,
      signal:
        abortController !== undefined ? abortController.signal : undefined,
    };

    const { clone, files } = extractFiles(operation.variables);
    const isFileUpload = !!files.size;

    if (isFileUpload) {
      fetchOptions.body = new FormData();

      fetchOptions.body.append(
        'operations',
        JSON.stringify({
          query: print(operation.query),
          variables: { ...clone },
        }),
      );

      const map = {};
      let i = 0;
      files.forEach(paths => {
        map[++i] = paths.map(path => `variables.${path}`);
      });

      fetchOptions.body.append('map', JSON.stringify(map));

      i = 0;
      files.forEach((paths, file) => {
        fetchOptions.body.append(`${++i}`, file, file.name);
      });
    } else {
      fetchOptions.body = JSON.stringify(body);
      fetchOptions.headers['content-type'] = 'application/json';
    }

    let ended = false;

    Promise.resolve()
      .then(() => (ended ? undefined : executeFetch(operation, fetchOptions)))
      .then(result => {
        if (!ended) {
          ended = true;
          if (result) next(result);
          complete();
        }
      });

    return () => {
      ended = true;
      if (abortController !== undefined) {
        abortController.abort();
      }
    };
  });
};

/** A default exchange for fetching GraphQL requests. */
export default ({ forward }) => {
  const isOperationFetchable = operation => {
    const { operationName } = operation;
    return operationName === 'query' || operationName === 'mutation';
  };

  return ops$ => {
    const sharedOps$ = share(ops$);
    const fetchResults$ = pipe(
      sharedOps$,
      filter(isOperationFetchable),
      mergeMap(operation => {
        const { key } = operation;
        const teardown$ = pipe(
          sharedOps$,
          filter(op => op.operationName === 'teardown' && op.key === key),
        );

        return pipe(
          createFetchSource(
            operation,
            operation.operationName === 'query' &&
              !!operation.context.preferGetMethod,
          ),
          takeUntil(teardown$),
        );
      }),
    );

    const forward$ = pipe(
      sharedOps$,
      filter(op => !isOperationFetchable(op)),
      forward,
    );

    return merge([fetchResults$, forward$]);
  };
};

Hey @kayluhb

Thanks for the update!
There's one in the linked PR as well and it will be available soon as @urql/exchange-multipart-fetch

@JoviDeCroock What do you think about package for refreshTokenExchange ?

Hey @roshangm1

I don't think there's an easy way to abstract something like that (some people will use oAuth, some people use graphql, ...). Might be worth checking if this can be triggered from the retryExchange instead.
Going to close this issue for now and report back when the new exchange is published.

This has been published and can be used, information can be found here: https://github.com/FormidableLabs/urql/tree/master/exchanges/multipart-fetch

Was this page helpful?
0 / 5 - 0 ratings