Skip to main content
Java · lesson 52 of 66
Open the Java page →
Java intermediate Lesson 52 of 66

Also part of: JUnit

Building REST APIs with Spring Boot

Build a REST API with Spring Boot 3 and curl it: path variables, query params, JSON bodies, ResponseEntity status codes, headers, CORS and MockMvc.

By SkillByExample · Updated

Spring MVC, which spring-boot-starter-web brings in, maps HTTP requests to Java methods, turns JSON into objects and back with Jackson, and lets the method decide the status code and headers. This lesson builds one small product API — a Spring Boot 3.5 project packaged with mvn package and started with java -jar — and every response below is what curl received from it. The data lives in an in-memory ProductStore bean so the HTTP layer is the only thing in focus.

The model and the store

// Product.java — what the API returns
public record Product(Long id, String name, String category, BigDecimal price) {}

// ProductRequest.java — what clients send (no id: the server assigns it)
public record ProductRequest(String name, String category, BigDecimal price) {}
@Component
public class ProductStore {                       // in-memory stand-in for a database
    private final Map<Long, Product> products = new ConcurrentSkipListMap<>();
    private final AtomicLong ids = new AtomicLong();

    public ProductStore() {
        save(new ProductRequest("Keyboard", "electronics", new BigDecimal("49.99")));
        save(new ProductRequest("Mouse", "electronics", new BigDecimal("19.99")));
        save(new ProductRequest("Desk", "furniture", new BigDecimal("199.00")));
    }

    public List<Product> findAll()            { return new ArrayList<>(products.values()); }
    public Optional<Product> findById(Long id) { return Optional.ofNullable(products.get(id)); }
    public boolean delete(Long id)            { return products.remove(id) != null; }

    public Product save(ProductRequest r) {
        Product p = new Product(ids.incrementAndGet(), r.name(), r.category(), r.price());
        products.put(p.id(), p);
        return p;
    }

    public Optional<Product> update(Long id, ProductRequest r) {
        if (!products.containsKey(id)) return Optional.empty();
        Product p = new Product(id, r.name(), r.category(), r.price());
        products.put(id, p);
        return Optional.of(p);
    }
}

Records make good request and response types (DTOs): Jackson reads and writes their components by name, and they cannot be mutated by accident. Keeping ProductRequest separate from Product means a client cannot choose its own id.

Path variables and ResponseEntity

@RestController
@RequestMapping("/api/products")                       // prefix for every method below
public class ProductController {

    private final ProductStore store;

