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 .
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.