Skip to content

Commit

Permalink
Add MultipartFile and documentation for Multipart (#4262)
Browse files Browse the repository at this point in the history
Motivation:

If a file is uploaded to a server using an annotated service,
the actual filename specified in the Content-Disposition header is lost
because a random file is created to avoid duplicate file names.

Modifications:

- Add `MultipartFile` to get the actual filename of a `BodyPart`.
- Add documentation for the usage of `Multipart`
  - How to encode and decode `Multipart` from and to `HttpRequest`
  - How to bind `multipart/form-data` in annotated services

Result:

You can now use `MultipartFile` to get the actual filename of an
uploaded file through `multipart/form-data`.
```java
@consumes(MediaTypeNames.MULTIPART_FORM_DATA)
@post("/upload")
public HttpResponse upload(@param MultipartFile multipartFile) {
  // The name parameter of the "content-disposition" header
  String name = multipartFile.name();
  // The filename parameter of the "content-disposition" header
  String filename = multipartFile.filename();
  // The file that stores the part content.
  File file = multipartFile.file();
  ...
}
```
  • Loading branch information
ikhoon committed Jun 14, 2022
1 parent c58b1f2 commit 8bc6ca1
Show file tree
Hide file tree
Showing 8 changed files with 446 additions and 85 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2022 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.multipart;

import java.nio.file.Path;
import java.util.Objects;

import com.google.common.base.MoreObjects;

final class DefaultMultipartFile implements MultipartFile {

private final String name;
private final String filename;
private final Path path;

DefaultMultipartFile(String name, String filename, Path path) {
this.name = name;
this.filename = filename;
this.path = path;
}

@Override
public String name() {
return name;
}

@Override
public String filename() {
return filename;
}

@Override
public Path path() {
return path;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}

if (!(o instanceof MultipartFile)) {
return false;
}

final MultipartFile that = (MultipartFile) o;
return name.equals(that.name()) &&
filename.equals(that.filename()) &&
path.equals(that.path());
}

@Override
public int hashCode() {
return Objects.hash(name, filename, path);
}

@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("name", name)
.add("filename", filename)
.add("path", path)
.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2022 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.multipart;

import static java.util.Objects.requireNonNull;

import java.io.File;
import java.nio.file.Path;

/**
* A file uploaded from a {@link Multipart} request.
*
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7578#section-4.2">
* Content-Disposition Header Field for Each Part</a>
*/
public interface MultipartFile {

/**
* Creates a new {@link MultipartFile}.
* @param name the name parameter of the {@code "content-disposition"}
* @param filename the filename parameter of the {@code "content-disposition"}
* header.
* @param file the file that stores the {@link BodyPart#content()}.
*/
static MultipartFile of(String name, String filename, File file) {
requireNonNull(name, "name");
requireNonNull(filename, "filename");
requireNonNull(file, "file");
return of(name, filename, file.toPath());
}

/**
* Creates a new {@link MultipartFile}.
* @param name the name parameter of the {@code "content-disposition"}
* @param filename the filename parameter of the {@code "content-disposition"}
* header.
* @param path the path that stores the {@link BodyPart#content()}.
*/
static MultipartFile of(String name, String filename, Path path) {
requireNonNull(name, "name");
requireNonNull(filename, "filename");
requireNonNull(path, "path");
return new DefaultMultipartFile(name, filename, path);
}

/**
* Returns the {@code name} parameter of the {@code "content-disposition"} header.
* @see BodyPart#name()
*/
String name();

/**
* Returns the {@code filename} parameter of the {@code "content-disposition"} header.
* @see BodyPart#filename()
*/
String filename();

/**
* Returns the file that stores the {@link BodyPart#content()}.
*/
default File file() {
return path().toFile();
}

/**
* Returns the path that stores the {@link BodyPart#content()}.
*/
Path path();
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ static String findName(Header header, Object nameRetrievalTarget) {
return toHeaderName(getName(nameRetrievalTarget));
}

private static String getName(Object element) {
/**
* Returns the name of the {@link Parameter} or the {@link Field}.
* @param element either {@link Parameter} or {@link Field}
*/
static String getName(Object element) {
if (element instanceof Parameter) {
final Parameter parameter = (Parameter) element;
if (!parameter.isNamePresent()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.findName;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.getName;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedServiceFactory.findDescription;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedServiceTypeUtil.stringToType;
import static com.linecorp.armeria.internal.server.annotation.DefaultValues.getSpecifiedValue;
Expand Down Expand Up @@ -61,6 +62,7 @@
import com.google.common.base.MoreObjects;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;

import com.linecorp.armeria.common.AggregatedHttpRequest;
import com.linecorp.armeria.common.Cookie;
Expand All @@ -75,6 +77,7 @@
import com.linecorp.armeria.common.RequestHeaders;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.multipart.Multipart;
import com.linecorp.armeria.common.multipart.MultipartFile;
import com.linecorp.armeria.common.util.Exceptions;
import com.linecorp.armeria.internal.server.annotation.AnnotatedBeanFactoryRegistry.BeanFactoryId;
import com.linecorp.armeria.server.ServiceRequestContext;
Expand Down Expand Up @@ -427,7 +430,7 @@ private static AnnotatedValueResolver of(AnnotatedElement annotatedElement,
final Param param = annotatedElement.getAnnotation(Param.class);
if (param != null) {
final String name = findName(param, typeElement);
if (type == File.class || type == Path.class) {
if (type == File.class || type == Path.class || type == MultipartFile.class) {
return ofFileParam(name, annotatedElement, typeElement, type, description);
}
if (pathParams.contains(name)) {
Expand Down Expand Up @@ -659,6 +662,16 @@ private static AnnotatedValueResolver ofInjectableTypes0(AnnotatedElement annota
.build();
}

if (actual == MultipartFile.class) {
return new Builder(annotatedElement, type)
.resolver((unused, ctx) -> {
final String filename = getName(annotatedElement);
return Iterables.getFirst(ctx.aggregatedMultipart().files().get(filename), null);
})
.aggregation(AggregationStrategy.ALWAYS)
.build();
}

if (actual == Cookies.class) {
return new Builder(annotatedElement, type)
.resolver((unused, ctx) -> {
Expand Down Expand Up @@ -788,14 +801,18 @@ private static BiFunction<AnnotatedValueResolver, ResolverContext, Object> fileR
if (fileAggregatedMultipart == null) {
return resolver.defaultOrException();
}
final Function<? super Path, Object> mapper;
if (resolver.elementType() == File.class) {
mapper = Path::toFile;
} else {
final Function<? super MultipartFile, Object> mapper;
final Class<?> elementType = resolver.elementType();
if (elementType == File.class) {
mapper = MultipartFile::file;
} else if (elementType == MultipartFile.class) {
mapper = Function.identity();
} else {
assert elementType == Path.class;
mapper = multipartFile -> multipartFile.file().toPath();
}
final String name = resolver.httpElementName();
final List<Path> values = fileAggregatedMultipart.files().get(name);
final List<MultipartFile> values = fileAggregatedMultipart.files().get(name);
if (!resolver.hasContainer()) {
if (values != null && !values.isEmpty()) {
return mapper.apply(values.get(0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,20 @@
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Maps;

import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.multipart.Multipart;
import com.linecorp.armeria.common.multipart.MultipartFile;
import com.linecorp.armeria.server.ServiceRequestContext;

import io.netty.channel.EventLoop;

final class FileAggregatedMultipart {
private final ListMultimap<String, String> params;
private final ListMultimap<String, Path> files;
private final ListMultimap<String, MultipartFile> files;

private FileAggregatedMultipart(ListMultimap<String, String> params,
ListMultimap<String, Path> files) {
ListMultimap<String, MultipartFile> files) {
this.params = params;
this.files = files;
}
Expand All @@ -48,43 +52,48 @@ ListMultimap<String, String> params() {
return params;
}

ListMultimap<String, Path> files() {
ListMultimap<String, MultipartFile> files() {
return files;
}

static CompletableFuture<FileAggregatedMultipart> aggregateMultipart(ServiceRequestContext ctx,
HttpRequest req) {
final Path multipartUploadsLocation = ctx.config().multipartUploadsLocation();
final Path destination = ctx.config().multipartUploadsLocation();
return Multipart.from(req).collect(bodyPart -> {
if (bodyPart.filename() != null) {
final ScheduledExecutorService blockingExecutorService =
ctx.blockingTaskExecutor().withoutContext();
return resolveTmpFile(multipartUploadsLocation.resolve("incomplete"),
blockingExecutorService)
.thenComposeAsync(
path -> bodyPart
.writeTo(path)
.thenCompose(ignore -> moveFile(
path,
multipartUploadsLocation.resolve("complete"),
blockingExecutorService))
.thenApply(completePath -> Maps.<String, Object>immutableEntry(
bodyPart.name(), completePath)),
ctx.eventLoop());
final String name = bodyPart.name();
assert name != null;
final String filename = bodyPart.filename();
final EventLoop eventLoop = ctx.eventLoop();

if (filename != null) {
final Path incompleteDir = destination.resolve("incomplete");
final ScheduledExecutorService executor = ctx.blockingTaskExecutor().withoutContext();

return resolveTmpFile(incompleteDir, filename, executor).thenCompose(path -> {
return bodyPart.writeTo(path, eventLoop, executor).thenCompose(ignore -> {
final Path completeDir = destination.resolve("complete");
return moveFile(path, completeDir, executor);
}).thenApply(completePath -> MultipartFile.of(name, filename, completePath.toFile()));
});
}
return bodyPart.aggregate()
.thenApply(aggregatedBodyPart -> Maps.<String, Object>immutableEntry(
bodyPart.name(), aggregatedBodyPart.contentUtf8()));
}).thenApply(result -> {

return bodyPart.aggregateWithPooledObjects(eventLoop, ctx.alloc()).thenApply(aggregatedBodyPart -> {
try (HttpData httpData = aggregatedBodyPart.content()) {
return Maps.<String, Object>immutableEntry(name, httpData.toStringUtf8());
}
});
}).thenApply(results -> {
final ImmutableListMultimap.Builder<String, String> params = ImmutableListMultimap.builder();
final ImmutableListMultimap.Builder<String, Path> files =
final ImmutableListMultimap.Builder<String, MultipartFile> files =
ImmutableListMultimap.builder();
for (Entry<String, Object> entry : result) {
final Object value = entry.getValue();
if (value instanceof Path) {
files.put(entry.getKey(), (Path) value);
for (Object result : results) {
if (result instanceof MultipartFile) {
final MultipartFile multipartFile = (MultipartFile) result;
files.put(multipartFile.name(), multipartFile);
} else {
params.put(entry.getKey(), (String) value);
@SuppressWarnings("unchecked")
final Entry<String, String> entry = (Entry<String, String>) result;
params.put(entry.getKey(), entry.getValue());
}
}
return new FileAggregatedMultipart(params.build(), files.build());
Expand All @@ -106,11 +115,12 @@ private static CompletableFuture<Path> moveFile(Path file, Path targetDirectory,
}

private static CompletableFuture<Path> resolveTmpFile(Path directory,
String filename,
ExecutorService blockingExecutorService) {
return CompletableFuture.supplyAsync(() -> {
try {
Files.createDirectories(directory);
return Files.createTempFile(directory, null, ".multipart");
return Files.createTempFile(directory, null, '-' + filename);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
Expand Down
Loading

0 comments on commit 8bc6ca1

Please sign in to comment.