react-relay-network-modern icon indicating copy to clipboard operation
react-relay-network-modern copied to clipboard

Add File Upload support by spec

Open nodkz opened this issue 7 years ago • 9 comments

Jayden Seric wrote an amazing spec for file upload: https://github.com/jaydenseric/graphql-multipart-request-spec

It has an implementation for most servers express, koa, also used in Apollo and Prisma. Will be nice if somebody takes care about writing a new fileUpload middleware for this package.

nodkz avatar Sep 24 '18 10:09 nodkz

+1 for this feature. It will enhances the interoperability of this library to the further stage.

ScripterSugar avatar Mar 01 '19 10:03 ScripterSugar

I made the middleware that mutates the req to match the spec, if there's any uploadable exists in req.

This middleware is working fine for my current envirionment. I've confirmed that it works well with Reference middleware implementation for the express-server But not sure if it works for any other enviornments. I'm just leaving the middleware code here as reference for someone who looking for the actual implementation of RRNM middleware.

const multipartRequestMiddleware = next => async (req) => {
  const {
    id, operation: { text: query }, variables, uploadables,
  } = req;

  if (uploadables) {
    const graphQLMultipart = new FormData();

    const fileMap = [];

    const writeMapFromFileAndMarkVariable = (searchable, parents) => {
      Object.keys(searchable).forEach((key) => {
        const currentValue = searchable[key];

        if (typeof currentValue === 'object' && (currentValue.constructor === Object || currentValue.constructor === Array)) {
          // Recursive
          writeMapFromFileAndMarkVariable(currentValue, [...parents, key]);
        } else {
          // Consider the non-plain objects inside uplodables as File data.

          fileMap.push({
            operationPath: ['variables', ...parents, key].join('.'),
            file: currentValue,
          });

          // Synchronize variable with uploadables.

          let currentDepthVariable = variables;

          parents.forEach((parent) => {
            if (!currentDepthVariable[parent]) {
              currentDepthVariable[parent] = {};
            }

            currentDepthVariable = currentDepthVariable[parent];
          });

          currentDepthVariable[key] = null; // Spec: Value of file key should be null.
        }
      });
    };

    writeMapFromFileAndMarkVariable(uploadables, []);

    const operations = {
      id,
      query,
      variables,
    };

    graphQLMultipart.append('operations', JSON.stringify(operations));
    graphQLMultipart.append('map', JSON.stringify(fileMap.reduce((reducedMap, value, index) => ({ ...reducedMap, [index]: [value.operationPath] }), {})));

    fileMap.forEach((mapValue, index) => {
      graphQLMultipart.append(index, mapValue.file);
    });

    req.fetchOpts.body = graphQLMultipart;
  }

  const res = await next(req);

  return res;
};

The middleware deep-merges and mutates value of file key inside variable to null, matching the specification. Here's example usage.

const variables = {
  input: {
    name: 'John doe',
    contact: '000-0000-0000',
  },
};

const uploadables = {
  input: {
    resume: files[0],
  },
  fieldNotExist: {
    exampleFile: files[1],
  },
};

/* variables in actual request will looks like:

variables: {
  input: {
    name: 'John doe',
    contact: '000-0000-0000',
    resume: null,
  },
  fieldNotExist: {
    exmapleFile: null
  }
}

*/

commitMutation(
  environment,
  {
    applyMutation,
    variables,
    uploadables,
  },
);

I'm also not sure this will works if there's array of files, as I did consider that case but not yet tested. Will appreciate any feedbacks :)

ScripterSugar avatar Mar 01 '19 12:03 ScripterSugar

This has served me well:

// uploadMiddleware.js
// @flow

import RelayRequestBatch from 'react-relay-network-modern/lib/RelayRequestBatch';
import { type Middleware } from 'react-relay-network-modern/lib/definition';
import { extractFiles } from 'extract-files';

export default function uploadMiddleware(): Middleware {
  return (next) => async (req) => {
    if (req instanceof RelayRequestBatch) {
      throw new Error('RelayRequestBatch is not supported');
    }

    const operations = {
      query: req.operation.text,
      variables: req.variables,
    };

    const { clone: extractedOperations, files } = extractFiles(operations);

    if (files.size) {
      const formData = new FormData();

      formData.append('operations', JSON.stringify(extractedOperations));

      const pathMap = {};
      let i = 0;
      files.forEach((paths) => {
        pathMap[++i] = paths;
      });
      formData.append('map', JSON.stringify(pathMap));

      i = 0;
      files.forEach((paths, file) => {
        formData.append(++i, file, file.name);
      });

      req.fetchOpts.method = 'POST';
      req.fetchOpts.body = formData;
    }

    const res = await next(req);

    return res;
  };
}

AlicanC avatar Jul 20 '19 12:07 AlicanC

@AlicanC would you like open a pull request with this new middleare?

nodkz avatar Jul 22 '19 15:07 nodkz

what about headers ?

i have an issue bcs my php server receives empty body:

