CodeGym /Courses /Spring REST & MVC /Upload request validation

Upload request validation

Spring REST & MVC
Level 26, Lesson 3
Available

1. Upload checks as a separate topic

POST /api/v1/tasks/{taskId}/attachments already looks like a normal contract: multipart with file and metadata parts, and on the happy path — 201 Created. But as soon as the contract becomes concrete, the next question immediately comes up: where exactly does it break, and how do we avoid turning every error into a faceless 400 “something went wrong”.

When you first add file upload support, it is very tempting to think: “Okay, I used to validate JSON, now I will validate... um... a file.” And at that point the brain usually freezes, because a file is not a string, not an enum, and not even a LocalDate. A file has a size, a name, a type, and the funniest part — it can be “kind of there” but actually empty.

In a regular JSON endpoint, everything revolves around one request body: either it parses or it does not, then Bean Validation checks the annotations, and we are happy. In a multipart scenario, there are multiple sources of problems. And if we do not sort them into “buckets,” we will start returning responses like “400 something went wrong,” which is a direct path to an API loved only by its creators... and not always even by them.

Four buckets of upload-scenario errors

To avoid drowning in chaos, it helps to think of file upload as a mini pipeline where each stage can fail for its own reason. It is important not only to check, but also to explain to the client clearly what exactly happened: the request is malformed, the metadata is invalid, the file is not acceptable, or everything is technically correct but the operation is blocked by business rules.

Below is a working “matrix” that is useful to keep in mind (and that we will implement in the project through a single ProblemDetail contract):

Error bucket What broke Typical reason Status (for our API) Example error code
Shape / binding The request as multipart cannot be read or a part is missing no file, no metadata, wrong part format 400 Bad Request INVALID_INPUT / MALFORMED_JSON
Metadata validation The JSON metadata was read, but does not pass Bean Validation description is too long 400 Bad Request INVALID_INPUT
File validation The file itself is not acceptable empty file, unknown Content-Type, too large size 400 or 415 (see below) INVALID_INPUT / UNSUPPORTED_MEDIA_TYPE
Business rules Everything is technically correct, but disallowed by the domain the task does not exist, the task is archived 404 / 409 TASK_NOT_FOUND / FILE_UPLOAD_NOT_ALLOWED

Pay attention to the “fine point”: 415 Unsupported Media Type is not “any bad file.” It is specifically the case where the content type is not supported, and the client must either change the file type, send a different file, or not make this request at all.

2. Multipart shape errors

There is a funny thing with multipart requests: sometimes your controller code does not run at all. And that is not a bug — it is logical. If the client did not send the file part, Spring MVC cannot honestly “pretend” that the file exists. It fails earlier, during method-argument binding, and this is exactly the area we classify as request shape errors.

With @RequestPart("..."), the part is required by default. In other words, such a method already defines the contract: “send file and metadata, otherwise there will be an error”:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

public void upload(
        // This part is required: if the client does not send "file", Spring will fail on binding before entering the method
        @RequestPart("file") MultipartFile file,
        // JSON metadata is read as a separate part and validated by Bean Validation
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) {
    // Inside the method we assume that the shape layer (presence of parts) has already passed
}

If the client forgets file, Spring will throw a framework exception (often MissingServletRequestPartException). And here it is important not to invent a “special format” for errors just because “it is a framework.” We already have global error handling, so the task is simple: translate the framework error into the same ProblemDetail format as the rest of the errors.

If you use ResponseEntityExceptionHandler, you can override missing-part handling specifically and return 400 with our code:

import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // In a real project the signature would be broader — this is a deliberately simplified example
    protected ResponseEntity<Object> handleMissingServletRequestPart(MissingServletRequestPartException ex) {
        // Build ProblemDetail in the unified error format of our API
        ProblemDetail pd = ProblemDetail.forStatus(400);
        // code is needed by the client for programmatic handling
        pd.setProperty("code", "INVALID_INPUT");
        return ResponseEntity.badRequest().body(pd);
    }
}

Yes, this is a “pseudo example” (in real code the method signature will be a bit larger), but the idea matters more: missing part is a 400, because the request structurally does not match the contract.

Now about “wrong format.” For example, the client sent metadata as text that is not JSON, or sent something that Jackson cannot parse at all. Then you get an error at the “failed to read body/part” level — and in your system this should look like MALFORMED_JSON in the problem details, just like for a regular JSON body. That is useful: the client does not care where the JSON was (in the body or in the part), it cares that it sent malformed JSON.

