Skip to main content

RESTful Web Services with Spring Boot

Source: Spring Boot in Practice — Chapter 7 by Somnath Musib (Manning). Concepts rewritten for this course; companion samples: spring-boot-in-practice/repo ch07.

Expose Course Tracker as JSON APIs: controllers, status codes, exceptions, MockMvc tests, OpenAPI docs, versioning, and JWT resource-server security.

Pattern-check challenges

Editors do not compile or run Spring Boot. Check uses sourceChecks only. Snippets above exercises show the usual shape with inline comments; type the code yourself. Paste is disabled on Spring Boot lessons.

What this lesson covers​

  • Designing CRUD REST endpoints with @RestController
  • Exception handling (@ControllerAdvice, HTTP status codes)
  • Integration testing with MockMvc
  • Documenting APIs with OpenAPI / springdoc
  • Versioning strategies (URI, query param, header, media type)
  • Securing APIs with OAuth2 resource server + JWT (overview)

1. Building a REST API​

REST clients request a resource; the server returns a representation (usually JSON). Spring Boot + Jackson map JSON ↔ Java types.

Typical stack:

Client → @RestController → service interface → repository → DB

Prefer plural nouns in paths (/courses), not verbs (/getCourses). The HTTP method already is the verb:

MethodPathPurpose
GET/courses/List all
GET/courses/{id}One course
GET/courses/category/{name}Filter by category
POST/courses/Create
PUT/courses/{id}Update
DELETE/courses/{id}Delete one
DELETE/courses/Delete all

@RestController = @Controller + @ResponseBody (return value goes in the HTTP body, not a view).

@RestController
@RequestMapping("/courses/") // base path for this resource
public class CourseController {

private final CourseService courseService;

@GetMapping // GET /courses/
public Iterable<Course> getAllCourses() {
return courseService.getCourses();
}

@GetMapping("{id}") // GET /courses/1
public Optional<Course> getCourseById(@PathVariable("id") long courseId) {
return courseService.getCourseById(courseId);
}

@PostMapping // POST /courses/ — JSON body → Course
public Course createCourse(@RequestBody Course course) {
return courseService.createCourse(course);
}

@PutMapping("{id}")
public void updateCourse(@PathVariable("id") long courseId, @RequestBody Course course) {
courseService.updateCourse(courseId, course);
}

@DeleteMapping("{id}")
public void deleteCourseById(@PathVariable("id") long courseId) {
courseService.deleteCourseById(courseId);
}
}

Wire the controller to a service interface (not the impl class) so you can swap implementations later.

@RestController
@RequestMapping("/courses/")
public class CourseController {

// inject CourseService here

@GetMapping
public Iterable<Course> getAllCourses() {
return null; // replace
}

@GetMapping("{id}")
public Optional<Course> getCourseById(@PathVariable("id") long courseId) {
return Optional.empty(); // replace
}

@PostMapping
public Course createCourse(@RequestBody Course course) {
return null; // replace
}
}
HTTP status polish

Returning an entity often yields 200. Many APIs use @ResponseStatus(HttpStatus.CREATED) on POST and 204 NO_CONTENT on delete/update-with-no-body. Pick status codes deliberately — clients depend on them.

Quiz

REST design quiz

1 / 2

@RestController differs from @Controller mainly because…

Pick an answer to see if you are right.


2. Exception handling​

Unhandled deletes on missing IDs often become 500 with stack traces — useless and leaky. Map domain errors to meaningful statuses:

  • Missing resource → 404
  • Malformed JSON / validation → 400

Throw a custom CourseNotFoundException, then handle it globally:

@ControllerAdvice // applies across controllers
public class CourseTrackerGlobalExceptionHandler extends ResponseEntityExceptionHandler {

@ExceptionHandler(CourseNotFoundException.class)
public ResponseEntity<?> handleCourseNotFound(
CourseNotFoundException ex, WebRequest request) {
return handleExceptionInternal(
ex, ex.getMessage(), new HttpHeaders(), HttpStatus.NOT_FOUND, request); // 404
}
}

In the service, fail closed:

courseRepository.findById(courseId)
.orElseThrow(() -> new CourseNotFoundException("No course with id " + courseId));
@ControllerAdvice
public class CourseTrackerGlobalExceptionHandler extends ResponseEntityExceptionHandler {

// handle CourseNotFoundException → 404
}

3. Testing with MockMvc​

CLI tools (HTTPie, cURL) and GUIs (Postman) are fine for exploration. For regressions, use MockMvc: Spring MVC request handling with mock request/response — no real HTTP server required for many tests.

@SpringBootTest
@AutoConfigureMockMvc
class CourseTrackerApiApplicationTests {

@Autowired MockMvc mockMvc;

@Test
void testPostCourse() throws Exception {
String json = """
{"name":"Rapid Spring Boot","category":"Spring","rating":5,"description":"..."}
""";
mockMvc.perform(post("/courses/")
.contentType("application/json")
.content(json))
.andExpect(status().isCreated()) // or isOk() depending on your API
.andExpect(jsonPath("$.name").value("Rapid Spring Boot"));
}
}

Key pieces: @SpringBootTest, @AutoConfigureMockMvc, fluent perform / andExpect, often jsonPath for JSON asserts.

@SpringBootTest
@AutoConfigureMockMvc
class CourseApiTests {

@Autowired
private MockMvc mockMvc;

@Test
void missingCourseIsNotFound() throws Exception {
// GET /courses/100 → expect 404
}
}

