Document deployment profile and verify API behaviour

This commit is contained in:
2026-08-19 21:24:58 +08:00
parent 5fb98402c1
commit ef008ced94
7 changed files with 326 additions and 1 deletions

174
README.md Normal file
View File

@@ -0,0 +1,174 @@
# Spring Boot JSONPlaceholder Showcase
A compact, production-minded REST API that turns the original JSONPlaceholder experiment into a public portfolio project. It demonstrates how I structure a modern Spring Boot application: clean HTTP contracts, secure write operations, JPA persistence, cache-aware reads, external API integration, tested behaviour, and environment-driven configuration.
The application is intentionally a runnable API rather than a full product. It is small enough to review quickly, but its patterns scale to admin portals, booking systems, internal tools, marketplace back ends, and integration services.
## What this demonstrates
| Capability | Implementation |
| --- | --- |
| Modern Java and dependencies | Java 21, Spring Boot 3.5.16, Maven Wrapper 3.9.11, dependency versions managed by Spring Boot's BOM |
| REST API design | `POST`, `GET`, `PUT`, and `DELETE` endpoints with a stable page envelope and proper `201`, `204`, `400`, `401`, `404`, `409`, and `503` responses |
| Spring Data JPA | UUID primary keys, derived search query, pagination, audit timestamps, transaction boundaries, and optimistic locking |
| Caching | Bounded Caffeine cache for individual read operations; update, delete, and import paths keep it coherent |
| Security | Stateless HTTP Basic authentication for writes, BCrypt password hashing, least-privilege route rules |
| External integration | Spring's `RestClient`, explicit timeout, separate remote DTO, failure translation, and idempotent JSONPlaceholder import |
| Configuration management | Validated typed configuration, local H2 defaults, a PostgreSQL production profile, environment-variable overrides, no machine paths, no committed production credentials |
| Reliability | RFC 9457-style `ProblemDetail` errors, input validation, health endpoint, and integration tests |
## Architecture
```text
HTTP client
├── PostController ── request/response DTOs
│ │
│ └── PostService ── transactions, cache policy, import workflow
│ ├── PostRepository ── Spring Data JPA ── H2 / production database
│ └── JsonPlaceholderClient ── RestClient ── JSONPlaceholder
└── ApiExceptionHandler ── consistent problem responses
```
The controller does not contain persistence or business logic. The service owns the transaction and cache rules, the repository owns database access, and the integration client owns the remote contract. This makes each responsibility straightforward to test and replace.
## Run locally
Prerequisites: JDK 21+ and an internet connection the first time Maven downloads dependencies.
```powershell
./mvnw.cmd spring-boot:run
```
The API starts on `http://localhost:8080`. Check its health at `GET /actuator/health`.
The checked-in account is deliberately a local demo account:
```text
username: demo-editor
password: changeit
```
It is not a secret and must be replaced outside local development. Use environment variables instead of editing configuration or committing credentials:
```powershell
$env:APP_EDITOR_USERNAME = "your-editor"
$env:APP_EDITOR_PASSWORD = "use-a-real-secret-manager-in-production"
./mvnw.cmd spring-boot:run
```
For a deployed service, the `prod` profile switches to PostgreSQL and requires `APP_DB_URL`, `APP_DB_USERNAME`, `APP_DB_PASSWORD`, `APP_EDITOR_USERNAME`, and `APP_EDITOR_PASSWORD`. It has no secret defaults:
```powershell
$env:SPRING_PROFILES_ACTIVE = "prod"
$env:APP_DB_URL = "jdbc:postgresql://host:5432/postshowcase"
$env:APP_DB_USERNAME = "application_user"
$env:APP_DB_PASSWORD = "store-this-outside-git"
./mvnw.cmd spring-boot:run
```
For a real deployment, run schema migrations with Flyway or Liquibase and supply all values from the hosting platform's secret store.
## API quick start
Public read endpoints require no login.
```powershell
curl http://localhost:8080/api/posts
curl "http://localhost:8080/api/posts?title=spring&page=0&size=10"
```
Create, update, delete, and import operations require the editor account.
```powershell
curl -u demo-editor:changeit -X POST http://localhost:8080/api/posts `
-H "Content-Type: application/json" `
-d '{"authorId":1,"title":"Spring Boot showcase","body":"A practical API example."}'
curl -u demo-editor:changeit -X POST http://localhost:8080/api/posts/import/jsonplaceholder
```
An update includes the resource's `version`. This is a deliberate optimistic-locking contract: if another editor saves first, the API returns `409 POST_VERSION_CONFLICT` instead of overwriting their work.
```json
{
"title": "Revised title",
"body": "Revised body",
"version": 0
}
```
The JSONPlaceholder import limit, remote base URL, and timeout are application properties. Imported source IDs are recorded, so repeating the import safely skips records already imported.
## Test and build
```powershell
./mvnw.cmd test
./mvnw.cmd package
```
The integration test verifies the public/private route split, authenticated creation, validation error shape, title search, and duplicate-safe import. It substitutes the remote client, so the test suite does not rely on a live third-party service.
## Learning journal
### 1. Prefer a standalone executable API over a legacy WAR
The previous project used a Spring Boot 2.3 WAR with JSP/Thymeleaf remnants and Java 8. This rewrite uses a runnable JAR, Java 21, Spring Boot 3.5.16, and the Maven Wrapper. This removes servlet-container deployment assumptions and keeps the repository focused on back-end skills.
### 2. Keep API, domain, persistence, and remote models separate
`CreatePostRequest`, `UpdatePostRequest`, and `PostResponse` describe the public API. `Post` describes a database row. `JsonPlaceholderPost` describes a third-party response. Those models happen to look similar today, but they will evolve for different reasons; separating them prevents accidental breaking changes.
### 3. Validate at the edge and transact in the service
Jakarta Validation rejects bad JSON before business work starts. `PostService` then provides read-only and write transaction boundaries. The entity is intentionally free of controller annotations, which makes it reusable outside HTTP.
### 4. Cache only a narrow, safe read path
Only `GET /api/posts/{id}` is cached. Search results are dynamic and therefore intentionally not cached. Write methods update or evict the cached item, and imports clear the small cache. The Caffeine cache is size-bounded and expires after ten minutes, avoiding unbounded memory growth and stale values that linger forever.
### 5. Secure mutation, not a toy login screen
Anyone may read sample posts. Every operation that changes data requires the `EDITOR` role. The application uses stateless HTTP Basic authentication because it is easy to inspect in a small API; a browser or mobile application would normally sit behind OAuth2/OIDC or a gateway. Password configuration comes from environment variables and is BCrypt-hashed in memory.
### 6. Make integration failures part of the contract
The remote API is called through Spring `RestClient` with a configurable timeout. A client error becomes an application exception and then a `503` problem response; it never becomes an unstructured stack trace. The import has a source identifier and skips known records, making retries safe.
### 7. Use actionable errors and tests
The exception advice returns a predictable problem document with a machine-readable code and field-level validation errors. The integration tests execute real MVC, security, JPA, cache, and transaction wiring. Only the outbound HTTP boundary is mocked, keeping tests deterministic.
## File-by-file learning notes
Every Java source file carries a dated header and closing marker in the style of the larger reference application. The source notes are intentionally concise; this table is the fuller reading guide.
| File | Why it exists / learning focus |
| --- | --- |
| `ShowcaseApplication` | Enables bootstrapping, configuration-property discovery, and JPA auditing in one explicit place. |
| `config/ApiProperties` | Binds and validates remote API settings as typed values. |
| `config/SecurityProperties` | Keeps login values external to source code. |
| `config/CacheConfig` | Defines a bounded, expiring Caffeine cache. |
| `config/RestClientConfig` | Names and configures the remote HTTP client with connection and read timeouts. |
| `config/SecurityConfig` | Defines stateless route authorization and BCrypt-backed editor credentials. |
| `integration/JsonPlaceholderPost` | Isolates the third-party JSON contract. |
| `integration/JsonPlaceholderClient` | Converts remote HTTP calls into a small application-facing API. |
| `post/Post` | Maps the persistence model with UUID, audit fields, source ID, and optimistic version. |
| `post/PostRepository` | Shows pagination and case-insensitive search without handwritten SQL. |
| `post/CreatePostRequest` | Defines and validates the create contract. |
| `post/UpdatePostRequest` | Defines the update contract and requires the expected version. |
| `post/PostResponse` | Shields API consumers from JPA implementation details. |
| `post/PageResponse` | Keeps paginated JSON stable even if framework serialization changes. |
| `post/ImportResult` | Reports an import outcome compactly. |
| `post/PostService` | Centralizes transactions, cache coherence, conflict detection, and idempotent import rules. |
| `post/PostController` | Translates HTTP verbs, status codes, URI creation, and pagination into service calls. |
| `support/PostNotFoundException` | Represents a missing domain resource. |
| `support/PostVersionConflictException` | Represents a safe concurrent-update failure. |
| `support/UpstreamServiceException` | Prevents third-party errors leaking through the API. |
| `support/ApiExceptionHandler` | Turns expected failures into a consistent public error format. |
| `test/PostApiIntegrationTest` | Tests behaviour across the actual Spring web stack without a live remote dependency. |
## What I can build from this foundation
This structure is suitable for secure CRUD APIs, internal dashboards, workflow back ends, integrations with external SaaS APIs, catalogue or content services, and the server side of web or mobile applications. Depending on the product, I can extend it with PostgreSQL/MySQL, schema migrations, OAuth2/OIDC, role and permission models, file uploads, scheduled work, queue consumers, email, metrics/tracing, Docker, CI, and cloud deployment configuration.