    public ProductController(ProductStore store) {
        this.store = store;
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> get(@PathVariable Long id) {
        return store.findById(id)
                .map(ResponseEntity::ok)                       // 200 + body
                .orElse(ResponseEntity.notFound().build());    // 404, empty body
    }
$ mvn -q -B package -DskipTests
$ java -jar target/shop-0.0.1-SNAPSHOT.jar &
$ curl -s -w '\n' localhost:8080/api/products/1
{"id":1,"name":"Keyboard","category":"electronics","price":49.99}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/products/99
404
$ curl -s -w '\n' localhost:8080/api/products/abc
{"timestamp":"2026-10-07T22:02:07.217+00:00","status":400,"error":"Bad Request","path":"/api/products/abc"}

{id} in the mapping binds to the parameter of the same name and is converted to Long. A missing product is a normal outcome, not an exception, so the method returns ResponseEntity to choose between 200 and 404. abc never reached the method: conversion to Long failed, and Spring Boot’s default error handler answered 400 with its standard JSON body. The exception handling lesson replaces that body with your own.

Query parameters

    @GetMapping
    public List<Product> list(@RequestParam(required = false) String category,
                              @RequestParam(required = false) BigDecimal maxPrice,
                              @RequestParam(defaultValue = "id") String sort) {
        Comparator<Product> order = switch (sort) {
            case "price" -> Comparator.comparing(Product::price);
            case "name"  -> Comparator.comparing(Product::name);
            default      -> Comparator.comparing(Product::id);
        };
        return store.findAll().stream()
                .filter(p -> category == null || p.category().equals(category))
                .filter(p -> maxPrice == null || p.price().compareTo(maxPrice) <= 0)
                .sorted(order)
                .toList();
    }
$ curl -s -w '\n' localhost:8080/api/products
[{"id":1,"name":"Keyboard","category":"electronics","price":49.99},{"id":2,"name":"Mouse","category":"electronics","price":19.99},{"id":3,"name":"Desk","category":"furniture","price":199.00}]
$ curl -s -w '\n' 'localhost:8080/api/products?category=electronics&sort=price'
[{"id":2,"name":"Mouse","category":"electronics","price":19.99},{"id":1,"name":"Keyboard","category":"electronics","price":49.99}]
$ curl -s -w '\n' 'localhost:8080/api/products?maxPrice=50&sort=name'
[{"id":1,"name":"Keyboard","category":"electronics","price":49.99},{"id":2,"name":"Mouse","category":"electronics","price":19.99}]

Query parameters are required unless marked required = false or given a defaultValue; an absent optional one arrives as null. The same URL /api/products serves both the full list and every filtered view — path for which resource, query string for which subset, in what order. A returned List becomes a JSON array. Quote URLs containing & in the shell, or it backgrounds the command at the &.

Request bodies, 201 Created and 415

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody ProductRequest request) {
        Product saved = store.save(request);
        URI location = URI.create("/api/products/" + saved.id());
        return ResponseEntity.created(location).body(saved);   // 201 + Location header
    }
$ curl -si -X POST localhost:8080/api/products -H 'Content-Type: application/json' -d '{"name":"Lamp","category":"furniture","price":35.50}' | grep -iE '^(HTTP|Location|Content-Type|\{)'
HTTP/1.1 201 
Location: /api/products/4
Content-Type: application/json
{"id":4,"name":"Lamp","category":"furniture","price":35.50}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/products -H 'Content-Type: application/json' -d '{"name": oops}'
400
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/products -H 'Content-Type: text/plain' -d 'Lamp'
415

(grep keeps the status line, the two interesting headers and the body out of curl’s -i dump.) @RequestBody hands the request body to Jackson, which builds the record from the JSON keys. A successful create returns 201 Created with a Location header pointing at the new resource, which is what REST clients follow. Malformed JSON is a 400; a body in a format no converter reads — text/plain here — is 415 Unsupported Media Type. Forgetting the Content-Type header is the most common cause of a surprise 415.

PUT, DELETE, 204 and 405

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody ProductRequest request) {
        return store.update(id, request)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        return store.delete(id)
                ? ResponseEntity.noContent().build()           // 204
                : ResponseEntity.notFound().build();
    }
}
$ curl -s -w '\n' -X PUT localhost:8080/api/products/4 -H 'Content-Type: application/json' -d '{"name":"Desk Lamp","category":"furniture","price":39.00}'
{"id":4,"name":"Desk Lamp","category":"furniture","price":39.00}
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/api/products/4
204
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/api/products/4
404
$ curl -s -o /dev/null -w '%{http_code}\n' -X PATCH localhost:8080/api/products/1
405

PUT replaces the whole resource, so it takes the same ProductRequest as POST. A successful DELETE has nothing to return: 204 No Content. Deleting again gives 404 — the end state is the same, so DELETE is idempotent even though the status differs. No method handles PATCH on that path, and Spring answers 405 Method Not Allowed rather than 404, because the path itself exists.

Request and response headers

@RestController
public class InfoController {

    record ClientInfo(String userAgent, String language, String requestId) {}

    @GetMapping("/api/whoami")
    public ResponseEntity<ClientInfo> whoami(
            @RequestHeader("User-Agent") String userAgent,
            @RequestHeader(value = "Accept-Language", defaultValue = "en") String language,
            @RequestHeader(value = "X-Request-Id", required = false) String requestId) {
        return ResponseEntity.ok()
                .header("X-Api-Version", "1")                  // custom response header
                .body(new ClientInfo(userAgent, language, requestId));
    }
}
$ curl -si localhost:8080/api/whoami -H 'User-Agent: demo-client/1.0' -H 'X-Request-Id: req-42' | grep -iE '^(HTTP|X-Api|\{)'
HTTP/1.1 200 
X-Api-Version: 1
{"userAgent":"demo-client/1.0","language":"en","requestId":"req-42"}
$ curl -s -w '\n' localhost:8080/api/whoami -H 'User-Agent: demo-client/1.0' -H 'Accept-Language: de'
{"userAgent":"demo-client/1.0","language":"de","requestId":null}

@RequestHeader follows the same rules as @RequestParam: required by default, with defaultValue or required = false to relax it — hence en when no Accept-Language was sent and null for the missing X-Request-Id. ResponseEntity.ok().header(...) adds headers to the response.

CORS

A browser page on another origin may only call the API if the API says so. Configure it once for every controller:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://shop.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE");
    }
}
$ curl -si -X OPTIONS localhost:8080/api/products -H 'Origin: https://shop.example.com' -H 'Access-Control-Request-Method: POST' | grep -iE '^(HTTP|Access-Control)'
HTTP/1.1 200 
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Max-Age: 1800
$ curl -si -X OPTIONS localhost:8080/api/products -H 'Origin: https://evil.example.net' -H 'Access-Control-Request-Method: POST' | grep -iE '^(HTTP|Access-Control)|Invalid'
HTTP/1.1 403 
Invalid CORS request

