Skip to content

Commit

Permalink
REST: add support for multipart
Browse files Browse the repository at this point in the history
323842
  • Loading branch information
stephan-merkli committed Apr 21, 2023
1 parent 31983da commit 3411536
Show file tree
Hide file tree
Showing 16 changed files with 1,310 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2010-2023 BSI Business Systems Integration AG.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* BSI Business Systems Integration AG - initial API and implementation
*/
package org.eclipse.scout.rt.rest.jersey.client.multipart;

import static org.eclipse.scout.rt.platform.util.Assertions.assertNotNull;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.ext.MessageBodyWriter;

import org.eclipse.scout.rt.platform.exception.PlatformException;
import org.eclipse.scout.rt.rest.client.IGlobalRestClientConfigurator;
import org.eclipse.scout.rt.rest.client.multipart.MultipartMessage;
import org.eclipse.scout.rt.rest.client.multipart.MultipartPart;

/**
* {@link MessageBodyWriter} for {@link MultipartMessage} used on the client side (sending).
* <p>
* Own implementation due to the lack of multipart support in JAX-RS. This implementation works stream-based, i.e.
* doesn't require persisted files or memory allocation for the parts. It doesn't rely on any additional dependencies.
* <p>
* Expects that the media type contains already a boundary parameter, as added when {@link MultipartMessage#toEntity()}
* is used.
* <p>
* Regarding imports, this class must not be part of the module <code>org.eclipse.scout.rt.rest.jersey.client</code> but
* could reside in <code>org.eclipse.scout.rt.rest</code> too. Because there is no direct access to this class, this
* module is used instead to hide implementation details.
* <p>
* Idea from <a href="https://guntherrotsch.github.io/blog_2021/jaxrs-multipart-client.html">JAX/RS Multipart Client by
* Gunther Rotsch</a>. Scout uses a different approach via direct stream processing instead of working with temporary
* files. {@link StandardCharsets#UTF_8} encoding is used instead of {@link StandardCharsets#US_ASCII}.
*/
public class MultipartMessageBodyWriter implements MessageBodyWriter<MultipartMessage> {

private static final String HTTP_LINE_DELIMITER = "\r\n";

@Override
public boolean isWriteable(Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {
return MultipartMessage.class.isAssignableFrom(type) && mediaType.isCompatible(MediaType.MULTIPART_FORM_DATA_TYPE);
}

@Override
public void writeTo(MultipartMessage t, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream)
throws IOException, WebApplicationException {

String boundary = assertNotNull(mediaType.getParameters().get(MultipartMessage.BOUNDARY_PARAMETER),
"boundary is missing, make sure to set media type via {}", MultipartMessage.class.getSimpleName() + "#toEntity()");

// parts
t.getParts().forEach(part -> writePart(boundary, entityStream, part));

// end boundary
write(entityStream, "--" + boundary + "--" + HTTP_LINE_DELIMITER);
}

/**
* Writes a single part to the entity stream containing
* <ol>
* <li>Start boundary</li>
* <li>Content headers</li>
* <li>Content</li>
* </ol>
* each separated by the HTTP line delimiter and ending with the HTTP line delimiter.
*/
protected void writePart(String boundary, OutputStream entityStream, MultipartPart part) {
try {
// start boundary
write(entityStream, "--" + boundary + HTTP_LINE_DELIMITER);

// headers
for (String contentHeader : getContentHeaders(part)) {
write(entityStream, contentHeader + HTTP_LINE_DELIMITER);
}

write(entityStream, HTTP_LINE_DELIMITER);

// content
try (InputStream contentStream = part.getInputStream()) {
contentStream.transferTo(entityStream);
}

write(entityStream, HTTP_LINE_DELIMITER);
}
catch (IOException e) {
throw new PlatformException("Failed to write part", e);
}
}

/**
* Writes the given string content with {@link StandardCharsets#UTF_8} encoding.
* <p>
* Use for content that only uses 7-bit ASCII anyway (boundary) or part headers including part and filenames. These
* are encoded as UTF-8 and not by using US-ASCII.
*/
protected void write(OutputStream entityStream, String content) throws IOException {
entityStream.write(content.getBytes(StandardCharsets.UTF_8));
}

/**
* Returns the content header for the given parts, which includes 'Content-Disposition' and if available the
* 'Content-Type'.
*/
protected List<String> getContentHeaders(MultipartPart part) {
List<String> headers = new ArrayList<>(2);

String contentDisposition = "Content-Disposition: form-data; name=\"" + part.getPartName() + "\"";
if (part.getFilename() != null) {
contentDisposition += "; filename=\"" + part.getFilename() + "\"";
}

headers.add(contentDisposition);

if (part.getContentType() != null) {
headers.add("Content-Type: " + part.getContentType());
}

return headers;
}

/**
* {@link IGlobalRestClientConfigurator} implementation registering {@link MultipartMessageBodyWriter}.
*/
public static class ScoutMultipartClientConfigurator implements IGlobalRestClientConfigurator {

@Override
public void configure(ClientBuilder clientBuilder) {
clientBuilder.register(MultipartMessageBodyWriter.class);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
package org.eclipse.scout.rt.rest.jersey.client;

import static org.junit.Assert.*;
import static org.junit.Assert.assertEquals;

import java.security.NoSuchAlgorithmException;
import java.util.Set;
Expand All @@ -20,14 +20,14 @@
import javax.ws.rs.client.Client;

import org.eclipse.scout.rt.platform.BEANS;
import org.eclipse.scout.rt.platform.util.CollectionUtility;
import org.eclipse.scout.rt.rest.client.AbstractRestClientHelper;
import org.eclipse.scout.rt.rest.client.AntiCsrfClientFilter;
import org.eclipse.scout.rt.rest.client.HttpHeadersRequestFilter;
import org.eclipse.scout.rt.rest.client.proxy.RestClientProxyFactory;
import org.eclipse.scout.rt.rest.jackson.ObjectMapperResolver;
import org.eclipse.scout.rt.rest.jersey.JerseyTestRestClientHelper;
import org.eclipse.scout.rt.rest.jersey.LanguageAndCorrelationIdRestRequestFilter;
import org.eclipse.scout.rt.rest.jersey.client.multipart.MultipartMessageBodyWriter;
import org.junit.Test;

/**
Expand All @@ -45,12 +45,12 @@ public void testDefaultSslContext() throws NoSuchAlgorithmException {
public void testBuildClient() {
JerseyTestRestClientHelper restClientHelper = BEANS.get(JerseyTestRestClientHelper.class);
Set<Class<?>> actualClasses = restClientHelper.rawClient().getConfiguration().getClasses();
Set<Class<?>> expectedClasses = Set.of(ScoutInvocationBuilderListener.class, ScoutJobExecutorServiceProvider.class);
assertTrue(CollectionUtility.equalsCollection(expectedClasses, actualClasses));
Set<Class<?>> expectedClasses = Set.of(ScoutInvocationBuilderListener.class, ScoutJobExecutorServiceProvider.class, MultipartMessageBodyWriter.class);
assertEquals(expectedClasses, actualClasses);

Set<Class<?>> actualInstances = restClientHelper.rawClient().getConfiguration().getInstances().stream().map(Object::getClass).collect(Collectors.toSet());
Set<Class<?>> expectedInstances = Set.of(ObjectMapperResolver.class, AntiCsrfClientFilter.class, HttpHeadersRequestFilter.class, LanguageAndCorrelationIdRestRequestFilter.class);
assertTrue(CollectionUtility.equalsCollection(expectedInstances, actualInstances));
assertEquals(expectedInstances, actualInstances);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2010-2023 BSI Business Systems Integration AG.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* BSI Business Systems Integration AG - initial API and implementation
*/
package org.eclipse.scout.rt.rest.jersey.client.multipart;

import static org.junit.Assert.*;

import javax.ws.rs.core.MediaType;

import org.eclipse.scout.rt.rest.client.multipart.MultipartMessage;
import org.eclipse.scout.rt.rest.client.multipart.MultipartPart;
import org.junit.Test;

/**
* Test for {@link MultipartMessageBodyWriter}, additional tests indirectly using this class are in
* {@link MultipartRestClientTest}.
*/
public class MultipartMessageBodyWriterTest {

@Test
public void testIsWritable() {
MultipartMessageBodyWriter reader = new MultipartMessageBodyWriter();
assertFalse(reader.isWriteable(String.class, null, null, MediaType.MULTIPART_FORM_DATA_TYPE)); // wrong class
assertFalse(reader.isWriteable(MultipartPart.class, null, null, MediaType.MULTIPART_FORM_DATA_TYPE)); // wrong class
assertFalse(reader.isWriteable(MultipartMessage.class, null, null, MediaType.TEXT_PLAIN_TYPE)); // wrong media type
assertTrue(reader.isWriteable(MultipartMessage.class, null, null, MediaType.MULTIPART_FORM_DATA_TYPE)); // correct
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2010-2020 BSI Business Systems Integration AG.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* BSI Business Systems Integration AG - initial API and implementation
*/
package org.eclipse.scout.rt.rest.jersey.client.multipart;

import static org.junit.Assert.assertEquals;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import org.eclipse.scout.rt.platform.BEANS;
import org.eclipse.scout.rt.platform.BeanMetaData;
import org.eclipse.scout.rt.platform.IBean;
import org.eclipse.scout.rt.platform.util.IOUtility;
import org.eclipse.scout.rt.platform.util.uuid.IUuidProvider;
import org.eclipse.scout.rt.rest.client.multipart.MultipartMessage;
import org.eclipse.scout.rt.rest.client.multipart.MultipartPart;
import org.eclipse.scout.rt.rest.jersey.EchoServletParameters;
import org.eclipse.scout.rt.rest.jersey.JerseyTestApplication;
import org.eclipse.scout.rt.rest.jersey.JerseyTestRestClientHelper;
import org.eclipse.scout.rt.rest.jersey.RestClientTestEchoResponse;
import org.eclipse.scout.rt.testing.platform.BeanTestingHelper;
import org.eclipse.scout.rt.testing.platform.runner.PlatformTestRunner;
import org.eclipse.scout.rt.testing.platform.util.uuid.FixedUuidProvider;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
* Test for {@link MultipartMessageBodyWriter} via {@link JerseyTestApplication}.
*/
@RunWith(PlatformTestRunner.class)
public class MultipartRestClientTest {

private static IBean<?> s_uuidProvider;

/**
* Used in .txt resource serving as expected echo response.
*/
private static final String FIXED_UUID = "3372ccc6-dada-4847-b189-3ff57a81e553";

@BeforeClass
public static void beforeClass() {
// Use a fixed uuid provider to create a known boundary for the multipart message.
s_uuidProvider = BEANS.get(BeanTestingHelper.class).registerBean(new BeanMetaData(IUuidProvider.class, new FixedUuidProvider(UUID.fromString(FIXED_UUID))));

BEANS.get(JerseyTestApplication.class).ensureStarted();
}

@AfterClass
public static void afterClass() {
BEANS.get(BeanTestingHelper.class).unregisterBean(s_uuidProvider);
}

private WebTarget m_target;

@Before
public void before() {
m_target = BEANS.get(JerseyTestRestClientHelper.class).target("echo");
}

@Test
public void testMultipart() {
byte[] plainTextBytes = "lorem ipsum dolor\nsit amet".getBytes(StandardCharsets.UTF_8);
byte[] jsonBytes = "{ \"lorem\": \"ipsum\"}".getBytes(StandardCharsets.UTF_8);
byte[] umlauteBytes = "Äpfel".getBytes(StandardCharsets.UTF_8);

MultipartMessage multiPartMessage = BEANS.get(MultipartMessage.class)
.addPart(MultipartPart.ofFile("plaintext", "text.txt", new ByteArrayInputStream(plainTextBytes)))
.addPart(MultipartPart.ofFile("json", "json.json", new ByteArrayInputStream(jsonBytes)))
.addPart(MultipartPart.ofFile("umlaute-öäü", "äpfel\uD83D\uDE00.txt", new ByteArrayInputStream(umlauteBytes)))
.addPart(MultipartPart.ofField("lorem", "lorem value"))
.addPart(MultipartPart.ofField("ipsum", "ipsum välüe"))
.addPart(MultipartPart.ofField("dol\"or", "dolor\"value"));

RestClientTestEchoResponse response = m_target
.queryParam(EchoServletParameters.STATUS, Response.Status.OK.getStatusCode())
.request()
.accept(MediaType.APPLICATION_JSON)
.post(multiPartMessage.toEntity(), RestClientTestEchoResponse.class);

String contentTypeHeader = response.getReceivedHeaders().get("Content-Type");
assertEquals("multipart/form-data;boundary=" + FIXED_UUID.replace("-", ""), contentTypeHeader);

// file contains \n only, replace by \r\n as the HTTP line delimiter is using \r\n too (except the plain text bytes)
String expectedEchoResponse = IOUtility.readStringUTF8(MultipartRestClientTest.class.getResourceAsStream("MultipartEchoResponse.txt"))
.replaceAll("\n", "\r\n")
.replaceFirst("lorem ipsum dolor\r\nsit amet", "lorem ipsum dolor\nsit amet");

assertEquals(expectedEchoResponse, response.getEcho().getBody());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="plaintext"; filename="text.txt"
Content-Type: text/plain

lorem ipsum dolor
sit amet
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="json"; filename="json.json"
Content-Type: application/json

{ "lorem": "ipsum"}
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="umlaute-öäü"; filename="äpfel😀.txt"
Content-Type: text/plain

Äpfel
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="lorem"

lorem value
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="ipsum"

ipsum välüe
--3372ccc6dada4847b1893ff57a81e553
Content-Disposition: form-data; name="dol"or"

dolor"value
--3372ccc6dada4847b1893ff57a81e553--
Loading

0 comments on commit 3411536

Please sign in to comment.