redux-toolkit icon indicating copy to clipboard operation
redux-toolkit copied to clipboard

RTK Query: Difficulties wrangling `content-type: 'multipart/form-data'` headers with form data bodies

Open komali2 opened this issue 3 years ago • 6 comments

I have an api definition based mostly off the examples in the docs, in full below. I have an endpoint I want to add that involves uploading a file to the server. In Axios, the api call looks like this:

      const data = new FormData();
      data.append('image', file);
      await axios.post(URLS.IMAGE(), data, {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      });

This works, despite me not defining a boundary. I don't know why. edit: It seems axios automatically adds form data boundary: multipart/form-data; boundary=---------------------------35214638641989039512767343688

If I attempt the same thing with an RTK mutation:

    uploadImage: builder.mutation<
      string,
      { payload: FormData}
    >({
      query: ({ payload }) => {
        return {
        url: URLS.IMAGE(),
        method: 'POST',
        body: payload,
        headers: {
          'Content-Type': 'multipart/form-data;',
        },
        };},
    }),

I get this error from Django: Multipart form parse error - Invalid boundary in multipart: None.

While I haven't guaranteed this issue isn't due to Django itself, since it works with axios, and not RTK's baseQuery, I feel I'm doing something wrong with RTK.

I tried removing the headers, and they set a default content-type of application/json, as per the docs: https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#using-fetchbasequery . This obviously causes an error from the server.

I tried adding a boundary myself that I found online, a big string like webkit---easdfajf but that was unrecognized as correct by Django - it didn't parse out the data correctly, it seems, which I believe means I guessed the wrong boundary.

Many solutions online suggest deleting the content-type header and allowing fetch to set it itself when it detects FormData in the body, however this doesn't seem to be an option with RTK baseQuery.

A potential solution could be to use a queryFn with axios, but I want to avoid that so I can remove axios entirely from my project, and also so I don't have to handle token setting in some other spot (plus token refresh) arbitrarily.

I could also use queryFn with default fetch api for this one endpoint, but, the issue with token refresh remains, I don't want to have to re-handle it just for this one endpoint if possible.

Am I missing something in configuration?

Api definition:

const baseQuery = fetchBaseQuery({
  baseUrl: API_URL,
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as RootState).user.token;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }

    return headers;
  },
});
const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  await mutex.waitForUnlock();
  const moddedArgs = args;
  if (args?.body) {
    moddedArgs.body = snakeCase(removeNullish(args.body));
  }

  let result = await baseQuery(args, api, extraOptions);
  if (
    result.error &&
    result.error.status === 401 &&
    result.error.data?.code !== 'no_active_account' &&
    result.error.data?.code !== 'not_authenticated'
  ) {
    if (!mutex.isLocked()) {
      const release = await mutex.acquire();
      try {
        const refreshResult = await baseQuery(
          {
            url: URLS.REFRESH_TOKEN,
            method: 'POST',
            body: {
              refresh: (api.getState() as RootState).user.refreshToken,
            },
          },
          api,
          extraOptions
        );
        if (refreshResult?.data?.access) {
          api.dispatch(setToken(refreshResult.data.access as string));

          result = await baseQuery(
            moddedArgs as string | FetchArgs,
            api,
            extraOptions
          );
        } else {
          api.dispatch(setToken(null));
          api.dispatch(setRefreshToken(null));
        }
      } finally {
        release();
      }
    } else {
      await mutex.waitForUnlock();
      result = await baseQuery(
        moddedArgs as string | FetchArgs,
        api,
        extraOptions
      );
    }
  }
  if (result.data) {
    result.data = camelize(result.data);
  }
  if (result.error) {
    result.error = camelize(result.error);
  }
  return result;
};

// Our endpoints are all injected
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: baseQueryWithReauth,
  endpoints: (builder) => ({}),
  tagTypes: [
...
  ],
});

edit3: Setting Content-Type to undefined in headers merely results in the Content-Type being set to application/json

komali2 avatar Sep 05 '22 11:09 komali2

Before we go deeper - fetchBaseQuery just uses fetch internally. Do you get this to work with fetch?

phryneas avatar Sep 05 '22 13:09 phryneas

Before we go deeper - fetchBaseQuery just uses fetch internally. Do you get this to work with fetch?

Yes, and without setting content type header, fetch automatically set the header correctly:

multipart/form-data; boundary=----WebKitFormBoundaryY2zumZlB8OHh3bBN

