Java · lesson 52 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also 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
- Add
GET /api/products/countreturning{"count":3}. It sits next toGET /{id}— why doesn’tcountget 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}.
- Add a
limitquery 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}]
- Add
PATCH /api/products/{id}/pricethat 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.