Skip to content

howtis/nested-multipart-spring-boot-starter

Repository files navigation

Nested Multipart Spring Boot Starter

Spring Boot starter that enables automatic binding of nested multipart/form-data requests to complex DTO structures. No more manual HttpServletRequest parsing — just annotate your controller parameter with @NestedMultipart.

Features

  • Deeply nested DTOs — bind files at any depth (a.b.c.file)
  • CollectionsList, Set, and arrays of DTOs, each with their own files
  • MapMap<String, MultipartFile> for dynamic file fields
  • Direct file listsList<MultipartFile> and MultipartFile[]
  • Optional<MultipartFile> — files that may or may not be present
  • JSON part merging — combine JSON bodies with file parts in the same request
  • Custom prefixes — override the parameter name prefix via @NestedMultipart("data")
  • Works alongside @RequestParam — handle mixed parameters naturally
  • Zero configuration — auto-registers via Spring Boot auto-configuration

Requirements

  • Java 17+
  • Spring Boot 3.2+
  • Spring Web MVC (not WebFlux)

Installation

Gradle

repositories {
    mavenCentral()
}

dependencies {
    implementation 'io.github.howtis:nested-multipart-spring-boot-starter:1.0.0'
}

Maven

<dependency>
    <groupId>io.github.howtis</groupId>
    <artifactId>nested-multipart-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>

Usage

Basic Nested DTO

// DTOs
public class Attachment {
    private MultipartFile file;
    private String description;
    // getters and setters
}

public class PostForm {
    private String title;
    private Attachment attachment;
    // getters and setters
}

// Controller
@PostMapping("/posts")
public ResponseEntity<?> create(@NestedMultipart PostForm form) {
    // form.getTitle()        -> "My Post"
    // form.getAttachment().getFile()      -> the uploaded file
    // form.getAttachment().getDescription() -> "My description"
}

Send the request with multipart parts:

form.title: My Post
form.attachment.description: My description
form.attachment.file: [binary data]

Three Levels Deep

DTOs can be nested arbitrarily deep. File parts use dot-notation to match the DTO structure:

public class C {
    private MultipartFile file;
}
public class B {
    private C c;
}
public class A {
    private B b;
}

@PostMapping("/deep")
public ResponseEntity<?> deep(@NestedMultipart A a) {
    // a.getB().getC().getFile() -> the uploaded file
}

Request part name: a.b.c.file

List of DTOs

public class Item {
    private MultipartFile file;
    private String description;
}
public class ItemListForm {
    private List<Item> items;
}

@PostMapping("/items")
public ResponseEntity<?> createItems(@NestedMultipart ItemListForm form) {
    // form.getItems().get(0).getFile() -> first file
    // form.getItems().get(1).getFile() -> second file
}

Request parts:

form.items[0].description: First item
form.items[0].file: [binary data]
form.items[1].description: Second item
form.items[1].file: [binary data]

Discontinuous indices (e.g., [0] and [2]) are supported — missing indices are filled with null.

Set of DTOs

Same as List<Item> but using java.util.Set<Item>:

public class SetItemForm {
    private Set<SetItem> items;
}

Array of DTOs

public class ArrayAttachmentForm {
    private Attachment[] attachments;
}

@PostMapping("/attachments")
public ResponseEntity<?> createAttachments(@NestedMultipart ArrayAttachmentForm form) {
    // form.getAttachments()[0].getFile() -> first file
}

Direct List of MultipartFile

When a field is List<MultipartFile> or MultipartFile[], files are injected directly without nested DTO wrappers:

public class MultiFileForm {
    private List<MultipartFile> images;
}

@PostMapping("/images")
public ResponseEntity<?> uploadImages(@NestedMultipart MultiFileForm form) {
    // form.getImages().get(0) -> first image
    // form.getImages().get(1) -> second image
}

Send multiple files with the same part name:

form.images: [binary data 1]
form.images: [binary data 2]

Map<String, MultipartFile>