------WebKitFormBoundarylH56I1IGAU5mDtsx
Content-Disposition: form-data; name="operations"

{"variables":{"merchantId":"7f563392ca6645deb2aaac28606d1b8d","data":{"virtual":true,"description":"Merchant description","categoryId":"94C02F5CC1C44D8186DFF63A17D18113","share":3,"active":true,"name":"Merchant name"}},"query":"mutation updateMerchantMutation( $merchantId: ID! $data: MerchantInput!) { merchant: updateMerchant(id: $merchantId, data: $data) { id name description virtual category { id name } shops { id address { location { lng lat } country street number city zip } } image {id} couponTemplates { id count value validUntil } }}","id":"updateMerchantMutation"}
------WebKitFormBoundarylH56I1IGAU5mDtsx
Content-Disposition: form-data; name="map"

{ "0": [ "variables.data.image" ] }
------WebKitFormBoundarylH56I1IGAU5mDtsx
Content-Disposition: form-data; name="0"; filename="image.png"
Content-Type: image/png

‰PNG


IHDR‡ŽéŒ IDATx^ìtUْþWãww
î.»»»»»wišÆà@ܓ«ÿµ÷…î~3ofÞê†ùÏLÎ}ë­¤CîÎ=uöþNÕWU_©,Å
Yùú2ŸØ¿ý§òU±€bÅË*«GF+‘ÑrÓû»
Àü-s*oV, XàÏPFيü0¨LwNZ±Ú¢$kÖ,ŠóÃL­,¬X ýY@eLzó;“þ._¹bŊ~¤€ù‘ÖUÖV,Î- L:ßÊå+ø‘PæGZWY[±@:·€0é|(—¯XàGZ@˜i]emÅéÜ

but server says:

"Request is expected to provide parsed body for \"multipart/form-data\" requests but got empty array

Postman example working perfectly so,its not problem on the server side i hope

serusko avatar Nov 27 '19 16:11 serusko

for future generations, u can save few hours with delete req.fetchOpts.headers['Content-Type'];

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

that boundary part is crucial and u cannot set it properly so just delete this header

so i highly recoment to put it right after req.fetchOpts.body = graphQLMultipart; and make sure your middleware is the last one or nothing behind could change this header

serusko avatar Nov 27 '19 17:11 serusko

@nodkz @AlicanC If this middleware was somehow tested, then probably later it was broken. Might be that I am wrong, could you please confirm if it working for you? do you have an example of the mutation (below mine)?

As nowadays, if (files.size) { returns always 0.

https://github.com/relay-tools/react-relay-network-modern/blob/bf0b16f7a008bdaf72e97a92eeddf9ee4d2d7a38/src/middlewares/upload.js#L19

The version fro @jaydenseric looks different, mainly we need to pass req.operation into extractFiles to get access to uploadables.

https://github.com/jaydenseric/graphql-react/blob/b86c916ad535cef9c5594515be556663779d85a1/fetchOptionsGraphQL.mjs#L31

// example

      commitMutation(environment, {
        mutation: graphql`
          mutation trial1Mutation($images: [Upload!]!) {
            uploadImages(images: $images)
          }
        `,
        variables: {
          images: [],
        },
        uploadables: {
          'variables.images': acceptedFiles,
        },
        onCompleted: ({uploadImages}) => {
          console.log('complete ===>', uploadImages);
        },
        onError: (e) => {
          console.log('error ===>', e);
        },
      });

artola avatar Dec 21 '21 13:12 artola

I cannot confirm. Right now I don't use Relay in my projects. Moreover I don't use file uploading via graphql. And I recommend to use S3 signed-urls - we just give user urls for file uploading via graphql and users upload directly their files to object storage.

Anyway if somebody figure out this problem and open Pull Request then I'll gladly accept it.

nodkz avatar Dec 21 '21 13:12 nodkz

I cannot confirm. Right now I don't use Relay in my projects. Moreover I don't use file uploading via graphql. And I recommend to use S3 signed-urls - we just give user urls for file uploading via graphql and users upload directly their files to object storage.

Anyway if somebody figure out this problem and open Pull Request then I'll gladly accept it.

@nodkz I am re-checking. I used uploadables config of the mutation as shown in the docs, but I see that this implementation uses variables only and also works.

It seems simpler, and even makes more sense. I will try to recheck with some of the usual Relay contributors. Which is the right approach? Is one of the legacy or better than the other?

CC @kassens @josephsavona @sibelius

Here my code refactored:

commitMutation(getEnvironment(), {
        mutation: graphql`
          mutation trial1Mutation($images: [Upload!]!) {
            uploadImages(images: $images)
          }
        `,
        variables: {
          images: acceptedFiles,
        },
        onCompleted: ({uploadImages}) => {
          console.log('complete ===>', uploadImages);
        },
        onError: (e) => {
          console.log('error ===>', e);
        },
      });

artola avatar Dec 21 '21 15:12 artola