diff --git a/README.md b/README.md index 4a0b593..980bd65 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/pom.xml b/pom.xml index db4da60..fb53d08 100644 --- a/pom.xml +++ b/pom.xml @@ -12,10 +12,10 @@ com.hoelee - springboot-jsonplaceholder-demo + spring-boot-demo 1.0.0 - springboot-jsonplaceholder-demo - A production-minded Spring Boot REST API showcase. + spring-boot-demo + An interactive Spring Boot and Thymeleaf portfolio showcase. 21 @@ -26,6 +26,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-thymeleaf + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/com/hoelee/jsonplaceholder/ShowcaseApplication.java b/src/main/java/com/hoelee/demo/DemoApplication.java similarity index 56% rename from src/main/java/com/hoelee/jsonplaceholder/ShowcaseApplication.java rename to src/main/java/com/hoelee/demo/DemoApplication.java index 4cb75e9..6be287c 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/ShowcaseApplication.java +++ b/src/main/java/com/hoelee/demo/DemoApplication.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/config/CacheConfig.java b/src/main/java/com/hoelee/demo/config/CacheConfig.java similarity index 60% rename from src/main/java/com/hoelee/jsonplaceholder/config/CacheConfig.java rename to src/main/java/com/hoelee/demo/config/CacheConfig.java index dbbe094..8106c14 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/config/CacheConfig.java +++ b/src/main/java/com/hoelee/demo/config/CacheConfig.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/config/CacheProperties.java b/src/main/java/com/hoelee/demo/config/CacheProperties.java new file mode 100644 index 0000000..72a2919 --- /dev/null +++ b/src/main/java/com/hoelee/demo/config/CacheProperties.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/config/SecurityConfig.java b/src/main/java/com/hoelee/demo/config/SecurityConfig.java similarity index 82% rename from src/main/java/com/hoelee/jsonplaceholder/config/SecurityConfig.java rename to src/main/java/com/hoelee/demo/config/SecurityConfig.java index d87e6cd..05322fa 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/config/SecurityConfig.java +++ b/src/main/java/com/hoelee/demo/config/SecurityConfig.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/config/SecurityProperties.java b/src/main/java/com/hoelee/demo/config/SecurityProperties.java new file mode 100644 index 0000000..76b3d2c --- /dev/null +++ b/src/main/java/com/hoelee/demo/config/SecurityProperties.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/CreatePostRequest.java b/src/main/java/com/hoelee/demo/post/CreatePostRequest.java similarity index 62% rename from src/main/java/com/hoelee/jsonplaceholder/post/CreatePostRequest.java rename to src/main/java/com/hoelee/demo/post/CreatePostRequest.java index 15300ad..0919114 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/post/CreatePostRequest.java +++ b/src/main/java/com/hoelee/demo/post/CreatePostRequest.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/post/PageResponse.java b/src/main/java/com/hoelee/demo/post/PageResponse.java new file mode 100644 index 0000000..e9c482f --- /dev/null +++ b/src/main/java/com/hoelee/demo/post/PageResponse.java @@ -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(List content, int page, int size, long totalElements, int totalPages) { + + public static PageResponse from(Page page, Function 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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/Post.java b/src/main/java/com/hoelee/demo/post/Post.java similarity index 57% rename from src/main/java/com/hoelee/jsonplaceholder/post/Post.java rename to src/main/java/com/hoelee/demo/post/Post.java index bc4d7b0..b3af664 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/post/Post.java +++ b/src/main/java/com/hoelee/demo/post/Post.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/PostController.java b/src/main/java/com/hoelee/demo/post/PostController.java similarity index 77% rename from src/main/java/com/hoelee/jsonplaceholder/post/PostController.java rename to src/main/java/com/hoelee/demo/post/PostController.java index 00bff54..4334bd6 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/post/PostController.java +++ b/src/main/java/com/hoelee/demo/post/PostController.java @@ -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 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 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 diff --git a/src/main/java/com/hoelee/demo/post/PostRepository.java b/src/main/java/com/hoelee/demo/post/PostRepository.java new file mode 100644 index 0000000..c85a86f --- /dev/null +++ b/src/main/java/com/hoelee/demo/post/PostRepository.java @@ -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 { + + Page findByTitleContainingIgnoreCase(String title, Pageable pageable); +} + +//~ v2, 2024-10-31 10:15:00PM - Last edited by hoelee diff --git a/src/main/java/com/hoelee/demo/post/PostResponse.java b/src/main/java/com/hoelee/demo/post/PostResponse.java new file mode 100644 index 0000000..84e9c84 --- /dev/null +++ b/src/main/java/com/hoelee/demo/post/PostResponse.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/post/PostService.java b/src/main/java/com/hoelee/demo/post/PostService.java new file mode 100644 index 0000000..bbfde4e --- /dev/null +++ b/src/main/java/com/hoelee/demo/post/PostService.java @@ -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 findPosts(String title, Pageable pageable) { + Page 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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/UpdatePostRequest.java b/src/main/java/com/hoelee/demo/post/UpdatePostRequest.java similarity index 61% rename from src/main/java/com/hoelee/jsonplaceholder/post/UpdatePostRequest.java rename to src/main/java/com/hoelee/demo/post/UpdatePostRequest.java index 2c8c351..cd49829 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/post/UpdatePostRequest.java +++ b/src/main/java/com/hoelee/demo/post/UpdatePostRequest.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/support/ApiExceptionHandler.java b/src/main/java/com/hoelee/demo/support/ApiExceptionHandler.java similarity index 77% rename from src/main/java/com/hoelee/jsonplaceholder/support/ApiExceptionHandler.java rename to src/main/java/com/hoelee/demo/support/ApiExceptionHandler.java index c66e6dd..57a0733 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/support/ApiExceptionHandler.java +++ b/src/main/java/com/hoelee/demo/support/ApiExceptionHandler.java @@ -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 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 diff --git a/src/main/java/com/hoelee/demo/support/PostNotFoundException.java b/src/main/java/com/hoelee/demo/support/PostNotFoundException.java new file mode 100644 index 0000000..eb9e187 --- /dev/null +++ b/src/main/java/com/hoelee/demo/support/PostNotFoundException.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/support/PostVersionConflictException.java b/src/main/java/com/hoelee/demo/support/PostVersionConflictException.java similarity index 52% rename from src/main/java/com/hoelee/jsonplaceholder/support/PostVersionConflictException.java rename to src/main/java/com/hoelee/demo/support/PostVersionConflictException.java index fbba20a..880f6ab 100644 --- a/src/main/java/com/hoelee/jsonplaceholder/support/PostVersionConflictException.java +++ b/src/main/java/com/hoelee/demo/support/PostVersionConflictException.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/web/DemoDataConfiguration.java b/src/main/java/com/hoelee/demo/web/DemoDataConfiguration.java new file mode 100644 index 0000000..3c01a80 --- /dev/null +++ b/src/main/java/com/hoelee/demo/web/DemoDataConfiguration.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/web/HomeController.java b/src/main/java/com/hoelee/demo/web/HomeController.java new file mode 100644 index 0000000..9b6e68f --- /dev/null +++ b/src/main/java/com/hoelee/demo/web/HomeController.java @@ -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 diff --git a/src/main/java/com/hoelee/demo/web/ShowcaseMetricsController.java b/src/main/java/com/hoelee/demo/web/ShowcaseMetricsController.java new file mode 100644 index 0000000..d4e6124 --- /dev/null +++ b/src/main/java/com/hoelee/demo/web/ShowcaseMetricsController.java @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/config/ApiProperties.java b/src/main/java/com/hoelee/jsonplaceholder/config/ApiProperties.java deleted file mode 100644 index e707a04..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/config/ApiProperties.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/config/RestClientConfig.java b/src/main/java/com/hoelee/jsonplaceholder/config/RestClientConfig.java deleted file mode 100644 index db82e73..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/config/RestClientConfig.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/config/SecurityProperties.java b/src/main/java/com/hoelee/jsonplaceholder/config/SecurityProperties.java deleted file mode 100644 index 16a2503..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/config/SecurityProperties.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderClient.java b/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderClient.java deleted file mode 100644 index 65a624f..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderClient.java +++ /dev/null @@ -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 fetchPosts(int limit) { - try { - List 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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderPost.java b/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderPost.java deleted file mode 100644 index dd09299..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/integration/JsonPlaceholderPost.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/ImportResult.java b/src/main/java/com/hoelee/jsonplaceholder/post/ImportResult.java deleted file mode 100644 index 3007835..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/post/ImportResult.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/PageResponse.java b/src/main/java/com/hoelee/jsonplaceholder/post/PageResponse.java deleted file mode 100644 index 6582c00..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/post/PageResponse.java +++ /dev/null @@ -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( - List content, - int page, - int size, - long totalElements, - int totalPages) { - - public static PageResponse from(Page page, Function 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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/PostRepository.java b/src/main/java/com/hoelee/jsonplaceholder/post/PostRepository.java deleted file mode 100644 index 51a5688..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/post/PostRepository.java +++ /dev/null @@ -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 { - - Page findByTitleContainingIgnoreCase(String title, Pageable pageable); - - List findAllBySourcePostIdIn(Collection sourcePostIds); -} - -//~ v1, 2024-10-24 08:20:00PM - Last edited by hoelee diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/PostResponse.java b/src/main/java/com/hoelee/jsonplaceholder/post/PostResponse.java deleted file mode 100644 index c4ee1c3..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/post/PostResponse.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/post/PostService.java b/src/main/java/com/hoelee/jsonplaceholder/post/PostService.java deleted file mode 100644 index 3ff5df0..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/post/PostService.java +++ /dev/null @@ -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 findPosts(String title, Pageable pageable) { - Page 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 received = jsonPlaceholderClient.fetchPosts(apiProperties.importLimit()); - List candidates = received.stream() - .filter(this::isImportable) - .toList(); - Set candidateSourceIds = candidates.stream().map(JsonPlaceholderPost::id) - .collect(java.util.stream.Collectors.toSet()); - Set knownSourceIds = candidateSourceIds.isEmpty() - ? Set.of() - : postRepository.findAllBySourcePostIdIn(candidateSourceIds).stream() - .map(Post::getSourcePostId) - .collect(java.util.stream.Collectors.toSet()); - Set seenSourceIds = new HashSet<>(); - List 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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/support/PostNotFoundException.java b/src/main/java/com/hoelee/jsonplaceholder/support/PostNotFoundException.java deleted file mode 100644 index 584ca23..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/support/PostNotFoundException.java +++ /dev/null @@ -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 diff --git a/src/main/java/com/hoelee/jsonplaceholder/support/UpstreamServiceException.java b/src/main/java/com/hoelee/jsonplaceholder/support/UpstreamServiceException.java deleted file mode 100644 index b574d1f..0000000 --- a/src/main/java/com/hoelee/jsonplaceholder/support/UpstreamServiceException.java +++ /dev/null @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df2b347..ad4fc04 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/main/resources/static/css/site.css b/src/main/resources/static/css/site.css new file mode 100644 index 0000000..118c5a5 --- /dev/null +++ b/src/main/resources/static/css/site.css @@ -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}} diff --git a/src/main/resources/static/js/showcase.js b/src/main/resources/static/js/showcase.js new file mode 100644 index 0000000..1fd2187 --- /dev/null +++ b/src/main/resources/static/js/showcase.js @@ -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 => `${$("
").text(post.title).html()}${post.authorId}${post.version}`).join(""); + $("#post-results").html(rows || 'No posts found.'); + 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(); +}); diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..cab3233 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,65 @@ + + + + + + + Hoe Lee | Spring Boot Developer + + + + + + +
+
+
+
+

FULL STACK DEVELOPER · SPRING BOOT & WEB INTEGRATION

+

Reliable backend systems,
built to do useful work.

+

I’m Hoe Lee, an experienced developer specializing in Spring Boot backend systems, API integration, and automation solutions. Over 10+ years, I’ve built systems for business automation, marketplace synchronization, and digital services.

+
Spring Boot 3Java 21REST APIsMySQLDockerWeb3
+ Explore live feature tour + Get in touch → +
+
+
+ +
+

PROFESSIONAL OVERVIEW

From product idea to working system.

+

A full-stack approach to professional Java Spring Boot engineering, REST API design, and real-world data integration for enterprise systems.

+
+
01

End-to-end architecture

Clean Controller → Service → Repository design with Spring Boot, JPA/Hibernate, and practical database modelling.

+
02

Secure & configurable

Authentication, access control, environment profiles, validation, and global exception handling are designed in from the start.

+
03

Automation & integration

Experience integrating marketplace workflows, CDN uploads, OCR, translation, and operational tooling.

+
+
+ +
+

CORE MODULES & CAPABILITIES

Enterprise features with practical purpose.

+
+

User management

Multi-role authentication, authorization, profiles, sessions, and Spring Security.

+

Marketplace management

Seller accounts, synchronization, order tracking, and operational balances across marketplace platforms.

+

Financial & reporting workflows

Transaction logging, withdrawal approvals, CSV processing, and business-facing reports.

+

Content, domains & processing

File management, multi-domain administration, OCR extraction, translation, and automated workflows.

+
+

TECHNOLOGY STACK

Spring BootJavaSpring SecuritySpring Data JPAHibernatejQueryBootstrapDockerJUnitGit
+
+ +
+

LET’S BUILD SOMETHING USEFUL

Have a system or integration in mind?

I enjoy turning ideas into efficient software systems that work in production—not only in theory.

+ +
+
+
© 2026 Hoe Lee · Full Stack Developer
+ + + diff --git a/src/main/resources/templates/showcase.html b/src/main/resources/templates/showcase.html new file mode 100644 index 0000000..2514be2 --- /dev/null +++ b/src/main/resources/templates/showcase.html @@ -0,0 +1,33 @@ + + + + + Spring Boot Feature Tour | Hoe Lee + + + + + +
+

LIVE APPLICATION TOUR

See the layers work together.

This page uses jQuery AJAX to call the application’s real Spring MVC controllers. The results below come from the running API and H2 database—not static mock data.

+
+
01 · THYMELEAF

Server-rendered UI

This page is a Thymeleaf view delivered by HomeController.

+
02 · JPA + REST

Query the database

Load and search persisted posts through GET /api/posts.

+
03 · CACHE

Observe read caching

Read a post twice and view live Caffeine hit/miss statistics.

+
+ +

DATABASE & CONTROLLER

Posts from Spring Data JPA

+
+
TitleAuthorVersionAction
Loading…
+
+ +

SECURITY + VALIDATION + TRANSACTION

Create a persisted post

Writes require the configured EDITOR account. Enter its credentials to send a Basic-authenticated AJAX request; they are used only by this browser request.

+
+
+

HEALTH + CACHE METRICS

Live operational signals

Click a button to call an endpoint.
+

HTTP RESPONSE

Last AJAX result

The request and response will appear here.
+
+ + + + diff --git a/src/test/java/com/hoelee/demo/post/DemoApplicationIntegrationTest.java b/src/test/java/com/hoelee/demo/post/DemoApplicationIntegrationTest.java new file mode 100644 index 0000000..34ccad5 --- /dev/null +++ b/src/test/java/com/hoelee/demo/post/DemoApplicationIntegrationTest.java @@ -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 diff --git a/src/test/java/com/hoelee/jsonplaceholder/post/PostApiIntegrationTest.java b/src/test/java/com/hoelee/jsonplaceholder/post/PostApiIntegrationTest.java deleted file mode 100644 index d06729d..0000000 --- a/src/test/java/com/hoelee/jsonplaceholder/post/PostApiIntegrationTest.java +++ /dev/null @@ -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 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4c8e0b0..4477a20 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -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