@RestController and @RequestMapping

Turn a class into a JSON endpoint with @RestController, and map paths and HTTP methods with @RequestMapping and its shortcuts.

Syntax@RestController @RequestMapping("/api/orders") class OrderController { @GetMapping("/{id}") OrderResponse get(@PathVariable long id) { ... } @PostMapping OrderResponse create(@RequestBody CreateOrder body) { ... } }

A Spring MVC controller is a bean whose methods handle HTTP requests. @RestController marks the class as a controller and says that every return value is the response body. Spring picks a message converter from the return type and the request's Accept header, and for a record or a list of records that means JSON written by Jackson.

@RestController is @Controller plus @ResponseBody. Without the second half, Spring treats a returned String as a view name and goes looking for a template, which is the classic "why is it rendering an error page" mistake when someone copies a controller from a server-rendered app.

Mapping requests

@RequestMapping on the class sets a shared path prefix. On methods you normally use the shortcuts @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping; each is @RequestMapping with the method fixed. The paths join, so a class mapped to /api/orders with a method mapped to /{id} answers GET /api/orders/42.

@RestController
@RequestMapping("/api/orders")
class OrderController {
    @GetMapping            // GET /api/orders
    @GetMapping("/{id}")   // GET /api/orders/42
    @PostMapping           // POST /api/orders
}

A mapping can be narrowed further with params, headers, consumes (the request Content-Type) and produces (what the client accepts). Spring answers with the right status when nothing fits: 404 when no path matches, 405 with an Allow header when the path exists but the method does not, 415 when consumes rejects the body type and 406 when produces cannot satisfy Accept.

Mistakes worth avoiding

  • A bare @RequestMapping on a method with no method attribute matches every HTTP verb, so a DELETE runs your read code. Use the shortcuts.
  • Trailing slashes no longer match: since Spring Framework 6.0 /api/orders/ is a different path from /api/orders and returns 404. If old clients send the slash, add a UrlHandlerFilter rather than duplicating mappings.
  • Two methods with the same path and method fail at startup with an ambiguous mapping error. That is a feature: you find out before a request does.
  • Keep controllers thin. Parse the request, call a service, shape the response. Business rules in controllers are hard to test and end up copied between endpoints.

Controllers are singletons shared by every request thread, so never store per-request data in fields.

Example

Example · java
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

record OrderResponse(long id, String customer, BigDecimal total) {}

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final Map<Long, OrderResponse> orders = new ConcurrentHashMap<>(Map.of(
            1L, new OrderResponse(1, "[email protected]", new BigDecimal("42.50")),
            2L, new OrderResponse(2, "[email protected]", new BigDecimal("9.99"))));

    // GET /api/orders -> a JSON array
    @GetMapping
    public List<OrderResponse> list() {
        return List.copyOf(orders.values());
    }

    // GET /api/orders/1 -> one JSON object, or 404
    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable long id) {
        OrderResponse order = orders.get(id);
        if (order == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "order " + id + " not found");
        }
        return order;
    }
}

When to use it

  • Expose GET /api/orders and GET /api/orders/{id} as JSON for a single-page front end.
  • Group every endpoint for one resource under a shared /api/customers prefix so paths stay consistent.
  • Stop a migrated client that sends /api/orders/ from getting 404s after moving to Spring Framework 6 or later.

More examples

What @RestController saves you

<code>@RestController</code> is a composed annotation made of exactly these two. Seeing the long form explains the most common bug in mixed applications: a class with only <code>@Controller</code> returns <code>"ok"</code>, and Spring tries to render a template called <code>ok</code>. In a JSON API, use <code>@RestController</code> everywhere and the question never comes up.

Example · java
import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

record Tag(String name) {}

// Equivalent to @RestController: @ResponseBody on the class applies to every method.
@Controller
@ResponseBody
@RequestMapping("/api/tags")
public class TagController {

    @GetMapping
    public List<Tag> list() {
        return List.of(new Tag("java"), new Tag("spring"));
    }
}

Narrowing a mapping with consumes, produces and params

Attributes on the class are inherited by every method, and method-level <code>consumes</code> overrides the class value. Two <code>GET /api/notes</code> methods can coexist because <code>params = "pinned"</code> makes one more specific. Use this sparingly: a separate path is usually easier for clients to discover than a mapping that depends on which parameters happen to be present.

Example · java
import java.util.List;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

record Note(String text) {}

@RestController
@RequestMapping(path = "/api/notes", produces = MediaType.APPLICATION_JSON_VALUE)
public class NoteController {

    // Only matches when the body is JSON; anything else gets 415 Unsupported Media Type.
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public Note create(@RequestBody Note note) {
        return note;
    }

    // GET /api/notes?pinned picks this method because the parameter is present.
    @GetMapping(params = "pinned")
    public List<Note> pinned() {
        return List.of(new Note("Ship on Friday? No."));
    }

    @GetMapping
    public List<Note> all() {
        return List.of(new Note("Ship on Friday? No."), new Note("Buy milk"));
    }
}

Redirecting trailing slashes instead of duplicating mappings

Spring MVC stopped treating <code>/orders/</code> as <code>/orders</code> in Framework 6.0, so an old client or a hand-written link can suddenly start getting 404s after an upgrade. A filter bean is picked up by Spring Boot and runs before routing. Use <code>redirect</code> to teach clients the canonical URL, or <code>wrapRequest()</code> to strip the slash silently when you cannot change the clients.

Example · java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.UrlHandlerFilter;

@Configuration
public class TrailingSlashConfig {

    // GET /api/orders/ -> 308 redirect to /api/orders, keeping the method and body.
    @Bean
    UrlHandlerFilter trailingSlashFilter() {
        return UrlHandlerFilter
                .trailingSlashHandler("/api/**")
                .redirect(HttpStatus.PERMANENT_REDIRECT)
                .build();
    }
}

Discussion

  • Be the first to comment on this lesson.