How to make multipart requests with file and object through autogenerated code defined in OpenAPI

This article explains creating a Spring Boot endpoint using OpenAPI to receive objects and, optionally, files through multipart form data, focusing on deserialization and validation of incoming objects.

Introduction

In this article we will continue and spin from the previous article about generating OpenAPI with Spring Boot for multipart endpoint and uploading files through it. We strongly recommend reading the last article if you want to import more than one file. But in this article, we will focus only on the optional import of a single file.

But instead of multiple files, we will try to upload only one file and one serialized object, for example, the information about the new user.

We will also show how to handle the deserialization of the incoming object from the string at the Spring Boot backend and how to control proper deconstruction if some fields are optional or not mandatory.

OpenAPI User import definition YAML

So we start where we end the last time. Let’s generate the Spring Boot endpoint through the OpenAPI schema, which will be able to receive objects and, optionally, a file through multipart form data.

openapi: 3.0.2
info:
  title: User management module
  description: User management
  version: '0.1'
  contact:
  email: optional@email.com
servers:
  - url: https://localhost:8080/api/user-management

paths:
  /users/import:
    post:
      summary: Create new Users with optional attachment
      tags:
        - users
      operationId: createUserWithOptionalFile
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                createUser:
                  description: Wrapper containing information for new user creation
                  type: object
                  properties:
                    username:
                      description: Users username
                      type: string
                    age:
                      description: Users password
                      type: number
                    creationTimestamp:
                      description: Time when the user will be defined as created in the system
                      type: string
                      format: date-time
                      example: "2024-02-29T12:30:00.000Z"
                    email:
                      description: Email of the user
                      type: string
                    addressId:
                      description: ID of the users address
                      type: string
                    accessRights:
                      description: List of access rights for the user
                      type: array
                      items:
                        type: object
                        properties:
                          accessRightId:
                            description: ID of the access right
                            type: string
                          note:
                            description: Note for the access right
                            type: string
                  required:
                    - username
                    - email
                    - accessRights
                optionalFile:
                  description: Optional file attachment
                  type: string
                  format: binary
              required:
              - createUser
      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateUserResponse'
        '400':
          description: Bad Request

As you can see, we have defined the endpoint /users/import, which will be able to receive the object createUser and optionally also a single file. The createUser object is defined in the schema and is required, while the file is optional. CreateUser object also has various definitions of fields, although unimportant and displayed only for current examples.

You can refer to the object schema to make the YAML file more readable and concise. The object’s schema can be defined in self-standing section dedicated to components.

openapi: 3.0.2
info:
  title: User management module
  description: User management
  version: '0.1'
  contact:
  email: optional@email.com
servers:
  - url: https://localhost:8080/api/user-management

paths:
  /users/import:
    post:
      summary: Create new Users with optional attachment
      tags:
        - users
      operationId: createUserWithOptionalFile
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                createUser:
                  $ref: '#/components/schemas/CreateUser'
                optionalFile:
                  description: Optional file attachment
                  type: string
                  format: binary
              required:
              - createUser
      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateUserResponse'
        '400':
          description: Bad Request

components:
  schemas:
    CreateUser:
      description: Wrapper containing information for new user creation
      type: object
      properties:
        username:
          description: Users username
          type: string
        age:
          description: Users password
          type: number
        creationTimestamp:
          description: Time when the user will be defined as created in the system
          type: string
          format: date-time
          example: "2024-02-29T12:30:00.000Z"
        email:
          description: Email of the user
          type: string
        addressId:
          description: ID of the users address
          type: string
        accessRights:
          description: List of access rights for the user
          type: array
          items:
            type: object
            properties:
              accessRightId:
                description: ID of the access right
                type: string
              note:
                description: Note for the access right
                type: string
      required:
        - username
        - email
        - accessRights

Enforcing and casting mandatory properties of multipart request

Multipart file can accept only the Strings or Files (better visible in testing with Postman section). So any string input – Java’s serialized object, or JSON, is at the end nothing more than just a string of bytes.

The problem will arise during the deserialization of autogenerated code. It does not matter if you declare the object cast as required. With proper mapping, the input will be taken as a String and not as the type you want to use from the method signature.

Mapper and validator factory beans can solve the issue and handle the input.

First, register one new bean for object Validation. Optionally, you can register the ObjectMapper with JavaTimeModule to handle the date-time format, as we will use the date-time string in ISO format (“YYYY-MM-DDTHH:MM:SS.mmmZ”). The Validator is created through LocalValidatorFactoryBean.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import jakarta.validation.Validator;
import lombok.val;

@Configuration
public class AppConfig {

  //@Bean
  //public ObjectMapper objectMapper() {
  //  val objectMapper = new ObjectMapper();
  //  objectMapper.registerModule(new JavaTimeModule());
  //  return objectMapper;
  //}

  @Bean
  public Validator validator() {
    return new LocalValidatorFactoryBean();
  }

}

The code was written for Java 21 dependencies with Lombok.

To enforce the proper conversion, you must create a new Spring component to convert the incoming string to the object. The component will be able to handle the deserialization and validation of the object.