WebTestClient (reactive / WebFlux-friendly) is another option; MockMvc is the classic servlet-stack choice.


4. Documenting with OpenAPI​

API docs are a contract: paths, bodies, status codes, security. OpenAPI is the spec; Swagger UI is a common viewer. In Spring Boot, springdoc-openapi introspects your app and serves UI (historically /swagger-ui.html).

<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.5.9</version> <!-- book-era artifact; Boot 3 often uses springdoc-openapi-starter-webmvc-ui -->
</dependency>

Annotate controllers:

@Tag(name = "Course Controller", description = "Manage courses")
@RestController
@RequestMapping("/courses/")
public class CourseController {

@GetMapping
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "List all courses")
public Iterable<Course> getAllCourses() { ... }

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create a course")
public Course createCourse(@Valid @RequestBody Course course) { ... }
}

Optional metadata bean:

@Bean
OpenAPI customOpenAPI(
@Value("${app.description}") String description,
@Value("${app.version}") String version) {
return new OpenAPI().info(new Info()
.title("Course Tracker API")
.version(version)
.description(description));
}

Export machine-readable docs from /v3/api-docs (JSON) for Swagger Editor / Codegen.

@RestController
@RequestMapping("/courses/")
@Tag(name = "Course Controller", description = "Manage courses in Course Tracker")
public class CourseController {

@GetMapping
@ResponseStatus(code = HttpStatus.OK)
@Operation(summary = "Provides all courses available")
public Iterable<Course> getAllCourses() {
return courseService.getCourses();
}
}

5. API versioning​

Breaking changes (e.g. adding required price) should not silently break existing clients. Common strategies:

StrategyExample
URI/courses/v1, /courses/v2
Request param/courses/?version=v2
Custom headerX-API-VERSION: v2
Media type (Accept)Accept: application/vnd.sbip.app-v2+json

URI versioning (clearest in demos):

@RestController
@RequestMapping("/courses/v1")
public class LegacyCourseController { ... }

@RestController
@RequestMapping("/courses/v2")
public class ModernCourseController { ... }

Request parameter:

@GetMapping(params = "version=v1")
public Iterable<Course> getAllLegacyCourses() { ... }

@GetMapping(params = "version=v2")
public Iterable<ModernCourse> getAllModernCourses() { ... }

Custom header:

@GetMapping(headers = "X-API-VERSION=v2")
public Iterable<ModernCourse> getAllModernCourses() { ... }

Accept / content negotiation:

@GetMapping(produces = "application/vnd.sbip.app-v2+json")
public Iterable<ModernCourse> getAllModernCourses() { ... }

No single industry winner (Amazon, GitHub, Microsoft, Twitter pick different styles). Choose deliberately and document it.

@RestController
@RequestMapping("/courses/")
public class VersionedCourseController {

@GetMapping(/* v1 */)
public Iterable<Course> getAllLegacyCourses() {
return List.of();
}

@GetMapping(/* v2 */)
public Iterable<ModernCourse> getAllModernCourses() {
return List.of();
}
}
Quiz

Versioning quiz

1 / 2

@GetMapping(headers = "X-API-VERSION=v2") selects handlers by…

Pick an answer to see if you are right.


6. Securing REST APIs​

HTTP Basic is simple (username/password, often Base64 over the wire) — fine for local demos, weak for public APIs without HTTPS and without central identity. Prefer OAuth2 / OIDC with a token from an authorization server (e.g. Keycloak).

Typical Bearer JWT flow:

  1. Client calls API without token → 401 + WWW-Authenticate: Bearer
  2. Client obtains access_token from the auth server
  3. Client retries with Authorization: Bearer <jwt>
  4. Resource server validates JWT and authorizes

Boot dependencies (resource server):

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9999/auth/realms/master

Use @AuthenticationPrincipal Jwt jwt to read claims (e.g. username). Fix IDOR (accessing another user’s resource by ID) with method security:

@EnableMethodSecurity // Boot 3; older books: @EnableGlobalMethodSecurity(prePostEnabled = true)
@GetMapping("{id}")
@PreAuthorize("hasAuthority('SCOPE_course:read')")
@PostAuthorize("@getAuthor.apply(returnObject, principal.claims['user_name'])")
public Optional<Course> getCourseById(@PathVariable("id") long courseId) {
return courseRepository.findById(courseId);
}

Scopes like course:read / course:write limit what a token may do even after authentication.

# JWT issuer — Keycloak realm example from the book era
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9999/auth/realms/master
@GetMapping("{id}")
public Optional<Course> getCourseById(@PathVariable("id") long courseId) {
return courseRepository.findById(courseId);
}

Chapter checklist​

TopicIdea
REST controller@RestController, nouns, GET/POST/PUT/DELETE
LayeringController → service interface → repository
ErrorsCustom exceptions + @ControllerAdvice → correct status
Tests@AutoConfigureMockMvc + MockMvc + jsonPath
Docsspringdoc + @Tag / @Operation / OpenAPI bean
VersioningURI, param, header, or Accept — pick and stick
SecurityPrefer JWT resource server over Basic for public APIs; scopes + @PreAuthorize / @PostAuthorize

Full samples: ch07 on GitHub.

Next in the book: reactive apps with WebFlux → Lesson 6.

Quiz

Chapter wrap-up

1 / 3

Deleting a missing course should usually return…

Pick an answer to see if you are right.