AdvancedJava · Lesson 6 of 9

REST APIs with Spring Boot

Controllers, records as DTOs, validation, error handling and tests with MockMvc.

Spring Boot is the most widely used Java framework for web services. It configures an embedded web server, JSON conversion, validation and much more automatically, so a REST API is a few annotated classes. Generate projects at start.spring.io.

A @RestController maps HTTP requests to methods (@GetMapping, @PostMapping). Records make clean request and response types; Jakarta Validation annotations (@NotBlank, @Min, @Max) plus @Valid reject bad input with a 400 response automatically.

Spring injects dependencies through constructors: declare a @Service and ask for it in the controller's constructor. Map your own exceptions to HTTP statuses with @ResponseStatus or an exception handler, and test the whole web layer with MockMvc.

pom.xmlXML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
  </parent>
  <groupId>tz.dolese</groupId>
  <artifactId>results-api</artifactId>
  <version>1.0.0</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>
src/main/java/tz/dolese/results/ResultsApplication.javaJava
package tz.dolese.results;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ResultsApplication {
    public static void main(String[] args) {
        SpringApplication.run(ResultsApplication.class, args);
    }
}
src/main/java/tz/dolese/results/StudentController.javaJava
package tz.dolese.results;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

record StudentRequest(@NotBlank String name, @Min(1) @Max(6) int form, List<@Min(0) @Max(100) Integer> scores) {}

record StudentResponse(long id, String name, int form, double average) {}

@ResponseStatus(HttpStatus.NOT_FOUND)
class StudentNotFoundException extends RuntimeException {
    StudentNotFoundException(long id) { super("Student " + id + " not found"); }
}

@Service
class StudentService {
    private final Map<Long, StudentRequest> store = new ConcurrentHashMap<>();
    private final AtomicLong ids = new AtomicLong();

    StudentResponse create(StudentRequest req) {
        long id = ids.incrementAndGet();
        store.put(id, req);
        return toResponse(id, req);
    }

    StudentResponse get(long id) {
        StudentRequest req = store.get(id);
        if (req == null) throw new StudentNotFoundException(id);
        return toResponse(id, req);
    }

    private StudentResponse toResponse(long id, StudentRequest req) {
        List<Integer> scores = req.scores() == null ? List.of() : req.scores();
        double avg = scores.stream().mapToInt(Integer::intValue).average().orElse(0);
        return new StudentResponse(id, req.name(), req.form(), Math.round(avg * 10) / 10.0);
    }
}

@RestController
@RequestMapping("/api/students")
class StudentController {
    private final StudentService service;

    StudentController(StudentService service) {   // constructor injection
        this.service = service;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    StudentResponse create(@Valid @RequestBody StudentRequest request) {
        return service.create(request);
    }

    @GetMapping("/{id}")
    StudentResponse get(@PathVariable long id) {
        return service.get(id);
    }
}
src/test/java/tz/dolese/results/StudentControllerTest.javaJava
package tz.dolese.results;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {
    @Autowired MockMvc mvc;

    @Test
    void createsAndFetchesStudent() throws Exception {
        mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Amina\",\"form\":4,\"scores\":[80,90]}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.average").value(85.0));

        mvc.perform(get("/api/students/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Amina"));
    }

    @Test
    void rejectsInvalidInputAndUnknownIds() throws Exception {
        mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\",\"form\":9}"))
            .andExpect(status().isBadRequest());

        mvc.perform(get("/api/students/999")).andExpect(status().isNotFound());
    }
}
TerminalShell
mvn test
mvn spring-boot:run
curl -X POST localhost:8080/api/students -H "Content-Type: application/json" \
     -d '{"name":"Amina","form":4,"scores":[80,90]}'

Key points

  • @RestController + mapping annotations turn methods into endpoints.
  • Records + Jakarta Validation + @Valid give typed, validated input.
  • Use constructor injection, map domain exceptions to statuses, and test with MockMvc.

Exercise

Add GET /api/students?form=4, PUT /api/students/{id} and DELETE /api/students/{id} with tests. Then replace the in-memory map with Spring Data JDBC or JPA backed by PostgreSQL.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The service gains list(form), update and delete; the controller maps them to GET with an optional @RequestParam, PUT and DELETE. Unknown ids still raise StudentNotFoundException, which becomes a 404. The tests cover each endpoint and its error case.

StudentController.java (additions)Java
// In StudentService:
List<StudentResponse> list(Integer form) {
    return store.entrySet().stream()
        .filter(e -> form == null || e.getValue().form() == form)
        .sorted(Map.Entry.comparingByKey())
        .map(e -> toResponse(e.getKey(), e.getValue()))
        .toList();
}

StudentResponse update(long id, StudentRequest req) {
    if (!store.containsKey(id)) throw new StudentNotFoundException(id);
    store.put(id, req);
    return toResponse(id, req);
}

void delete(long id) {
    if (store.remove(id) == null) throw new StudentNotFoundException(id);
}

// In StudentController:
@GetMapping
List<StudentResponse> list(@RequestParam(required = false) Integer form) {
    return service.list(form);
}

@PutMapping("/{id}")
StudentResponse update(@PathVariable long id, @Valid @RequestBody StudentRequest request) {
    return service.update(id, request);
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void delete(@PathVariable long id) {
    service.delete(id);
}
src/test/java/tz/dolese/results/StudentCrudTest.javaJava
package tz.dolese.results;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
class StudentCrudTest {
    @Autowired MockMvc mvc;

    void create(String json) throws Exception {
        mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON).content(json))
            .andExpect(status().isCreated());
    }

    @Test
    void filtersByForm() throws Exception {
        create("{\"name\":\"Amina\",\"form\":4}");
        create("{\"name\":\"Juma\",\"form\":3}");
        mvc.perform(get("/api/students").param("form", "4"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.length()").value(1))
            .andExpect(jsonPath("$[0].name").value("Amina"));
    }

    @Test
    void updatesAndDeletes() throws Exception {
        create("{\"name\":\"Amina\",\"form\":4}");
        mvc.perform(put("/api/students/1").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Amina Hassan\",\"form\":4,\"scores\":[90]}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.average").value(90.0));
        mvc.perform(delete("/api/students/1")).andExpect(status().isNoContent());
        mvc.perform(delete("/api/students/1")).andExpect(status().isNotFound());
        mvc.perform(put("/api/students/9").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Nobody\",\"form\":1}"))
            .andExpect(status().isNotFound());
    }
}

Check your understanding

  1. What does @RestController do?

  2. What happens when a @Valid @RequestBody fails validation?

  3. Why prefer constructor injection over field injection?

  4. How does @ResponseStatus(HttpStatus.NOT_FOUND) on an exception class help?

Ask AI