diff --git a/README.md b/README.md index 9eba0b8..533ba91 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ It demonstrates: - REST endpoints for a small process-related resource - layered backend structure with controller, service and repository -- request validation -- status-based filtering +- request validation and matching database constraints +- status-based filtering and pagination - explicit HTTP success and error behavior - basic persistence with Spring Data JPA +- restrained parameterized logging for write operations - an H2 in-memory database for local development and tests - automated API integration tests with MockMvc - a reproducible Maven Wrapper workflow @@ -52,10 +53,12 @@ It complements my main Data/BI portfolio projects around SQL, Python, Power BI, | Language | Java 21 | Main implementation language | | Framework | Spring Boot 4.1 | REST API application framework | | API layer | Spring Web MVC | HTTP endpoints and JSON responses | +| Pagination | Spring Data `Pageable` and `PagedModel` | Bounded list responses with stable page metadata | | Persistence | Spring Data JPA | Repository abstraction and entity persistence | | Database | H2 | In-memory local development and test database | | Validation | Jakarta Validation | Validation for incoming request data | | Error format | Spring `ProblemDetail` | Consistent `application/problem+json` responses | +| Logging | SLF4J | Structured create, update and delete messages | | Tests | JUnit 5, Spring Boot Test, MockMvc | API integration and persistence verification | | Build tool | Maven Wrapper | Reproducible builds on Windows, macOS and Linux | | CI | GitHub Actions | Automated Java 21 Maven verification | @@ -86,13 +89,21 @@ The API uses request and response records instead of exposing the JPA entity dir | Method | Endpoint | Success | Purpose | |---|---|---:|---| -| `GET` | `/api/process-checks` | `200 OK` | Return all process-check records | -| `GET` | `/api/process-checks?status=OK` | `200 OK` | Filter records by `OK`, `WARNING` or `CRITICAL` | +| `GET` | `/api/process-checks?page=0&size=20` | `200 OK` | Return one page of process-check records | +| `GET` | `/api/process-checks?status=OK&page=0&size=20` | `200 OK` | Filter and page records by status | | `GET` | `/api/process-checks/{id}` | `200 OK` | Return one process-check record by ID | | `POST` | `/api/process-checks` | `201 Created` | Create a record and return its URI in `Location` | | `PUT` | `/api/process-checks/{id}` | `200 OK` | Replace the editable values of an existing record | | `DELETE` | `/api/process-checks/{id}` | `204 No Content` | Delete an existing record | +List endpoints accept the standard Spring Data parameters: + +- `page`: zero-based page number, default `0` +- `size`: requested page size, default `20`, capped at `100` +- `sort`: field and direction, for example `sort=processName,asc` + +The default list order is `lastCheckedAt,desc`. + Typical client errors: | Situation | Result | @@ -103,27 +114,39 @@ Typical client errors: --- -## Example Process-Check Record +## Example Paged Response ```json { - "id": 1, - "processName": "Daily sales import", - "owner": "Data Operations", - "status": "OK", - "lastCheckedAt": "2026-07-10T00:25:00", - "slaMinutes": 60 + "content": [ + { + "id": 1, + "processName": "Daily sales import", + "owner": "Data Operations", + "status": "OK", + "lastCheckedAt": "2026-07-10T00:25:00", + "slaMinutes": 60 + } + ], + "page": { + "size": 20, + "totalElements": 1, + "totalPages": 1, + "number": 0 + } } ``` Request validation requires: -- a non-blank `processName` -- a non-blank `owner` +- a non-blank `processName` with at most 120 characters +- a non-blank `owner` with at most 120 characters - a valid status: `OK`, `WARNING` or `CRITICAL` - a non-null ISO local date-time value - `slaMinutes` of at least `1` +The entity mirrors the non-null and maximum-length constraints so the API and database schema enforce the same basic rules. + --- ## Error Response Example @@ -164,10 +187,18 @@ Then open: http://localhost:8080/api/process-checks ``` -At first startup, the API returns an empty JSON array because the H2 database is empty: +At first startup, the H2 database is empty, so the list endpoint returns an empty page: ```json -[] +{ + "content": [], + "page": { + "size": 20, + "totalElements": 0, + "totalPages": 0, + "number": 0 + } +} ``` --- @@ -189,13 +220,14 @@ At first startup, the API returns an empty JSON array because the H2 database is The automated suite verifies: - application context startup +- default and requested pagination - unfiltered and status-filtered list requests - empty filter results - invalid status handling - lookup by ID - `404` Problem Detail responses - successful creation with `201 Created` and `Location` -- request validation failures +- blank, invalid and oversized request values - update behavior and persisted values - successful deletion with `204 No Content` - update and delete behavior for unknown IDs @@ -223,11 +255,12 @@ The workflow has read-only repository permissions and cancels superseded runs fo The full CRUD flow can also be exercised manually with curl, an API client or an IDE HTTP client: ```text -POST /api/process-checks create a process-check record -GET /api/process-checks list all process-check records -GET /api/process-checks/1 read one process-check record -PUT /api/process-checks/1 update one process-check record -DELETE /api/process-checks/1 delete one process-check record +POST /api/process-checks +GET /api/process-checks?page=0&size=20 +GET /api/process-checks?status=OK&page=0&size=20 +GET /api/process-checks/1 +PUT /api/process-checks/1 +DELETE /api/process-checks/1 ``` Example test data: @@ -242,6 +275,14 @@ slaMinutes: 60 --- +## Logging + +Create, update and delete operations write one parameterized application log entry containing the record ID and, where useful, its status. + +Read requests and complete request bodies are not logged. This keeps the example useful for troubleshooting without producing noisy logs or copying input data unnecessarily. + +--- + ## H2 Database Note This project uses an **H2 in-memory database** for local development and API testing. @@ -314,12 +355,14 @@ This repository demonstrates a small but realistic backend foundation: - Spring Boot application structure - REST endpoint and HTTP-status design - JSON request and response handling -- CRUD operations and status filtering +- paginated list queries and status filtering - layered backend organization -- request validation +- request validation aligned with persistence constraints - standard Problem Detail error responses - explicit transaction boundaries +- JPA dirty checking for managed updates - persistence abstraction with Spring Data JPA +- restrained parameterized logging - local development and testing with H2 - automated integration testing - reproducible Maven builds @@ -332,7 +375,7 @@ This repository demonstrates a small but realistic backend foundation: This is a learning project. -It does not include production database configuration, Docker deployment, authentication and authorization, a frontend UI, cloud deployment, monitoring infrastructure, pagination or enterprise-scale operational error handling. +It does not include production database configuration, Docker deployment, authentication and authorization, a frontend UI, cloud deployment, metrics, tracing or enterprise-scale operational error handling. These omissions are intentional. The current scope is limited to a clean, understandable and tested Spring Boot REST API baseline. diff --git a/docs/index.md b/docs/index.md index 337ddbb..5a32798 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Small Java 21 / Spring Boot REST API portfolio project # Spring Boot Process API Basics -**Small Java 21 / Spring Boot REST API project exposing validated process-check data through a layered backend structure, automated tests and H2 persistence.** +**Small Java 21 / Spring Boot REST API project exposing validated and paginated process-check data through a layered backend structure, automated tests and H2 persistence.** [View repository](https://github.com/DataTideHH/spring-boot-process-api-basics) · [Read the full README](https://github.com/DataTideHH/spring-boot-process-api-basics/blob/main/README.md) · [View CI](https://github.com/DataTideHH/spring-boot-process-api-basics/actions/workflows/ci.yml) · [DataTideHH portfolio](https://datatidehh.de/) @@ -17,7 +17,7 @@ This project is a deliberately compact backend learning project. It demonstrates how process-related records can be represented, validated, persisted and exposed through a small REST API using Spring Boot. -The goal is not to present a production service or an enterprise backend system. The goal is to document a clean first step from Java basics toward a small layered REST API with explicit HTTP behavior, persistence and automated verification. +The goal is not to present a production service or an enterprise backend system. The goal is to document a clean first step from Java basics toward a small layered REST API with explicit HTTP behavior, bounded list queries, persistence and automated verification. --- @@ -37,11 +37,14 @@ It follows the [IPv4 Subnet Calculator Multilang](https://datatidehh.github.io/i - controller, service and repository separation - Spring Data JPA repository usage - request and response records -- Jakarta Validation +- Jakarta Validation aligned with entity constraints - H2 in-memory persistence - CRUD endpoints for process-check data -- optional status filtering +- status filtering and pagination +- stable page metadata through Spring Data `PagedModel` - standard `ProblemDetail` error responses +- explicit transaction boundaries and JPA dirty checking +- restrained parameterized logging for write operations - integration tests with Spring Boot Test and MockMvc - reproducible Maven Wrapper builds - GitHub Actions verification on Java 21 @@ -52,30 +55,44 @@ It follows the [IPv4 Subnet Calculator Multilang](https://datatidehh.github.io/i | Method | Endpoint | Result | Purpose | |---|---|---:|---| -| `GET` | `/api/process-checks` | `200` | List all records | -| `GET` | `/api/process-checks?status=OK` | `200` | Filter by `OK`, `WARNING` or `CRITICAL` | +| `GET` | `/api/process-checks?page=0&size=20` | `200` | List one page of records | +| `GET` | `/api/process-checks?status=OK&page=0&size=20` | `200` | Filter and page by status | | `GET` | `/api/process-checks/{id}` | `200` | Read one record | | `POST` | `/api/process-checks` | `201` | Create a record and return `Location` | | `PUT` | `/api/process-checks/{id}` | `200` | Update a record | | `DELETE` | `/api/process-checks/{id}` | `204` | Delete a record | +The list endpoint defaults to 20 records, sorts by `lastCheckedAt` descending and caps requested page sizes at 100. + Invalid input returns `400 Bad Request`. Unknown record IDs return `404 Not Found` as `application/problem+json`. --- -## Example process-check record +## Example paged response ```json { - "id": 1, - "processName": "Daily sales import", - "owner": "Data Operations", - "status": "OK", - "lastCheckedAt": "2026-07-10T00:25:00", - "slaMinutes": 60 + "content": [ + { + "id": 1, + "processName": "Daily sales import", + "owner": "Data Operations", + "status": "OK", + "lastCheckedAt": "2026-07-10T00:25:00", + "slaMinutes": 60 + } + ], + "page": { + "size": 20, + "totalElements": 1, + "totalPages": 1, + "number": 0 + } } ``` +`processName` and `owner` are required and limited to 120 characters. The JPA entity mirrors these length and nullability rules. + --- ## Local usage @@ -104,7 +121,7 @@ http://localhost:8080/api/process-checks ## Verification -The integration suite covers list and filter behavior, lookup by ID, creation, validation failures, updates, deletion, persistence effects and `404` Problem Detail responses. +The integration suite covers pagination, sorting, status filtering, lookup by ID, creation, blank and oversized input, updates, deletion, persistence effects and `404` Problem Detail responses. The GitHub Actions workflow runs `clean verify` with Eclipse Temurin Java 21 for pull requests and pushes to `main`. @@ -136,4 +153,4 @@ The project uses an H2 in-memory database. Data is reset when the application st The sample data is synthetic and does not contain personal, customer or production data. -This is a learning project with a deliberately limited scope. It does not claim production deployment, authentication, cloud operation or enterprise-scale infrastructure. +This is a learning project with a deliberately limited scope. It does not claim production deployment, authentication, cloud operation, monitoring infrastructure or enterprise-scale operation. diff --git a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheck.java b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheck.java index 8a0fc86..e9020d2 100644 --- a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheck.java +++ b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheck.java @@ -1,6 +1,13 @@ package de.datatidehh.processapi.processcheck; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + import java.time.LocalDateTime; @Entity @@ -10,13 +17,20 @@ public class ProcessCheck { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false, length = 120) private String processName; + + @Column(nullable = false, length = 120) private String owner; @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) private ProcessStatus status; + @Column(nullable = false) private LocalDateTime lastCheckedAt; + + @Column(nullable = false) private Integer slaMinutes; protected ProcessCheck() { diff --git a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckController.java b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckController.java index 315abb0..6ee3bc6 100644 --- a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckController.java +++ b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckController.java @@ -1,6 +1,10 @@ package de.datatidehh.processapi.processcheck; import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PagedModel; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -14,7 +18,6 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.net.URI; -import java.util.List; @RestController @RequestMapping("/api/process-checks") @@ -27,10 +30,15 @@ public ProcessCheckController(ProcessCheckService service) { } @GetMapping - public List findAll( - @RequestParam(required = false) ProcessStatus status + public PagedModel findAll( + @RequestParam(required = false) ProcessStatus status, + @PageableDefault( + size = 20, + sort = "lastCheckedAt", + direction = Sort.Direction.DESC + ) Pageable pageable ) { - return service.findAll(status); + return new PagedModel<>(service.findAll(status, pageable)); } @GetMapping("/{id}") @@ -70,4 +78,4 @@ private URI buildLocation(Long id) { .buildAndExpand(id) .toUri(); } -} \ No newline at end of file +} diff --git a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRepository.java b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRepository.java index 9f947cd..a504696 100644 --- a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRepository.java +++ b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRepository.java @@ -1,10 +1,10 @@ package de.datatidehh.processapi.processcheck; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface ProcessCheckRepository extends JpaRepository { - List findAllByStatus(ProcessStatus status); + Page findAllByStatus(ProcessStatus status, Pageable pageable); } diff --git a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRequest.java b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRequest.java index 8822853..64eb498 100644 --- a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRequest.java +++ b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckRequest.java @@ -3,12 +3,13 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; public record ProcessCheckRequest( - @NotBlank String processName, - @NotBlank String owner, + @NotBlank @Size(max = 120) String processName, + @NotBlank @Size(max = 120) String owner, @NotNull ProcessStatus status, @NotNull LocalDateTime lastCheckedAt, @NotNull @Min(1) Integer slaMinutes diff --git a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckService.java b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckService.java index 4d9f44d..d78a727 100644 --- a/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckService.java +++ b/src/main/java/de/datatidehh/processapi/processcheck/ProcessCheckService.java @@ -1,28 +1,33 @@ package de.datatidehh.processapi.processcheck; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @Transactional(readOnly = true) public class ProcessCheckService { + private static final Logger log = LoggerFactory.getLogger(ProcessCheckService.class); + private final ProcessCheckRepository repository; public ProcessCheckService(ProcessCheckRepository repository) { this.repository = repository; } - public List findAll(ProcessStatus status) { - List processChecks = status == null - ? repository.findAll() - : repository.findAllByStatus(status); + public Page findAll( + ProcessStatus status, + Pageable pageable + ) { + Page processChecks = status == null + ? repository.findAll(pageable) + : repository.findAllByStatus(status, pageable); - return processChecks.stream() - .map(ProcessCheckResponse::fromEntity) - .toList(); + return processChecks.map(ProcessCheckResponse::fromEntity); } public ProcessCheckResponse findById(Long id) { @@ -39,7 +44,15 @@ public ProcessCheckResponse create(ProcessCheckRequest request) { request.slaMinutes() ); - return ProcessCheckResponse.fromEntity(repository.save(processCheck)); + ProcessCheck savedProcessCheck = repository.save(processCheck); + + log.info( + "Created process check id={} status={}", + savedProcessCheck.getId(), + savedProcessCheck.getStatus() + ); + + return ProcessCheckResponse.fromEntity(savedProcessCheck); } @Transactional @@ -54,17 +67,25 @@ public ProcessCheckResponse update(Long id, ProcessCheckRequest request) { request.slaMinutes() ); - return ProcessCheckResponse.fromEntity(repository.save(processCheck)); + log.info( + "Updated process check id={} status={}", + processCheck.getId(), + processCheck.getStatus() + ); + + return ProcessCheckResponse.fromEntity(processCheck); } @Transactional public void delete(Long id) { ProcessCheck processCheck = findEntityById(id); repository.delete(processCheck); + + log.info("Deleted process check id={}", id); } private ProcessCheck findEntityById(Long id) { return repository.findById(id) .orElseThrow(() -> new ProcessCheckNotFoundException(id)); } -} \ No newline at end of file +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9cc2a2b..301d6b0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,7 +11,9 @@ spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false spring.jpa.open-in-view=false +spring.data.web.pageable.max-page-size=100 + spring.h2.console.enabled=true spring.h2.console.path=/h2-console -spring.mvc.problemdetails.enabled=true \ No newline at end of file +spring.mvc.problemdetails.enabled=true diff --git a/src/test/java/de/datatidehh/processapi/processcheck/ProcessCheckApiIntegrationTests.java b/src/test/java/de/datatidehh/processapi/processcheck/ProcessCheckApiIntegrationTests.java index 2780bcd..dcf5785 100644 --- a/src/test/java/de/datatidehh/processapi/processcheck/ProcessCheckApiIntegrationTests.java +++ b/src/test/java/de/datatidehh/processapi/processcheck/ProcessCheckApiIntegrationTests.java @@ -30,6 +30,7 @@ class ProcessCheckApiIntegrationTests { private static final long MISSING_ID = 999_999L; + private static final int MAX_TEXT_LENGTH = 120; @Autowired private MockMvc mockMvc; @@ -69,11 +70,33 @@ void tearDown() { void findAllWithoutStatusReturnsRecordsOfAllStatuses() throws Exception { mockMvc.perform(get("/api/process-checks")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$.content", hasSize(2))) .andExpect(jsonPath( - "$[*].status", + "$.content[*].status", containsInAnyOrder("OK", "WARNING") - )); + )) + .andExpect(jsonPath("$.page.number", is(0))) + .andExpect(jsonPath("$.page.size", is(20))) + .andExpect(jsonPath("$.page.totalElements", is(2))) + .andExpect(jsonPath("$.page.totalPages", is(1))); + } + + @Test + void findAllUsesRequestedPageSizeAndSortOrder() throws Exception { + mockMvc.perform(get("/api/process-checks") + .param("page", "0") + .param("size", "1") + .param("sort", "processName,asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath( + "$.content[0].processName", + is("Daily sales import") + )) + .andExpect(jsonPath("$.page.number", is(0))) + .andExpect(jsonPath("$.page.size", is(1))) + .andExpect(jsonPath("$.page.totalElements", is(2))) + .andExpect(jsonPath("$.page.totalPages", is(2))); } @Test @@ -81,24 +104,26 @@ void findAllWithOkStatusReturnsOnlyOkRecords() throws Exception { mockMvc.perform(get("/api/process-checks") .param("status", "OK")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$.content", hasSize(1))) .andExpect(jsonPath( - "$[*].status", + "$.content[*].status", everyItem(is("OK")) )) .andExpect(jsonPath( - "$[0].processName", + "$.content[0].processName", is("Daily sales import") )); } @Test - void findAllWithCriticalStatusReturnsEmptyArrayWhenNoRecordsMatch() + void findAllWithCriticalStatusReturnsEmptyPageWhenNoRecordsMatch() throws Exception { mockMvc.perform(get("/api/process-checks") .param("status", "CRITICAL")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(0))); + .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.page.totalElements", is(0))) + .andExpect(jsonPath("$.page.totalPages", is(0))); } @Test @@ -200,6 +225,40 @@ void createWithInvalidRequestReturnsBadRequest() throws Exception { .hasSize(2); } + @Test + void createWithOversizedProcessNameReturnsBadRequest() throws Exception { + mockMvc.perform(post("/api/process-checks") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson( + "x".repeat(MAX_TEXT_LENGTH + 1), + "Data Quality", + "OK", + "2026-07-11T08:30:00", + 30 + ))) + .andExpect(status().isBadRequest()); + + org.assertj.core.api.Assertions.assertThat(repository.findAll()) + .hasSize(2); + } + + @Test + void createWithOversizedOwnerReturnsBadRequest() throws Exception { + mockMvc.perform(post("/api/process-checks") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson( + "Customer data quality check", + "x".repeat(MAX_TEXT_LENGTH + 1), + "OK", + "2026-07-11T08:30:00", + 30 + ))) + .andExpect(status().isBadRequest()); + + org.assertj.core.api.Assertions.assertThat(repository.findAll()) + .hasSize(2); + } + @Test void updateChangesAndPersistsExistingRecord() throws Exception { mockMvc.perform(put(