MultipartFileBuilderImpl.java

/*
 * Copyright 2020 the original author or authors.
 *
 * Licensed 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
 *
 *      http://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 org.bremersee.web.reactive.multipart;

import eu.maxschuster.dataurl.DataUrl;
import eu.maxschuster.dataurl.DataUrlSerializer;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.bremersee.exception.ServiceException;
import org.bremersee.web.multipart.FileAwareMultipartFile;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.lang.NonNull;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MimeType;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;

/**
 * The multipart file builder implementation.
 *
 * @author Christian Bremer
 */
@Slf4j
public class MultipartFileBuilderImpl implements MultipartFileBuilder {

  private final File tmpDir;

  /**
   * Instantiates a new multipart file builder.
   */
  public MultipartFileBuilderImpl() {
    this((File) null);
  }

  /**
   * Instantiates a new multipart file builder.
   *
   * @param tmpDir the tmp dir
   */
  public MultipartFileBuilderImpl(String tmpDir) {
    this(StringUtils.hasText(tmpDir) ? new File(tmpDir) : null);
  }

  /**
   * Instantiates a new multipart file builder.
   *
   * @param tmpDir the tmp dir
   */
  public MultipartFileBuilderImpl(File tmpDir) {
    this.tmpDir = tmpDir != null && tmpDir.isDirectory()
        ? tmpDir
        : new File(System.getProperty("java.io.tmpdir"));
  }

  private Mono<MultipartFile> build(FilePart filePart) {
    if (filePart == null) {
      return Mono.just(FileAwareMultipartFile.empty());
    }
    File file = newTmpFile();
    return filePart.transferTo(file)
        .then(Mono.just(new FileAwareMultipartFile(
            file,
            filePart.name(),
            filePart.filename(),
            Optional.of(filePart)
                .map(part -> part.headers().getContentType())
                .map(MimeType::toString)
                .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE))));
  }

  private Mono<MultipartFile> build(FormFieldPart formFieldPart) {
    if (formFieldPart == null || !StringUtils.hasText(formFieldPart.value())) {
      return Mono.just(FileAwareMultipartFile.empty());
    }
    return Mono.just(formFieldPart)
        .map(part -> {
          String value = part.value();
          byte[] data = null;
          String contentType = null;
          if (value.toLowerCase().startsWith("data:") && value.indexOf(',') > -1) {
            DataUrl dataUrl = null;
            try {
              dataUrl = new DataUrlSerializer().unserialize(value);
            } catch (Exception e) {
              log.debug("Parsing form field as data url failed, "
                  + "treating value as plain/text (value = {}).", value);
            }
            if (dataUrl != null) {
              data = dataUrl.getData();
              contentType = dataUrl.getMimeType();
            }
          }
          if (data == null) {
            data = value.getBytes(StandardCharsets.UTF_8);
            contentType = MediaType.TEXT_PLAIN_VALUE;
          }
          try {
            return new FileAwareMultipartFile(
                new ByteArrayInputStream(data),
                tmpDir,
                part.name(),
                null,
                contentType);

          } catch (IOException e) {
            throw ServiceException.internalServerError(
                "Creating multipart file from form field part failed.", e);
          }
        });
  }

  @Override
  public Mono<MultipartFile> build(Part part) {
    if (part instanceof FilePart) {
      return build((FilePart) part);
    }
    if (part instanceof FormFieldPart) {
      return build((FormFieldPart) part);
    }
    return Mono.just(FileAwareMultipartFile.empty());
  }

  @Override
  public Mono<MultipartFile> build(Flux<? extends Part> parts) {
    //noinspection unchecked
    return ((Flux<Part>) parts)
        .last(new EmptyPart())
        .flatMap(this::build);
  }

  @Override
  public Mono<List<MultipartFile>> buildList(Flux<? extends Part> parts) {
    return parts.flatMap(this::build)
        .collectList();
  }

  @Override
  public Mono<List<MultipartFile>> buildList(
      MultiValueMap<String, Part> multiPartData, String... requestParameters) {

    return Flux.fromArray(Optional.ofNullable(requestParameters).orElseGet(() -> new String[0]))
        .flatMap(requestParameter -> build(multiPartData.getFirst(requestParameter)))
        .collectList();
  }

  @Override
  public Flux<List<MultipartFile>> buildLists(
      MultiValueMap<String, Part> multiPartData, String... requestParameters) {

    return Flux.fromArray(Optional.ofNullable(requestParameters).orElseGet(() -> new String[0]))
        .flatMap(reqParam -> createList(multiPartData, reqParam));
  }

  @SuppressWarnings("unchecked")
  @Override
  public Mono<Map<String, MultipartFile>> buildMap(Flux<? extends Part>... parts) {
    return Flux.fromArray(parts)
        .flatMap(this::build)
        .collectMap(MultipartFile::getName, multipartFile -> multipartFile, LinkedHashMap::new);
  }

  @Override
  public Mono<Map<String, MultipartFile>> buildMap(
      MultiValueMap<String, Part> multiPartData, String... requestParameters) {

    return Flux.fromArray(Optional.ofNullable(requestParameters).orElseGet(() -> new String[0]))
        .flatMap(reqParam -> build(multiPartData.getFirst(reqParam))
            .zipWith(Mono.just(reqParam)))
        .collectMap(Tuple2::getT2, Tuple2::getT1, LinkedHashMap::new);
  }

  @SuppressWarnings("unchecked")
  @Override
  public Mono<MultiValueMap<String, MultipartFile>> buildMultiValueMap(
      Flux<? extends Part>... parts) {
    return Flux.fromArray(parts)
        .flatMap(this::buildList)
        .filter(list -> !list.isEmpty())
        .collectMap(list -> list.get(0).getName(), list -> list, LinkedHashMap::new)
        .map(LinkedMultiValueMap::new);
  }

  @Override
  public Mono<MultiValueMap<String, MultipartFile>> buildMultiValueMap(
      MultiValueMap<String, Part> multiPartData, String... requestParameters) {

    return Flux.fromArray(Optional.ofNullable(requestParameters).orElseGet(() -> new String[0]))
        .flatMap(reqParam -> createList(multiPartData, reqParam)
            .zipWith(Mono.just(reqParam)))
        .collectMap(Tuple2::getT2, Tuple2::getT1, LinkedHashMap::new)
        .map(LinkedMultiValueMap::new);
  }

  private File newTmpFile() {
    try {
      return File.createTempFile("uploaded-", ".tmp", tmpDir);
    } catch (Exception e) {
      throw ServiceException.internalServerError("Creating tmp file failed.", e);
    }
  }

  private Mono<List<MultipartFile>> createList(
      MultiValueMap<String, Part> multiPartData,
      String requestParameter) {

    List<Part> contentParts = multiPartData
        .getOrDefault(requestParameter, Collections.emptyList());
    return contentParts.isEmpty()
        ? Mono.empty()
        : Flux.fromIterable(contentParts).flatMap(this::build).collectList();
  }

  private static class EmptyPart implements Part {

    private EmptyPart() {
    }

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

    @NonNull
    @Override
    public HttpHeaders headers() {
      return new HttpHeaders();
    }

    @NonNull
    @Override
    public Flux<DataBuffer> content() {
      return Flux.empty();
    }
  }

}