View source on GitHub

Module 39 - Spring REST APIs

Building a fully-featured REST API with Spring MVC: @RestController for request handling, Bean Validation for input constraints, @RestControllerAdvice + ProblemDetail for standardized error responses (RFC 9457), HATEOAS for hypermedia links, and content negotiation to control acceptable response formats. Tests use the @WebMvcTest slice - no real HTTP server, no database.


@RestController Anatomy

// @RestController = @Controller + @ResponseBody on every method
// Every return value is serialized directly to the response body (JSON by default).

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {

    private final ProductService productService;

    // Constructor injection - no @Autowired needed (single constructor)
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<EntityModel<ProductResponse>> getById(@PathVariable Long id) { ... }

    @PostMapping
    public ResponseEntity<EntityModel<ProductResponse>> create(
            @Valid @RequestBody ProductRequest request) { ... }

    @PutMapping("/{id}")
    public ResponseEntity<EntityModel<ProductResponse>> update(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request) { ... }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) { ... }
}
  Key annotations:
  @PathVariable   - bind URI template segment {id} to a method parameter
  @RequestParam   - bind query string ?category=X (required=false → optional)
  @RequestBody    - deserialize JSON body into a Java object (Jackson)
  @Valid          - trigger Bean Validation before the method body runs
  ResponseEntity  - control status code + headers + body explicitly

Content Negotiation

// produces = APPLICATION_JSON_VALUE declares what this controller can produce.
// If the client sends Accept: text/html → 406 Not Acceptable.
// If the client sends Accept: application/json → 200 OK.

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController { ... }
  Content negotiation:
    Client sends Accept header           Spring MVC checks produces list
    ─────────────────────────────────    ────────────────────────────────────────
    Accept: application/json         →   match → 200 OK, JSON body
    Accept: application/hal+json     →   match (HATEOAS alias) → 200 OK
    Accept: text/html                →   no match → 406 Not Acceptable
    (no Accept header)               →   defaults to first produces entry → 200 OK

Bean Validation

// Request DTO as a Java record with constraint annotations.
// @Valid on @RequestBody triggers validation before the method runs.
// If any constraint fails → MethodArgumentNotValidException (→ handled by @ControllerAdvice).

public record ProductRequest(

        @NotBlank(message = "Name is required")
        @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
        String name,

        @NotNull(message = "Price is required")
        @DecimalMin(value = "0.01", message = "Price must be at least 0.01")
        BigDecimal price,

        @NotBlank(message = "Category is required")
        String category
) {}
  Common constraints:
  @NotNull         - value must not be null
  @NotBlank        - String must not be null, empty, or whitespace
  @Size(min, max)  - String/Collection size range
  @Min / @Max      - numeric lower/upper bound
  @DecimalMin      - BigDecimal lower bound (string, to avoid floating-point issues)
  @Email           - valid email format
  @Pattern(regexp) - regex match

  Validation happens BEFORE the controller method body runs.
  No need to check constraints manually inside the method.

@RestControllerAdvice + ProblemDetail (RFC 9457)

// @RestControllerAdvice = @ControllerAdvice + @ResponseBody
// Catches exceptions thrown from any @RestController in the application.
// ProblemDetail is Spring 6's built-in RFC 9457 implementation.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 404 - resource not found
    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail handleProductNotFound(ProductNotFoundException ex,
                                               HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Product Not Found");
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }

    // 400 - @Valid constraint violations
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex,
                                                HttpServletRequest request) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .toList();

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Validation failed");
        problem.setTitle("Validation Error");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("errors", errors);   // custom extension property
        return problem;
    }
}
  RFC 9457 ProblemDetail JSON response:
  {
    "type":     "about:blank",
    "title":    "Product Not Found",
    "status":   404,
    "detail":   "Product not found: 99",
    "instance": "/products/99"
  }

  Standard fields:   type, title, status, detail, instance
  Extension fields:  any via problem.setProperty("key", value)
  Status code:       returned as both HTTP response status AND "status" field in body

HATEOAS

Why HATEOAS?

  REST Level 3 (Richardson Maturity Model) - clients follow links, not hard-coded URLs.
  API can change URL structure without breaking clients that follow the links.
  Self-documenting: each response tells the client what it can do next.

  HAL (Hypertext Application Language) format:
  {
    "id": 1,
    "name": "Laptop",
    "_links": {
      "self":     { "href": "http://localhost/products/1" },
      "products": { "href": "http://localhost/products"  }
    }
  }

EntityModel and CollectionModel

// EntityModel<T> - wraps a single item with links
// CollectionModel<EntityModel<T>> - wraps a list with links

@GetMapping("/{id}")
public ResponseEntity<EntityModel<ProductResponse>> getById(@PathVariable Long id) {
    ProductResponse product = productService.findById(id);

    EntityModel<ProductResponse> model = EntityModel.of(product,
            linkTo(methodOn(ProductController.class).getById(id)).withSelfRel(),
            linkTo(methodOn(ProductController.class).getAll(null)).withRel("products"));

    return ResponseEntity.ok(model);
}

@GetMapping
public ResponseEntity<CollectionModel<EntityModel<ProductResponse>>> getAll(
        @RequestParam(required = false) String category) {

    List<EntityModel<ProductResponse>> items = products.stream()
            .map(p -> EntityModel.of(p,
                    linkTo(methodOn(ProductController.class).getById(p.id())).withSelfRel()))
            .toList();

    CollectionModel<EntityModel<ProductResponse>> collection = CollectionModel.of(items,
            linkTo(methodOn(ProductController.class).getAll(null)).withSelfRel());

    return ResponseEntity.ok(collection);
}
  linkTo(methodOn(C.class).method(args))  - type-safe link generation (no string URLs)
  .withSelfRel()                          - rel="self"
  .withRel("products")                   - custom rel name
  .toUri()                               - extract URI for Location header