3. Validating JSON metadata

Once the shape errors are understood, you want to finally feel something familiar. And that is where good old Bean Validation comes on stage: the metadata part is ordinary JSON, which can be mapped to a DTO and validated just like we validated @RequestBody for tasks and comments.

For our Task Tracker API, attachment metadata is intentionally minimalist: the client can specify only a description. Everything else (original name, size, type, upload time) is server-managed and is computed by the server from the file part and the operation context.

The DTO looks roughly like this:

import jakarta.validation.constraints.Size;

public record AttachmentUploadMetadataRequest(
        // Description is the only field the client can currently send
        @Size(max = 255) String description
) {
}

And in the controller it is important not to forget @Valid specifically on @RequestPart("metadata"):

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.RequestPart;

public void upload(
        // We validate exactly the "metadata" part, not some "general" request body
        @Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) {
}

If description is 1000 characters long, that is not a “conflict,” not a “server error,” not “unsupported media type.” That is classic invalid input, meaning 400 Bad Request, and it should reach the client in the same field-errors format that we already use for JSON endpoints.

There is a very typical anti-pattern: “metadata is small, let us skip the DTO and just use a string.” And then suddenly a category field appears, then visibility, then “let us add a list of attachment tags,” and you end up serializing manually, checking manually, handling errors manually... and somewhere along the way you realize that the DTO was not bureaucracy, but insurance.

4. File validation: empty file

Now we move to something we almost never had in our JSON endpoints before: checking the actual content. And the very first, most down-to-earth case is an empty file. And emptiness comes in two forms: “the part did not arrive at all” and “the part arrived, but the file is empty.” The first problem is a shape error (MissingServletRequestPartException). The second is already file validation, because the contract is formally met, but the operation has no meaning.

In Spring MultipartFile can tell you whether it is empty:

import org.springframework.web.multipart.MultipartFile;

public void validateNotEmpty(MultipartFile file) {
    // At controller level file is usually not null (if the part is required),
    // but here we protect against surprises and simplify method reuse.
    if (file == null || file.isEmpty()) {
        // This is "bad input": the request arrived formally, but there is no data for the operation
        throw new InvalidInputException("The file is required and must not be empty");
    }
}

Pay attention: file == null should not happen in a normal scenario (if @RequestPart("file") is required), but validation often becomes more robust if you do not rely on “never.”

And an important semantic distinction: an empty file is not 409 Conflict. A conflict is when the client makes a logically valid request, but domain rules forbid the operation. An empty file is “you did not even send data for the operation,” meaning 400 Bad Request with code=INVALID_INPUT.

Here is also a small “human” rule for yourself: if I can fix the request without changing server state and without asking the business team “are we allowed to?”, then it is almost always 400.

5. File validation: whitelist Content-Type

The next “favorite” case is the client sending a file format we do not support. And this is where two temptations appear. The first is to check only the file extension. The second is to not check anything at all, “let them upload whatever they want.” In real systems both approaches lead to amusing consequences, but in a training project it is important to fix the right contract discipline: file type is part of the API contract.

MultipartFile lets you read the content type sent by the client:

import org.springframework.web.multipart.MultipartFile;

public String contentTypeOf(MultipartFile file) {
    // Content-Type can be null if the client did not send it
    return file.getContentType();
}

Within the Task Tracker API we define a small whitelist, which reads directly as part of the contract:

import java.util.Set;

public final class AttachmentContentTypes {
    // Explicitly fix the set of supported media types as part of the API contract
    public static final Set<String> ALLOWED = Set.of(
            "text/plain",
            "application/pdf",
            "image/png",
            "image/jpeg"
    );

    private AttachmentContentTypes() {
        // utility class
    }
}

And the check looks as straightforward as possible:

import org.springframework.web.multipart.MultipartFile;

public void validateContentType(MultipartFile file) {
    // Take Content-Type exactly as the client sent it (it may be spoofed — see below)
    String ct = file.getContentType();
    if (ct == null || !AttachmentContentTypes.ALLOWED.contains(ct)) {
        // Here it is important to distinguish "invalid input" from "unsupported type"
        throw new UnsupportedAttachmentMediaTypeException(ct);
    }
}

Now the main lecture question: which status? And here 415 Unsupported Media Type is our best friend. The client sent a media type the server is not ready to accept. That means it is logical to return 415 and the code UNSUPPORTED_MEDIA_TYPE. This is exactly the situation where the status carries meaning, rather than just being “a number for the sake of it.”