Here's what the queryFn looks like:

    uploadLogo: builder.mutation<
      string,
      { payload: FormData }
    >({
      queryFn: async ({ payload }) => {
        const result = await fetch(URLS.IMAGE(), {
          method: 'POST',
          body: payload,
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        })
        const data = await result.json();
        return { data };
      },
    }),

komali2 avatar Sep 06 '22 01:09 komali2

This is the code in the current fetchBaseQuery that should only set content-type for JSON content if it is a plain object:

    // Only set the content-type to json if appropriate. Will not be true for FormData, ArrayBuffer, Blob, etc.
    const isJsonifiable = (body: any) =>
      typeof body === 'object' &&
      (isPlainObject(body) ||
        Array.isArray(body) ||
        typeof body.toJSON === 'function')

    if (!config.headers.has('content-type') && isJsonifiable(body)) {
      config.headers.set('content-type', jsonContentType)
    }

Are you sure that you are on the latest version of RTK? I remember that was changed a while ago.

phryneas avatar Sep 06 '22 03:09 phryneas

Ah, that's a good point, I should have checked that.

While our package.json specifies an older version, it appears, according to yarn.lock, that we have the latest version:

"@reduxjs/toolkit@^1.7.2":
  version "1.8.5"
  resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.5.tgz#c14bece03ee08be88467f22dc0ecf9cf875527cd"
  integrity sha512-f4D5EXO7A7Xq35T0zRbWq5kJQyXzzscnHKmjnu2+37B3rwHU6mX9PYlbfXdnxcY6P/7zfmjhgan0Z+yuOfeBmA==
  dependencies:
    immer "^9.0.7"
    redux "^4.1.2"
    redux-thunk "^2.4.1"
    reselect "^4.1.5"

Is my reading of our yarn.lock correct?

komali2 avatar Sep 06 '22 10:09 komali2

But, it sounds like thus something, somewhere, in my config must be setting a content-type header! I'll hunt again

Edit: I searched everywhere, with full project greps as well, and found nothing! very weird. I'll try stepping through with a debugger and see when the header gets set

Edit: as an afterthought, I made sure isJsonifiable returns false for my payload, and it does.

komali2 avatar Sep 06 '22 11:09 komali2

Can you try the current 1.9 alpha? I'm at this point not sure if this could be a new change.

phryneas avatar Sep 06 '22 13:09 phryneas

I'm on version 1.9.1, and I'm trying to send an image with a mutation request, but the "content-type" in the request header is always set to "application/json", If I do set it manually in the mutation to "multipart/form-data", it doesn't set the boundaries, and my request is not successful.

Shaker-Pelcro avatar Jan 18 '23 20:01 Shaker-Pelcro

@Shaker-Pelcro does the exact same call with fetch work? RTK Query just calls fetch in the end.

phryneas avatar Jan 18 '23 20:01 phryneas

@phryneas Yes, it works fine with fetch and the content-type is set properly without me setting it manually at all.

Shaker-Pelcro avatar Jan 18 '23 20:01 Shaker-Pelcro

@Shaker-Pelcro the question is less about the content-type, but about the boundaries. What are you passing in there?

phryneas avatar Jan 18 '23 20:01 phryneas

I don't see any repros attached to this issue, so I'm going to close it. If someone can provide a repo or CodeSandbox that shows this happening, we might be able to take a look.

markerikson avatar Jan 28 '23 19:01 markerikson

You can modify your RTK api as : query: ({ jobId, formData }) => ({ url: your url, method: "POST", body: formData, formData: true, }),

poojavirgo avatar Jul 04 '23 09:07 poojavirgo

Just make sure you are not setting any "Content-type" header for the POST request. This fixed it for me.

MayorErnest avatar Aug 07 '23 17:08 MayorErnest

add formData : true

poojavirgo avatar Aug 08 '23 12:08 poojavirgo

@MayorErnest thank you, it helped. I don't know why it is almost everywhere stated to use 'Content-Type': 'multipart/form-data' 😣

Iluxmas avatar Nov 21 '23 14:11 Iluxmas

Thank you @poojavirgo and @MayorErnest for providing the solution. It was really helpful.

sthautsab avatar Feb 18 '24 06:02 sthautsab

just remove the header for POST req

Just make sure you are not setting any "Content-type" header for the POST request. This fixed it for me.

Thank you it worked for me don't know why there are a lot of people adding 'Content-Type': 'multipart/form-data' in header :(

meamrit777 avatar May 05 '24 08:05 meamrit777