@Relation - Controlling the _embedded Key

// Without @Relation, HAL uses the class name → "productResponseList" (ugly)
// @Relation sets predictable keys for collection and single item

@Relation(collectionRelation = "products", itemRelation = "product")
public record ProductResponse(Long id, String name, BigDecimal price, String category) {}

// Collection response:
// {
//   "_embedded": {
//     "products": [{ "id": 1, "name": "Laptop", ... }, ...]
//   },
//   "_links": { "self": { "href": "http://localhost/products" } }
// }

201 Created with Location Header

// POST /products - return 201 + Location pointing to the new resource
@PostMapping
public ResponseEntity<EntityModel<ProductResponse>> create(
        @Valid @RequestBody ProductRequest request) {

    ProductResponse created = productService.create(request);

    return ResponseEntity
            .created(linkTo(methodOn(ProductController.class).getById(created.id())).toUri())
            .body(EntityModel.of(created,
                    linkTo(methodOn(ProductController.class).getById(created.id())).withSelfRel()));
}

// Location: http://localhost/products/1
// Status: 201 Created

@WebMvcTest - Slice Testing

// @WebMvcTest loads only the web layer:
//   - @RestController beans
//   - @ControllerAdvice / @RestControllerAdvice
//   - Jackson, HATEOAS converters, Spring MVC config
//   - Does NOT load @Service, @Repository, @Component
//
// MockMvc performs mock HTTP requests - no real server, no real HTTP.
// @MockBean creates a Mockito mock and registers it in the Spring context.

@WebMvcTest
class ProductControllerTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;

    @MockBean ProductService productService;  // replaces the real service

    @Test
    void getById_found_returns_entity_with_links() throws Exception {
        given(productService.findById(1L)).willReturn(
                new ProductResponse(1L, "Laptop", new BigDecimal("999.00"), "Electronics"));

        mockMvc.perform(get("/products/1").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$._links.self").exists())
                .andExpect(jsonPath("$._links.products").exists());
    }

    @Test
    void create_blank_name_returns_400_problem_detail() throws Exception {
        ProductRequest bad = new ProductRequest("", new BigDecimal("999.00"), "Electronics");

        mockMvc.perform(post("/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(bad)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.title").value("Validation Error"))
                .andExpect(jsonPath("$.errors").isArray());
    }
}
  BDDMockito patterns for void methods:
    given(service.findById(1L)).willReturn(response)     - stub return value
    given(service.findById(99L)).willThrow(new Ex(99L))  - stub exception
    willDoNothing().given(service).delete(1L)            - void method, no-op
    willThrow(new Ex(99L)).given(service).delete(99L)    - void method, throw

Module 39 - What Was Built

  module-39-spring-rest/
  ├── pom.xml     (Spring Boot 3.3.5, spring-boot-starter-hateoas,
  │               spring-boot-starter-validation, lombok 1.18.34)
  └── src/
      ├── main/java/com/javatraining/springrest/
      │   ├── SpringRestApplication.java
      │   ├── model/
      │   │   └── Product.java            - @Data @Builder (in-memory entity)
      │   ├── dto/
      │   │   ├── ProductRequest.java     - record + Bean Validation constraints
      │   │   └── ProductResponse.java    - record + @Relation(collectionRelation="products")
      │   ├── exception/
      │   │   ├── ProductNotFoundException.java   - RuntimeException with id in message
      │   │   └── GlobalExceptionHandler.java     - @RestControllerAdvice, ProblemDetail
      │   ├── service/
      │   │   └── ProductService.java     - ConcurrentHashMap store, AtomicLong IDs
      │   └── controller/
      │       └── ProductController.java  - produces=APPLICATION_JSON_VALUE,
      │                                     EntityModel/CollectionModel, WebMvcLinkBuilder
      └── test/java/com/javatraining/springrest/
          └── ProductControllerTest.java  - 12 tests:
              GET all (HAL collection + links)
              GET by category (?category= filter)
              GET by id (entity + links)
              GET 404 (ProblemDetail)
              POST valid (201 + Location header)
              POST blank name (400 ProblemDetail + errors array)
              POST null price (400 ProblemDetail)
              PUT existing (200)
              PUT not found (404)
              DELETE existing (204)
              DELETE not found (404)
              Accept: text/html (406 content negotiation)

All tests: 12 passing.


Key Takeaways

  @RestController              @Controller + @ResponseBody - returns JSON by default
  produces = APPLICATION_JSON  declares acceptable response type; other Accept → 406
  @PathVariable                bind {id} segment to method parameter
  @RequestParam(required=false) optional query string; null when absent
  @Valid @RequestBody          trigger Bean Validation on the deserialized object
  ResponseEntity               explicit control of status code, headers, body

  ProblemDetail                RFC 9457: type/title/status/detail/instance + extensions
  @RestControllerAdvice        cross-cutting exception → ProblemDetail mapping
  setProperty("errors", list)  add custom extension fields to the problem response

  EntityModel.of(t, links)     wrap item with HATEOAS links
  CollectionModel.of(items, links) wrap list with HATEOAS links
  linkTo(methodOn(C.class).m()) type-safe link - no string URLs
  .withSelfRel()               rel="self" link
  .withRel("products")         custom named relation link
  .toUri()                     extract URI for Location header (POST 201)
  @Relation(collectionRelation) control HAL _embedded key name (avoid ugly defaults)

  @WebMvcTest                  web-layer-only slice: no @Service, no DB
  @MockBean                    Mockito mock registered in the slice context
  MockMvc                      mock HTTP requests without a real server
  BDDMockito                   given/willReturn, willThrow, willDoNothing patterns