Audience: Lecturer guiding a 3-hour lab. How to use: Read top to bottom. Every code block is meant to be copied verbatim into the editor or terminal. Each “STEP” is one paste-and-explain moment. End state: A working Spring Boot REST API that creates / reads / updates / deletes students, validates input, returns clean error JSON, and ships with Swagger UI.
The full finished project lives at examples/lab06_students_api/ — open it in IntelliJ or VS Code if you’d rather demo from a complete copy.
application.propertiesStudent entityStudentRepositoryStudentServiceStudentControllerGlobalExceptionHandlerA REST API that manages student records. By the end of the lab the API will support:
| Method | URL | Purpose |
|---|---|---|
GET |
/api/students |
List all students |
GET |
/api/students?course=BCS |
Filter by course |
GET |
/api/students/{id} |
Get one student |
POST |
/api/students |
Create a student |
PUT |
/api/students/{id} |
Replace a student |
DELETE |
/api/students/{id} |
Delete a student |
HTTP request
│
▼
┌─────────────────────┐
│ StudentController │ ← @RestController (thin: HTTP only)
└─────────────────────┘
│
▼
┌─────────────────────┐
│ StudentService │ ← @Service (business rules + transactions)
└─────────────────────┘
│
▼
┌─────────────────────┐
│ StudentRepository │ ← extends JpaRepository (CRUD comes free)
└─────────────────────┘
│
▼
H2 in-memory DB ← students table
Plus a side-channel: GlobalExceptionHandler (@RestControllerAdvice) catches every exception thrown anywhere above and turns it into clean JSON.
@Transactional work happens here.@Entity directly to clients; that leaks DB columns and opens us to over-posting attacks.Run these four commands. All four must work before continuing.
java -version # expect 21 (any modern JDK ≥ 17 works, but Initializr will assume 21)
mvn -version # expect Apache Maven ≥ 3.9
docker --version # we use Docker to run Postgres locally
curl --version # any version
If any of those fail, fix it before proceeding — see the Setup Guide.
Open https://start.spring.io and fill it in:
| Field | Value |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 3.3.4 (or latest 3.3.x) |
| Group | tz.ac.suza.wt |
| Artifact | students-api |
| Packaging | Jar |
| Java | 21 |
Click ADD DEPENDENCIES and tick all five:
Click GENERATE. A students-api.zip downloads.
unzip students-api.zip -d ~/Code/
cd ~/Code/students-api
💡 Teach moment. Initializr is just a UI over a Maven archetype. You can do the same thing with a single
curlcommand — but the website is friendlier for class.
Spring needs a real database to talk to. We use PostgreSQL — the same database students will use in production. Docker means “no install ceremony”: one command and we have a running DB.
Create docker-compose.yml in the project root:
services:
db:
image: postgres:16-alpine
container_name: students-api-db
restart: unless-stopped
environment:
POSTGRES_DB: studentsdb
POSTGRES_USER: studentsapi
POSTGRES_PASSWORD: studentsapi
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U studentsapi -d studentsdb"]
interval: 5s
retries: 10
volumes:
pgdata:
Start it:
docker compose up -d
docker compose ps # status column should say "healthy" after ~5s
You now have a Postgres running at localhost:5432, with:
studentsdbstudentsapistudentsapi💡 Teach moment. The named volume
pgdatakeeps the data alive between restarts.docker compose down -v(with-v) deletes the volume too — handy for a clean slate, dangerous in production.
You can connect from the host with psql or DBeaver:
docker exec -it students-api-db psql -U studentsapi -d studentsdb
# inside psql:
\l # list databases
\dt # list tables (none yet)
\q
Open the folder in IntelliJ IDEA or VS Code. Wait for it to import the Maven project — that downloads Spring Boot and all its transitive dependencies (200 MB of jars, one-time).
💡 Teach moment. We can’t run the app yet — Spring needs a datasource URL and we haven’t given it one. We’ll do that in STEP 4 and start the app in STEP 14.
application.propertiesReplace the contents of src/main/resources/application.properties with:
spring.application.name=students-api
# ----- PostgreSQL ---------------------------------------------------------
# Override these via env vars in production:
# SPRING_DATASOURCE_URL=jdbc:postgresql://prod-host:5432/studentsdb
# SPRING_DATASOURCE_USERNAME=... SPRING_DATASOURCE_PASSWORD=...
spring.datasource.url=jdbc:postgresql://localhost:5432/studentsdb
spring.datasource.username=studentsapi
spring.datasource.password=studentsapi
spring.datasource.driver-class-name=org.postgresql.Driver
# ----- JPA / Hibernate ----------------------------------------------------
# 'update' is fine for development; for production use Flyway/Liquibase migrations
# and set ddl-auto=validate.
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false
# ----- Swagger / OpenAPI --------------------------------------------------
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/v3/api-docs
💡 Teach moment.
ddl-auto=updatelets Hibernate add columns when entities change. Never use this in production — write proper migrations with Flyway or Liquibase.spring.jpa.open-in-view=falseturns off a feature that’s convenient for templated MVC apps but harmful for REST APIs (it keeps DB connections open way too long).- Every property here is overridable by env var.
SPRING_DATASOURCE_PASSWORDis the standard name Spring reads in production.
Inside src/main/java/tz/ac/suza/wt/studentsapi/ create six new packages:
config/
controller/
dto/
entity/
exception/
repository/
service/
💡 Teach moment. We’re organising by layer, not by feature. Both are valid. By-layer is easier to learn; by-feature scales better. We pick by-layer here.
Student entityCreate entity/Student.java:
package tz.ac.suza.wt.studentsapi.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "students")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 150)
private String email;
@Column(nullable = false, length = 100)
private String course;
@Column(nullable = false)
private Integer year;
@Column(nullable = false, updatable = false)
private Instant createdAt;
}
💡 Teach moment.
@Entity+@Tabletells JPA “this class maps to thestudentstable”.- Lombok’s
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructorremoves 80 lines of getters, setters, and constructors. Make sure students have the IDE’s Lombok plugin enabled, or the code won’t compile in the editor.- No validation annotations here. Validation belongs on the DTO. The entity defines the storage shape; the DTO defines the public input shape. We will keep them separate.
StudentRepositoryCreate repository/StudentRepository.java:
package tz.ac.suza.wt.studentsapi.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import tz.ac.suza.wt.studentsapi.entity.Student;
import java.util.List;
import java.util.Optional;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
List<Student> findByCourse(String course);
Optional<Student> findByEmail(String email);
boolean existsByEmail(String email);
}
💡 Teach moment. We didn’t write any SQL. Spring Data JPA reads our method names and generates the queries at runtime:
findByCoursebecomesSELECT * FROM students WHERE course = ?. This is called derived query methods. It’s magical the first time you see it.
We need three:
StudentCreateDto — incoming JSON for POSTStudentUpdateDto — incoming JSON for PUTStudentResponseDto — outgoing JSON to clientsdto/StudentCreateDto.javapackage tz.ac.suza.wt.studentsapi.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record StudentCreateDto(
@NotBlank(message = "name is required")
@Size(min = 2, max = 100, message = "name must be 2..100 characters")
String name,
@NotBlank(message = "email is required")
@Email(message = "email must be a valid address")
@Size(max = 150)
String email,
@NotBlank(message = "course is required")
@Size(min = 2, max = 100)
String course,
@NotNull(message = "year is required")
@Min(value = 1, message = "year must be between 1 and 4")
@Max(value = 4, message = "year must be between 1 and 4")
Integer year
) {}
dto/StudentUpdateDto.javapackage tz.ac.suza.wt.studentsapi.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record StudentUpdateDto(
@NotBlank @Size(min = 2, max = 100)
String name,
@NotBlank @Email @Size(max = 150)
String email,
@NotBlank @Size(min = 2, max = 100)
String course,
@NotNull @Min(1) @Max(4)
Integer year
) {}
dto/StudentResponseDto.javapackage tz.ac.suza.wt.studentsapi.dto;
import tz.ac.suza.wt.studentsapi.entity.Student;
import java.time.Instant;
public record StudentResponseDto(
Long id,
String name,
String email,
String course,
Integer year,
Instant createdAt
) {
public static StudentResponseDto from(Student s) {
return new StudentResponseDto(
s.getId(), s.getName(), s.getEmail(),
s.getCourse(), s.getYear(), s.getCreatedAt()
);
}
}
💡 Teach moment.
- We use Java records — concise, immutable, perfect for DTOs.
- The validation annotations (
@NotBlank,@Min,@Max) belong on the incoming DTOs, not on the entity. They protect us at the API boundary.StudentResponseDto.from(...)is a tiny static mapper. In a bigger project, use MapStruct to generate these.- Why separate Create and Update DTOs? Today they look identical. Tomorrow you’ll want different rules — e.g.
passwordrequired only on create. Splitting them now costs nothing and saves pain later.
These let the service throw meaningful errors that the advice can catch later.
exception/StudentNotFoundException.javapackage tz.ac.suza.wt.studentsapi.exception;
public class StudentNotFoundException extends RuntimeException {
public StudentNotFoundException(Long id) {
super("Student with id " + id + " was not found");
}
}
exception/EmailAlreadyUsedException.javapackage tz.ac.suza.wt.studentsapi.exception;
public class EmailAlreadyUsedException extends RuntimeException {
public EmailAlreadyUsedException(String email) {
super("Email '" + email + "' is already used by another student");
}
}
💡 Teach moment. Custom exceptions read like English at the call site:
throw new StudentNotFoundException(id);is much clearer thanthrow new RuntimeException("not found: " + id);. They also become the keys for our error mapping in STEP 11.
StudentServiceCreate service/StudentService.java:
package tz.ac.suza.wt.studentsapi.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tz.ac.suza.wt.studentsapi.dto.StudentCreateDto;
import tz.ac.suza.wt.studentsapi.dto.StudentResponseDto;
import tz.ac.suza.wt.studentsapi.dto.StudentUpdateDto;
import tz.ac.suza.wt.studentsapi.entity.Student;
import tz.ac.suza.wt.studentsapi.exception.EmailAlreadyUsedException;
import tz.ac.suza.wt.studentsapi.exception.StudentNotFoundException;
import tz.ac.suza.wt.studentsapi.repository.StudentRepository;
import java.time.Instant;
import java.util.List;
@Service
@Transactional
public class StudentService {
private final StudentRepository repository;
public StudentService(StudentRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<StudentResponseDto> findAll() {
return repository.findAll().stream()
.map(StudentResponseDto::from)
.toList();
}
@Transactional(readOnly = true)
public List<StudentResponseDto> findByCourse(String course) {
return repository.findByCourse(course).stream()
.map(StudentResponseDto::from)
.toList();
}
@Transactional(readOnly = true)
public StudentResponseDto findById(Long id) {
Student s = repository.findById(id)
.orElseThrow(() -> new StudentNotFoundException(id));
return StudentResponseDto.from(s);
}
public StudentResponseDto create(StudentCreateDto dto) {
if (repository.existsByEmail(dto.email())) {
throw new EmailAlreadyUsedException(dto.email());
}
Student s = Student.builder()
.name(dto.name())
.email(dto.email())
.course(dto.course())
.year(dto.year())
.createdAt(Instant.now())
.build();
return StudentResponseDto.from(repository.save(s));
}
public StudentResponseDto update(Long id, StudentUpdateDto dto) {
Student s = repository.findById(id)
.orElseThrow(() -> new StudentNotFoundException(id));
if (!s.getEmail().equals(dto.email())
&& repository.existsByEmail(dto.email())) {
throw new EmailAlreadyUsedException(dto.email());
}
s.setName(dto.name());
s.setEmail(dto.email());
s.setCourse(dto.course());
s.setYear(dto.year());
return StudentResponseDto.from(s);
}
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new StudentNotFoundException(id);
}
repository.deleteById(id);
}
}
💡 Teach moment.
@Servicemarks this as a Spring bean. The container constructs it once.- Constructor injection (not
@Autowiredon a field). Two reasons: it’s testable without Spring, and the field isfinal.@Transactionalon the class means every method runs inside one DB transaction. Read methods are tagged@Transactional(readOnly = true)so Hibernate can skip the dirty-checking — a real performance win.- Notice
update(...)callss.setName(...)and never callsrepository.save(...). Because we’re inside a transaction andsis a managed entity, Hibernate auto-flushes changes at commit. This is called dirty checking.
StudentControllerCreate controller/StudentController.java:
package tz.ac.suza.wt.studentsapi.controller;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tz.ac.suza.wt.studentsapi.dto.StudentCreateDto;
import tz.ac.suza.wt.studentsapi.dto.StudentResponseDto;
import tz.ac.suza.wt.studentsapi.dto.StudentUpdateDto;
import tz.ac.suza.wt.studentsapi.service.StudentService;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/students")
public class StudentController {
private final StudentService service;
public StudentController(StudentService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<StudentResponseDto>> list(
@RequestParam(required = false) String course) {
List<StudentResponseDto> students = (course == null)
? service.findAll()
: service.findByCourse(course);
return ResponseEntity.ok(students);
}
@GetMapping("/{id}")
public ResponseEntity<StudentResponseDto> getOne(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
@PostMapping
public ResponseEntity<StudentResponseDto> create(
@Valid @RequestBody StudentCreateDto dto) {
StudentResponseDto saved = service.create(dto);
URI location = URI.create("/api/students/" + saved.id());
return ResponseEntity.created(location).body(saved);
}
@PutMapping("/{id}")
public ResponseEntity<StudentResponseDto> update(
@PathVariable Long id,
@Valid @RequestBody StudentUpdateDto dto) {
return ResponseEntity.ok(service.update(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
💡 Teach moment.
@RestController=@Controller+@ResponseBody. Every return value is serialised to JSON by Jackson.@RequestMapping("/api/students")is the common URL prefix. Method annotations add the verb + path suffix.@Validtriggers bean validation before the method body runs. If validation fails, Spring throwsMethodArgumentNotValidException— we’ll catch that in STEP 11.- Notice every method returns
ResponseEntity. That’s how we control status codes (200, 201, 204, etc.) and headers likeLocation.- Notice what isn’t here. No business rules. No DB calls. No
try/catch. The controller is HTTP plumbing only.
GlobalExceptionHandlerThis is the single most important pattern in the lab. Create exception/GlobalExceptionHandler.java:
package tz.ac.suza.wt.studentsapi.exception;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(StudentNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(
StudentNotFoundException ex, HttpServletRequest req) {
ProblemDetail body = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
body.setTitle("Resource not found");
body.setInstance(URI.create(req.getRequestURI()));
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(EmailAlreadyUsedException.class)
public ResponseEntity<ProblemDetail> handleConflict(
EmailAlreadyUsedException ex, HttpServletRequest req) {
ProblemDetail body = ProblemDetail
.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
body.setTitle("Email conflict");
body.setInstance(URI.create(req.getRequestURI()));
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest req) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fe ->
fieldErrors.put(fe.getField(), fe.getDefaultMessage()));
ProblemDetail body = ProblemDetail
.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
body.setTitle("Bad request");
body.setInstance(URI.create(req.getRequestURI()));
body.setProperty("errors", fieldErrors);
return ResponseEntity.badRequest().body(body);
}
}
💡 Teach moment.
@RestControllerAdvicemakes this a global interceptor. Every controller’s exceptions pass through here before reaching the client.- We use
ProblemDetail— Spring 6’s built-in RFC 7807 error type. It’s a standard JSON shape:type,title,status,detail,instance.- For validation errors, we attach the per-field messages under an
errorsproperty so the frontend can show them next to the right input.- No stack traces leak. Even if something deep in JPA throws an
EntityNotFoundException, the client only sees clean JSON.
A successful POST will now look like:
{
"id": 4,
"name": "Asha Juma",
"email": "asha@example.com",
"course": "BCS",
"year": 2,
"createdAt": "2026-06-15T08:00:00Z"
}
A POST with a bad email returns:
{
"type": "about:blank",
"title": "Bad request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/students",
"errors": {
"email": "email must be a valid address"
}
}
So the API isn’t empty on first run, create config/DataSeeder.java:
package tz.ac.suza.wt.studentsapi.config;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tz.ac.suza.wt.studentsapi.entity.Student;
import tz.ac.suza.wt.studentsapi.repository.StudentRepository;
import java.time.Instant;
@Configuration
public class DataSeeder {
@Bean
CommandLineRunner seed(StudentRepository repository) {
return args -> {
if (repository.count() > 0) return;
repository.save(Student.builder()
.name("Asha Juma").email("asha@example.com")
.course("BCS").year(2).createdAt(Instant.now()).build());
repository.save(Student.builder()
.name("Hassan Ali").email("hassan@example.com")
.course("BITAM").year(3).createdAt(Instant.now()).build());
repository.save(Student.builder()
.name("Mwajuma Said").email("mwajuma@example.com")
.course("BCS").year(1).createdAt(Instant.now()).build());
};
}
}
💡 Teach moment. A
CommandLineRunnerbean runs once, right after Spring finishes wiring. Perfect for seeding demo data.
mvn spring-boot:run
In another terminal:
curl -s http://localhost:8080/api/students | jq
You should see the three seeded students.
🎉 You have a working REST API.
Open VS Code, install the REST Client extension, then create requests.http:
@host = http://localhost:8080
@base = /api/students
### 1. List all students
GET
Accept: application/json
### 2. Create a new student (201 Created)
POST
Content-Type: application/json
{
"name": "Salma Khamis",
"email": "salma@example.com",
"course": "BITAM",
"year": 2
}
### 3. Get one student by id
GET /1
Accept: application/json
### 4. Filter by course (query param)
GET ?course=BCS
Accept: application/json
### 5. Update an existing student (200 OK)
PUT /1
Content-Type: application/json
{
"name": "Asha Juma",
"email": "asha.juma@example.com",
"course": "BCS",
"year": 3
}
### 6. Delete a student (204 No Content)
DELETE /2
### 7. NEGATIVE TEST — invalid email (expect 400 with field errors)
POST
Content-Type: application/json
{
"name": "Bad Email",
"email": "not-an-email",
"course": "BCS",
"year": 2
}
### 8. NEGATIVE TEST — student not found (expect 404)
GET /9999
### 9. NEGATIVE TEST — duplicate email (expect 409)
POST
Content-Type: application/json
{
"name": "Duplicate",
"email": "asha@example.com",
"course": "BCS",
"year": 1
}
Click Send Request above each block. Demonstrate all four success codes (200, 201, 204) and all three error codes (400, 404, 409) live.
curl -i -X POST http://localhost:8080/api/students \
-H 'Content-Type: application/json' \
-d '{"name":"Salma","email":"salma@example.com","course":"BCS","year":2}'
curl -i http://localhost:8080/api/students/1
curl -i -X DELETE http://localhost:8080/api/students/2
curl -i -X POST http://localhost:8080/api/students \
-H 'Content-Type: application/json' \
-d '{"name":"Bad","email":"not-an-email","course":"BCS","year":2}'
We already added springdoc-openapi to pom.xml. If you generated through Initializr without it, add this dependency now:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
Restart the app, then open:
http://localhost:8080/swagger-ui.html
You should see every endpoint listed. Click any one → Try it out → fill in the body → Execute. Live request + response with no extra tools.
💡 Teach moment. Swagger UI is generated from the same controller code. No separate spec file. When the API changes, the docs change.
Per the lab brief, the deliverables are:
requests.http in the repo root, with the 5 CRUD requests + 1 negative test.README.md.| Criterion | Marks |
|---|---|
| Entity + repository | 1 |
| Controller + service | 3 |
| Validation + error handling | 2 |
| Correct HTTP status codes (201, 204) | 1 |
Tested via requests.http |
2 |
| Swagger UI live | 1 |
| Total | 10 |
log / builder / getters”Lombok plugin not installed in the IDE.
# macOS / Linux
lsof -ti:8080 | xargs kill -9
# Windows PowerShell
Get-Process -Id (Get-NetTCPConnection -LocalPort 8080).OwningProcess | Stop-Process
Or change the port in application.properties:
server.port=8081
MethodArgumentNotValidException reaches the browser as a 500You forgot @Valid on the controller method. Add it:
public ResponseEntity<...> create(@Valid @RequestBody StudentCreateDto dto) { ... }
You used ResponseEntity.ok(...). Switch to:
return ResponseEntity.created(URI.create("/api/students/" + saved.id())).body(saved);
You returned the deleted object. Use noContent():
return ResponseEntity.noContent().build();
Your derived query method has a typo. findByCourse is fine; findByCorse will fail at startup with a clear error message — read it carefully.
Connection refused to PostgresThe DB container isn’t running.
docker compose ps # should say "healthy"
docker compose up -d # start it
docker compose logs db --tail 30 # check for errors
If port 5432 is already taken (a system Postgres is running), either stop that one or change the host port:
ports:
- "5433:5432" # then use jdbc:postgresql://localhost:5433/studentsdb
relation "students" does not existddl-auto isn’t generating the table. Confirm:
spring.jpa.hibernate.ddl-auto=update
Then restart the app — Hibernate prints create table students (...) in the log when it works.
docker exec -it students-api-db psql -U studentsapi -d studentsdb
\dt # list tables
SELECT * FROM students;
\q
Or use DBeaver / pgAdmin pointed at localhost:5432, db studentsdb, user studentsapi.
Spring Boot tests start a context but on a random port by default. If you wrote @SpringBootTest(webEnvironment = RANDOM_PORT) and still hit this, kill any leftover Java processes.
Lab 7 — Authentication & Security: signup, login, JWT, role-based access, rate limiting. We’ll add spring-boot-starter-security to this same project and protect every endpoint behind a token.