Those OPTIONS requests are the preflight a browser sends before a cross-origin POST. The allowed origin gets Access-Control-Allow-* headers and the browser proceeds; any other origin gets 403. CORS is enforced by browsers only — curl and server-to-server calls ignore it, so it is not an authentication mechanism.

Testing the controller with MockMvc

@WebMvcTest(ProductController.class)        // only the web layer, no server started
@Import(ProductStore.class)
class ProductControllerTest {

    @Autowired MockMvc mvc;

    @Test
    void getsAProduct() throws Exception {
        mvc.perform(get("/api/products/2"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("Mouse"));
    }

    @Test
    void createReturns201WithLocation() throws Exception {
        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Lamp\",\"category\":\"furniture\",\"price\":35.5}"))
           .andExpect(status().isCreated())
           .andExpect(header().string("Location", "/api/products/4"));
    }

    @Test
    void unknownIdIs404() throws Exception {
        mvc.perform(get("/api/products/99")).andExpect(status().isNotFound());
    }
}
$ mvn -B test 2>&1 | grep -E 'Tests run:|BUILD'
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.109 s -- in com.example.shop.ProductControllerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

@WebMvcTest starts the MVC machinery — routing, JSON conversion, status handling — but no Tomcat and only the named controller, so the ProductStore it depends on is pulled in with @Import (or replaced by a @MockitoBean mock). Every assertion here checks the HTTP contract: status, header, JSON field. The static imports come from MockMvcRequestBuilders and MockMvcResultMatchers.

Practice

  1. Add GET /api/products/count returning {"count":3}. It sits next to GET /{id} — why doesn’t count get treated as an id and fail with 400?
Solution
    record Count(int count) {}

    @GetMapping("/count")
    public Count count() {
        return new Count(store.findAll().size());
    }
$ curl -s -w '\n' localhost:8080/api/products/count
{"count":3}

When several mappings match, Spring picks the most specific: the literal /count beats the pattern /{id}.

  1. Add a limit query parameter to the list endpoint, defaulting to 100, and request the two cheapest products.
Solution
                              @RequestParam(defaultValue = "id") String sort,
                              @RequestParam(defaultValue = "100") int limit) {
        // ...
                .sorted(order)
                .limit(limit)
                .toList();
$ curl -s -w '\n' 'localhost:8080/api/products?sort=price&limit=2'
[{"id":2,"name":"Mouse","category":"electronics","price":19.99},{"id":1,"name":"Keyboard","category":"electronics","price":49.99}]
  1. Add PATCH /api/products/{id}/price that takes {"price": 149.00} and changes only the price.
Solution
    record PriceChange(BigDecimal price) {}

    @PatchMapping("/{id}/price")
    public ResponseEntity<Product> changePrice(@PathVariable Long id, @RequestBody PriceChange change) {
        return store.findById(id)
                .flatMap(p -> store.update(id, new ProductRequest(p.name(), p.category(), change.price())))
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
$ curl -s -w '\n' -X PATCH localhost:8080/api/products/3/price -H 'Content-Type: application/json' -d '{"price":149.00}'
{"id":3,"name":"Desk","category":"furniture","price":149.00}
$ curl -s -o /dev/null -w '%{http_code}\n' -X PATCH localhost:8080/api/products/99/price -H 'Content-Type: application/json' -d '{"price":1}'
404

PATCH is for partial updates; the dedicated small DTO makes it impossible to change the name by accident.

Frequently Asked Questions

What is the difference between @Controller and @RestController?
@Controller marks a Spring MVC controller whose methods normally return view names that a template engine renders. @RestController is @Controller plus @ResponseBody, so every method's return value is serialised straight into the HTTP response body, usually as JSON via Jackson. Use @RestController for APIs.
What is the difference between @RequestParam and @PathVariable?
@PathVariable reads a segment of the URL path, as in /api/products/{id}, and identifies a resource. @RequestParam reads the query string, as in /api/products?category=furniture, and is used for filtering, sorting and paging. Both convert the text to the parameter type and answer 400 Bad Request if conversion fails.
When should I return ResponseEntity instead of the object?
Return the object directly when the answer is always 200 OK with a body. Return ResponseEntity when the status or headers depend on the outcome, such as 201 Created with a Location header after a POST, 204 No Content after a DELETE, or 404 Not Found when an id does not exist.
Why does Spring return 415 Unsupported Media Type?
A @RequestBody parameter needs a message converter that can read the request's Content-Type. When a client posts JSON without the header Content-Type: application/json, or sends text/plain, no converter matches and Spring rejects the request with 415 before the controller method runs.
How do I test a Spring Boot REST controller?
Annotate a test class with @WebMvcTest(YourController.class) and inject MockMvc. It starts only the web layer, without a real server, and lets you perform requests and assert on status, headers and JSON fields with jsonPath. Beans the controller needs are supplied with @Import or with @MockitoBean mocks.