View File

@@ -55,6 +55,11 @@
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

View File

@@ -3,6 +3,7 @@ package com.hoelee.jsonplaceholder.config;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@@ -16,7 +17,7 @@ import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "app.api")
public record ApiProperties(
@NotBlank String jsonPlaceholderBaseUrl,
Duration requestTimeout,
@NotNull Duration requestTimeout,
@Min(1) @Max(100) int importLimit) {
}

View File

@@ -21,6 +21,7 @@ public class CacheConfig {
CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("posts");
cacheManager.setCaffeine(Caffeine.newBuilder()
.recordStats()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10)));
return cacheManager;

View File

@@ -0,0 +1,17 @@
spring:
config:
activate:
on-profile: prod
datasource:
url: ${APP_DB_URL}
username: ${APP_DB_USERNAME}
password: ${APP_DB_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
app:
security:
editor-username: ${APP_EDITOR_USERNAME}
editor-password: ${APP_EDITOR_PASSWORD}

View File

@@ -0,0 +1,122 @@
package com.hoelee.jsonplaceholder.post;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderClient;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderPost;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
/**
* @version v1, 2024-10-28 08:40:00PM
* @author hoelee
* Learning note: integration tests exercise the web, security, validation, cache, and JPA layers together.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class PostApiIntegrationTest {
private static final String EDITOR_USERNAME = "test-editor";
private static final String EDITOR_PASSWORD = "test-password";
@Autowired
private MockMvc mockMvc;
@Autowired
private PostRepository postRepository;
@Autowired
private CacheManager cacheManager;
@MockitoBean
private JsonPlaceholderClient jsonPlaceholderClient;
@BeforeEach
void cleanDatabaseAndCache() {
postRepository.deleteAll();
cacheManager.getCache("posts").clear();
}
@Test
void publicReadsWorkButWritesRequireAuthentication() throws Exception {
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"authorId": 1, "title": "A title", "body": "A post body"}
"""))
.andExpect(status().isUnauthorized());
}
@Test
void editorCanCreateAndPublicCanReadAPost() throws Exception {
mockMvc.perform(post("/api/posts")
.with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"authorId": 7, "title": "Spring Boot showcase", "body": "A complete API example."}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Spring Boot showcase"))
.andExpect(jsonPath("$.version").value(0));
mockMvc.perform(get("/api/posts?title=showcase"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].authorId").value(7));
}
@Test
void invalidRequestUsesAConsistentProblemResponse() throws Exception {
mockMvc.perform(post("/api/posts")
.with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"authorId": 0, "title": "", "body": ""}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.errors.authorId").exists());
}
@Test
void importIsIdempotentForKnownJsonPlaceholderRecords() throws Exception {
when(jsonPlaceholderClient.fetchPosts(anyInt())).thenReturn(List.of(
new JsonPlaceholderPost(1L, 1L, "Remote title", "Remote body"),
new JsonPlaceholderPost(2L, 2L, "Another title", "Another body")));
mockMvc.perform(post("/api/posts/import/jsonplaceholder")
.with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.fetched").value(2))
.andExpect(jsonPath("$.imported").value(2))
.andExpect(jsonPath("$.skipped").value(0));
mockMvc.perform(post("/api/posts/import/jsonplaceholder")
.with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.imported").value(0))
.andExpect(jsonPath("$.skipped").value(2));
}
}
//~ v1, 2024-10-28 08:40:00PM - Last edited by hoelee

View File

@@ -2,10 +2,15 @@ spring:
datasource:
url: jdbc:h2:mem:postshowcase-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
jpa:
open-in-view: false
hibernate:
ddl-auto: create-drop
app:
api:
json-placeholder-base-url: http://localhost:65535
request-timeout: 1s
import-limit: 20
security:
editor-username: test-editor
editor-password: test-password