import java.util.Set;

import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
import lombok.val;

@Component
public class StringToCreateUserDtoConverter implements Converter<String, CreateUserDto> {

  private final Validator validator;
  private final ObjectMapper objectMapper;

  public StringToCreateUserDtoConverter(final Validator validator, final ObjectMapper objectMapper) {
    this.validator = validator;
    this.objectMapper = objectMapper;
  }

  @Override
  public CreateUserDto convert(final String source) {
    CreateUserDto createUserDto;
    try {
        createUserDto = objectMapper.readValue(source, CreateUserDto.class);
    } catch (JsonProcessingException e) {
        throw new CreateUserDeserializationException("Input mapping and processing error: " + e.getMessage());
    }
    val violations = validator.validate(createUserDto);
    if (!violations.isEmpty()) {
        throw new ConstraintViolationException(violations);
    }
    return createUserDto;
  }

}

Just for the record, I will also add here the custom exception you can throw and catch in the custom exception handler:

import java.io.Serial;

import lombok.Getter;

@Getter
public class CreateUserDeserializationException extends RuntimeException {

  @Serial
  private static final long serialVersionUID = 1659771269447893294L;

  public CreateUserDeserializationException(String msg) {
    super(msg);
  }

}

Unit tests

I recommend writing the unit tests for the converter. It is a good practice to test the conversion and validation of the object. As we rely heavily upon Spring magic, a lot can unintentionally influence the component’s behaviour without us discovering it.

The code below is just an example of the unit tests with the usage of SpringBootTest and LocalTestcontainers which ties to get close to real-world use scenarios. We highly recommend to accommodate tests to your environment and needs.


import org.testcontainers.containers.PostgreSQLContainer;

public class LocalTestcontainers {

    public static void startPostgres(){
        PostgreSQLContainer postgre = new PostgreSQLContainer<>("postgres:16.0")
            .withDatabaseName("INSERT_DATABASE_NAME")
            .withUsername("INSERT_DATABASE_USERNAME")
            .withPassword("INSERT_DATABASE_PASSWORD");
        postgre.start();

        System.setProperty("spring.datasource.url", postgre.getJdbcUrl());
        System.setProperty("spring.datasource.username", postgre.getUsername());
        System.setProperty("spring.datasource.password", postgre.getPassword());
    }

}
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.testcontainers.junit.jupiter.Testcontainers;

import com.codepills.usermanagementmodule.api.exception.CreateUserDeserializationException;
import com.codepills.usermanagementmodule.utils.LocalTestcontainers;

import jakarta.validation.ConstraintViolationException;
import lombok.val;

@SpringBootTest
@Testcontainers
class StringToCreateUserDtoConverterTest {

    static {
        LocalTestcontainers.startPostgres();
    }

    @Autowired
    private StringToCreateUserDtoConverter stringToCreateUserDtoConverter;

    @Test
    void testConvertValidInputString() {
        // Arrange
        val username = "JohnDoe";
        val age = 25;
        val formatter = DateTimeFormatter.ISO_INSTANT;
        val now4CreationTime = Instant.now();
        val email = "john.doe@noemail.com";
        val addressId = UUID.randomUUID();
        val accessRightId1 = UUID.randomUUID();
        val accessRightId2 = UUID.randomUUID();

        val stringBuilder = new StringBuilder();
        stringBuilder.append("{");
        stringBuilder.append("\"username\" : \"").append(username).append("\",");
        stringBuilder.append("\"age\" : ").append(age).append(",");
        stringBuilder.append("\"creationTimestamp\" : \"").append(formatter.format(now4CreationTime)).append("\",");
        stringBuilder.append("\"email\" : \"").append(email).append("\",");
        stringBuilder.append("\"addressId\" : \"").append(addressId).append("\",");
        stringBuilder.append("\"accessRights\" : [");
        stringBuilder.append("{");
        stringBuilder.append("\"accessRightId\" : \"").append(accessRightId1).append("\",");
        stringBuilder.append("}, {");
        stringBuilder.append("\"accessRightId\" : \"").append(accessRightId2).append("\"");
        stringBuilder.append("}]}");
        val validInput = stringBuilder.toString();

        // Act
        val createUserDto = stringToCreatUserDtoConverter.convert(validInput);

        // Assert
        assertNotNull(createUserDto);
        assertEquals(username, createUserDto.getUsername());
        assertEquals(age, createUserDto.getAge());
        assertEquals(now4CreationTime, createUserDto.getCreationTimestamp());
        assertEquals(email, createUserDto.getEmail());
        assertEquals(addressId.toString(), createUserDto.getAddressId());
        assertEquals(2, createUserDto.getAccessRights().size());
        assertEquals(accessRightId1.toString(), createUserDto.getAccessRights().get(0).getAccessRightId());
        assertEquals(accessRightId2.toString(), createUserDto.getAccessRights().get(1).getAccessRightId());
    }

