2

SITUATION:

Submitting a multipart form request from Node.js (via node core HTTPS module) to a spring-boot Java API. The API requires two form-data elements:

"route"
"files"

FULL ERROR: Exception processed - Main Exception: org.springframework.web.multipart.MultipartException: Could not parse multipart servlet request; nested exception is org.apache.commons.fileupload.FileUploadException: Stream ended unexpectedly

REQUEST HEADERS:

{"Accept":"*/*",
  "cache-control":"no-cache",
  "Content-Type":"multipart/form-data; boundary=2baac014-7974-49dd-ae87-7ce56c36c9e7",
  "Content-Length":7621}

FORM-DATA BEING WRITTEN (all written as binary):

Content-Type: multipart/form-data; boundary=2baac014-7974-49dd-ae87-7ce56c36c9e7

--2baac014-7974-49dd-ae87-7ce56c36c9e7

Content-Disposition:form-data; name="route"

...our route object

--2baac014-7974-49dd-ae87-7ce56c36c9e7
Content-Disposition:form-data; name="files"; filename="somefile.xlsx"
Content-Type:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

...excel file contents

--2baac014-7974-49dd-ae87-7ce56c36c9e7--

NODE CODE:

let mdtHttpMultipart = (options, data = reqParam('data'), cb) => {
  const boundaryUuid = getUuid()
    , baseHeaders = {
        'Accept': '*/*',
        'cache-control': 'no-cache'
      }
    , composedHeaders = Object.assign({}, baseHeaders, options.headers)
    ;

  options.path = checkPath(options.path);

  let composedOptions = Object.assign({}, {
    'host': getEdiHost(),
    'path': buildPathFromObject(options.path, options.urlParams),
    'method': options.method || 'GET',
    'headers': composedHeaders,
    'rejectUnauthorized': false
  });


  composedOptions.headers['Content-Type'] = `multipart/form-data; boundary=${boundaryUuid}`;

  let multipartChunks = [];
  let dumbTotal = 0;

  let writePart = (_, encType = 'binary', skip = false) => {
    if (!_) { return; }

    let buf = Buffer.from(_, encType);
    if (!skip) {dumbTotal += Buffer.byteLength(buf, encType);}
    multipartChunks.push(buf);
  };

  writePart(`Content-Type: multipart/form-data; boundary=${boundaryUuid}\r\n\r\n`, 'binary', true)
  writePart(`--${boundaryUuid}\r\n`)
  writePart(`Content-Disposition:form-data; name="route"\r\n`)
  writePart(JSON.stringify(data[0]) + '\r\n')
  writePart(`--${boundaryUuid}\r\n`)
  writePart(`Content-Disposition:form-data; name="files"; filename="${data[1].name}"\r\n`)
  writePart(`Content-Type:${data[1].contentType}\r\n`)
  writePart(data[1].contents + '\r\n')
  writePart(`\r\n--${boundaryUuid}--\r\n`);

  let multipartBuffer = Buffer.concat(multipartChunks);

  composedOptions.headers['Content-Length'] = dumbTotal;
  let request = https.request(composedOptions);

  // on nextTick write multipart to request
  process.nextTick(() => {
    request.write(multipartBuffer, 'binary');
    request.end();
  });

  // handle response
  request.on('response', (httpRequestResponse) => {
    let chunks = []
      , errObject = handleHttpStatusCodes(httpRequestResponse);
    ;

    if (errObject !== null) {
      return cb(errObject, null);
    }

    httpRequestResponse.on('data', (chunk) => { chunks.push(chunk); });
    httpRequestResponse.on('end', () => {
      let responseString = Buffer.concat(chunks).toString()
        ;

      return cb(null, JSON.parse(responseString));
    });

  });

  request.on('error', (err) => cb(err));
};

We cannot see any reason for the 500 to be thrown based on the spec. Tons of tinkering around with the format here but we have yet to achieve the result correctly.

SIDE NOTE: It works for us using POSTMAN, just can't get it to work using our our own application server (where we actually build the excel file).

Any help would be appreciated even if just ideas to try.

4
  • Probably not the problem, but the first time you call writePart() you're adding the Content-Type header which is already added for you when you specify composedOptions.headers['Content-Type'] = .... You also do not need line breaks around the final --${boundaryUuid}--. Commented Aug 26, 2016 at 20:29
  • Went ahead and made those changes, still same result as expected. Commented Aug 26, 2016 at 20:31
  • You sure about the binary encoding? That's an alias for latin1. See if base64 encoding everything makes any difference? Commented Aug 26, 2016 at 20:39
  • So the reason behind needing binary is due to the excel file contents. We actually are receiving that file from a seperate micro-service that builds it in memory and posts it to a separate API, where it transmits it as binary. Doing a single multipart form request with that file (as binary) works for us, but adding the extra JSON form data object ('route' in this case') cases the same server to give us back that 500. Commented Aug 26, 2016 at 20:52

2 Answers 2

2

Try this:

let mdtHttpMultipart = (options, data = reqParam('data'), cb) => {
  const boundaryUuid = getUuid()
    , baseHeaders = {
        'Accept': '*/*',
        'cache-control': 'no-cache'
      }
    , composedHeaders = Object.assign({}, baseHeaders, options.headers)
    ;

  let file = data[1]
  let xlsx = file.contents

  options.path = checkPath(options.path);

  let composedOptions = Object.assign({}, {
    'host': getEdiHost(),
    'path': buildPathFromObject(options.path, options.urlParams),
    'method': options.method || 'GET',
    'headers': composedHeaders,
    'rejectUnauthorized': false
  });

  let header = Buffer.from(`--${boundaryUuid}
    Content-Disposition: form-data; name="route"

    ${JSON.stringify(data[0])})
    --${boundaryUuid}
    Content-Disposition: form-data; name="files"; filename="${file.name}"
    Content-Type: ${file.contentType}

  `.replace(/\r?\n */gm, '\r\n'))
  let footer = Buffer.from(`\r\n--${boundaryUuid}--`)
  let length = header.length + xlsx.length + footer.length
  let body = Buffer.concat([header, xlsx, footer], length)

  composedOptions.headers['Content-Length'] = length;
  composedOptions.headers['Content-Type'] = `multipart/form-data; boundary=${boundaryUuid}`;

  let request = https.request(composedOptions);

  // handle response
  request.on('response', (httpRequestResponse) => {
    let chunks = []
      , errObject = handleHttpStatusCodes(httpRequestResponse);
    ;

    if (errObject !== null) {
      return cb(errObject, null);
    }

    httpRequestResponse.on('data', (chunk) => { chunks.push(chunk); });
    httpRequestResponse.on('end', () => {
      let responseString = Buffer.concat(chunks).toString()
        ;

      return cb(null, JSON.parse(responseString));
    });

  });

  request.on('error', (err) => cb(err));

  // write multipart to request
  request.end(body);
};
Sign up to request clarification or add additional context in comments.

Comments

0

Is it that you're not calling request.end() anywhere?

The (very general) form for sending a request with a body is https.request(opts).end(body).

Also, you could just call request.write(buf) every time you want to send data instead of accumulating into one giant buffer (and you shouldn't need to do it on nextTick) (EDIT: as OP points out in the comments, this will prevent setting Content-Length, so perhaps keep as is)

1 Comment

I added the request.end() after we write the buf. We previously had that code writing each part one at a time, we just changed it to try and calculate the content-length before sending the request as we thought that might be the issue but it doesn't appear to be. Still same error after adding request.end(). I'll modify my code example above. Thanks though..

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.