To make this work cleanly and consistently, we add exception translation to ProblemDetail in the global handler:

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(UnsupportedAttachmentMediaTypeException.class)
public ProblemDetail handleUnsupported(UnsupportedAttachmentMediaTypeException ex) {
    // 415 is specifically about unsupported Content-Type
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
    // A unified "machine" code that tells the client the scenario
    pd.setProperty("code", "UNSUPPORTED_MEDIA_TYPE");
    // detail is a human-readable explanation, can be shown to the user
    pd.setDetail("File type is not supported: " + ex.getContentType());
    return pd;
}

A small nuance for real life: Content-Type sent by the client is not cryptographic truth. It can be spoofed. In production people often do deeper checks, but within our course the more important thing is to learn how to fix the contract and return a clear error.

6. File size and multipart limits

File size is even more interesting: some checks can be done “at the application level” (in our code), and some at the platform level (multipart settings in Spring Boot / the container). And it is important to understand that these are two different protective layers, not mutually exclusive options.

Let us start with the simple part: MultipartFile.getSize() returns the size in bytes, and you can compare it with a limit:

import org.springframework.web.multipart.MultipartFile;

public void validateMaxSize(MultipartFile file, long maxBytes) {
    // Pass the limit as a parameter so that we do not scatter "magic numbers" through the code
    if (file.getSize() > maxBytes) {
        // From the contract perspective, this is "input that is too large"
        throw new InvalidInputException("The file exceeds the allowed size");
    }
}

The purpose of this validation layer is very practical: you can give the client a clear message tied to your contract. Yes, the real limit is better not to sprinkle throughout the code base (that quickly turns into “magic numbers”), but right now the semantics matter more: “file too large” is an input error.

And now the second layer: Spring Boot can already limit multipart uploads at the configuration level. The official documentation explicitly says that Spring Boot relies on the servlet API Part and by default configures a maximum size of 1MB per file and 10MB of data per request. This means that if the client tries to upload a file bigger than the limit, the request may not even reach your controller — it will be cut off earlier.

That is not “bad” or “good” — it is simply an architectural fact: the platform protects your service from overly large requests before your code even starts doing anything. That is why in the lecture we say: limits are part of the upload-scenario contract, not a random technical detail.

Which status should be used for oversized uploads? At the pure HTTP level, the answer is 413 Payload Too Large. And it is important to remember that the platform-level multipart limit often triggers before the controller: the request is rejected before your code gets a chance to do anything at all.

If in a specific project you additionally perform an application-level size check and map some oversized scenarios to a common INVALID_INPUT / 400, that should be understood as a local project policy, not as a replacement for 413 in general. The most important thing is that the client receives a clear ProblemDetail, and that you yourself understand which layer just fired: the platform or the application validator.

Who checks what

Where it fires What exactly it checks Examples
Spring MVC binding + global handler multipart shape and part reading no file, no metadata, malformed JSON in metadata
Bean Validation on @Valid @RequestPart("metadata") metadata structure and constraints description is too long
Cheap file checks at the operation boundary the file itself as input file.isEmpty(), whitelist Content-Type, application-level max size
Config / platform limits hard multipart limits request or file larger than the configured limit
Service / business rules domain restrictions task not found, task archived

It is useful to perform cheap file checks before file.getBytes() and before writing to storage: if the file is empty or unsupported, there is no point in reading it fully and going further.

7. Business rules: 404 and 409

Now we move to the most important boundary in the course: where a “bad request” ends and a “conflict with domain rules” begins. In the upload scenario this boundary is especially visible, because technically the request can be perfect: parts are present, JSON is valid, the content type is allowed, the size is within limits... but the operation is still forbidden.

If the task does not exist, this is the classic 404 Not Found: we are trying to create a subresource (an attachment) inside a specific task, but the “container” does not exist.

public Task getTaskOrThrow(String taskId) {
    // Find the "container" resource for the attachment
    return taskRepository.findById(taskId)
            // If the task does not exist, this is exactly 404, not 400
            .orElseThrow(() -> new TaskNotFoundException(taskId));
}

If the task exists but is in the ARCHIVED state, then this is no longer 400 and no longer 404. The client is addressing an existing resource and making a valid request, but by business rule attaching a file is not allowed. That is a valid 409 Conflict:

import com.example.tasktracker.domain.model.TaskStatus;