    @Test
    void testConvertInvalidInputString() {
        // Arrange
        val invalidInput = "invalid input string";
        // Act & Assert
        try {
            stringToCreateUserDtoConverter.convert(invalidInput);
        } catch (CreateUserDeserializationException ex) {
            assertEquals("Input mapping and processing error: Unrecognized token 'invalid': was expecting (JSON String, Number, Array, "
                             + "Object or token 'null', 'true' or 'false')\n at [Source: (String)\"invalid input string\"; line: 1, "
                             + "column: 8]", ex.getMessage());
        }
    }

    @Test
    void testConvertInputWithValidationErrors() {
        // Arrange
        val username = "JohnDoe";
        val age = 25;
        val formatter = DateTimeFormatter.ISO_INSTANT;
        val now4CreationTime = Instant.now();
        val email = "john.doe@noemail.com";
        val addressId = UUID.randomUUID();
        val accessRightId1 = UUID.randomUUID();
        val accessRightId2 = UUID.randomUUID();

        val stringBuilder = new StringBuilder();
        stringBuilder.append("{");
        stringBuilder.append("\"username\" : \"").append(username).append("\",");
        stringBuilder.append("\"age\" : ").append(age).append(",");
        stringBuilder.append("\"creationTimestamp\" : \"").append(formatter.format(now4CreationTime)).append("\",");
        stringBuilder.append("\"email\" : \"").append(email).append("\",");
        // Missing addressId
        stringBuilder.append("\"addressId\" : null,");
        stringBuilder.append("\"accessRights\" : [");
        stringBuilder.append("{");
        stringBuilder.append("\"accessRightId\" : \"").append(accessRightId1).append("\",");
        stringBuilder.append("}, {");
        stringBuilder.append("\"accessRightId\" : \"").append(accessRightId2).append("\"");
        stringBuilder.append("}]}");
        val validInput = stringBuilder.toString();

        // Act & Assert
        try {
            stringToCreateUserDtoConverter.convert(inputWithValidationErrors);
        } catch (ConstraintViolationException ex) {
            assertEquals(1, ex.getConstraintViolations().size());
        }
    }

}

User Management Controller

Now, all we need to do is create a controller to implement the OpenAPI schema.

Here is an example of the controller:

@Logger
@RestController
@RequiredArgsConstructor
public class UserManagementController implements UserManagementApi {

  @Override
  public ResponseEntity<Void>> createUserWithOptionalFile(
        @RequestPart(name = "createUser") final CreateUserDto createUserDto,
        @RequestPart(name = "optionalFile", required = false) MultipartFile optionalFile
     ) {
    if (optionalFile == null || optionalFile.isEmpty()) {
        log.info("No optionalFile uploaded.");
    } else {
        log.info("PDF optionalFile {} uploaded but not saved.", optionalFile.getOriginalFilename());
    }

    // You can work with createUserDto here

    return ResponseEntity.ok().build();
  }

}

I will leave the implementation of createUserWithOptionalFile() method up to you as it is not the main focus of this article.

Again, notice the optionalFile and createUser keywords in the method arguments. It is the same as the keyword as defined in the OpenAPI schema.

Testing with Postman

Nothing blocks our development anymore, and we can start testing the endpoint with Postman. The endpoint will be able to receive the serialized object in the form of string and also the file. However, attaching the file to the request is, of course, optional.

As it might need to be stressed more, the most important thing is using the exact keywords defined in the OpenAPI schema. In our case, for importing object, it will be createUser and for optional file, it will be optionalFile.

Let’s run the SpringBoot app at localhost.

Backend should be running at localhost. And if added the necessary logic for file processing into the controller, we should be able to upload serialized objects in JSON format and optional file through the /users/import endpoint.

When using Postman, you also need to define the type of multipart key. It is either the String or File. For files, use File, and for the object, use String. Do not forget that in the previous article we discuss the implementation of sending multiple files through multipart request.

All you need to do is to define your endpoint POST request in Postman. Select POST request, add the URL, and in the body tab, select form-data and add the keywords createUser set as String and optionalFile as File .

Postman multipart request with multiple objects and files

The most important part is to really define serialized object as String and not File.

Here is an example of createUser as String:

{
	"username" : "johndoe",
	"age" : "25",
	"creationTimestamp" : "2024-02-29T12:30:00.000Z",
	"email" : "john.doe@noemail.com",
	"addressId" : "f8935f28-8d7b-40a4-96d7-a3288976617e",
	"accessRights" : [
		{
      "accessRightId" : "712d5a0a-11b5-4a44-9e92-6e67d16be2aa"
    }
	]
}

Conclusion

This article has shown how to create a Spring Boot endpoint to receive objects and, optionally, file through multipart form data. We have also demonstrated how to handle the deserialization of the incoming object from the string at the Spring Boot backend and how to control proper deconstruction if some fields are optional or not mandatory.

Is it applicable to auto-generating infrastructure code through OpenAPI with Spring Boot for multipart endpoint and uploading files? Let us know in the comments below. Do you have your trick or see another way how to enforce and deserialize multipart object? Let us know in the comments below the article. We would like to hear your ideas and stories.

This entry was posted in Solutions and tagged , , , , , , , , , , , , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.