public class MapForm {
    private Map<String, MultipartFile> documents;
    private String title;
}

@PostMapping("/documents")
public ResponseEntity<?> uploadDocuments(@NestedMultipart MapForm form) {
    // form.getDocuments().get("contract") -> contract file
    // form.getDocuments().get("id")       -> id file
}

Request parts:

form.title: Documents
form.documents[contract]: [binary data]
form.documents[id]: [binary data]

Optional

public class OptionalFileForm {
    private Optional<MultipartFile> avatar;
    private Optional<MultipartFile> thumbnail;
}

@PostMapping("/profile")
public ResponseEntity<?> uploadProfile(@NestedMultipart OptionalFileForm form) {
    // form.getAvatar().isPresent()    -> true if avatar was sent
    // form.getThumbnail().isPresent() -> true if thumbnail was sent
}

Missing files result in Optional.empty().

JSON Part Merging

Send structured data as a JSON part alongside file parts. The JSON fields are merged into the DTO using Jackson's readerForUpdating:

public class ArticleForm {
    private String title;
    private String tags;
    private MultipartFile thumb;
}

@PostMapping("/article")
public ResponseEntity<?> createArticle(@NestedMultipart ArticleForm form) {
    // form.getTitle() -> "Hello World"
    // form.getTags()  -> "java,spring"
    // form.getThumb() -> the uploaded thumbnail
}

Request parts:

form: {"title":"Hello World","tags":"java,spring"}  (Content-Type: application/json)
form.thumb: [binary data]

If a field appears in both the JSON part and a text parameter, a conflict error is raised.

Custom Prefix

Override the parameter name prefix via the annotation value:

@PostMapping("/submit")
public ResponseEntity<?> submit(@NestedMultipart("data") PostForm form) {
    // Parts are expected under "data." prefix
    // data.title, data.attachment.file, ...
}

Coexisting with @RequestParam

@NestedMultipart works alongside standard @RequestParam:

@PostMapping("/submit")
public ResponseEntity<?> submit(
        @NestedMultipart PostForm form,
        @RequestParam("tag") String tag) {
    // Both form and tag are bound correctly
}

How It Works

The binding happens in three passes:

flowchart TD
    A[multipart/form-data Request] --> B[Pass 1: DataBinder]
    B --> C[Pass 1.5: JSON Merge]
    C --> D[Pass 2: File Injection]
    B -->|Binds text fields| E["form.title, form.items[0].description"]
    C -->|Merges JSON body| F["form: {'title':'...','tags':'...'}"]
    D -->|Injects MultipartFile| G[form.attachment.file, form.images]
Loading
  1. Pass 1ServletRequestDataBinder binds text/primitive fields (strips the parameter name prefix)
  2. Pass 1.5 — JSON parts (Content-Type: application/json) are deserialized and merged via Jackson. Conflicts with Pass 1 fields are rejected.
  3. Pass 2MultipartFileFieldInjector recursively traverses the DTO tree via reflection, matching file parts by dot-path and injecting MultipartFile instances

DTO Requirements

  • DTOs must have a no-arg constructor
  • Fields must have getters and setters following JavaBeans conventions
  • Add -parameters compiler flag to avoid specifying the prefix in the annotation (Gradle: configured automatically; Maven: set <parameters>true</parameters> in maven-compiler-plugin)

Configuration

No configuration required. The starter auto-registers through Spring Boot's AutoConfiguration.imports mechanism.

# If you want to disable it (not typical):
spring.autoconfigure.exclude=io.github.howtis.nestedmultipart.NestedMultipartAutoConfiguration

Limitations

  • Only works with Spring Web MVC (Servlet-based), not WebFlux
  • DTOs must have a no-arg constructor
  • Map fields with non-MultipartFile values (e.g., Map<String, String>) are bound by Pass 1 only and use standard Spring binding notation

About

Spring Boot starter for automatic binding of nested multipart/form-data requests to complex DTO structures

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors