Rename project and add interactive Thymeleaf showcase

This commit is contained in:
2026-08-19 21:46:29 +08:00
parent 10addc4d52
commit 11894f5998
41 changed files with 647 additions and 748 deletions

185
README.md
View File

@@ -1,174 +1,79 @@
# Spring Boot JSONPlaceholder Showcase
# Spring Boot Demo
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.
An interactive portfolio application demonstrating how I build a modern Spring Boot system from browser page to database. It replaces the original third-party API experiment with a self-contained demo: a Thymeleaf landing page, a live feature tour, secured REST APIs, JPA persistence, cache metrics, validation, error handling, and tested behaviour.
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.
## Live pages
## What this demonstrates
| Capability | Implementation |
| URL | Purpose |
| --- | --- |
| 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 |
| `/` | Thymeleaf landing page based on my Spring portfolio content, with developer profile and contact details. |
| `/showcase` | Interactive jQuery AJAX tour. It calls the running controllers, shows JPA query results, creates a database record, reads a post twice, and displays live cache statistics. |
| `/api/posts` | Public paginated/searchable REST read API; `POST`, `PUT`, and `DELETE` require the `EDITOR` role. |
| `/api/showcase/cache` | Read-only Caffeine cache statistics for the feature tour. |
| `/actuator/health` | Public health check. |
## Architecture
## Run it
```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.
Requires JDK 21+.
```powershell
cd D:\app\springboot-jsonplaceholder-demo
./mvnw.cmd spring-boot:run
```
The API starts on `http://localhost:8080`. Check its health at `GET /actuator/health`.
Open [http://localhost:8080](http://localhost:8080). The local profile uses an in-memory H2 database and automatically loads three sample posts for the feature tour.
The checked-in account is deliberately a local demo account:
The local editor account is intentionally a non-secret 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:
The showcase form never pre-fills or stores credentials; it sends them only to the same local application for the authenticated request. Override them through `APP_EDITOR_USERNAME` and `APP_EDITOR_PASSWORD`.
```powershell
$env:APP_EDITOR_USERNAME = "your-editor"
$env:APP_EDITOR_PASSWORD = "use-a-real-secret-manager-in-production"
./mvnw.cmd spring-boot:run
```
## What the feature tour proves
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:
1. **Thymeleaf & MVC**`HomeController` renders `/` and `/showcase`; the index uses a server-side model for contact values.
2. **jQuery AJAX & REST controllers** — the browser calls `GET /api/posts`, `POST /api/posts`, `GET /api/posts/{id}`, health, and cache-stat endpoints. Responses and errors are displayed directly in the page.
3. **Spring Data JPA**`PostRepository` provides pagination and case-insensitive title search; `PostService` owns read/write transaction boundaries.
4. **Caching** — individual post reads use bounded Caffeine caching. The “Read twice” button produces real cache activity, then requests the cache-stat endpoint.
5. **Security** — public portfolio pages and read APIs are open; data mutations require the `EDITOR` role with BCrypt-backed, stateless HTTP Basic authentication.
6. **Validation & error handling** — request records validate input at the boundary and `ApiExceptionHandler` returns consistent RFC-style problem documents.
7. **Safe concurrent updates** — the JPA `@Version` field makes an outdated update return a conflict instead of silently overwriting a newer change.
8. **Configuration management** — typed cache and security settings live in application configuration. The `prod` profile uses PostgreSQL and requires database/editor environment variables with no committed production secrets.
```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
## Build and test
```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.
The integration test verifies the rendered Thymeleaf pages, unauthenticated versus editor-only API access, validation responses, search, and cache metrics.
## Learning journal
## Project layout
### 1. Prefer a standalone executable API over a legacy WAR
```text
com.hoelee.demo
├── config typed cache and security configuration
├── post REST contract, JPA entity, repository, and service layer
├── support consistent API error mapping
└── web Thymeleaf pages, cache metrics, and local demo data
```
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.
Each Java file has a compact learning note in the author/version comment format used by the larger reference application. The source uses 2024 timestamps in the requested evening window; the Git commits themselves retain their real creation times.
### 2. Keep API, domain, persistence, and remote models separate
## Production profile
`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.
`SPRING_PROFILES_ACTIVE=prod` selects PostgreSQL and requires:
### 3. Validate at the edge and transact in the service
```text
APP_DB_URL
APP_DB_USERNAME
APP_DB_PASSWORD
APP_EDITOR_USERNAME
APP_EDITOR_PASSWORD
```
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.
For production, use a managed secret store and add Flyway or Liquibase migrations before setting `ddl-auto` to `validate`.

10
pom.xml
View File

@@ -12,10 +12,10 @@
</parent>
<groupId>com.hoelee</groupId>
<artifactId>springboot-jsonplaceholder-demo</artifactId>
<artifactId>spring-boot-demo</artifactId>
<version>1.0.0</version>
<name>springboot-jsonplaceholder-demo</name>
<description>A production-minded Spring Boot REST API showcase.</description>
<name>spring-boot-demo</name>
<description>An interactive Spring Boot and Thymeleaf portfolio showcase.</description>
<properties>
<java.version>21</java.version>
@@ -26,6 +26,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder;
package com.hoelee.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -6,18 +6,18 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* @version v1, 2024-10-21 08:05:00PM
* @version v2, 2024-10-29 08:05:00PM
* @author hoelee
* Learning note: configuration scanning and JPA auditing keep cross-cutting setup explicit and compact.
* Learning note: the demo application enables typed configuration and database auditing at the composition root.
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableJpaAuditing
public class ShowcaseApplication {
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(ShowcaseApplication.class, args);
SpringApplication.run(DemoApplication.class, args);
}
}
//~ v1, 2024-10-21 08:05:00PM - Last edited by hoelee
//~ v2, 2024-10-29 08:05:00PM - Last edited by hoelee

View File

@@ -1,7 +1,6 @@
package com.hoelee.jsonplaceholder.config;
package com.hoelee.demo.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
@@ -9,23 +8,23 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @version v1, 2024-10-22 08:10:00PM
* @version v2, 2024-10-29 10:05:00PM
* @author hoelee
* Learning note: a bounded local cache improves hot read latency without making cached data permanent.
* Learning note: a bounded local cache improves hot-read latency while its statistics make the benefit observable.
*/
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
CacheManager cacheManager(CacheProperties properties) {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("posts");
cacheManager.setCaffeine(Caffeine.newBuilder()
.recordStats()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10)));
.maximumSize(properties.maximumSize())
.expireAfterWrite(properties.ttl()));
return cacheManager;
}
}
//~ v1, 2024-10-22 08:10:00PM - Last edited by hoelee
//~ v2, 2024-10-29 10:05:00PM - Last edited by hoelee

View File

@@ -0,0 +1,19 @@
package com.hoelee.demo.config;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* @version v2, 2024-10-29 09:05:00PM
* @author hoelee
* Learning note: cache limits are configuration rather than magic numbers, so operations can tune them safely.
*/
@Validated
@ConfigurationProperties(prefix = "app.cache")
public record CacheProperties(@Min(1) int maximumSize, @NotNull Duration ttl) {
}
//~ v2, 2024-10-29 09:05:00PM - Last edited by hoelee

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder.config;
package com.hoelee.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -15,9 +15,9 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* @version v1, 2024-10-22 10:10:00PM
* @version v2, 2024-10-30 09:10:00PM
* @author hoelee
* Learning note: read operations stay public while state-changing API operations require a role.
* Learning note: public portfolio pages and reads remain accessible while every data mutation needs the editor role.
*/
@Configuration
public class SecurityConfig {
@@ -28,7 +28,8 @@ public class SecurityConfig {
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/", "/showcase", "/css/**", "/js/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/showcase/**", "/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/**").hasRole("EDITOR")
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults())
@@ -49,4 +50,4 @@ public class SecurityConfig {
}
}
//~ v1, 2024-10-22 10:10:00PM - Last edited by hoelee
//~ v2, 2024-10-30 09:10:00PM - Last edited by hoelee

View File

@@ -0,0 +1,17 @@
package com.hoelee.demo.config;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* @version v2, 2024-10-30 08:10:00PM
* @author hoelee
* Learning note: credentials are configuration inputs, keeping real deployment values out of source control.
*/
@Validated
@ConfigurationProperties(prefix = "app.security")
public record SecurityProperties(@NotBlank String editorUsername, @NotBlank String editorPassword) {
}
//~ v2, 2024-10-30 08:10:00PM - Last edited by hoelee

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder.post;
package com.hoelee.demo.post;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -6,9 +6,9 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
/**
* @version v1, 2024-10-24 09:20:00PM
* @version v2, 2024-11-01 08:20:00PM
* @author hoelee
* Learning note: immutable request records make the public contract concise and validation rules visible.
* Learning note: an immutable request record makes the create contract and its validation rules easy to review.
*/
public record CreatePostRequest(
@NotNull @Positive Long authorId,
@@ -16,4 +16,4 @@ public record CreatePostRequest(
@NotBlank @Size(max = 10_000) String body) {
}
//~ v1, 2024-10-24 09:20:00PM - Last edited by hoelee
//~ v2, 2024-11-01 08:20:00PM - Last edited by hoelee

View File

@@ -0,0 +1,20 @@
package com.hoelee.demo.post;
import java.util.List;
import java.util.function.Function;
import org.springframework.data.domain.Page;
/**
* @version v2, 2024-11-02 08:25:00PM
* @author hoelee
* Learning note: a custom page envelope avoids coupling browser clients to framework serialization details.
*/
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {
public static <S, T> PageResponse<T> from(Page<S> page, Function<S, T> mapper) {
return new PageResponse<>(page.map(mapper).getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages());
}
}
//~ v2, 2024-11-02 08:25:00PM - Last edited by hoelee

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder.post;
package com.hoelee.demo.post;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -16,9 +16,9 @@ import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* @version v1, 2024-10-23 10:15:00PM
* @version v2, 2024-10-31 09:15:00PM
* @author hoelee
* Learning note: the entity owns persistence concerns, while request validation remains at the API boundary.
* Learning note: the entity models persistence, audit metadata, and concurrent-update protection without HTTP concerns.
*/
@Entity
@Table(name = "posts", indexes = @Index(name = "idx_posts_title", columnList = "title"))
@@ -30,9 +30,6 @@ public class Post {
@UuidGenerator
private UUID id;
@Column(name = "source_post_id", unique = true)
private Long sourcePostId;
@Column(nullable = false)
private long authorId;
@@ -56,19 +53,14 @@ public class Post {
protected Post() {
}
private Post(Long sourcePostId, long authorId, String title, String body) {
this.sourcePostId = sourcePostId;
private Post(long authorId, String title, String body) {
this.authorId = authorId;
this.title = title;
this.body = body;
}
public static Post create(long authorId, String title, String body) {
return new Post(null, authorId, title, body);
}
public static Post imported(long sourcePostId, long authorId, String title, String body) {
return new Post(sourcePostId, authorId, title, body);
return new Post(authorId, title, body);
}
public void update(String title, String body) {
@@ -76,37 +68,13 @@ public class Post {
this.body = body;
}
public UUID getId() {
return id;
}
public long getAuthorId() {
return authorId;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
public long getVersion() {
return version;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Long getSourcePostId() {
return sourcePostId;
}
public UUID getId() { return id; }
public long getAuthorId() { return authorId; }
public String getTitle() { return title; }
public String getBody() { return body; }
public long getVersion() { return version; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}
//~ v1, 2024-10-23 10:15:00PM - Last edited by hoelee
//~ v2, 2024-10-31 09:15:00PM - Last edited by hoelee

View File

@@ -1,11 +1,10 @@
package com.hoelee.jsonplaceholder.post;
package com.hoelee.demo.post;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -19,9 +18,9 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/**
* @version v1, 2024-10-27 09:35:00PM
* @version v2, 2024-11-02 10:25:00PM
* @author hoelee
* Learning note: the controller is deliberately thin: it translates HTTP, delegates, and returns standard status codes.
* Learning note: the REST controller maps HTTP semantics to a thin, testable service boundary.
*/
@RestController
@RequestMapping("/api/posts")
@@ -48,10 +47,8 @@ public class PostController {
@PostMapping
public ResponseEntity<PostResponse> createPost(@Valid @RequestBody CreatePostRequest request) {
PostResponse response = postService.createPost(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{postId}")
.buildAndExpand(response.id())
.toUri();
URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{postId}")
.buildAndExpand(response.id()).toUri();
return ResponseEntity.created(location).body(response);
}
@@ -65,11 +62,6 @@ public class PostController {
postService.deletePost(postId);
return ResponseEntity.noContent().build();
}
@PostMapping("/import/jsonplaceholder")
public ResponseEntity<ImportResult> importFromJsonPlaceholder() {
return ResponseEntity.status(HttpStatus.CREATED).body(postService.importFromJsonPlaceholder());
}
}
//~ v1, 2024-10-27 09:35:00PM - Last edited by hoelee
//~ v2, 2024-11-02 10:25:00PM - Last edited by hoelee

View File

@@ -0,0 +1,18 @@
package com.hoelee.demo.post;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @version v2, 2024-10-31 10:15:00PM
* @author hoelee
* Learning note: Spring Data keeps ordinary pagination and case-insensitive search declarative instead of handwritten SQL.
*/
public interface PostRepository extends JpaRepository<Post, UUID> {
Page<Post> findByTitleContainingIgnoreCase(String title, Pageable pageable);
}
//~ v2, 2024-10-31 10:15:00PM - Last edited by hoelee

View File

@@ -0,0 +1,20 @@
package com.hoelee.demo.post;
import java.time.Instant;
import java.util.UUID;
/**
* @version v2, 2024-11-01 10:20:00PM
* @author hoelee
* Learning note: a response DTO gives the public API a stable shape independent of JPA implementation details.
*/
public record PostResponse(UUID id, long authorId, String title, String body, long version,
Instant createdAt, Instant updatedAt) {
public static PostResponse from(Post post) {
return new PostResponse(post.getId(), post.getAuthorId(), post.getTitle(), post.getBody(),
post.getVersion(), post.getCreatedAt(), post.getUpdatedAt());
}
}
//~ v2, 2024-11-01 10:20:00PM - Last edited by hoelee

View File

@@ -0,0 +1,69 @@
package com.hoelee.demo.post;
import com.hoelee.demo.support.PostNotFoundException;
import com.hoelee.demo.support.PostVersionConflictException;
import java.util.UUID;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* @version v2, 2024-11-02 09:25:00PM
* @author hoelee
* Learning note: service methods centralize transaction boundaries, cache coherence, and optimistic-locking rules.
*/
@Service
public class PostService {
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
@Transactional(readOnly = true)
public PageResponse<PostResponse> findPosts(String title, Pageable pageable) {
Page<Post> posts = StringUtils.hasText(title)
? postRepository.findByTitleContainingIgnoreCase(title.trim(), pageable)
: postRepository.findAll(pageable);
return PageResponse.from(posts, PostResponse::from);
}
@Cacheable(cacheNames = "posts", key = "#postId")
@Transactional(readOnly = true)
public PostResponse findPost(UUID postId) {
return postRepository.findById(postId).map(PostResponse::from)
.orElseThrow(() -> new PostNotFoundException(postId));
}
@Transactional
public PostResponse createPost(CreatePostRequest request) {
Post post = Post.create(request.authorId(), request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId).orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CacheEvict(cacheNames = "posts", key = "#postId")
@Transactional
public void deletePost(UUID postId) {
Post post = postRepository.findById(postId).orElseThrow(() -> new PostNotFoundException(postId));
postRepository.delete(post);
}
}
//~ v2, 2024-11-02 09:25:00PM - Last edited by hoelee

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder.post;
package com.hoelee.demo.post;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -6,9 +6,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* @version v1, 2024-10-24 10:20:00PM
* @version v2, 2024-11-01 09:20:00PM
* @author hoelee
* Learning note: accepting a version makes concurrent edits explicit instead of silently overwriting data.
* Learning note: the expected version is part of the update contract, making optimistic locking visible to clients.
*/
public record UpdatePostRequest(
@NotBlank @Size(max = 160) String title,
@@ -16,4 +16,4 @@ public record UpdatePostRequest(
@NotNull @Min(0) Long version) {
}
//~ v1, 2024-10-24 10:20:00PM - Last edited by hoelee
//~ v2, 2024-11-01 09:20:00PM - Last edited by hoelee

View File

@@ -1,4 +1,4 @@
package com.hoelee.jsonplaceholder.support;
package com.hoelee.demo.support;
import java.net.URI;
import java.util.LinkedHashMap;
@@ -10,14 +10,13 @@ import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.RestClientException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/**
* @version v1, 2024-10-27 10:35:00PM
* @version v2, 2024-11-03 08:30:00PM
* @author hoelee
* Learning note: one error boundary makes RFC 9457-style problem responses consistent across every endpoint.
* Learning note: a single error boundary keeps browser AJAX and API clients on one predictable problem-response format.
*/
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@@ -32,12 +31,6 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
return problem(HttpStatus.CONFLICT, "POST_VERSION_CONFLICT", exception.getMessage());
}
@ExceptionHandler({UpstreamServiceException.class, RestClientException.class})
ProblemDetail handleUpstreamFailure(RuntimeException exception) {
return problem(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_UNAVAILABLE",
"The post import service is temporarily unavailable");
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
@@ -55,10 +48,10 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
private ProblemDetail problem(HttpStatus status, String code, String detail) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setType(URI.create("https://github.com/hoelee/springboot-jsonplaceholder-demo/problems/" + code));
problem.setType(URI.create("https://github.com/hoelee/spring-boot-demo/problems/" + code));
problem.setProperty("code", code);
return problem;
}
}
//~ v1, 2024-10-27 10:35:00PM - Last edited by hoelee
//~ v2, 2024-11-03 08:30:00PM - Last edited by hoelee

View File

@@ -0,0 +1,17 @@
package com.hoelee.demo.support;
import java.util.UUID;
/**
* @version v2, 2024-10-30 10:10:00PM
* @author hoelee
* Learning note: a domain-specific exception lets the HTTP layer return an accurate 404 response.
*/
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(UUID postId) {
super("Post %s was not found".formatted(postId));
}
}
//~ v2, 2024-10-30 10:10:00PM - Last edited by hoelee

View File

@@ -1,11 +1,11 @@
package com.hoelee.jsonplaceholder.support;
package com.hoelee.demo.support;
import java.util.UUID;
/**
* @version v1, 2024-10-26 09:30:00PM
* @version v2, 2024-10-31 08:15:00PM
* @author hoelee
* Learning note: optimistic locking gives API clients a clear retry signal when data changed concurrently.
* Learning note: version conflicts prevent one editor from unknowingly overwriting another editor's work.
*/
public class PostVersionConflictException extends RuntimeException {
@@ -14,4 +14,4 @@ public class PostVersionConflictException extends RuntimeException {
}
}
//~ v1, 2024-10-26 09:30:00PM - Last edited by hoelee
//~ v2, 2024-10-31 08:15:00PM - Last edited by hoelee

View File

@@ -0,0 +1,33 @@
package com.hoelee.demo.web;
import com.hoelee.demo.post.Post;
import com.hoelee.demo.post.PostRepository;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
/**
* @version v2, 2024-11-04 08:35:00PM
* @author hoelee
* Learning note: a local-only seed makes the interactive portfolio demonstrable immediately without hiding how the database is used.
*/
@Configuration
@Profile("!prod & !test")
public class DemoDataConfiguration {
@Bean
CommandLineRunner samplePosts(PostRepository postRepository) {
return arguments -> {
if (postRepository.count() == 0) {
postRepository.saveAll(List.of(
Post.create(1, "Layered Spring Boot API", "Controller, service, repository, validation, and problem details work together."),
Post.create(2, "Cache-aware reads", "Caffeine caches individual post reads and exposes live hit and miss statistics."),
Post.create(3, "Secure data mutations", "The editor role protects create, update, and delete operations with stateless HTTP Basic authentication.")));
}
};
}
}
//~ v2, 2024-11-04 08:35:00PM - Last edited by hoelee

View File

@@ -0,0 +1,29 @@
package com.hoelee.demo.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
/**
* @version v2, 2024-11-03 09:30:00PM
* @author hoelee
* Learning note: Thymeleaf gives the portfolio a server-rendered first page while the feature tour uses the same REST API asynchronously.
*/
@Controller
public class HomeController {
@GetMapping("/")
public String index(Model model) {
model.addAttribute("developerName", "Hoe Lee");
model.addAttribute("email", "me@hoelee.com");
model.addAttribute("phone", "+60 12-7972 969");
return "index";
}
@GetMapping("/showcase")
public String showcase() {
return "showcase";
}
}
//~ v2, 2024-11-03 09:30:00PM - Last edited by hoelee

View File

@@ -0,0 +1,41 @@
package com.hoelee.demo.web;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @version v2, 2024-11-03 10:30:00PM
* @author hoelee
* Learning note: a small read-only endpoint makes cache behaviour observable without exposing application internals.
*/
@RestController
@RequestMapping("/api/showcase")
public class ShowcaseMetricsController {
private final CacheManager cacheManager;
public ShowcaseMetricsController(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@GetMapping("/cache")
public CacheStatsResponse cacheStats() {
Cache cache = cacheManager.getCache("posts");
if (cache instanceof CaffeineCache caffeineCache
&& caffeineCache.getNativeCache() instanceof com.github.benmanes.caffeine.cache.Cache<?, ?> nativeCache) {
CacheStats stats = nativeCache.stats();
return new CacheStatsResponse(stats.requestCount(), stats.hitCount(), stats.missCount(), stats.hitRate());
}
return new CacheStatsResponse(0, 0, 0, 0);
}
public record CacheStatsResponse(long requests, long hits, long misses, double hitRate) {
}
}
//~ v2, 2024-11-03 10:30:00PM - Last edited by hoelee

View File

@@ -1,24 +0,0 @@
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;
/**
* @version v1, 2024-10-21 09:05:00PM
* @author hoelee
* Learning note: typed, validated properties make operational settings discoverable and fail fast.
*/
@Validated
@ConfigurationProperties(prefix = "app.api")
public record ApiProperties(
@NotBlank String jsonPlaceholderBaseUrl,
@NotNull Duration requestTimeout,
@Min(1) @Max(100) int importLimit) {
}
//~ v1, 2024-10-21 09:05:00PM - Last edited by hoelee

View File

@@ -1,32 +0,0 @@
package com.hoelee.jsonplaceholder.config;
import java.net.http.HttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* @version v1, 2024-10-22 09:10:00PM
* @author hoelee
* Learning note: an explicitly named HTTP client isolates a remote integration from the application API.
*/
@Configuration
public class RestClientConfig {
@Bean
RestClient jsonPlaceholderRestClient(RestClient.Builder builder, ApiProperties properties) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(properties.requestTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(properties.requestTimeout());
return builder
.baseUrl(properties.jsonPlaceholderBaseUrl())
.requestFactory(requestFactory)
.build();
}
}
//~ v1, 2024-10-22 09:10:00PM - Last edited by hoelee

View File

@@ -1,19 +0,0 @@
package com.hoelee.jsonplaceholder.config;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* @version v1, 2024-10-21 10:05:00PM
* @author hoelee
* Learning note: credentials are externalized so a repository never needs a real environment secret.
*/
@Validated
@ConfigurationProperties(prefix = "app.security")
public record SecurityProperties(
@NotBlank String editorUsername,
@NotBlank String editorPassword) {
}
//~ v1, 2024-10-21 10:05:00PM - Last edited by hoelee

View File

@@ -1,37 +0,0 @@
package com.hoelee.jsonplaceholder.integration;
import com.hoelee.jsonplaceholder.support.UpstreamServiceException;
import java.util.List;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
/**
* @version v1, 2024-10-23 09:15:00PM
* @author hoelee
* Learning note: RestClient provides a typed boundary where timeouts and remote failures can be handled consistently.
*/
@Component
public class JsonPlaceholderClient {
private final RestClient restClient;
public JsonPlaceholderClient(RestClient jsonPlaceholderRestClient) {
this.restClient = jsonPlaceholderRestClient;
}
public List<JsonPlaceholderPost> fetchPosts(int limit) {
try {
List<JsonPlaceholderPost> posts = restClient.get()
.uri("/posts")
.retrieve()
.body(new ParameterizedTypeReference<>() { });
return posts == null ? List.of() : posts.stream().limit(limit).toList();
} catch (RestClientException exception) {
throw new UpstreamServiceException("JSONPlaceholder could not be reached", exception);
}
}
}
//~ v1, 2024-10-23 09:15:00PM - Last edited by hoelee

View File

@@ -1,11 +0,0 @@
package com.hoelee.jsonplaceholder.integration;
/**
* @version v1, 2024-10-23 08:15:00PM
* @author hoelee
* Learning note: a remote DTO is kept separate from the persisted domain model to prevent API coupling.
*/
public record JsonPlaceholderPost(Long id, Long userId, String title, String body) {
}
//~ v1, 2024-10-23 08:15:00PM - Last edited by hoelee

View File

@@ -1,11 +0,0 @@
package com.hoelee.jsonplaceholder.post;
/**
* @version v1, 2024-10-25 10:25:00PM
* @author hoelee
* Learning note: import results report what happened without returning an unnecessary full data set.
*/
public record ImportResult(int fetched, int imported, int skipped) {
}
//~ v1, 2024-10-25 10:25:00PM - Last edited by hoelee

View File

@@ -1,29 +0,0 @@
package com.hoelee.jsonplaceholder.post;
import java.util.List;
import java.util.function.Function;
import org.springframework.data.domain.Page;
/**
* @version v1, 2024-10-25 09:25:00PM
* @author hoelee
* Learning note: a stable page envelope avoids exposing Spring Data's serialized implementation details.
*/
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages) {
public static <S, T> PageResponse<T> from(Page<S> page, Function<S, T> mapper) {
return new PageResponse<>(
page.map(mapper).getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());
}
}
//~ v1, 2024-10-25 09:25:00PM - Last edited by hoelee

View File

@@ -1,22 +0,0 @@
package com.hoelee.jsonplaceholder.post;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @version v1, 2024-10-24 08:20:00PM
* @author hoelee
* Learning note: Spring Data derives focused queries from method names and keeps data access out of controllers.
*/
public interface PostRepository extends JpaRepository<Post, UUID> {
Page<Post> findByTitleContainingIgnoreCase(String title, Pageable pageable);
List<Post> findAllBySourcePostIdIn(Collection<Long> sourcePostIds);
}
//~ v1, 2024-10-24 08:20:00PM - Last edited by hoelee

View File

@@ -1,32 +0,0 @@
package com.hoelee.jsonplaceholder.post;
import java.time.Instant;
import java.util.UUID;
/**
* @version v1, 2024-10-25 08:25:00PM
* @author hoelee
* Learning note: response DTOs stop database implementation details from becoming a permanent API contract.
*/
public record PostResponse(
UUID id,
long authorId,
String title,
String body,
long version,
Instant createdAt,
Instant updatedAt) {
public static PostResponse from(Post post) {
return new PostResponse(
post.getId(),
post.getAuthorId(),
post.getTitle(),
post.getBody(),
post.getVersion(),
post.getCreatedAt(),
post.getUpdatedAt());
}
}
//~ v1, 2024-10-25 08:25:00PM - Last edited by hoelee

View File

@@ -1,123 +0,0 @@
package com.hoelee.jsonplaceholder.post;
import com.hoelee.jsonplaceholder.config.ApiProperties;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderClient;
import com.hoelee.jsonplaceholder.integration.JsonPlaceholderPost;
import com.hoelee.jsonplaceholder.support.PostNotFoundException;
import com.hoelee.jsonplaceholder.support.PostVersionConflictException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* @version v1, 2024-10-27 08:35:00PM
* @author hoelee
* Learning note: this service owns business transactions, cache invalidation, and integration orchestration.
*/
@Service
public class PostService {
private final PostRepository postRepository;
private final JsonPlaceholderClient jsonPlaceholderClient;
private final ApiProperties apiProperties;
public PostService(
PostRepository postRepository,
JsonPlaceholderClient jsonPlaceholderClient,
ApiProperties apiProperties) {
this.postRepository = postRepository;
this.jsonPlaceholderClient = jsonPlaceholderClient;
this.apiProperties = apiProperties;
}
@Transactional(readOnly = true)
public PageResponse<PostResponse> findPosts(String title, Pageable pageable) {
Page<Post> posts = StringUtils.hasText(title)
? postRepository.findByTitleContainingIgnoreCase(title.trim(), pageable)
: postRepository.findAll(pageable);
return PageResponse.from(posts, PostResponse::from);
}
@Cacheable(cacheNames = "posts", key = "#postId")
@Transactional(readOnly = true)
public PostResponse findPost(UUID postId) {
return postRepository.findById(postId)
.map(PostResponse::from)
.orElseThrow(() -> new PostNotFoundException(postId));
}
@Transactional
public PostResponse createPost(CreatePostRequest request) {
Post post = Post.create(request.authorId(), request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CachePut(cacheNames = "posts", key = "#postId")
@Transactional
public PostResponse updatePost(UUID postId, UpdatePostRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
if (post.getVersion() != request.version()) {
throw new PostVersionConflictException(postId);
}
post.update(request.title().trim(), request.body().trim());
return PostResponse.from(postRepository.saveAndFlush(post));
}
@CacheEvict(cacheNames = "posts", key = "#postId")
@Transactional
public void deletePost(UUID postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostNotFoundException(postId));
postRepository.delete(post);
}
@CacheEvict(cacheNames = "posts", allEntries = true)
@Transactional
public ImportResult importFromJsonPlaceholder() {
List<JsonPlaceholderPost> received = jsonPlaceholderClient.fetchPosts(apiProperties.importLimit());
List<JsonPlaceholderPost> candidates = received.stream()
.filter(this::isImportable)
.toList();
Set<Long> candidateSourceIds = candidates.stream().map(JsonPlaceholderPost::id)
.collect(java.util.stream.Collectors.toSet());
Set<Long> knownSourceIds = candidateSourceIds.isEmpty()
? Set.of()
: postRepository.findAllBySourcePostIdIn(candidateSourceIds).stream()
.map(Post::getSourcePostId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> seenSourceIds = new HashSet<>();
List<Post> newPosts = candidates.stream()
.filter(remotePost -> !knownSourceIds.contains(remotePost.id()))
.filter(remotePost -> seenSourceIds.add(remotePost.id()))
.map(remotePost -> Post.imported(
remotePost.id(),
remotePost.userId(),
remotePost.title().trim(),
remotePost.body().trim()))
.toList();
postRepository.saveAllAndFlush(newPosts);
return new ImportResult(received.size(), newPosts.size(), received.size() - newPosts.size());
}
private boolean isImportable(JsonPlaceholderPost post) {
return post.id() != null
&& post.userId() != null
&& post.userId() > 0
&& StringUtils.hasText(post.title())
&& post.title().length() <= 160
&& StringUtils.hasText(post.body())
&& post.body().length() <= 10_000;
}
}
//~ v1, 2024-10-27 08:35:00PM - Last edited by hoelee

View File

@@ -1,17 +0,0 @@
package com.hoelee.jsonplaceholder.support;
import java.util.UUID;
/**
* @version v1, 2024-10-26 08:30:00PM
* @author hoelee
* Learning note: domain-specific exceptions let the HTTP adapter provide precise client feedback.
*/
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(UUID postId) {
super("Post %s was not found".formatted(postId));
}
}
//~ v1, 2024-10-26 08:30:00PM - Last edited by hoelee

View File

@@ -1,15 +0,0 @@
package com.hoelee.jsonplaceholder.support;
/**
* @version v1, 2024-10-26 10:30:00PM
* @author hoelee
* Learning note: wrapping remote client errors prevents third-party exception details leaking through the API.
*/
public class UpstreamServiceException extends RuntimeException {
public UpstreamServiceException(String message, Throwable cause) {
super(message, cause);
}
}
//~ v1, 2024-10-26 10:30:00PM - Last edited by hoelee

View File

@@ -1,6 +1,6 @@
spring:
application:
name: jsonplaceholder-showcase
name: spring-boot-demo
datasource:
url: jdbc:h2:mem:postshowcase;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
@@ -23,10 +23,9 @@ management:
show-details: never
app:
api:
json-placeholder-base-url: https://jsonplaceholder.typicode.com
request-timeout: 2s
import-limit: 20
cache:
maximum-size: 500
ttl: 10m
security:
editor-username: ${APP_EDITOR_USERNAME:demo-editor}
editor-password: ${APP_EDITOR_PASSWORD:changeit}

View File

@@ -0,0 +1 @@
:root{--ink:#101828;--muted:#667085;--navy:#0b1636;--blue:#276ef1;--mint:#65e4c7;--surface:#f4f7fb}*{box-sizing:border-box}body{color:var(--ink);font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;background:#fff}.nav-glass{background:rgba(8,18,47,.94);backdrop-filter:blur(14px);z-index:10}.hero-section{background:radial-gradient(circle at 75% 30%,#213f91 0,transparent 32%),linear-gradient(135deg,#08132e,#101f50);color:#fff}.hero-section h1,.showcase-header h1{font-size:clamp(2.8rem,7vw,5.7rem);line-height:1.03;font-weight:800;letter-spacing:-.055em}.hero-section h1 span{color:var(--mint)}.eyebrow{font-size:.74rem;font-weight:800;letter-spacing:.16em;margin-bottom:1rem}.hero-copy{font-size:1.2rem;line-height:1.8;max-width:740px;color:#d5dcf4}.tech-pill,.stack-box span{border:1px solid rgba(255,255,255,.25);border-radius:999px;padding:.4rem .8rem;font-size:.85rem}.btn-accent{background:var(--mint);border-color:var(--mint);color:#07283a;font-weight:700}.btn-accent:hover{background:#91f3dd;border-color:#91f3dd}.btn-link-light{color:#fff;text-decoration:none}.section-space{padding:6rem 0}.section-space h2{font-size:clamp(2rem,4vw,3.4rem);font-weight:800;letter-spacing:-.04em}.section-muted{background:var(--surface)}.feature-card,.skill-item,.demo-card{height:100%;padding:2rem;border:1px solid #e4e9f2;border-radius:1rem;background:#fff;box-shadow:0 12px 30px rgba(16,24,40,.05)}.feature-card span,.demo-card small{color:var(--blue);font-weight:800}.feature-card h3,.skill-item h3{font-weight:750;margin-top:1.1rem}.feature-card p,.skill-item p{color:var(--muted);line-height:1.7}.skill-item{padding:1.5rem;box-shadow:none}.stack-box{padding:2rem;border-radius:1rem;background:var(--navy);color:#fff}.contact-panel{padding:3rem;border-radius:1rem;background:linear-gradient(130deg,#eaf4ff,#f3fffb)}.contact-links{display:grid;gap:.7rem}.contact-links a{color:var(--blue);font-weight:700;text-decoration:none;font-size:1.1rem}footer{background:var(--navy);color:#aab7db}.showcase-body{background:#08132e;color:#e9efff}.showcase-header{padding:4rem 0 2rem;max-width:850px}.showcase-header p{color:#b9c7e9;font-size:1.15rem;line-height:1.8}.showcase-header h1{font-size:clamp(2.7rem,6vw,4.7rem)}.workbench{background:#101f43;border:1px solid #29416e;border-radius:1rem;padding:1.5rem}.workbench .form-control{background:#091631;color:#fff;border-color:#37517f}.workbench .form-control:focus{background:#091631;color:#fff;border-color:var(--mint);box-shadow:0 0 0 .2rem rgba(101,228,199,.15)}.workbench code{color:var(--mint)}.demo-card{background:#102247;border-color:#29416e;color:#e9efff;box-shadow:none}.demo-card p{color:#b9c7e9}.api-result{white-space:pre-wrap;word-break:break-word;min-height:110px;padding:1rem;background:#071126;color:#bff8df;border-radius:.6rem;font-size:.82rem}.table-dark{--bs-table-bg:transparent;--bs-table-hover-bg:rgba(255,255,255,.05)}@media(max-width:767px){.section-space{padding:4rem 0}.contact-panel{padding:2rem}.showcase-header{padding-top:2rem}}

View File

@@ -0,0 +1,54 @@
$(function () {
const pretty = value => JSON.stringify(value, null, 2);
const showResult = (target, label, value) => $(target).text(`${label}\n${pretty(value)}`);
const showError = (target, xhr) => {
const body = xhr.responseJSON || {status: xhr.status, message: xhr.statusText};
showResult(target, `HTTP ${xhr.status} error`, body);
};
const credentials = () => {
const username = $("#editor-username").val();
const password = $("#editor-password").val();
return username && password ? `Basic ${btoa(`${username}:${password}`)}` : null;
};
function loadPosts() {
const title = $("#search-title").val().trim();
$.getJSON("/api/posts", title ? {title: title} : {})
.done(page => {
const rows = page.content.map(post => `<tr><td>${$("<div>").text(post.title).html()}</td><td>${post.authorId}</td><td>${post.version}</td><td><button class="btn btn-sm btn-outline-info read-post" data-id="${post.id}">Read twice</button></td></tr>`).join("");
$("#post-results").html(rows || '<tr><td colspan="4" class="text-secondary">No posts found.</td></tr>');
showResult("#api-result", "GET /api/posts", page);
}).fail(xhr => showError("#api-result", xhr));
}
function cacheStats() {
$.getJSON("/api/showcase/cache").done(data => showResult("#metrics-result", "GET /api/showcase/cache", data))
.fail(xhr => showError("#metrics-result", xhr));
}
$("#refresh-posts, #search-posts").on("click", loadPosts);
$("#search-title").on("keydown", event => { if (event.key === "Enter") { event.preventDefault(); loadPosts(); } });
$("#check-health").on("click", () => $.getJSON("/actuator/health").done(data => showResult("#metrics-result", "GET /actuator/health", data)).fail(xhr => showError("#metrics-result", xhr)));
$("#check-cache").on("click", cacheStats);
$(document).on("click", ".read-post", function () {
const url = `/api/posts/${$(this).data("id")}`;
$.getJSON(url).then(() => $.getJSON(url)).done(post => {
showResult("#api-result", `${url} called twice (second call is cacheable)`, post);
cacheStats();
}).fail(xhr => showError("#api-result", xhr));
});
$("#create-post").on("submit", function (event) {
event.preventDefault();
const authorization = credentials();
if (!authorization) { $("#api-result").text("Enter the configured editor username and password before creating a post."); return; }
const payload = {authorId: Number($("#author-id").val()), title: $("#post-title").val(), body: $("#post-body").val()};
$.ajax({url: "/api/posts", method: "POST", contentType: "application/json", data: JSON.stringify(payload), headers: {Authorization: authorization}})
.done(post => { showResult("#api-result", "POST /api/posts — created", post); this.reset(); $("#author-id").val(1); loadPosts(); })
.fail(xhr => showError("#api-result", xhr));
});
loadPosts();
cacheStats();
});

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Hoe Lee — Spring Boot and web integration specialist.">
<title>Hoe Lee | Spring Boot Developer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" th:href="@{/css/site.css}">
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top nav-glass">
<div class="container"><a class="navbar-brand fw-bold" href="#top">HL / Spring</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav"><span class="navbar-toggler-icon"></span></button>
<div id="nav" class="collapse navbar-collapse justify-content-end"><div class="navbar-nav gap-md-2">
<a class="nav-link" href="#overview">Overview</a><a class="nav-link" href="#skills">Skills</a><a class="nav-link" href="#contact">Contact</a>
<a class="btn btn-outline-light btn-sm px-3 ms-md-2" th:href="@{/showcase}">Interactive demo</a>
</div></div>
</div>
</nav>
<main id="top">
<section class="hero-section">
<div class="container position-relative"><div class="row align-items-center min-vh-100 py-5">
<div class="col-lg-8">
<p class="eyebrow">FULL STACK DEVELOPER · SPRING BOOT &amp; WEB INTEGRATION</p>
<h1>Reliable backend systems,<br><span>built to do useful work.</span></h1>
<p class="hero-copy">Im <strong th:text="${developerName}">Hoe Lee</strong>, an experienced developer specializing in Spring Boot backend systems, API integration, and automation solutions. Over 10+ years, Ive built systems for business automation, marketplace synchronization, and digital services.</p>
<div class="d-flex flex-wrap gap-2 mb-4"><span class="tech-pill">Spring Boot 3</span><span class="tech-pill">Java 21</span><span class="tech-pill">REST APIs</span><span class="tech-pill">MySQL</span><span class="tech-pill">Docker</span><span class="tech-pill">Web3</span></div>
<a class="btn btn-accent btn-lg me-2" th:href="@{/showcase}">Explore live feature tour</a>
<a class="btn btn-link-light btn-lg" href="#contact">Get in touch →</a>
</div>
</div></div>
</section>
<section id="overview" class="section-space"><div class="container">
<p class="eyebrow text-primary">PROFESSIONAL OVERVIEW</p><h2>From product idea to working system.</h2>
<p class="lead text-secondary col-lg-8">A full-stack approach to professional Java Spring Boot engineering, REST API design, and real-world data integration for enterprise systems.</p>
<div class="row g-4 mt-2">
<div class="col-md-6 col-xl-4"><article class="feature-card"><span>01</span><h3>End-to-end architecture</h3><p>Clean Controller → Service → Repository design with Spring Boot, JPA/Hibernate, and practical database modelling.</p></article></div>
<div class="col-md-6 col-xl-4"><article class="feature-card"><span>02</span><h3>Secure &amp; configurable</h3><p>Authentication, access control, environment profiles, validation, and global exception handling are designed in from the start.</p></article></div>
<div class="col-md-6 col-xl-4"><article class="feature-card"><span>03</span><h3>Automation &amp; integration</h3><p>Experience integrating marketplace workflows, CDN uploads, OCR, translation, and operational tooling.</p></article></div>
</div>
</div></section>
<section id="skills" class="section-space section-muted"><div class="container">
<p class="eyebrow text-primary">CORE MODULES &amp; CAPABILITIES</p><h2>Enterprise features with practical purpose.</h2>
<div class="row g-3 mt-3">
<div class="col-md-6"><div class="skill-item"><h3>User management</h3><p>Multi-role authentication, authorization, profiles, sessions, and Spring Security.</p></div></div>
<div class="col-md-6"><div class="skill-item"><h3>Marketplace management</h3><p>Seller accounts, synchronization, order tracking, and operational balances across marketplace platforms.</p></div></div>
<div class="col-md-6"><div class="skill-item"><h3>Financial &amp; reporting workflows</h3><p>Transaction logging, withdrawal approvals, CSV processing, and business-facing reports.</p></div></div>
<div class="col-md-6"><div class="skill-item"><h3>Content, domains &amp; processing</h3><p>File management, multi-domain administration, OCR extraction, translation, and automated workflows.</p></div></div>
</div>
<div class="stack-box mt-5"><p class="eyebrow text-white-50">TECHNOLOGY STACK</p><div class="d-flex flex-wrap gap-2"><span>Spring Boot</span><span>Java</span><span>Spring Security</span><span>Spring Data JPA</span><span>Hibernate</span><span>jQuery</span><span>Bootstrap</span><span>Docker</span><span>JUnit</span><span>Git</span></div></div>
</div></section>
<section id="contact" class="section-space"><div class="container"><div class="contact-panel row align-items-center g-4">
<div class="col-lg-7"><p class="eyebrow text-primary">LETS BUILD SOMETHING USEFUL</p><h2>Have a system or integration in mind?</h2><p class="lead text-secondary">I enjoy turning ideas into efficient software systems that work in production—not only in theory.</p></div>
<div class="col-lg-5"><div class="contact-links"><a th:href="|mailto:${email}|" th:text="${email}">me@hoelee.com</a><a th:href="|tel:${phone}|" th:text="${phone}">+60 12-7972 969</a><a href="https://www.hoelee.com" target="_blank" rel="noopener">www.hoelee.com ↗</a><a href="https://github.com/hoelee" target="_blank" rel="noopener">github.com/hoelee ↗</a></div></div>
</div></div></section>
</main>
<footer class="py-4 text-center">© 2026 Hoe Lee · Full Stack Developer</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spring Boot Feature Tour | Hoe Lee</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/site.css">
</head>
<body class="showcase-body">
<nav class="navbar navbar-dark nav-glass"><div class="container"><a class="navbar-brand fw-bold" href="/">← HL / Spring</a><span class="navbar-text text-white-50">Interactive feature tour</span></div></nav>
<main class="container py-5">
<header class="showcase-header"><p class="eyebrow">LIVE APPLICATION TOUR</p><h1>See the layers work together.</h1><p>This page uses jQuery AJAX to call the applications real Spring MVC controllers. The results below come from the running API and H2 database—not static mock data.</p></header>
<div class="row g-4 mb-4">
<div class="col-md-4"><article class="demo-card"><small>01 · THYMELEAF</small><h2>Server-rendered UI</h2><p>This page is a Thymeleaf view delivered by <code>HomeController</code>.</p></article></div>
<div class="col-md-4"><article class="demo-card"><small>02 · JPA + REST</small><h2>Query the database</h2><p>Load and search persisted posts through <code>GET /api/posts</code>.</p></article></div>
<div class="col-md-4"><article class="demo-card"><small>03 · CACHE</small><h2>Observe read caching</h2><p>Read a post twice and view live Caffeine hit/miss statistics.</p></article></div>
</div>
<section class="workbench mb-4"><div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3"><div><p class="eyebrow mb-1">DATABASE &amp; CONTROLLER</p><h2 class="h3 mb-0">Posts from Spring Data JPA</h2></div><button id="refresh-posts" class="btn btn-outline-light">Reload with AJAX</button></div>
<div class="input-group mb-3"><input id="search-title" class="form-control" placeholder="Search title, then press Enter"><button id="search-posts" class="btn btn-accent">Search</button></div>
<div class="table-responsive"><table class="table table-dark table-hover align-middle"><thead><tr><th>Title</th><th>Author</th><th>Version</th><th>Action</th></tr></thead><tbody id="post-results"><tr><td colspan="4" class="text-secondary">Loading…</td></tr></tbody></table></div>
</section>
<div class="row g-4"><div class="col-lg-7"><section class="workbench h-100"><p class="eyebrow">SECURITY + VALIDATION + TRANSACTION</p><h2 class="h3">Create a persisted post</h2><p class="text-secondary">Writes require the configured <code>EDITOR</code> account. Enter its credentials to send a Basic-authenticated AJAX request; they are used only by this browser request.</p>
<form id="create-post" class="row g-3"><div class="col-md-6"><label class="form-label" for="editor-username">Editor username</label><input id="editor-username" class="form-control" autocomplete="username"></div><div class="col-md-6"><label class="form-label" for="editor-password">Editor password</label><input id="editor-password" type="password" class="form-control" autocomplete="current-password"></div><div class="col-12"><label class="form-label" for="post-title">Title</label><input id="post-title" class="form-control" maxlength="160" required></div><div class="col-12"><label class="form-label" for="post-body">Body</label><textarea id="post-body" class="form-control" rows="3" required></textarea></div><div class="col-md-4"><label class="form-label" for="author-id">Author ID</label><input id="author-id" type="number" min="1" value="1" class="form-control" required></div><div class="col-12"><button class="btn btn-accent" type="submit">POST /api/posts</button></div></form>
</section></div>
<div class="col-lg-5"><section class="workbench h-100"><p class="eyebrow">HEALTH + CACHE METRICS</p><h2 class="h3">Live operational signals</h2><button id="check-health" class="btn btn-outline-light me-2">Check health</button><button id="check-cache" class="btn btn-outline-light">Refresh cache stats</button><pre id="metrics-result" class="api-result mt-3">Click a button to call an endpoint.</pre></section></div></div>
<section class="workbench mt-4"><p class="eyebrow">HTTP RESPONSE</p><h2 class="h3">Last AJAX result</h2><pre id="api-result" class="api-result">The request and response will appear here.</pre></section>
</main>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="/js/showcase.js"></script>
</body>
</html>

View File

@@ -0,0 +1,97 @@
package com.hoelee.demo.post;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
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.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
/**
* @version v2, 2024-11-04 09:35:00PM
* @author hoelee
* Learning note: these tests exercise rendered pages, security, MVC, JPA, validation, and cache statistics as one running application.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class DemoApplicationIntegrationTest {
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;
@BeforeEach
void cleanDatabaseAndCache() {
postRepository.deleteAll();
cacheManager.getCache("posts").clear();
}
@Test
void portfolioPagesArePublicAndRenderedByThymeleaf() throws Exception {
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Hoe Lee")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Explore live feature tour")));
mockMvc.perform(get("/showcase"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("jQuery AJAX")));
}
@Test
void writesRequireAnEditorButCreatedPostsCanBeSearchedPublicly() throws Exception {
String request = """
{"authorId": 7, "title": "Spring Boot showcase", "body": "A complete API example."}
""";
mockMvc.perform(post("/api/posts").contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isUnauthorized());
mockMvc.perform(post("/api/posts").with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD))
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Spring Boot showcase"));
mockMvc.perform(get("/api/posts?title=showcase"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void validationUsesAConsistentProblemResponse() 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 repeatedReadsAreVisibleInTheCacheMetrics() throws Exception {
MvcResult createResult = mockMvc.perform(post("/api/posts").with(httpBasic(EDITOR_USERNAME, EDITOR_PASSWORD))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"authorId\": 1, \"title\": \"Cache demo\", \"body\": \"Read this post twice.\"}"))
.andExpect(status().isCreated()).andReturn();
String id = com.jayway.jsonpath.JsonPath.read(createResult.getResponse().getContentAsString(), "$.id");
mockMvc.perform(get("/api/posts/{id}", id)).andExpect(status().isOk());
mockMvc.perform(get("/api/posts/{id}", id)).andExpect(status().isOk());
mockMvc.perform(get("/api/showcase/cache"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hits").value(greaterThanOrEqualTo(1)));
}
}
//~ v2, 2024-11-04 09:35:00PM - Last edited by hoelee

View File

@@ -1,122 +0,0 @@
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

@@ -7,10 +7,9 @@ spring:
ddl-auto: create-drop
app:
api:
json-placeholder-base-url: http://localhost:65535
request-timeout: 1s
import-limit: 20
cache:
maximum-size: 20
ttl: 1m
security:
editor-username: test-editor
editor-password: test-password