public void ensureUploadAllowed(TaskStatus status) {
    // Check the domain rule: files cannot be attached to archived tasks
    if (status == TaskStatus.ARCHIVED) {
        throw new FileUploadNotAllowedException("You cannot attach a file to an archived task");
    }
}

These are the cases that make an API “mature”: the client sees the difference between “I broke the request” and “I made a request, but the operation is forbidden by rules.”

8. A single error contract based on ProblemDetail

The last step is to bring everything into one system so that the upload endpoint does not live a “special life.” And honestly, this is the most common beginner problem: they create a “special” error JSON for upload because “there is a file there.” Then the client has to write two error parsers: one for JSON endpoints, one for multipart. And now you no longer have a REST API, but a museum of unique snowflakes.

We stick to one simple idea: all errors go through ProblemDetail, and differences are expressed by three things: HTTP status, code, and sometimes the structure of details.

It is very convenient to pin down the “problem → status → code” correspondence directly as a team agreement (and as a reminder for the future):

Scenario Where it appears HTTP code
No file or metadata part Spring MVC binding 400 INVALID_INPUT
metadata is invalid (description is too long) Bean Validation 400 INVALID_INPUT
The file is empty file validation 400 INVALID_INPUT
Content-Type is not in the whitelist file validation 415 UNSUPPORTED_MEDIA_TYPE
Task not found business rule 404 TASK_NOT_FOUND
Task is archived business rule 409 FILE_UPLOAD_NOT_ALLOWED

And for all of this to actually work, you do exactly two things: first, you throw predictable exceptions (domain or application), second, you translate them into ProblemDetail in one place (the global handler). That is all — no manual JSON errors in the controller.

A mini example of a domain exception for unsupported type:

public class UnsupportedAttachmentMediaTypeException extends RuntimeException {
    // Keep the Content-Type sent by the client so that we can return it in ProblemDetail
    private final String contentType;

    public UnsupportedAttachmentMediaTypeException(String contentType) {
        this.contentType = contentType;
    }

    public String getContentType() {
        return contentType;
    }
}

And the key effect: the client gets an easier life. It can programmatically say: “if code=UNSUPPORTED_MEDIA_TYPE, show the user ‘only PDF/PNG/JPEG/TXT are supported’.” And it does not need to guess from the text of the message, and it does not need separate scenarios for the multipart endpoint.

9. Typical mistakes in upload validation

Mistake #1: mix up 400 and 409 because “both are errors.”
In practice this leads to an API where the client cannot tell whether it sent bad data or hit a business-rule restriction. An empty file, a missing part, an invalid description — these are 400 because the request does not match the contract. An archived task is 409 because the request is correct, but the operation is forbidden by domain rules.

Mistake #2: check only the file extension and ignore Content-Type.
An extension is convenient, but it is not part of the HTTP contract as such. In the upload endpoint contract, the media type is often the “language” the client and server speak. In a training project, a whitelist by Content-Type and a proper 415 are enough for the client to see a clear signal: the format is not supported.

Mistake #3: assume that “if the part exists, the file is not empty.”
Multipart allows sending a part with zero size. And that is not rare: someone submitted the form without selecting a file, someone uploaded a “placeholder,” someone made a mistake in client code. The file.isEmpty() check is basic sanitation. Without it, you risk “creating an attachment without a file,” and then your API will live in a world of strange ghost entities.

Mistake #4: rely only on platform limits and do not do application-level size checks.
The platform can indeed limit the maximum size before the controller is entered, and that is useful. But if you want to control the contract and give the client a meaningful, domain-friendly error, you often also need an application-level check: “in our API the maximum is N megabytes.” Otherwise the client will get “some” error at the container level, and it is harder to align that with a unified error contract.

Mistake #5: create a separate error format for upload “because there is a file there.”
This usually starts innocently: “okay, let us return {message: 'bad file'}.” Then it turns out that the project already has ProblemDetail, code, fieldErrors, and the client is expecting exactly those. The strongest design move is not to invent “upload errors,” but to integrate them into the same error contract: then the endpoint stays predictable, documentable, and testable with exactly the same tools as the rest of the API.

1
Task
Spring REST & MVC, level 26, lesson 3
Locked
Empty file and unsupported type
Empty file and unsupported type
1
Task
Spring REST & MVC, level 26, lesson 3
Locked
`404 Not Found` and `409 Conflict` for the upload scenario
`404 Not Found` and `409 Conflict` for the upload scenario
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION