diff --git a/README.ko.md b/README.ko.md index 08c6a77..d4d1421 100644 --- a/README.ko.md +++ b/README.ko.md @@ -43,6 +43,16 @@ Spring Boot 3.3–3.5 사용 중인 앱용. 스타터의 [`3.x` 브랜치](https | [`ssrf-guard-jdkhttp-demo`](ssrf-guard-jdkhttp-demo/) | `java.net.http.HttpClient`(Java 11+) 래퍼 — 라이브러리 자체엔 Spring 의존성 없음. `main()`에서 3줄 wiring | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-jdkhttp?label=kr.devslab%3Assrf-guard-jdkhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-jdkhttp) | | [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` — Spring 필요 없음. `OkHttpClient.Builder`에 3줄 wiring | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp?label=kr.devslab%3Assrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) | +### api-log + +[api-log](https://github.com/devslab-kr/api-log) 스타터를 통한 비동기 API 호출 로깅 (PostgreSQL JSONB) — 영속 백엔드별 데모 1개씩. 모든 데모가 **self-loopback** 디자인: 같은 앱이 `/upstream/widgets` 엔드포인트 ("호출당하는 서비스")와 `/client/widgets` 엔드포인트 (api-log HTTP 클라이언트 유틸로 upstream 호출) 둘 다 노출. 세 번째 `/api-log` 엔드포인트가 `api_log` 테이블을 읽어줘서 INITIATED → SUCCESS / ERROR / RETRY_ERROR 전체 라이프사이클을 데모 안에서 curl로 바로 확인 가능. 로컬 DB는 Docker Compose, 통합 테스트는 Testcontainers + `@ServiceConnection`. + +| 데모 | 보여주는 것 | Maven Central | +| --- | --- | --- | +| [`api-log-jpa-demo`](api-log-jpa-demo/) | **JPA 백엔드** — Spring MVC + `RestApiClientUtil` (블로킹) + `JpaApiLogWriter`. `ApiLogRepository` (Spring Data JPA)로 로그 행을 읽어옴. Servlet/JPA 앱의 가장 흔한 drop-in. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-jpa?label=kr.devslab%3Aapi-log-jpa)](https://central.sonatype.com/artifact/kr.devslab/api-log-jpa) | +| [`api-log-mybatis-demo`](api-log-mybatis-demo/) | **MyBatis 백엔드** — Spring MVC + `RestApiClientUtil` + `MybatisApiLogWriter`. 번들 `ApiLogMapper`는 request_id 조회용, `recent` / `by-event` 쿼리는 데모가 커스텀 `ApiLogQueryMapper` (xml) 추가. 이미 MyBatis 쓰고 JPA 안 원하는 팀용. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-mybatis?label=kr.devslab%3Aapi-log-mybatis)](https://central.sonatype.com/artifact/kr.devslab/api-log-mybatis) | +| [`api-log-r2dbc-demo`](api-log-r2dbc-demo/) | **R2DBC 백엔드 (리액티브)** — WebFlux + `ReactiveApiClientUtil` (`Mono` 기반) + `R2dbcApiLogWriter`. 리더는 `DatabaseClient`로 `Flux` 스트리밍. HTTP 경로 전체 논블로킹; api-log 쓰기도 논블로킹. 요청 경로에 JDBC가 전혀 없는 WebFlux 앱용. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-r2dbc?label=kr.devslab%3Aapi-log-r2dbc)](https://central.sonatype.com/artifact/kr.devslab/api-log-r2dbc) | + ## 컨벤션 - 각 데모는 **독립 Gradle 프로젝트** — 자체 `settings.gradle.kts`, `build.gradle.kts`, `gradlew`를 가짐. 루트 빌드를 공유하지 않으므로 의존성 버전이나 JDK 타겟이 독립적으로 변할 수 있음. diff --git a/README.md b/README.md index c6077fc..b580809 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,16 @@ For apps still on Spring Boot 3.3–3.5. The starter's [`3.x` branch](https://gi | [`ssrf-guard-jdkhttp-demo`](ssrf-guard-jdkhttp-demo/) | `java.net.http.HttpClient` (Java 11+) wrapper — no Spring required by the library. Three-line wiring in `main()`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-jdkhttp?label=kr.devslab%3Assrf-guard-jdkhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-jdkhttp) | | [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` integration — also no Spring needed. Three-line wiring on `OkHttpClient.Builder`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp?label=kr.devslab%3Assrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) | +### api-log + +Async API-call logging into PostgreSQL JSONB via the [api-log](https://github.com/devslab-kr/api-log) starter — one demo per persistence backend. Every demo uses the **self-loopback** design: the same app exposes both an `/upstream/widgets` endpoint (the "service being called") and a `/client/widgets` endpoint that calls the upstream via the api-log HTTP client util. A third `/api-log` endpoint reads the `api_log` table so you can curl the full lifecycle — INITIATED → SUCCESS / ERROR / RETRY_ERROR — without leaving the demo. Docker Compose for the local DB; Testcontainers + `@ServiceConnection` for the integration tests. + +| Demo | Showcases | Maven Central | +| --- | --- | --- | +| [`api-log-jpa-demo`](api-log-jpa-demo/) | **JPA backend** — Spring MVC + `RestApiClientUtil` (blocking) + `JpaApiLogWriter`. Reads the logged rows via `ApiLogRepository` (Spring Data JPA). The most common drop-in for Servlet/JPA apps. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-jpa?label=kr.devslab%3Aapi-log-jpa)](https://central.sonatype.com/artifact/kr.devslab/api-log-jpa) | +| [`api-log-mybatis-demo`](api-log-mybatis-demo/) | **MyBatis backend** — Spring MVC + `RestApiClientUtil` + `MybatisApiLogWriter`. Uses the bundled `ApiLogMapper` for by-request lookup, plus a custom `ApiLogQueryMapper` (xml) for `recent` / `by-event` queries. For teams already on MyBatis who don't want JPA. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-mybatis?label=kr.devslab%3Aapi-log-mybatis)](https://central.sonatype.com/artifact/kr.devslab/api-log-mybatis) | +| [`api-log-r2dbc-demo`](api-log-r2dbc-demo/) | **R2DBC backend (reactive)** — WebFlux + `ReactiveApiClientUtil` (`Mono`-based) + `R2dbcApiLogWriter`. Reader uses `DatabaseClient` for streaming `Flux`. Entire HTTP path is non-blocking; api-log writes also non-blocking. For WebFlux apps that have no JDBC anywhere on the request path. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-r2dbc?label=kr.devslab%3Aapi-log-r2dbc)](https://central.sonatype.com/artifact/kr.devslab/api-log-r2dbc) | + ## Conventions - Each demo is a **standalone Gradle project** — its own `settings.gradle.kts`, `build.gradle.kts`, and `gradlew`. Demos do not share a root build, so their dependency versions and JDK targets can drift independently. diff --git a/api-log-jpa-demo/README.ko.md b/api-log-jpa-demo/README.ko.md new file mode 100644 index 0000000..40f1b77 --- /dev/null +++ b/api-log-jpa-demo/README.ko.md @@ -0,0 +1,166 @@ +# api-log-jpa-demo + +[English](README.md) · **한국어** + +> ✨ **JPA 백엔드.** Servlet + JPA + PostgreSQL. R2DBC 버전: [`api-log-r2dbc-demo`](../api-log-r2dbc-demo/). MyBatis 버전: [`api-log-mybatis-demo`](../api-log-mybatis-demo/). + +[`api-log`](https://github.com/devslab-kr/api-log) (`api-log-core` + `api-log-jpa` 모듈, v3.0.0)의 실행 가능한 예제 — 아웃바운드 HTTP 호출을 PostgreSQL JSONB 기반 `api_log` 테이블에 감사 로깅. + +자동 구성된 `RestApiClientUtil`을 통한 모든 호출은 세 종류의 라이프사이클 이벤트(`INITIATED` → `SUCCESS` 또는 `ERROR`)를 발행하며, api-log 리스너가 비동기로 `api_log` 테이블에 기록한다. 호출자는 write를 기다리지 않음; 감사 행은 `request_id`로 호출과 연결됨. + +이 데모는 **self-contained** — 동일한 Spring Boot 앱이 응답하는 upstream 서비스와 호출하는 클라이언트 컨트롤러를 모두 노출하므로, 단일 `bootRun`으로 두 번째 프로세스 없이 전체 라이프사이클을 실행할 수 있음. + +## 전제조건 + +- JDK 21+ +- **Docker** (Docker Desktop 또는 호환 런타임) +- 그 외 없음. 로컬 Postgres 설치 불필요. psql 클라이언트 불필요. + +## 실행 + +```bash +cd api-log-jpa-demo + +# Postgres 백그라운드로 시작. compose 파일이 localhost:5432에 바인드 +docker compose up -d db + +# 앱 부팅. api-log 스타터의 BUILTIN 스키마 초기화기가 첫 시작 시 +# api_log 테이블 생성 (멱등성 — IF NOT EXISTS) +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 끝나면: + +```bash +docker compose down # 컨테이너 중지 +docker compose down -v # ... 그리고 볼륨 삭제 (다음에 clean slate) +``` + +## 시험해보기 + +### 1. Happy path: GET 후 감사 추적 읽기 + +```bash +# Self-loopback GET — 클라이언트가 upstream을 호출, 둘 다 같은 JVM +curl 'http://localhost:8080/client/widgets/123' +# → {"id":123,"name":"Widget-123","sku":"SKU-123","price":1230} + +# api-log가 해당 호출의 INITIATED + SUCCESS 행을 비동기로 기록 +curl 'http://localhost:8080/api-log/recent' | jq '.[0:4] | .[] | {eventType, requestId, endpoint, statusCode}' +# → +# {"eventType":"SUCCESS", "requestId":"...uuid...", "endpoint":"http://localhost:8080/upstream/widgets/123", "statusCode":200} +# {"eventType":"INITIATED", "requestId":"...same...", "endpoint":"http://localhost:8080/upstream/widgets/123", "statusCode":null} +``` + +### 2. POST: 페이로드가 JSONB로 보존됨 + +```bash +curl -X POST 'http://localhost:8080/client/widgets' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Gizmo","sku":"SKU-Gizmo","price":19.99}' + +# 직렬화된 요청 바디가 api_log.payload (JSONB)에 그대로 저장됨 +curl 'http://localhost:8080/api-log/recent' | jq '.[0:2] | .[] | {eventType, payload}' +``` + +### 3. Error path: 5xx는 ERROR 행이 됨 + +```bash +# /upstream/widgets/999는 일부러 500을 던지도록 와이어링됨 +curl -i 'http://localhost:8080/client/widgets/999' +# → HTTP/1.1 500 ... + +# api-log가 upstream 응답 바디를 api_log.error_message에 캡처 +curl 'http://localhost:8080/api-log/by-event/ERROR' | jq '.[0] | {eventType, statusCode, errorMessage}' +``` + +### 4. 명시적 requestId: 논리적 호출 그룹 상관관계 + +```bash +# /with-request-id 엔드포인트는 코어 send(HttpMethod, ApiRequest) 오버로드를 통해 +# requestId="demo-fixed-rid"를 전달. 실제 재시도 시나리오에서는 모든 시도에 같은 +# id를 재사용해서 전체 시퀀스가 단일 키 아래 그룹핑되도록 함. +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' + +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' | jq '. | length' +# → 2 (INITIATED + SUCCESS) +``` + +## 아키텍처 + +``` ++--------------+ +--------------------+ +----------------------+ +| curl / | HTTP | ClientController | HTTP | UpstreamController | +| test client | ------> | (이 앱) | ------> | (같은 JVM) | ++--------------+ +---------+----------+ +----------------------+ + | + | 호출 + v + +-----------------------+ + | RestApiClientUtil | (api-log-core가 자동 구성) + +---------+-------------+ + | + | 동기로 이벤트 발행 + v + +------------------------------+ + | ApplicationEventPublisher | ApiCallInitiatedEvent / SuccessEvent / ErrorEvent + +---------+--------------------+ + | + | (@Async executor로 리스너에 전달 — JDK 21+에서 virtual thread) + v + +-------------------+ + | ApiEventListener | (api-log-core) + +---------+---------+ + | + | writer.writeInitiated() / writeSuccess() / writeError() + v + +-------------------+ + | JpaApiLogWriter | (api-log-jpa, @Transactional REQUIRES_NEW) + +---------+---------+ + | + v + +---------------------+ + | PostgreSQL api_log | (BUILTIN 초기화기가 boot에서 테이블 생성) + +---------------------+ +``` + +HTTP 호출자는 DB를 기다리지 않음. 리스너의 `@Async` hop과 writer의 `REQUIRES_NEW` propagation이 함께: + +- writer 예외가 컨슈머의 트랜잭션을 롤백할 수 없음 +- 컨슈머의 트랜잭션 롤백이 이미 나간 호출의 이미 기록된 행을 삭제할 수 없음 + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `kr.devslab:api-log-core:3.0.0` + `kr.devslab:api-log-jpa:3.0.0` 선언; JPA 백엔드가 `spring-boot-starter-data-jpa`와 `org.postgresql:postgresql`을 끌어옴 | +| `src/main/resources/application.yml` | `api.log.schema.management=BUILTIN` — 스타터가 boot에서 `api_log` 테이블 생성. 실제 앱에선 `NONE` (DDL 직접 적용) 또는 `FLYWAY` (Flyway가 소유) 사용 | +| `widget/ClientController.java` | `RestApiClientUtil` 한 번 생성자 주입; 모든 메서드가 그걸 거치므로 모든 아웃바운드 호출이 균일하게 로깅됨 | +| `widget/UpstreamController.java` | self-loopback의 "외부 서비스" 반쪽 — 같은 JVM, 다른 라우트 — id=999에 의도적 500 포함 | +| `widget/ApiLogController.java` | 자동 등록된 `ApiLogRepository`를 통한 `api_log` 읽기 전용 뷰; 스타터는 reporting API를 제공하지 않으므로 데모용 최소한 | +| `src/test/.../ApiLogLifecycleIT.java` | 전체 라이프사이클 통합 테스트: 랜덤 포트에 실제 HTTP, Awaitility가 비동기 write를 폴, `@ServiceConnection` 통한 Testcontainers Postgres | + +## 테스트 동작 방식 (Docker Compose vs Testcontainers) + +이 데모엔 두 Docker 경로가 의도적으로 분리되어 있음: + +| 경로 | 언제 실행 | 무엇 사용 | +| --- | --- | --- | +| `docker compose up -d db` | **사람**이 장수명 DB 상대 `bootRun` 할 때 | 이 디렉토리의 `docker-compose.yml` — 5432 포트 publish, named volume `pgdata` | +| `ApiLogLifecycleIT`의 Testcontainers | `./gradlew test` 실행 시 (로컬/CI) | **단명** `postgres:16-alpine` 컨테이너, 임의 포트, 테스트 클래스마다 시작/종료 | + +통합 테스트는 Spring Boot 3.1+의 [`@ServiceConnection`](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections)을 사용 — Testcontainers 인스턴스로 `spring.datasource.url`을 자동 재배선. `application-test.yml` 없음, `@DynamicPropertySource` 없음. + +## 프로덕션 노트 + +- **`api_log` 스키마**: 데모는 `api.log.schema.management=BUILTIN`을 사용 — 매 boot마다 번들된 `V1.0__create_api_log.sql` 실행 (`CREATE TABLE IF NOT EXISTS`로 멱등성). **프로덕션에선** `FLYWAY` (스타터가 `classpath:db/api-log`를 Flyway 위치에 append, 마이그레이션이 본인 마이그레이션과 함께 `flyway_schema_history`에 기록됨) 또는 `NONE` (Liquibase 등으로 DDL 직접 적용) 권장. +- **`RestApiClientUtil`**: 스타터가 단일 자동 구성 `RestClient` bean을 노출하고 wrapping함. 앱에 자체 `RestClient`가 이미 구성되어 있다면 (auth header, custom timeout, mTLS), `@Bean`으로 선언 — 스타터의 `@ConditionalOnMissingBean`이 본인 것에 위임. `RestApiClientUtil`은 본인 클라이언트를 사용하면서도 여전히 이벤트를 emit. +- **비동기 리스너**: 이벤트는 `ApiLogEvent-` executor에서 처리됨 — `spring.threads.virtual.enabled=true`일 때 virtual thread (JDK 21+ 권장), 그 외 platform-thread pool. 각 write는 `@Retryable(maxAttempts = 3)`로 wrapping되어 일시적 DB blip이 로그 행을 떨어뜨리지 않음. + +## 빌드 검증 + +```bash +./gradlew build +``` + +`ApiLogLifecycleIT`가 단명 Testcontainers Postgres 상대로 실행됨. 첫 실행은 `postgres:16-alpine` 이미지 (~80MB) 풀; 이후 실행은 캐시된 이미지로 몇 초 안에 완료. diff --git a/api-log-jpa-demo/README.md b/api-log-jpa-demo/README.md new file mode 100644 index 0000000..2060bd6 --- /dev/null +++ b/api-log-jpa-demo/README.md @@ -0,0 +1,167 @@ +# api-log-jpa-demo + +**English** · [한국어](README.ko.md) + +> ✨ **JPA backend.** Servlet + JPA + PostgreSQL. R2DBC variant: [`api-log-r2dbc-demo`](../api-log-r2dbc-demo/). MyBatis variant: [`api-log-mybatis-demo`](../api-log-mybatis-demo/). + +Runnable example for [`api-log`](https://github.com/devslab-kr/api-log) (modules `api-log-core` + `api-log-jpa`, v3.0.0) — outbound HTTP audit logging into a PostgreSQL JSONB-backed `api_log` table. + +Every call made through the auto-configured `RestApiClientUtil` publishes three lifecycle events (`INITIATED` → `SUCCESS` or `ERROR`) which the api-log listener writes asynchronously into the `api_log` table. The caller doesn't wait for the write; the audit row is correlated to the call by `request_id`. + +This demo is **self-contained** — the same Spring Boot app exposes both the upstream service that responds and the client controller that calls it, so a single `bootRun` exercises the full lifecycle without needing a second process. + +## Prerequisites + +- JDK 21+ +- **Docker** (Docker Desktop or any Docker-compatible runtime) +- That's it. No local Postgres install. No psql client needed. + +## Run + +```bash +cd api-log-jpa-demo + +# Start PostgreSQL in the background. The compose file binds 5432 on localhost. +docker compose up -d db + +# Boot the app. The api-log starter's BUILTIN schema initializer creates +# the api_log table on first start (idempotent — IF NOT EXISTS). +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. When you're done: + +```bash +docker compose down # stop the container +docker compose down -v # ...and drop the volume (clean slate next time) +``` + +## Try it + +### 1. Happy path: GET, then read the audit trail + +```bash +# Self-loopback GET — client side calls upstream side, both in this JVM. +curl 'http://localhost:8080/client/widgets/123' +# → {"id":123,"name":"Widget-123","sku":"SKU-123","price":1230} + +# api-log writes INITIATED + SUCCESS rows for that call (asynchronously). +curl 'http://localhost:8080/api-log/recent' | jq '.[0:4] | .[] | {eventType, requestId, endpoint, statusCode}' +# → +# {"eventType":"SUCCESS", "requestId":"...uuid...", "endpoint":"http://localhost:8080/upstream/widgets/123", "statusCode":200} +# {"eventType":"INITIATED", "requestId":"...same...", "endpoint":"http://localhost:8080/upstream/widgets/123", "statusCode":null} +``` + +### 2. POST: payload is preserved as JSONB + +```bash +curl -X POST 'http://localhost:8080/client/widgets' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Gizmo","sku":"SKU-Gizmo","price":19.99}' + +# The serialized request body is stored verbatim in api_log.payload (JSONB). +curl 'http://localhost:8080/api-log/recent' | jq '.[0:2] | .[] | {eventType, payload}' +``` + +### 3. Error path: 5xx becomes an ERROR row + +```bash +# /upstream/widgets/999 is wired to throw 500 on purpose. +curl -i 'http://localhost:8080/client/widgets/999' +# → HTTP/1.1 500 ... + +# api-log captures the error with the upstream's body in api_log.error_message. +curl 'http://localhost:8080/api-log/by-event/ERROR' | jq '.[0] | {eventType, statusCode, errorMessage}' +``` + +### 4. Explicit requestId: correlate a logical group of calls + +```bash +# The /with-request-id endpoint passes requestId="demo-fixed-rid" through +# the core send(HttpMethod, ApiRequest) overload. In a real retry scenario +# you'd reuse the same id across all attempts so the full sequence groups +# under one key. +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' + +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' | jq '. | length' +# → 2 (INITIATED + SUCCESS) +``` + +## Architecture + +``` ++--------------+ +--------------------+ +----------------------+ +| curl / | HTTP | ClientController | HTTP | UpstreamController | +| test client | ------> | (this app) | ------> | (same JVM) | ++--------------+ +---------+----------+ +----------------------+ + | + | calls + v + +-----------------------+ + | RestApiClientUtil | (auto-configured by api-log-core) + +---------+-------------+ + | + | publishes events synchronously + v + +------------------------------+ + | ApplicationEventPublisher | ApiCallInitiatedEvent / SuccessEvent / ErrorEvent + +---------+--------------------+ + | + | (delivered to listener on @Async executor — virtual thread on JDK 21+) + v + +-------------------+ + | ApiEventListener | (api-log-core) + +---------+---------+ + | + | writer.writeInitiated() / writeSuccess() / writeError() + v + +-------------------+ + | JpaApiLogWriter | (api-log-jpa, @Transactional REQUIRES_NEW) + +---------+---------+ + | + v + +---------------------+ + | PostgreSQL api_log | (BUILTIN initializer created the table on boot) + +---------------------+ +``` + +The HTTP caller never waits on the database. The `@Async` hop on the listener and `REQUIRES_NEW` propagation on the writer together guarantee that: + +- An exception in the writer can't roll back the consumer's transaction. +- A rollback in the consumer's transaction can't erase already-logged rows for calls that already went out. + +## Files of interest + +| File | Why | +| --- | --- | +| `build.gradle.kts` | declares `kr.devslab:api-log-core:3.0.0` + `kr.devslab:api-log-jpa:3.0.0`; the JPA backend pulls in `spring-boot-starter-data-jpa` and `org.postgresql:postgresql` | +| `src/main/resources/application.yml` | `api.log.schema.management=BUILTIN` — the starter creates the `api_log` table on boot. Set `NONE` (apply DDL yourself) or `FLYWAY` (let Flyway own it) for real apps | +| `widget/ClientController.java` | one constructor injection of `RestApiClientUtil`; every method funnels through it so every outbound call is logged uniformly | +| `widget/UpstreamController.java` | the "external service" half of the self-loopback — same JVM, separate routes — including a deliberate 500 on id=999 | +| `widget/ApiLogController.java` | read-only view of `api_log` via the auto-registered `ApiLogRepository`; the starter doesn't ship a reporting API, so this is just enough to demo | +| `src/test/.../ApiLogLifecycleIT.java` | full lifecycle integration test: real HTTP on a random port, Awaitility polls for the async writes, Testcontainers Postgres via `@ServiceConnection` | + +## How testing works (Docker Compose vs Testcontainers) + +There are two Docker paths in this demo, deliberately separate: + +| Path | When it runs | What it uses | +| --- | --- | --- | +| `docker compose up -d db` | When a **human** wants to `bootRun` against a long-lived DB | `docker-compose.yml` in this directory — published port 5432, named volume `pgdata` | +| Testcontainers in `ApiLogLifecycleIT` | When `./gradlew test` runs (locally or in CI) | An **ephemeral** `postgres:16-alpine` container on a random port, started + torn down per test class | + +The integration test uses Spring Boot 3.1+'s [`@ServiceConnection`](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections), which auto-rewires `spring.datasource.url` to the Testcontainers instance — no `application-test.yml`, no `@DynamicPropertySource`. + +## Production notes + +- **`api_log` schema**: the demo uses `api.log.schema.management=BUILTIN`, which runs the bundled `V1.0__create_api_log.sql` on every boot (idempotent via `CREATE TABLE IF NOT EXISTS`). **For production**, prefer `FLYWAY` (the starter appends `classpath:db/api-log` to Flyway's locations so the migration gets recorded in `flyway_schema_history` alongside your own) or `NONE` (apply the DDL yourself, e.g. via Liquibase or a managed schema pipeline). +- **`RestApiClientUtil`**: the starter exposes a single auto-configured `RestClient` bean and wraps it. If your app already has its own `RestClient` configured (auth headers, custom timeouts, mTLS), declare it as `@Bean` and the starter's `@ConditionalOnMissingBean` will defer to yours — `RestApiClientUtil` will then use your client and still emit the events. +- **Async listener**: events are processed on an `ApiLogEvent-` executor — virtual threads when `spring.threads.virtual.enabled=true` (recommended on JDK 21+), platform-thread pool otherwise. Each write is wrapped in `@Retryable(maxAttempts = 3)` so a transient DB blip doesn't drop a log row. + +## Verify the build + +```bash +./gradlew build +``` + +This runs `ApiLogLifecycleIT` against an ephemeral Testcontainers Postgres. First run pulls the `postgres:16-alpine` image (~80MB); subsequent runs use the cached image and complete in seconds. diff --git a/api-log-jpa-demo/build.gradle.kts b/api-log-jpa-demo/build.gradle.kts new file mode 100644 index 0000000..8a2aee5 --- /dev/null +++ b/api-log-jpa-demo/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + java + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + + // The library this demo showcases — JPA backend. + implementation("kr.devslab:api-log-core:3.0.1") + implementation("kr.devslab:api-log-jpa:3.0.1") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Awaitility — api-log writes happen on a separate event-listener thread, + // so the test polls /api-log/recent until the expected rows show up rather + // than sleeping with a fixed timeout. + testImplementation("org.awaitility:awaitility:4.2.2") + + // Testcontainers — tests spin up a real PostgreSQL container instead of + // relying on a developer/CI-side install. @ServiceConnection (added in + // Spring Boot 3.1) auto-rewires the datasource to the container, so no + // application-test.yml shenanigans needed. + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:junit-jupiter") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/api-log-jpa-demo/docker-compose.yml b/api-log-jpa-demo/docker-compose.yml new file mode 100644 index 0000000..9add328 --- /dev/null +++ b/api-log-jpa-demo/docker-compose.yml @@ -0,0 +1,32 @@ +# Single-service compose file for the local "run it yourself" path. +# +# The CI / `./gradlew test` path does NOT use this file — Testcontainers spins +# up its own ephemeral PostgreSQL on a random port. This compose file exists +# so a human can do: +# +# docker compose up -d db +# ./gradlew bootRun +# +# ...without installing Postgres on their laptop. The credentials and port +# below match what application.yml reads. + +services: + db: + image: postgres:16-alpine + container_name: api-log-jpa-demo-db + environment: + POSTGRES_DB: apilog + POSTGRES_USER: apilog + POSTGRES_PASSWORD: apilog + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U apilog -d apilog"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/api-log-jpa-demo/gradle.properties b/api-log-jpa-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/api-log-jpa-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.jar b/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.properties b/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/api-log-jpa-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/api-log-jpa-demo/gradlew b/api-log-jpa-demo/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/api-log-jpa-demo/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/api-log-jpa-demo/gradlew.bat b/api-log-jpa-demo/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/api-log-jpa-demo/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/api-log-jpa-demo/settings.gradle.kts b/api-log-jpa-demo/settings.gradle.kts new file mode 100644 index 0000000..ea09e8e --- /dev/null +++ b/api-log-jpa-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "api-log-jpa-demo" diff --git a/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/ApiLogJpaDemoApplication.java b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/ApiLogJpaDemoApplication.java new file mode 100644 index 0000000..9b96e2d --- /dev/null +++ b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/ApiLogJpaDemoApplication.java @@ -0,0 +1,11 @@ +package kr.devslab.examples.apilogjpa; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ApiLogJpaDemoApplication { + public static void main(String[] args) { + SpringApplication.run(ApiLogJpaDemoApplication.class, args); + } +} diff --git a/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ApiLogController.java b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ApiLogController.java new file mode 100644 index 0000000..dfa2cb3 --- /dev/null +++ b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ApiLogController.java @@ -0,0 +1,52 @@ +package kr.devslab.examples.apilogjpa.widget; + +import java.util.List; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Read-only view over the {@code api_log} table. The starter doesn't ship its + * own reporting API — that's intentional, since every consumer wants something + * different — so this controller is just enough to let curl examples in the + * README show how the writes correlate to the calls that produced them. + * + *

Three lookups, each showcasing a different built-in repository method: + *

    + *
  • {@code /api-log/by-request/{requestId}} — full lifecycle for one call + * (INITIATED + SUCCESS, or INITIATED + ERROR, etc.)
  • + *
  • {@code /api-log/recent} — latest 20 rows across all calls, newest first
  • + *
  • {@code /api-log/by-event/{eventType}} — filter by event class
  • + *
+ */ +@RestController +@RequestMapping("/api-log") +public class ApiLogController { + + private final ApiLogRepository repo; + + public ApiLogController(ApiLogRepository repo) { + this.repo = repo; + } + + @GetMapping("/by-request/{requestId}") + public List byRequestId(@PathVariable String requestId) { + return repo.findByRequestId(requestId); + } + + @GetMapping("/recent") + public List recent() { + return repo.findAll(PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "timestamp"))) + .getContent(); + } + + @GetMapping("/by-event/{eventType}") + public List byEventType(@PathVariable String eventType) { + return repo.findByEventType(eventType); + } +} diff --git a/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ClientController.java b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ClientController.java new file mode 100644 index 0000000..2f1d50c --- /dev/null +++ b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/ClientController.java @@ -0,0 +1,93 @@ +package kr.devslab.examples.apilogjpa.widget; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.util.RestApiClientUtil; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The "outbound caller" half of the self-loopback demo. Every method here + * funnels through {@link RestApiClientUtil}, which publishes + * {@code ApiCallInitiatedEvent} / {@code ApiCallSuccessEvent} / + * {@code ApiCallErrorEvent} around each HTTP call. The api-log JPA listener + * picks those up and writes them to {@code api_log} asynchronously. + * + *

The base URL is built at request time from {@code local.server.port} (a + * property Spring Boot sets after the embedded server binds). That way the + * self-loopback works both in {@code ./gradlew bootRun} (port 8080) and in the + * integration test ({@code RANDOM_PORT}) without any test-specific config + * override. In a real app this method would point at the external service URL + * instead — the demo's only special thing is that "upstream" lives in the + * same JVM for convenience. + */ +@RestController +@RequestMapping("/client/widgets") +public class ClientController { + + private final RestApiClientUtil api; + private final Environment env; + + public ClientController(RestApiClientUtil api, Environment env) { + this.api = api; + this.env = env; + } + + private String upstream(String path) { + return "http://localhost:" + env.getProperty("local.server.port") + path; + } + + @GetMapping("/{id}") + public Widget get(@PathVariable Long id) { + return api.getSyncTyped(upstream("/upstream/widgets/" + id), Widget.class); + } + + @GetMapping("/{id}/async") + public Widget getAsync(@PathVariable Long id) { + // join() makes this synchronous from the controller's perspective so we + // can still return a body — the underlying call still goes through the + // async path and exercises sendAsyncTyped + CompletableFuture wiring. + return api.getAsyncTyped(upstream("/upstream/widgets/" + id), Widget.class).join(); + } + + @PostMapping + public Widget create(@RequestBody Widget body) { + return api.postSyncTyped(upstream("/upstream/widgets"), body, Widget.class); + } + + @PutMapping("/{id}") + public Widget update(@PathVariable Long id, @RequestBody Widget body) { + return api.putSyncTyped(upstream("/upstream/widgets/" + id), body, Widget.class); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + ApiResponse response = api.deleteSync(upstream("/upstream/widgets/" + id)); + return ResponseEntity.status(response.getStatusCode()).build(); + } + + /** + * Demonstrates the core {@code send(HttpMethod, ApiRequest)} overload — the + * caller supplies an explicit {@code requestId} so multiple related calls + * (e.g. a retry sequence) share a single correlation key in {@code api_log}. + * The {@code requestId} is echoed in the response so callers can immediately + * look up the lifecycle via {@code GET /api-log/by-request/{requestId}}. + */ + @PostMapping("/with-request-id/{id}") + public ApiResponse postWithRequestId(@PathVariable Long id) { + ApiRequest request = ApiRequest.builder() + .endpoint(upstream("/upstream/widgets/" + id)) + .requestId("demo-fixed-rid") + .build(); + return api.send(HttpMethod.GET, request); + } +} diff --git a/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/UpstreamController.java b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/UpstreamController.java new file mode 100644 index 0000000..b0ccaa1 --- /dev/null +++ b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/UpstreamController.java @@ -0,0 +1,56 @@ +package kr.devslab.examples.apilogjpa.widget; + +import java.math.BigDecimal; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * The "external service" half of the self-loopback demo. In a real app this + * controller would live in a different process — here it shares the JVM with + * the {@link ClientController} so a {@code docker compose up -d db} is the + * only external dependency needed to exercise the full request lifecycle. + * + *

Endpoints intentionally cover the four verbs api-log captures + * (GET/POST/PUT/DELETE) plus one deliberate error trigger ({@code GET /999}) + * so the integration test can assert {@code event_type = ERROR} rows show up + * in {@code api_log} too. + */ +@RestController +@RequestMapping("/upstream/widgets") +public class UpstreamController { + + @GetMapping("/{id}") + public Widget getById(@PathVariable Long id) { + if (id == 999L) { + // Deliberate 5xx so the demo can show what an ERROR-type api_log row looks like. + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "simulated upstream failure"); + } + return new Widget(id, "Widget-" + id, "SKU-" + id, BigDecimal.valueOf(id * 10)); + } + + @PostMapping + public Widget create(@RequestBody Widget body) { + Long assignedId = body.id() != null ? body.id() : System.currentTimeMillis(); + return new Widget(assignedId, body.name(), body.sku(), body.price()); + } + + @PutMapping("/{id}") + public Widget update(@PathVariable Long id, @RequestBody Widget body) { + return new Widget(id, body.name(), body.sku(), body.price()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + return ResponseEntity.noContent().build(); + } +} diff --git a/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/Widget.java b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/Widget.java new file mode 100644 index 0000000..651c96c --- /dev/null +++ b/api-log-jpa-demo/src/main/java/kr/devslab/examples/apilogjpa/widget/Widget.java @@ -0,0 +1,12 @@ +package kr.devslab.examples.apilogjpa.widget; + +import java.math.BigDecimal; + +/** + * Tiny payload type the demo's client and upstream controllers pass back and + * forth. Records keep the surface area small — Jackson handles both directions + * out of the box, so the demo focuses on what api-log captures (the request + * payload, the response body, the status code) rather than on bean plumbing. + */ +public record Widget(Long id, String name, String sku, BigDecimal price) { +} diff --git a/api-log-jpa-demo/src/main/resources/application.yml b/api-log-jpa-demo/src/main/resources/application.yml new file mode 100644 index 0000000..cf9fb4c --- /dev/null +++ b/api-log-jpa-demo/src/main/resources/application.yml @@ -0,0 +1,36 @@ +spring: + application: + name: api-log-jpa-demo + + datasource: + url: jdbc:postgresql://localhost:5432/apilog + username: apilog + password: apilog + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: none # api-log starter manages api_log schema; we have no other entities + properties: + hibernate: + format_sql: false + +api: + log: + enabled: true + schema: + management: BUILTIN # starter creates api_log on boot + +server: + port: 8080 + +# ClientController.upstream(...) builds the self-loopback URL from +# `local.server.port` (set by Spring Boot after the embedded server binds), so +# the demo works the same on bootRun (port 8080) and in RANDOM_PORT tests. In +# a real app, replace ClientController to point at your external service. + +logging: + level: + root: WARN + kr.devslab.examples: INFO + kr.devslab.apilog: INFO diff --git a/api-log-jpa-demo/src/test/java/kr/devslab/examples/apilogjpa/ApiLogLifecycleIT.java b/api-log-jpa-demo/src/test/java/kr/devslab/examples/apilogjpa/ApiLogLifecycleIT.java new file mode 100644 index 0000000..a5773e2 --- /dev/null +++ b/api-log-jpa-demo/src/test/java/kr/devslab/examples/apilogjpa/ApiLogLifecycleIT.java @@ -0,0 +1,156 @@ +package kr.devslab.examples.apilogjpa; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; +import kr.devslab.examples.apilogjpa.widget.Widget; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * End-to-end test for the api-log JPA backend. Boots the full Spring app on a + * random port (so the self-loopback {@code ClientController} -> {@code UpstreamController} + * call actually goes over HTTP and triggers the api-log event publisher), then + * verifies that {@code api_log} rows show up with the expected lifecycle. + * + *

The api-log listener is {@code @Async}, so each assertion uses Awaitility + * to poll for the expected row rather than {@code Thread.sleep}. + * + *

{@code @ServiceConnection} auto-rewires {@code spring.datasource.url} to + * the Testcontainers Postgres — no {@code application-test.yml} or + * {@code @DynamicPropertySource} needed. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +class ApiLogLifecycleIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); + + @LocalServerPort + int port; + + @Autowired + ApiLogRepository repo; + + private RestClient http; + + @BeforeEach + void setUp() { + // Fresh client per test to keep base-url scoped to the random port the + // server picked for this run. Truncate the audit table between tests so + // each test's assertions only see rows it generated itself. + http = RestClient.create("http://localhost:" + port); + repo.deleteAll(); + } + + @Test + void happyGetPath_writesInitiatedThenSuccessRows() { + Widget body = http.get().uri("/client/widgets/123").retrieve().body(Widget.class); + + assertThat(body).isNotNull(); + assertThat(body.id()).isEqualTo(123L); + assertThat(body.name()).isEqualTo("Widget-123"); + + // The async writer hasn't necessarily flushed by the time the HTTP + // response returns, so poll until both rows show up. + await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(100)).untilAsserted(() -> { + List rows = repo.findAll(); + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); + + // Both rows for the same call share the same requestId. + String requestId = rows.stream() + .filter(r -> "INITIATED".equals(r.getEventType())) + .map(ApiLogEntity::getRequestId) + .findFirst() + .orElseThrow(); + List sameCall = repo.findByRequestId(requestId); + assertThat(sameCall).extracting(ApiLogEntity::getEventType) + .containsExactlyInAnyOrder("INITIATED", "SUCCESS"); + ApiLogEntity success = sameCall.stream() + .filter(r -> "SUCCESS".equals(r.getEventType())) + .findFirst() + .orElseThrow(); + assertThat(success.getStatusCode()).isEqualTo(200); + assertThat(success.getResponse()).isNotNull(); + }); + } + + @Test + void errorPath_writesInitiatedThenErrorRow() { + // GET /upstream/widgets/999 throws ResponseStatusException(INTERNAL_SERVER_ERROR); + // RestClient (Spring) propagates that as HttpServerErrorException. + try { + http.get().uri("/client/widgets/999").retrieve().body(Widget.class); + } catch (HttpServerErrorException expected) { + // expected — the upstream returns 500, the client wrapper propagates it + } + + await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(100)).untilAsserted(() -> { + List rows = repo.findAll(); + assertThat(rows).extracting(ApiLogEntity::getEventType) + .contains("INITIATED", "ERROR"); + }); + } + + @Test + void postBodyIsPreservedInPayloadColumn() { + Widget input = new Widget(null, "Gizmo", "SKU-Gizmo", new BigDecimal("19.99")); + Widget echoed = http.post().uri("/client/widgets").body(input).retrieve().body(Widget.class); + + assertThat(echoed).isNotNull(); + assertThat(echoed.name()).isEqualTo("Gizmo"); + + await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(100)).untilAsserted(() -> { + List postRows = repo.findByEventType("INITIATED").stream() + .filter(r -> r.getEndpoint().endsWith("/upstream/widgets")) + .toList(); + assertThat(postRows).isNotEmpty(); + ApiLogEntity row = postRows.get(0); + assertThat(row.getPayload()).isNotNull(); + // payload is a JsonNode — assert the fields the controller serialised + assertThat(row.getPayload().toString()).contains("Gizmo").contains("SKU-Gizmo"); + }); + } + + @Test + void explicitRequestId_correlatesAllRowsForThatCall() { + ResponseEntity response = http.post() + .uri("/client/widgets/with-request-id/42") + .retrieve() + .toEntity(String.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + + await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(100)).untilAsserted(() -> { + List rows = repo.findByRequestId("demo-fixed-rid"); + // Should at minimum have an INITIATED + SUCCESS, both keyed off the fixed id. + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); + assertThat(rows).extracting(ApiLogEntity::getRequestId) + .allMatch("demo-fixed-rid"::equals); + }); + } + + @Test + void schemaInitializedOnBoot_repositoryIsWiredAndTableExists() { + // The starter's BUILTIN schema initializer should have created api_log + // before this test runs; count() succeeding without an SQL exception + // proves the table is there and the JPA wiring resolved correctly. + assertThat(repo.count()).isGreaterThanOrEqualTo(0); + } +} diff --git a/api-log-mybatis-demo/README.ko.md b/api-log-mybatis-demo/README.ko.md new file mode 100644 index 0000000..5ffa8e6 --- /dev/null +++ b/api-log-mybatis-demo/README.ko.md @@ -0,0 +1,144 @@ +# api-log-mybatis-demo + +[English](README.md) · **한국어** + +> ✨ **MyBatis 백엔드.** 이 데모는 [`api-log`](https://github.com/devslab-kr/api-log) 스타터를 **MyBatis**로 연결. JPA 백엔드는 [`api-log-jpa-demo`](../api-log-jpa-demo/), 리액티브/R2DBC 백엔드는 [`api-log-r2dbc-demo`](../api-log-r2dbc-demo/) 참조. + +[`api-log`](https://github.com/devslab-kr/api-log)의 실행 가능한 예제 — PostgreSQL `api_log` JSONB 테이블에 MyBatis 매퍼로 기록하는 이벤트 기반 API 호출 로깅. + +## 이 데모가 보여주는 것 + +- `RestApiClientUtil`로 나가는 모든 HTTP 호출이 라이프사이클 이벤트 발행 (INITIATED → SUCCESS 또는 INITIATED → ERROR). +- 스타터의 리스너가 그 이벤트를 **별도 스레드**에서 소비하여 자동 등록된 `ApiLogMapper`를 통해 영속화. +- `api_log` 테이블은 시작 시 자동 생성 (`api.log.schema.management=BUILTIN`). +- 단일 프로세스 데모: 같은 앱이 `/upstream/widgets`("외부 서비스" 역할)과 `/client/widgets`(호출자) 둘 다 노출. 호출자의 base URL은 `localhost`를 가리키므로 두 번째 서비스 없이 전체 파이프라인 관찰 가능. + +## 전제조건 + +- JDK 21+ +- **Docker** (Docker Desktop 또는 호환 런타임) + +## 실행 + +```bash +cd api-log-mybatis-demo +docker compose up -d db +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 끝나면: + +```bash +docker compose down # 컨테이너 중지 +docker compose down -v # ... 그리고 볼륨 삭제 (다음에 clean slate) +``` + +## 시험해보기 + +```bash +# Happy path - api_log에 INITIATED + SUCCESS 행 생성 +curl 'http://localhost:8080/client/widgets/123' + +# 비동기 변형 - 같은 로깅, CompletableFuture 경유 +curl 'http://localhost:8080/client/widgets/123/async' + +# 에러 경로 - upstream이 id=999에 throw, ERROR 행 생성 +curl -i 'http://localhost:8080/client/widgets/999' + +# POST - 바디는 api_log.payload에 표시됨 (payload가 JSONB이므로 JSON 텍스트로) +curl -X POST 'http://localhost:8080/client/widgets' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Sprocket-7","sku":"SKU-7","price":19.99}' + +# 명시적 requestId - 일반적인 retry 상관관계 패턴 +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' | jq + +# 로그 읽기 +curl 'http://localhost:8080/api-log/recent' | jq +curl 'http://localhost:8080/api-log/by-event/SUCCESS' | jq +curl 'http://localhost:8080/api-log/by-event/ERROR' | jq +``` + +## 아키텍처 + +``` + (같은 JVM) + +---------------------+ + curl --GET--> | ClientController | + | /client/widgets | + +---------------------+ + | + | 사용 + v + +---------------------+ + | RestApiClientUtil | (api-log-core) + +---------------------+ + | | + HTTP --> | | --> ApplicationEventPublisher + v | + +-----------------+ | ApiCallInitiatedEvent + | UpstreamCtrl | | ApiCallSuccessEvent + | /upstream/... | | ApiCallErrorEvent + +-----------------+ | + v + +--------------------+ + | ApiEventListener | (api-log-core, + | (async 스레드) | 리스너 스레드) + +--------------------+ + | + v + +--------------------+ + | MybatisApiLogWriter| (api-log-mybatis) + +--------------------+ + | + v + +--------------------+ + | ApiLogMapper | (MyBatis @Mapper, + | INSERT api_log | 자동 wiring) + +--------------------+ + | + v + +--------------------+ + | PostgreSQL JSONB | + +--------------------+ +``` + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `api-log-core` + `api-log-mybatis` + `mybatis-spring-boot-starter:4.0.1` 추가 — 스타터 wiring은 이게 전부 | +| `src/main/resources/application.yml` | `api.log.enabled` + `api.log.schema.management=BUILTIN` + `mybatis.mapper-locations` | +| `widget/ClientController.java` | 모든 종류의 호출 (sync/async/typed/POST/PUT/DELETE/explicit-requestId)을 `RestApiClientUtil` 경유 | +| `widget/ApiLogController.java` | 리더 엔드포인트 — 스타터의 `ApiLogMapper.findByRequestId`와 데모 자체의 `ApiLogQueryMapper`("recent N" / "by event type") 둘 다 사용 | +| `mapper/ApiLogQueryMapper.xml` | JSONB 컬럼을 MyBatis `String` 프로퍼티로 읽기 위한 `payload::text AS payload` 캐스트 패턴 | +| `src/test/.../ApiLogLifecycleIT.java` | 실제 Postgres Testcontainer 상대 end-to-end 테스트 — 비동기 리스너를 위해 Awaitility 사용 | + +## 테스트 동작 방식 (Docker Compose vs Testcontainers) + +이 repo의 다른 데모와 동일한 2-경로 셋업: + +| 경로 | 언제 실행 | 무엇 사용 | +| --- | --- | --- | +| `docker compose up -d db` | **사람**이 장수명 DB 상대 `bootRun` 할 때 | 이 디렉토리의 `docker-compose.yml` — 5432 포트 publish, named volume `pgdata` | +| `ApiLogLifecycleIT`의 Testcontainers | `./gradlew test` 실행 시 (로컬/CI) | **단명** `postgres:16-alpine` 컨테이너, 임의 포트 | + +`@ServiceConnection` (Spring Boot 3.1+)이 Testcontainers 인스턴스로 `spring.datasource.url`을 자동 재배선 — `application-test.yml` 없음, `@DynamicPropertySource` 없음. + +## 프로덕션 노트 + +이 데모에서 "실행 편의성" 위주로 튠된, 실제 서비스에선 달라질 부분 몇 가지: + +- **스키마 관리.** `api.log.schema.management=BUILTIN`은 매 부팅마다 스타터의 번들 DDL 실행 (멱등 — `CREATE TABLE IF NOT EXISTS`). 프로덕션에선 `EXTERNAL`로 설정하고 Flyway 또는 Liquibase가 마이그레이션을 소유하게 할 것. +- **매퍼 스캔.** 데모의 `@MapperScan`은 `kr.devslab.examples.apilogmybatis`만 커버. **`kr.devslab.apilog.mybatis.mapper`를 스캔에 추가하지 말 것** — 스타터의 auto-config가 이미 `ApiLogMapper`를 등록하므로 이중 스캔하면 충돌하는 bean 정의가 생김. +- **`application.yml`의 `map-underscore-to-camel-case`** 가 데모의 커스텀 `ApiLogQueryMapper`로 하여금 snake_case 컬럼을 `ApiLogRow`의 camelCase 필드로 resultMap에 일일이 적지 않고도 매핑 가능하게 함. 스타터 자체 매퍼는 같은 이유로 명시적 `AS camelCase` 별칭을 사용 — 어느 패턴이 코드베이스에 맞는지 선택. +- **Self-loopback.** 실제 앱은 `api-log-demo.upstream-base-url`을 다른 서비스로 가리킴. loopback은 데모를 단일 프로세스로 실행하기 위한 것. + +## 빌드 검증 + +```bash +./gradlew build +``` + +컴파일 + 단명 Testcontainers Postgres 상대 `ApiLogLifecycleIT` 실행 + 실행 가능한 jar 생성. 첫 실행은 `postgres:16-alpine` (~80 MB) 풀; 이후는 캐시된 이미지 사용. diff --git a/api-log-mybatis-demo/README.md b/api-log-mybatis-demo/README.md new file mode 100644 index 0000000..11509f8 --- /dev/null +++ b/api-log-mybatis-demo/README.md @@ -0,0 +1,144 @@ +# api-log-mybatis-demo + +**English** · [한국어](README.ko.md) + +> ✨ **MyBatis backend.** This demo wires the [`api-log`](https://github.com/devslab-kr/api-log) starter to **MyBatis**. For the JPA backend variant see [`api-log-jpa-demo`](../api-log-jpa-demo/); for the reactive/R2DBC variant see [`api-log-r2dbc-demo`](../api-log-r2dbc-demo/). + +Runnable example for [`api-log`](https://github.com/devslab-kr/api-log) — event-driven API call logging into a PostgreSQL `api_log` JSONB table, written through a MyBatis mapper. + +## What this demo shows + +- Every outbound HTTP call made via `RestApiClientUtil` produces lifecycle events (INITIATED → SUCCESS or INITIATED → ERROR). +- The starter's listener consumes those events **on a separate thread** and persists them through the auto-registered `ApiLogMapper`. +- The `api_log` table is created automatically at startup (`api.log.schema.management=BUILTIN`). +- The demo is single-process: the same app exposes both `/upstream/widgets` (the "external service") and `/client/widgets` (the caller). The caller's base URL points back at `localhost`, so the whole pipeline is observable without a second service. + +## Prerequisites + +- JDK 21+ +- **Docker** (Docker Desktop or any Docker-compatible runtime) + +## Run + +```bash +cd api-log-mybatis-demo +docker compose up -d db +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. When you're done: + +```bash +docker compose down # stop the container +docker compose down -v # ...and drop the volume (clean slate next time) +``` + +## Try it + +```bash +# Happy path - produces INITIATED + SUCCESS rows in api_log +curl 'http://localhost:8080/client/widgets/123' + +# Async variant - same logging, called via CompletableFuture +curl 'http://localhost:8080/client/widgets/123/async' + +# Error path - the upstream throws on id=999, producing an ERROR row +curl -i 'http://localhost:8080/client/widgets/999' + +# POST - body shows up in api_log.payload (as JSON text, since payload is JSONB) +curl -X POST 'http://localhost:8080/client/widgets' \ + -H 'Content-Type: application/json' \ + -d '{"name":"Sprocket-7","sku":"SKU-7","price":19.99}' + +# Explicit requestId - the typical retry-correlation pattern +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' | jq + +# Read the log +curl 'http://localhost:8080/api-log/recent' | jq +curl 'http://localhost:8080/api-log/by-event/SUCCESS' | jq +curl 'http://localhost:8080/api-log/by-event/ERROR' | jq +``` + +## Architecture + +``` + (this same JVM) + +---------------------+ + curl --GET--> | ClientController | + | /client/widgets | + +---------------------+ + | + | uses + v + +---------------------+ + | RestApiClientUtil | (api-log-core) + +---------------------+ + | | + HTTP --> | | --> ApplicationEventPublisher + v | + +-----------------+ | ApiCallInitiatedEvent + | UpstreamCtrl | | ApiCallSuccessEvent + | /upstream/... | | ApiCallErrorEvent + +-----------------+ | + v + +--------------------+ + | ApiEventListener | (api-log-core, + | (async thread) | listener thread) + +--------------------+ + | + v + +--------------------+ + | MybatisApiLogWriter| (api-log-mybatis) + +--------------------+ + | + v + +--------------------+ + | ApiLogMapper | (MyBatis @Mapper, + | INSERT api_log | auto-wired) + +--------------------+ + | + v + +--------------------+ + | PostgreSQL JSONB | + +--------------------+ +``` + +## What to read + +| File | Why | +| --- | --- | +| `build.gradle.kts` | adds `api-log-core` + `api-log-mybatis` + `mybatis-spring-boot-starter:4.0.1` — that's the whole starter wiring | +| `src/main/resources/application.yml` | `api.log.enabled` + `api.log.schema.management=BUILTIN` + `mybatis.mapper-locations` | +| `widget/ClientController.java` | every kind of call (sync/async/typed/POST/PUT/DELETE/explicit-requestId) routed through `RestApiClientUtil` | +| `widget/ApiLogController.java` | reader endpoints — uses the starter's `ApiLogMapper.findByRequestId` AND the demo's own `ApiLogQueryMapper` for "recent N" / "by event type" | +| `mapper/ApiLogQueryMapper.xml` | shows the `payload::text AS payload` cast pattern needed to read JSONB columns into MyBatis `String` properties | +| `src/test/.../ApiLogLifecycleIT.java` | end-to-end test against a real Postgres Testcontainer — uses Awaitility to wait on the async listener | + +## How testing works (Docker Compose vs Testcontainers) + +Same two-path setup as the other demos in this repo: + +| Path | When it runs | What it uses | +| --- | --- | --- | +| `docker compose up -d db` | When a **human** wants to `bootRun` against a long-lived DB | `docker-compose.yml` in this directory — published port 5432, named volume `pgdata` | +| Testcontainers in `ApiLogLifecycleIT` | When `./gradlew test` runs (locally or in CI) | An **ephemeral** `postgres:16-alpine` container on a random port | + +`@ServiceConnection` (Spring Boot 3.1+) auto-rewires `spring.datasource.url` to the Testcontainers instance — no `application-test.yml`, no `@DynamicPropertySource`. + +## Production notes + +A few things in this demo are tuned for "easy to run" and would be different in a real service: + +- **Schema management.** `api.log.schema.management=BUILTIN` runs the starter's bundled DDL on every boot (idempotent — `CREATE TABLE IF NOT EXISTS`). In production set it to `EXTERNAL` and let Flyway or Liquibase own the migration. +- **Mapper scan.** The demo's `@MapperScan` covers only `kr.devslab.examples.apilogmybatis`. **Do not** add `kr.devslab.apilog.mybatis.mapper` to your scan — the starter's auto-config already registers `ApiLogMapper` and scanning it twice will produce conflicting bean definitions. +- **`map-underscore-to-camel-case`** in `application.yml` lets the demo's custom `ApiLogQueryMapper` map snake_case columns onto the camelCase fields of `ApiLogRow` without spelling each one out in the resultMap. The starter's own mapper uses explicit `AS camelCase` aliases for the same reason — pick whichever pattern fits your codebase. +- **Self-loopback.** Real apps point `api-log-demo.upstream-base-url` at a different service. The loopback exists so the demo runs in a single process. + +## Verify the build + +```bash +./gradlew build +``` + +This compiles, runs `ApiLogLifecycleIT` against an ephemeral Testcontainers Postgres, and produces a runnable jar. First run pulls `postgres:16-alpine` (~80 MB); subsequent runs use the cached image. diff --git a/api-log-mybatis-demo/build.gradle.kts b/api-log-mybatis-demo/build.gradle.kts new file mode 100644 index 0000000..6b50b20 --- /dev/null +++ b/api-log-mybatis-demo/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + java + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4") + + // The library this demo showcases - MyBatis backend. + implementation("kr.devslab:api-log-core:3.0.1") + implementation("kr.devslab:api-log-mybatis:3.0.1") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Awaitility - api-log writes happen on a separate event-listener thread, + // so the test polls /api-log/recent until the expected rows show up rather + // than sleeping with a fixed timeout. + testImplementation("org.awaitility:awaitility:4.2.2") + + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:junit-jupiter") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/api-log-mybatis-demo/docker-compose.yml b/api-log-mybatis-demo/docker-compose.yml new file mode 100644 index 0000000..496a3b4 --- /dev/null +++ b/api-log-mybatis-demo/docker-compose.yml @@ -0,0 +1,32 @@ +# Single-service compose file for the local "run it yourself" path. +# +# The CI / `./gradlew test` path does NOT use this file - Testcontainers spins +# up its own ephemeral PostgreSQL on a random port. This compose file exists +# so a human can do: +# +# docker compose up -d db +# ./gradlew bootRun +# +# ...without installing Postgres on their laptop. The credentials and port +# below match what application.yml reads. + +services: + db: + image: postgres:16-alpine + container_name: api-log-mybatis-demo-db + environment: + POSTGRES_DB: apilog + POSTGRES_USER: apilog + POSTGRES_PASSWORD: apilog + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U apilog -d apilog"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/api-log-mybatis-demo/gradle.properties b/api-log-mybatis-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/api-log-mybatis-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.jar b/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.properties b/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/api-log-mybatis-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/api-log-mybatis-demo/gradlew b/api-log-mybatis-demo/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/api-log-mybatis-demo/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/api-log-mybatis-demo/gradlew.bat b/api-log-mybatis-demo/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/api-log-mybatis-demo/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/api-log-mybatis-demo/settings.gradle.kts b/api-log-mybatis-demo/settings.gradle.kts new file mode 100644 index 0000000..fcb61b6 --- /dev/null +++ b/api-log-mybatis-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "api-log-mybatis-demo" diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/ApiLogMybatisDemoApplication.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/ApiLogMybatisDemoApplication.java new file mode 100644 index 0000000..516fa31 --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/ApiLogMybatisDemoApplication.java @@ -0,0 +1,21 @@ +package kr.devslab.examples.apilogmybatis; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Demo entry point. + * + *

The {@link MapperScan} targets THIS app's package only. The starter's own + * {@code ApiLogMapper} (in {@code kr.devslab.apilog.mybatis.mapper}) is wired + * up by the starter's auto-config - scanning it twice produces conflicting + * mapper bean definitions. + */ +@SpringBootApplication +@MapperScan("kr.devslab.examples.apilogmybatis") +public class ApiLogMybatisDemoApplication { + public static void main(String[] args) { + SpringApplication.run(ApiLogMybatisDemoApplication.class, args); + } +} diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogController.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogController.java new file mode 100644 index 0000000..ca0dd9e --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogController.java @@ -0,0 +1,51 @@ +package kr.devslab.examples.apilogmybatis.widget; + +import kr.devslab.apilog.mybatis.mapper.ApiLogMapper; +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Reader endpoints over {@code api_log}. Lets the integration test (and a + * human poking around) inspect what the writer produced - the starter only + * exposes the writer side, so visibility is the consumer's job. + * + *

Two mappers used: + *

    + *
  • {@link ApiLogMapper} - the starter's built-in, used here for the + * {@code findByRequestId} correlation lookup.
  • + *
  • {@link ApiLogQueryMapper} - this demo's custom mapper, for "recent N" + * and "by event type" reads the starter doesn't ship.
  • + *
+ */ +@RestController +@RequestMapping("/api-log") +public class ApiLogController { + + private final ApiLogMapper apiLogMapper; + private final ApiLogQueryMapper queryMapper; + + public ApiLogController(ApiLogMapper apiLogMapper, ApiLogQueryMapper queryMapper) { + this.apiLogMapper = apiLogMapper; + this.queryMapper = queryMapper; + } + + @GetMapping("/by-request/{requestId}") + public List byRequest(@PathVariable String requestId) { + return apiLogMapper.findByRequestId(requestId); + } + + @GetMapping("/recent") + public List recent() { + return queryMapper.findRecent(20); + } + + @GetMapping("/by-event/{eventType}") + public List byEvent(@PathVariable String eventType) { + return queryMapper.findByEvent(eventType); + } +} diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogQueryMapper.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogQueryMapper.java new file mode 100644 index 0000000..eed87ef --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ApiLogQueryMapper.java @@ -0,0 +1,21 @@ +package kr.devslab.examples.apilogmybatis.widget; + +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * Demo-side reader queries against {@code api_log}. + * + *

The starter's built-in {@link kr.devslab.apilog.mybatis.mapper.ApiLogMapper} + * only exposes {@code insert} (used by the writer) and {@code findByRequestId} + * (correlation lookup). For "recent N" / "by event type" reads the demo needs + * its own mapper - we keep it in the demo so the starter's API surface stays + * minimal (consumers add only what they actually query). + */ +@Mapper +public interface ApiLogQueryMapper { + List findRecent(int limit); + List findByEvent(String eventType); +} diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ClientController.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ClientController.java new file mode 100644 index 0000000..3705d9f --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/ClientController.java @@ -0,0 +1,85 @@ +package kr.devslab.examples.apilogmybatis.widget; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.util.RestApiClientUtil; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The "caller" half of the self-loopback. Every method here goes through + * {@link RestApiClientUtil}, so each call produces three events (INITIATED + + * SUCCESS, or INITIATED + ERROR) that the starter's listener persists to + * {@code api_log} via the MyBatis mapper. + * + *

The upstream URL is built at request time from {@code local.server.port} + * (a property Spring Boot sets after the embedded server binds). That makes the + * self-loopback work both in {@code ./gradlew bootRun} (port 8080) and in the + * integration test ({@code RANDOM_PORT}) without any test-specific override. + * In a real app this method would point at the external service URL — the demo's + * only special thing is that "upstream" lives in the same JVM for convenience. + */ +@RestController +@RequestMapping("/client/widgets") +public class ClientController { + + private final RestApiClientUtil api; + private final Environment env; + + public ClientController(RestApiClientUtil api, Environment env) { + this.api = api; + this.env = env; + } + + private String upstream(String path) { + return "http://localhost:" + env.getProperty("local.server.port") + path; + } + + @GetMapping("/{id}") + public Widget getWidget(@PathVariable long id) { + return api.getSyncTyped(upstream("/upstream/widgets/" + id), Widget.class); + } + + @GetMapping("/{id}/async") + public Widget getWidgetAsync(@PathVariable long id) { + return api.getAsyncTyped(upstream("/upstream/widgets/" + id), Widget.class).join(); + } + + @PostMapping + public Widget createWidget(@RequestBody Widget body) { + return api.postSyncTyped(upstream("/upstream/widgets"), body, Widget.class); + } + + @PutMapping("/{id}") + public Widget updateWidget(@PathVariable long id, @RequestBody Widget body) { + return api.putSyncTyped(upstream("/upstream/widgets/" + id), body, Widget.class); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteWidget(@PathVariable long id) { + api.deleteSync(upstream("/upstream/widgets/" + id)); + return ResponseEntity.noContent().build(); + } + + /** + * Demonstrates explicit-requestId correlation - the typical retry pattern. + * Every attempt for the same logical operation reuses the same requestId + * so {@code findByRequestId} returns the full attempt history. + */ + @PostMapping("/with-request-id/{id}") + public String getWithFixedRequestId(@PathVariable long id) { + api.send(HttpMethod.GET, ApiRequest.builder() + .endpoint(upstream("/upstream/widgets/" + id)) + .requestId("demo-fixed-rid") + .build()); + return "demo-fixed-rid"; + } +} diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/UpstreamController.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/UpstreamController.java new file mode 100644 index 0000000..df3ae94 --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/UpstreamController.java @@ -0,0 +1,52 @@ +package kr.devslab.examples.apilogmybatis.widget; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; + +/** + * The "external service" half of the self-loopback. ClientController calls + * these endpoints via RestApiClientUtil, which emits api-log events around + * each call. + * + *

id == 999 deliberately throws so the error-path test can assert that + * the failure shows up in api_log as an ERROR row. + */ +@RestController +@RequestMapping("/upstream/widgets") +public class UpstreamController { + + @GetMapping("/{id}") + public Widget getWidget(@PathVariable long id) { + if (id == 999L) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "simulated upstream failure"); + } + return new Widget(id, "Widget-" + id, "SKU-" + id, BigDecimal.valueOf(id * 10)); + } + + @PostMapping + public Widget createWidget(@RequestBody Widget body) { + Long id = body.id() != null ? body.id() : System.currentTimeMillis(); + return new Widget(id, body.name(), body.sku(), body.price()); + } + + @PutMapping("/{id}") + public Widget updateWidget(@PathVariable long id, @RequestBody Widget body) { + return new Widget(id, body.name(), body.sku(), body.price()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteWidget(@PathVariable long id) { + return ResponseEntity.noContent().build(); + } +} diff --git a/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/Widget.java b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/Widget.java new file mode 100644 index 0000000..17473a1 --- /dev/null +++ b/api-log-mybatis-demo/src/main/java/kr/devslab/examples/apilogmybatis/widget/Widget.java @@ -0,0 +1,10 @@ +package kr.devslab.examples.apilogmybatis.widget; + +import java.math.BigDecimal; + +/** + * The demo domain object - small enough that the api_log payload column shows + * the whole record (handy when grepping logs in the README walkthrough). + */ +public record Widget(Long id, String name, String sku, BigDecimal price) { +} diff --git a/api-log-mybatis-demo/src/main/resources/application.yml b/api-log-mybatis-demo/src/main/resources/application.yml new file mode 100644 index 0000000..0247299 --- /dev/null +++ b/api-log-mybatis-demo/src/main/resources/application.yml @@ -0,0 +1,35 @@ +spring: + application: + name: api-log-mybatis-demo + datasource: + url: jdbc:postgresql://localhost:5432/apilog + username: apilog + password: apilog + driver-class-name: org.postgresql.Driver + +api: + log: + enabled: true + schema: + management: BUILTIN + +# Self-loopback - same app responds at /upstream/widgets and consumes via /client/widgets. +# ClientController.upstream(...) reads `local.server.port` at request time (set by Spring +# Boot after the embedded server binds), so the same code works in bootRun (port 8080) +# and in RANDOM_PORT tests. In a real app, replace ClientController to point at the +# external service. + +mybatis: + mapper-locations: + - classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + +server: + port: 8080 + +logging: + level: + root: WARN + kr.devslab.examples: INFO + kr.devslab.apilog: INFO diff --git a/api-log-mybatis-demo/src/main/resources/mapper/ApiLogQueryMapper.xml b/api-log-mybatis-demo/src/main/resources/mapper/ApiLogQueryMapper.xml new file mode 100644 index 0000000..7340308 --- /dev/null +++ b/api-log-mybatis-demo/src/main/resources/mapper/ApiLogQueryMapper.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api-log-mybatis-demo/src/test/java/kr/devslab/examples/apilogmybatis/ApiLogLifecycleIT.java b/api-log-mybatis-demo/src/test/java/kr/devslab/examples/apilogmybatis/ApiLogLifecycleIT.java new file mode 100644 index 0000000..32ac30c --- /dev/null +++ b/api-log-mybatis-demo/src/test/java/kr/devslab/examples/apilogmybatis/ApiLogLifecycleIT.java @@ -0,0 +1,166 @@ +package kr.devslab.examples.apilogmybatis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; + +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import kr.devslab.examples.apilogmybatis.widget.ApiLogQueryMapper; +import kr.devslab.examples.apilogmybatis.widget.Widget; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * End-to-end lifecycle test against a real PostgreSQL container. + * + *

The whole point is to prove the event pipeline lands rows in api_log + * the way the README claims: + *

    + *
  • RestApiClientUtil publishes events around every call
  • + *
  • The starter's listener consumes them on a separate thread
  • + *
  • MybatisApiLogWriter persists them via the auto-wired ApiLogMapper
  • + *
  • BUILTIN schema management creates the api_log table at startup
  • + *
+ * + *

Because the listener runs on a different thread than the HTTP call, + * tests use Awaitility instead of fixed sleeps to poll until the expected + * rows appear. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +class ApiLogLifecycleIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate rest; + + @Autowired + ApiLogQueryMapper queryMapper; + + private String url(String path) { + return "http://localhost:" + port + path; + } + + @Test + void happyGetProducesInitiatedAndSuccessRows() { + ResponseEntity response = rest.getForEntity(url("/client/widgets/123"), Widget.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().id()).isEqualTo(123L); + + // The listener is async - wait for both lifecycle rows to land. + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List recent = recent(); + + // Find a request id that has both INITIATED and SUCCESS rows. + boolean foundPair = recent.stream() + .map(ApiLogRow::getRequestId) + .distinct() + .anyMatch(rid -> { + List rowsForRid = recent.stream() + .filter(r -> rid.equals(r.getRequestId())) + .toList(); + boolean hasInitiated = rowsForRid.stream() + .anyMatch(r -> "INITIATED".equals(r.getEventType())); + boolean hasSuccess = rowsForRid.stream() + .anyMatch(r -> "SUCCESS".equals(r.getEventType())); + return hasInitiated && hasSuccess; + }); + assertThat(foundPair).isTrue(); + }); + } + + @Test + void errorPathProducesErrorRow() { + ResponseEntity response = rest.getForEntity(url("/client/widgets/999"), String.class); + assertThat(response.getStatusCode().is5xxServerError()).isTrue(); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List errors = byEvent("ERROR"); + assertThat(errors).isNotEmpty(); + assertThat(errors).anySatisfy(row -> + assertThat(row.getEndpoint()).contains("/upstream/widgets/999")); + }); + } + + @Test + void postBodyIsPreservedInPayloadColumn() { + Widget body = new Widget(null, "Sprocket-7", "SKU-7", new BigDecimal("19.99")); + ResponseEntity response = rest.postForEntity(url("/client/widgets"), body, Widget.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List successes = byEvent("SUCCESS"); + assertThat(successes).anySatisfy(row -> { + assertThat(row.getPayload()).isNotNull(); + assertThat(row.getPayload()).contains("Sprocket-7"); + }); + }); + } + + @Test + void explicitRequestIdCorrelatesEntries() { + ResponseEntity response = rest.postForEntity( + url("/client/widgets/with-request-id/123"), null, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + ResponseEntity> entries = rest.exchange( + url("/api-log/by-request/demo-fixed-rid"), + org.springframework.http.HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + assertThat(entries.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entries.getBody()).isNotNull(); + assertThat(entries.getBody().size()).isGreaterThanOrEqualTo(2); + }); + } + + @Test + void schemaInitializerCreatedTheTable() { + // If BUILTIN schema management didn't run, this query would throw with a + // "relation api_log does not exist" SQLException - the assertion is just + // "the query completes and returns a list". + List rows = queryMapper.findRecent(1); + assertThat(rows).isNotNull(); + } + + // ----- helpers wrapping the demo's own /api-log endpoints ----- + + private List recent() { + ResponseEntity> response = rest.exchange( + url("/api-log/recent"), + org.springframework.http.HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + return response.getBody(); + } + + private List byEvent(String eventType) { + ResponseEntity> response = rest.exchange( + url("/api-log/by-event/" + eventType), + org.springframework.http.HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + return response.getBody(); + } +} diff --git a/api-log-r2dbc-demo/README.ko.md b/api-log-r2dbc-demo/README.ko.md new file mode 100644 index 0000000..3265ac4 --- /dev/null +++ b/api-log-r2dbc-demo/README.ko.md @@ -0,0 +1,134 @@ +# api-log-r2dbc-demo + +[English](README.md) · **한국어** + +> ✨ **R2DBC 백엔드 (Reactive / WebFlux).** 이 데모는 [`api-log`](https://github.com/devslab-kr/api-log)의 R2DBC 변형(`api-log-core` + `api-log-r2dbc`, 둘 다 `3.0.0`)을 사용. 블로킹 변형은 자매 데모 참조: [`api-log-jpa-demo`](../api-log-jpa-demo/) 및 [`api-log-mybatis-demo`](../api-log-mybatis-demo/). + +[`api-log`](https://github.com/devslab-kr/api-log)의 프로덕션 풍 실행 가능한 예제 — HTTP 경로가 전 구간 **non-blocking**: 프런트엔드는 WebFlux, 아웃바운드 호출은 `ReactiveApiClientUtil`, R2DBC 라이터가 R2DBC의 reactive `ConnectionFactory`를 거쳐 `api_log` JSONB 테이블에 영속. + +## 이 데모가 보여주는 것 + +- `api-log`가 R2DBC의 reactive `ConnectionFactory`를 통해 기록 — JDBC 커넥션 풀 없음, 감사 로그 기록 경로가 플랫폼 스레드 블로킹 없음 +- 전체 HTTP 경로(인바운드 WebFlux 요청 → `ReactiveApiClientUtil` 아웃바운드 호출 → 클라이언트로 응답)가 Reactor 이벤트 루프 위에 머무름 +- JPA / MyBatis 백엔드와 동일한 `api_log` 테이블 스키마 — 동일한 JSONB 컬럼(`payload`, `response`, `error_message`), 동일한 `event_type` 라이프사이클(`INITIATED` → `SUCCESS` | `ERROR`) — 감사 컨슈머는 백엔드 종류와 무관하게 동일하게 읽을 수 있음 +- **셀프 루프백** 데모 토폴로지: 한 앱이 upstream과 client 양쪽을 노출. client는 `ReactiveApiClientUtil`을 통해 HTTP로 upstream을 호출하고, 이게 호출당 전체 INITIATED + SUCCESS / ERROR 페어를 기록하는 데 충분 +- R2DBC 앱에서 테이블을 **읽는** 방법 — `api-log-r2dbc` 아티팩트는 라이터만 출하; 이 데모는 자체 `DatabaseClient` 기반 reader를 가져와서 read 엔드포인트(`/api-log/recent`, `/api-log/by-request/{requestId}`, `/api-log/by-event/{eventType}`)가 기록된 내용을 보여줌 + +## 전제조건 + +- JDK 21+ +- **Docker** (Docker Desktop 또는 호환 런타임) +- 그 외 없음. 로컬 Postgres 설치 불필요. psql 클라이언트 불필요. + +## 빠른 시작 + +```bash +cd api-log-r2dbc-demo + +# Postgres 백그라운드로 시작. compose 파일이 localhost:5432에 바인드 +docker compose up -d db + +# 앱 부팅. ApiLogR2dbcSchemaInitializer가 시작 시 api_log 테이블 생성 +# (api.log.r2dbc.schema.enabled=true, 기본값). +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 시험: + +```bash +# 정상 GET — api_log에 INITIATED + SUCCESS 페어 생성 +curl 'http://localhost:8080/client/widgets/123' + +# 에러 경로 — INITIATED + ERROR 페어 생성 +curl -i 'http://localhost:8080/client/widgets/999' + +# POST — 요청 본문이 payload JSONB 컬럼에 캡처됨 +curl -X POST -H 'Content-Type: application/json' \ + -d '{"name":"Hyperbolic Cog","sku":"SKU-COG-42"}' \ + 'http://localhost:8080/client/widgets' + +# 명시적 requestId 상관관계 — 두 행이 request_id=demo-fixed-rid 공유 +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' + +# 기록된 내용 확인 +curl 'http://localhost:8080/api-log/recent' +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' +curl 'http://localhost:8080/api-log/by-event/SUCCESS' +curl 'http://localhost:8080/api-log/by-event/ERROR' +``` + +끝나면: + +```bash +docker compose down # 컨테이너 중지 +docker compose down -v # ... 그리고 볼륨 삭제 (다음에 clean slate) +``` + +## 아키텍처 + +``` +caller (curl / browser) + │ + ▼ HTTP (Netty 이벤트 루프, non-blocking) +┌──────────────────────────────────────────────────┐ +│ WebFlux │ +│ ClientController │ +│ └─ ReactiveApiClientUtil.getTyped(...) │ +│ └─ WebClient → /upstream/widgets/{id} │ +│ │ │ +│ └─ ApplicationEventPublisher │ +│ (INITIATED → SUCCESS|ERROR)│ +└─────────────────────────────────────┬────────────┘ + ▼ + ApiEventListener + │ + ▼ + R2dbcApiLogWriter + │ + ▼ r2dbc:postgresql://... + DatabaseClient + │ + ▼ + PostgreSQL + api_log + (id, event_type, request_id, + endpoint, payload, response, + status_code, error_message, + timestamp, retry_count, is_retry) +``` + +감사 로그 기록은 응답을 막지 않음 — `ApplicationEventPublisher`가 이벤트를 리스너로 fan-out하고, 리스너가 자체 subscriber에서 R2DBC 인서트를 수행. 호출자에 대한 HTTP 응답은 upstream 응답이 돌아오는 즉시 반환. + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `spring-boot-starter-webflux`, `spring-boot-starter-data-r2dbc`, `api-log-core` + `api-log-r2dbc`, 그리고 R2DBC와 JDBC PostgreSQL 드라이버 양쪽 추가 (JDBC 드라이버는 Testcontainers의 `@ServiceConnection` 용으로만 사용) | +| `src/main/resources/application.yml` | `spring.datasource.url` 대신 `spring.r2dbc.url`; `api.log.schema.management=BUILTIN` 대신 `api.log.r2dbc.schema.enabled=true` — R2DBC와 블로킹 백엔드는 서로 다른 프로퍼티 이름을 사용 | +| `widget/ClientController.java` | `ReactiveApiClientUtil` 사용 — `getTyped`, `postTyped`, `putTyped`, `delete`, 그리고 명시적 requestId 상관관계 케이스를 위한 `send(method, ApiRequest)` 변형 | +| `widget/UpstreamController.java` | 셀프 루프백 타깃; `id == 999`는 5xx를 반환해서 에러 경로 동작 가능 | +| `widget/ApiLogReader.java` | `DatabaseClient` 기반 reader — `api-log-r2dbc` 아티팩트는 라이터만 출하하므로 데모가 자체 SELECT를 가져옴. JSONB 컬럼을 `text`로 캐스트해 `String` 필드에 깔끔히 바인딩 | +| `src/test/.../ApiLogLifecycleIT.java` | R2DBC용 `@Testcontainers` + `@ServiceConnection` + WebFlux용 `WebTestClient` + 비동기 리스너를 기다리기 위한 Awaitility — happy, error, payload 보존, requestId 상관관계, "스키마 쿼리 가능"의 5개 테스트 | + +## 프로덕션 노트 + +- Reactive 스키마 초기자(`api.log.r2dbc.schema.enabled`, 기본 `true`)는 시작 시 `api_log` 테이블이 없으면 생성. **데모에는 OK, 프로덕션엔 좋지 않음.** R2DBC에는 first-class 마이그레이션 도구가 없음 — 실제 배포에선 부팅 시 [Flyway](https://flywaydb.org/)를 **별도 JDBC 커넥션**으로 실행 (Flyway는 R2DBC 런타임 옆에서 잘 동작 — Spring Boot의 `flyway.url` / `flyway.user` / `flyway.password` 프로퍼티는 `spring.r2dbc.*`와 독립적)하고 `api.log.r2dbc.schema.enabled=false`로 reactive 초기자를 비활성화. +- R2DBC 백엔드는 `api.log.schema.management`가 **아니라** `api.log.r2dbc.schema.enabled`를 사용한다는 점에 유의. 후자 프로퍼티는 JPA + MyBatis 백엔드에만 적용; 둘을 헷갈리는 게 초보자가 흔히 빠지는 함정. +- `ReactiveApiClientUtil`은 `WebClient` 위에 빌드 — 자동 구성되지만 타임아웃 없음. 프로덕션에선 앱이 받는 `WebClient.Builder`에 connect / response 타임아웃을 구성할 것 — `ReactiveApiClientUtil`의 생성자는 `WebClient`를 받으므로, 이미 커스터마이즈한 것을 넘길 수 있음. + +## 테스트 동작 방식 + +`./gradlew test`는 `docker-compose.yml`을 읽지 않음. Testcontainers가 단명 `postgres:16-alpine`을 임의 포트로 띄우고, `PostgreSQLContainer`의 `@ServiceConnection`이 양쪽을 생성: + +- `DataSourceConnectionDetails` (JDBC, 요청하는 모든 것에) +- `spring.r2dbc.url`을 컨테이너로 재배선하는 `R2dbcConnectionDetails` + +…그래서 앱의 R2DBC 런타임이 `application-test.yml`이나 `@DynamicPropertySource` 없이 컨테이너를 가리킴. CI도 동일하게 실행: Ubuntu 러너엔 이미 Docker가 있으므로 IT 스위트는 셋업 없이 실제 Postgres 상대로 실행. + +## 빌드 검증 + +```bash +./gradlew build +``` + +첫 실행은 `postgres:16-alpine` 이미지(~80MB) 풀; 이후 실행은 캐시된 이미지 사용. diff --git a/api-log-r2dbc-demo/README.md b/api-log-r2dbc-demo/README.md new file mode 100644 index 0000000..1910df7 --- /dev/null +++ b/api-log-r2dbc-demo/README.md @@ -0,0 +1,134 @@ +# api-log-r2dbc-demo + +**English** · [한국어](README.ko.md) + +> ✨ **R2DBC backend (Reactive / WebFlux).** This demo runs against the R2DBC variant of [`api-log`](https://github.com/devslab-kr/api-log) (`api-log-core` + `api-log-r2dbc`, both `3.0.0`). For the blocking variants see the sibling demos: [`api-log-jpa-demo`](../api-log-jpa-demo/) and [`api-log-mybatis-demo`](../api-log-mybatis-demo/). + +Production-flavoured runnable example for [`api-log`](https://github.com/devslab-kr/api-log) with a fully **non-blocking** HTTP path: WebFlux on the front, `ReactiveApiClientUtil` for the outbound call, and the R2DBC writer persisting into the `api_log` JSONB table via R2DBC's reactive `ConnectionFactory`. + +## What this demo shows + +- `api-log` writes through R2DBC's reactive `ConnectionFactory` — no JDBC connection pool, no platform-thread blocking for the audit-log write path +- The entire HTTP path (inbound WebFlux request → `ReactiveApiClientUtil` outbound call → response back to the client) stays on Reactor's event loop +- Same `api_log` table shape as the JPA / MyBatis backends — same JSONB columns (`payload`, `response`, `error_message`), same `event_type` lifecycle (`INITIATED` → `SUCCESS` | `ERROR`), so audit consumers can read uniformly across backends +- A **self-loopback** demo topology: one app exposes both the upstream and the client; the client calls the upstream over HTTP through `ReactiveApiClientUtil`, which is enough for `api-log` to write a full INITIATED + SUCCESS / ERROR pair per call +- How to **read** the table from an R2DBC app — the `api-log-r2dbc` artifact only ships a writer; this demo brings its own `DatabaseClient`-backed reader so the read endpoints (`/api-log/recent`, `/api-log/by-request/{requestId}`, `/api-log/by-event/{eventType}`) can show what got persisted + +## Prerequisites + +- JDK 21+ +- **Docker** (Docker Desktop or any Docker-compatible runtime) +- That's it. No local Postgres install. No psql client needed. + +## Quickstart + +```bash +cd api-log-r2dbc-demo + +# Start PostgreSQL in the background. The compose file binds 5432 on localhost. +docker compose up -d db + +# Boot the app. ApiLogR2dbcSchemaInitializer creates the api_log table on +# startup (api.log.r2dbc.schema.enabled=true, which is the default). +./gradlew bootRun +``` + +The app comes up on `http://localhost:8080`. Try it: + +```bash +# Happy GET — produces an INITIATED + SUCCESS pair in api_log +curl 'http://localhost:8080/client/widgets/123' + +# Error path — produces an INITIATED + ERROR pair +curl -i 'http://localhost:8080/client/widgets/999' + +# POST — the request body is captured into the payload JSONB column +curl -X POST -H 'Content-Type: application/json' \ + -d '{"name":"Hyperbolic Cog","sku":"SKU-COG-42"}' \ + 'http://localhost:8080/client/widgets' + +# Explicit requestId correlation — both rows share request_id=demo-fixed-rid +curl -X POST 'http://localhost:8080/client/widgets/with-request-id/123' + +# Inspect what got written +curl 'http://localhost:8080/api-log/recent' +curl 'http://localhost:8080/api-log/by-request/demo-fixed-rid' +curl 'http://localhost:8080/api-log/by-event/SUCCESS' +curl 'http://localhost:8080/api-log/by-event/ERROR' +``` + +When you're done: + +```bash +docker compose down # stop the container +docker compose down -v # ...and drop the volume (clean slate next time) +``` + +## Architecture + +``` +caller (curl / browser) + │ + ▼ HTTP (Netty event loop, non-blocking) +┌──────────────────────────────────────────────────┐ +│ WebFlux │ +│ ClientController │ +│ └─ ReactiveApiClientUtil.getTyped(...) │ +│ └─ WebClient → /upstream/widgets/{id} │ +│ │ │ +│ └─ ApplicationEventPublisher │ +│ (INITIATED → SUCCESS|ERROR)│ +└─────────────────────────────────────┬────────────┘ + ▼ + ApiEventListener + │ + ▼ + R2dbcApiLogWriter + │ + ▼ r2dbc:postgresql://... + DatabaseClient + │ + ▼ + PostgreSQL + api_log + (id, event_type, request_id, + endpoint, payload, response, + status_code, error_message, + timestamp, retry_count, is_retry) +``` + +The audit-log write doesn't block the response — `ApplicationEventPublisher` fans the event out to the listener, and the listener performs the R2DBC insert on its own subscriber. The HTTP response to the caller returns as soon as the upstream's response is back. + +## Files of interest + +| File | Why | +| --- | --- | +| `build.gradle.kts` | adds `spring-boot-starter-webflux`, `spring-boot-starter-data-r2dbc`, `api-log-core` + `api-log-r2dbc`, and both the R2DBC and JDBC PostgreSQL drivers (the JDBC one is only for Testcontainers' `@ServiceConnection`) | +| `src/main/resources/application.yml` | `spring.r2dbc.url` instead of `spring.datasource.url`; `api.log.r2dbc.schema.enabled=true` instead of `api.log.schema.management=BUILTIN` — R2DBC and the blocking backends use different property names | +| `widget/ClientController.java` | uses `ReactiveApiClientUtil` — `getTyped`, `postTyped`, `putTyped`, `delete`, and a `send(method, ApiRequest)` variant for the explicit-requestId correlation case | +| `widget/UpstreamController.java` | the self-loopback target; `id == 999` returns 5xx so the error path can be exercised | +| `widget/ApiLogReader.java` | `DatabaseClient`-backed reader — the `api-log-r2dbc` artifact ships only a writer, so the demo brings its own SELECT. Casts JSONB columns to `text` so they bind into `String` fields cleanly | +| `src/test/.../ApiLogLifecycleIT.java` | `@Testcontainers` + `@ServiceConnection` for R2DBC + `WebTestClient` for WebFlux + Awaitility to wait out the async listener — five tests covering happy, error, payload preservation, requestId correlation, and "schema is queryable" | + +## Production notes + +- The reactive schema initializer (`api.log.r2dbc.schema.enabled`, default `true`) creates the `api_log` table on startup if it doesn't exist. **It's fine for a demo, not great for production.** R2DBC has no first-class migration tooling — for a real deployment, run [Flyway](https://flywaydb.org/) against a **separate JDBC connection** at boot time (Flyway works fine alongside an R2DBC runtime — Spring Boot's `flyway.url` / `flyway.user` / `flyway.password` properties are independent of `spring.r2dbc.*`) and disable the reactive initializer with `api.log.r2dbc.schema.enabled=false`. +- Note that the R2DBC backend uses `api.log.r2dbc.schema.enabled`, **not** `api.log.schema.management`. The latter property only applies to the JPA + MyBatis backends; mixing them up is a common first-time-user trap. +- `ReactiveApiClientUtil` builds on top of `WebClient`, which is auto-configured with sensible defaults but no timeout. For production, configure a connect / response timeout on the `WebClient.Builder` your app gets — `ReactiveApiClientUtil`'s constructor takes a `WebClient`, so you can hand it one you've already customized. + +## How testing works + +`./gradlew test` doesn't read `docker-compose.yml`. Testcontainers spins up an ephemeral `postgres:16-alpine` on a random port and `@ServiceConnection` on the `PostgreSQLContainer` produces both: + +- a `DataSourceConnectionDetails` (JDBC, for anything that asks) +- an `R2dbcConnectionDetails` that rewires `spring.r2dbc.url` to the container + +…so the app's R2DBC runtime points at the container without an `application-test.yml` or a `@DynamicPropertySource`. CI runs the same way: an Ubuntu runner already has Docker, so the IT suite runs against a real Postgres with no setup. + +## Verify the build + +```bash +./gradlew build +``` + +First run pulls the `postgres:16-alpine` image (~80MB); subsequent runs use the cached image. diff --git a/api-log-r2dbc-demo/build.gradle.kts b/api-log-r2dbc-demo/build.gradle.kts new file mode 100644 index 0000000..f309e11 --- /dev/null +++ b/api-log-r2dbc-demo/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + java + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "kr.devslab.examples" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +repositories { + mavenCentral() +} + +dependencies { + // WebFlux — the demo's HTTP path is fully non-blocking, top to bottom. + implementation("org.springframework.boot:spring-boot-starter-webflux") + + // R2DBC — Spring Boot wires up DatabaseClient + R2dbcEntityTemplate + + // the reactive ConnectionFactory automatically when this is on the classpath. + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + + // The library this demo showcases — R2DBC backend. core publishes the + // ReactiveApiClientUtil + the ApplicationEvent contract; r2dbc plugs in + // the reactive ApiLogWriter + the reactive schema initializer. + // api-log 3.0.0 is built against Spring Boot 3.5.6 (lib major = SB major + // per VERSIONING.md), so this demo pins the plugin to 3.5.6 to match. + implementation("kr.devslab:api-log-core:3.0.1") + implementation("kr.devslab:api-log-r2dbc:3.0.1") + + // R2DBC PostgreSQL driver — for the app's runtime path. + runtimeOnly("org.postgresql:r2dbc-postgresql") + // JDBC PostgreSQL driver — Testcontainers' @ServiceConnection wants a JDBC + // URL for its JdbcDatabaseContainer, even when the app itself uses R2DBC. + runtimeOnly("org.postgresql:postgresql") + + // spring-boot-starter-test pulls in WebTestClient transitively when + // spring-boot-starter-webflux is on the classpath — no need for the + // SB4-only spring-boot-starter-webflux-test split module. + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") + + // Awaitility — api-log writes happen on a separate event-listener thread, + // so the test polls /api-log/recent until the expected rows show up rather + // than sleeping with a fixed timeout. + testImplementation("org.awaitility:awaitility:4.2.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Testcontainers. spring-boot-testcontainers provides @ServiceConnection, + // which auto-rewires r2dbc:postgresql:... to the container at test time. + // testcontainers:r2dbc is the bridge that exposes R2dbcConnectionDetails. + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:r2dbc") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/api-log-r2dbc-demo/docker-compose.yml b/api-log-r2dbc-demo/docker-compose.yml new file mode 100644 index 0000000..42e3db1 --- /dev/null +++ b/api-log-r2dbc-demo/docker-compose.yml @@ -0,0 +1,32 @@ +# Single-service compose file for the local "run it yourself" path. +# +# The CI / `./gradlew test` path does NOT use this file — Testcontainers spins +# up its own ephemeral PostgreSQL on a random port. This compose file exists +# so a human can do: +# +# docker compose up -d db +# ./gradlew bootRun +# +# ...without installing Postgres on their laptop. The credentials and port +# below match what application.yml reads. + +services: + db: + image: postgres:16-alpine + container_name: api-log-r2dbc-demo-db + environment: + POSTGRES_DB: apilog + POSTGRES_USER: apilog + POSTGRES_PASSWORD: apilog + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U apilog -d apilog"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/api-log-r2dbc-demo/gradle.properties b/api-log-r2dbc-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/api-log-r2dbc-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.jar b/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.properties b/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/api-log-r2dbc-demo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/api-log-r2dbc-demo/gradlew b/api-log-r2dbc-demo/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/api-log-r2dbc-demo/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/api-log-r2dbc-demo/gradlew.bat b/api-log-r2dbc-demo/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/api-log-r2dbc-demo/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/api-log-r2dbc-demo/settings.gradle.kts b/api-log-r2dbc-demo/settings.gradle.kts new file mode 100644 index 0000000..04110ec --- /dev/null +++ b/api-log-r2dbc-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "api-log-r2dbc-demo" diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/ApiLogR2dbcDemoApplication.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/ApiLogR2dbcDemoApplication.java new file mode 100644 index 0000000..1d72697 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/ApiLogR2dbcDemoApplication.java @@ -0,0 +1,11 @@ +package kr.devslab.examples.apilogr2dbc; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ApiLogR2dbcDemoApplication { + public static void main(String[] args) { + SpringApplication.run(ApiLogR2dbcDemoApplication.class, args); + } +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogController.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogController.java new file mode 100644 index 0000000..98c7636 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogController.java @@ -0,0 +1,38 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import reactor.core.publisher.Flux; + +/** + * Read endpoints over the {@code api_log} table — useful for live inspection in + * the demo and for the integration test to poll until expected rows arrive. + */ +@RestController +@RequestMapping("/api-log") +public class ApiLogController { + + private final ApiLogReader reader; + + public ApiLogController(ApiLogReader reader) { + this.reader = reader; + } + + @GetMapping("/by-request/{requestId}") + public Flux byRequest(@PathVariable String requestId) { + return reader.findByRequestId(requestId); + } + + @GetMapping("/recent") + public Flux recent() { + return reader.findRecent(20); + } + + @GetMapping("/by-event/{eventType}") + public Flux byEvent(@PathVariable String eventType) { + return reader.findByEvent(eventType); + } +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogReader.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogReader.java new file mode 100644 index 0000000..c841ba5 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogReader.java @@ -0,0 +1,76 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import java.time.LocalDateTime; + +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Flux; + +/** + * Reads from the {@code api_log} table — the api-log R2DBC backend only ships a + * writer, so the demo brings its own reader to expose what the starter wrote. + * + *

Uses {@link DatabaseClient} (autoconfigured by + * {@code spring-boot-starter-data-r2dbc}) instead of {@code R2dbcEntityTemplate} + * because the table isn't ours — we don't want to declare a managed entity for a + * table whose schema is owned by the starter, and {@code DatabaseClient} is the + * lighter tool for ad-hoc SELECTs. + * + *

The {@code JSONB} columns are cast to {@code text} in the SELECT so they + * bind cleanly into {@code String} fields without an explicit codec for the + * driver's PGobject type. + */ +@Service +public class ApiLogReader { + + private static final String SELECT_COLS = + "SELECT id, event_type, request_id, endpoint, " + + "payload::text AS payload, response::text AS response, " + + "status_code, error_message::text AS error_message, " + + "timestamp, retry_count, is_retry " + + "FROM api_log "; + + private final DatabaseClient databaseClient; + + public ApiLogReader(DatabaseClient databaseClient) { + this.databaseClient = databaseClient; + } + + public Flux findByRequestId(String requestId) { + return databaseClient.sql(SELECT_COLS + "WHERE request_id = :rid ORDER BY id ASC") + .bind("rid", requestId) + .map(ApiLogReader::mapRow) + .all(); + } + + public Flux findRecent(int limit) { + return databaseClient.sql(SELECT_COLS + "ORDER BY timestamp DESC, id DESC LIMIT :lim") + .bind("lim", limit) + .map(ApiLogReader::mapRow) + .all(); + } + + public Flux findByEvent(String eventType) { + return databaseClient.sql(SELECT_COLS + "WHERE event_type = :etype ORDER BY id ASC") + .bind("etype", eventType) + .map(ApiLogReader::mapRow) + .all(); + } + + private static ApiLogView mapRow(io.r2dbc.spi.Row row, io.r2dbc.spi.RowMetadata meta) { + return new ApiLogView( + row.get("id", Long.class), + row.get("event_type", String.class), + row.get("request_id", String.class), + row.get("endpoint", String.class), + row.get("payload", String.class), + row.get("response", String.class), + row.get("status_code", Integer.class), + row.get("error_message", String.class), + row.get("timestamp", LocalDateTime.class), + row.get("retry_count", Integer.class), + row.get("is_retry", Boolean.class) + ); + } +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogView.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogView.java new file mode 100644 index 0000000..a9d8bf5 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ApiLogView.java @@ -0,0 +1,28 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import java.time.LocalDateTime; + +/** + * Read-only projection of one row in the {@code api_log} table. The R2DBC + * backend ships only a writer; this record is the demo's view of what got + * persisted, intentionally typed so it serializes back over the HTTP read + * endpoints with camelCase JSON field names. + * + *

{@code JSONB} columns are exposed as raw {@code String} (already cast to + * text in the SELECT) — the api-log starter serializes payload/response with + * Jackson, so they come out as JSON-formatted strings. + */ +public record ApiLogView( + Long id, + String eventType, + String requestId, + String endpoint, + String payload, + String response, + Integer statusCode, + String errorMessage, + LocalDateTime timestamp, + Integer retryCount, + Boolean isRetry +) { +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ClientController.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ClientController.java new file mode 100644 index 0000000..55f6d09 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/ClientController.java @@ -0,0 +1,88 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import org.springframework.core.env.Environment; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.util.ReactiveApiClientUtil; +import reactor.core.publisher.Mono; + +/** + * The "client" side of the self-loopback. Every method here makes an outbound + * HTTP call via {@link ReactiveApiClientUtil}; the starter auto-publishes an + * INITIATED + (SUCCESS|ERROR) {@code ApiEvent} pair for each call, which the + * R2DBC backend writes into the {@code api_log} table. + * + *

All methods return {@link Mono} — the whole path stays non-blocking from + * the inbound WebFlux request through {@code ReactiveApiClientUtil}'s WebClient + * call to the upstream and back. + * + *

The {@code /with-request-id} variant shows the explicit-correlation path: + * when you build an {@link ApiRequest} with a {@code requestId}, the same id is + * stamped on both the INITIATED and SUCCESS/ERROR rows so you can join the + * INITIATED + terminal rows yourself. + */ +@RestController +@RequestMapping("/client/widgets") +public class ClientController { + + private final ReactiveApiClientUtil api; + private final Environment env; + + public ClientController(ReactiveApiClientUtil api, Environment env) { + this.api = api; + this.env = env; + } + + private String upstream(String path) { + // local.server.port is set by Spring Boot after the embedded server binds — works + // for both bootRun (port 8080) and RANDOM_PORT integration tests without an override. + return "http://localhost:" + env.getProperty("local.server.port") + path; + } + + @GetMapping("/{id}") + public Mono get(@PathVariable Long id) { + return api.getTyped(upstream("/upstream/widgets/" + id), Widget.class); + } + + @PostMapping + public Mono create(@RequestBody Widget body) { + return api.postTyped(upstream("/upstream/widgets"), body, Widget.class); + } + + @PutMapping("/{id}") + public Mono update(@PathVariable Long id, @RequestBody Widget body) { + return api.putTyped(upstream("/upstream/widgets/" + id), body, Widget.class); + } + + @DeleteMapping("/{id}") + public Mono> delete(@PathVariable Long id) { + return api.delete(upstream("/upstream/widgets/" + id)) + .then(Mono.just(ResponseEntity.noContent().build())); + } + + /** + * Demonstrates explicit-requestId correlation. Builds an {@link ApiRequest} + * with a known {@code requestId} so callers can group all rows belonging to + * one logical operation (including any retries) by querying + * {@code /api-log/by-request/{requestId}}. + */ + @PostMapping("/with-request-id/{id}") + public Mono withFixedRequestId(@PathVariable Long id) { + ApiRequest request = ApiRequest.builder() + .endpoint(upstream("/upstream/widgets/" + id)) + .requestId("demo-fixed-rid") + .build(); + return api.send(HttpMethod.GET, request); + } +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/UpstreamController.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/UpstreamController.java new file mode 100644 index 0000000..fd1d018 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/UpstreamController.java @@ -0,0 +1,59 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import java.math.BigDecimal; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import reactor.core.publisher.Mono; + +/** + * The "upstream" side of the self-loopback. In a real deployment this would be + * a separate service — for the demo it lives in the same JVM and the {@link + * ClientController} calls it via {@code http://localhost:8080/upstream/widgets}. + * + *

WebFlux returns {@link Mono}/{@code Flux} from every handler; that keeps + * the entire request path off the platform thread pool and matches what the + * R2DBC backend on the api-log writer side expects too. + * + *

The {@code id == 999} branch exists specifically so the integration test + * has a way to assert that ERROR rows are written when the upstream returns 5xx. + */ +@RestController +@RequestMapping("/upstream/widgets") +public class UpstreamController { + + @GetMapping("/{id}") + public Mono get(@PathVariable Long id) { + if (id == 999L) { + return Mono.error(new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "simulated upstream failure")); + } + return Mono.just(new Widget(id, "Widget-" + id, "SKU-" + id, BigDecimal.valueOf(id * 10))); + } + + @PostMapping + public Mono create(@RequestBody Widget body) { + Long assignedId = body.id() != null ? body.id() : System.currentTimeMillis(); + return Mono.just(new Widget(assignedId, body.name(), body.sku(), body.price())); + } + + @PutMapping("/{id}") + public Mono update(@PathVariable Long id, @RequestBody Widget body) { + return Mono.just(new Widget(id, body.name(), body.sku(), body.price())); + } + + @DeleteMapping("/{id}") + public Mono> delete(@PathVariable Long id) { + return Mono.just(ResponseEntity.noContent().build()); + } +} diff --git a/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/Widget.java b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/Widget.java new file mode 100644 index 0000000..9d997ef --- /dev/null +++ b/api-log-r2dbc-demo/src/main/java/kr/devslab/examples/apilogr2dbc/widget/Widget.java @@ -0,0 +1,12 @@ +package kr.devslab.examples.apilogr2dbc.widget; + +import java.math.BigDecimal; + +/** + * Tiny payload type the demo's client and upstream controllers pass back and + * forth. Records keep the surface area small — Jackson handles both directions + * out of the box, so the demo focuses on what api-log captures (the request + * payload, the response body, the status code) rather than on bean plumbing. + */ +public record Widget(Long id, String name, String sku, BigDecimal price) { +} diff --git a/api-log-r2dbc-demo/src/main/resources/application.yml b/api-log-r2dbc-demo/src/main/resources/application.yml new file mode 100644 index 0000000..51a6c54 --- /dev/null +++ b/api-log-r2dbc-demo/src/main/resources/application.yml @@ -0,0 +1,37 @@ +spring: + application: + name: api-log-r2dbc-demo + + # R2DBC connection — non-blocking driver, separate config namespace from + # spring.datasource. Spring Boot auto-builds a ConnectionFactory from this. + r2dbc: + url: r2dbc:postgresql://localhost:5432/apilog + username: apilog + password: apilog + +api: + log: + enabled: true + r2dbc: + # ApiLogR2dbcSchemaInitializer creates the api_log table on boot. This + # is the R2DBC-flavoured equivalent of api.log.schema.management=BUILTIN + # (which only applies to the JPA / MyBatis backends — R2DBC has its own + # property because the underlying mechanism is different). + schema: + enabled: true + +server: + port: 8080 + +# Self-loopback — the same app exposes both /upstream/widgets (the responder) +# and /client/widgets (the caller via ReactiveApiClientUtil). ClientController +# builds the URL from `local.server.port` at request time (Spring Boot sets it +# after the embedded server binds), so the demo works the same on bootRun (port +# 8080) and in RANDOM_PORT integration tests. In a real app, replace +# ClientController to point at the external service. + +logging: + level: + root: WARN + kr.devslab.examples: INFO + kr.devslab.apilog: INFO diff --git a/api-log-r2dbc-demo/src/test/java/kr/devslab/examples/apilogr2dbc/ApiLogLifecycleIT.java b/api-log-r2dbc-demo/src/test/java/kr/devslab/examples/apilogr2dbc/ApiLogLifecycleIT.java new file mode 100644 index 0000000..fbe488d --- /dev/null +++ b/api-log-r2dbc-demo/src/test/java/kr/devslab/examples/apilogr2dbc/ApiLogLifecycleIT.java @@ -0,0 +1,126 @@ +package kr.devslab.examples.apilogr2dbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import kr.devslab.examples.apilogr2dbc.widget.ApiLogReader; +import kr.devslab.examples.apilogr2dbc.widget.ApiLogView; +import kr.devslab.examples.apilogr2dbc.widget.Widget; + +/** + * End-to-end lifecycle test against a real PostgreSQL container. + * + *

{@link ServiceConnection} on a {@link PostgreSQLContainer} produces both + * {@code DataSourceConnectionDetails} (JDBC) and — since + * {@code spring-boot-starter-data-r2dbc} is on the classpath — an + * {@code R2dbcConnectionDetails} that rewires {@code spring.r2dbc.url} to the + * Testcontainers instance. No manual {@code @DynamicPropertySource} needed. + * + *

api-log writes happen on the {@code ApplicationEventListener} thread, so + * every assertion that inspects {@code api_log} runs inside an + * {@link org.awaitility.Awaitility} block — the test polls the read endpoint + * (or the reader directly) until the expected rows show up rather than + * sleeping with a fixed timeout. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Testcontainers +class ApiLogLifecycleIT { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private ApiLogReader reader; + + @Test + void happyGetWritesInitiatedAndSuccessRows() { + webTestClient.get().uri("/client/widgets/123") + .exchange() + .expectStatus().isOk() + .expectBody(Widget.class) + .value(w -> { + assertThat(w.id()).isEqualTo(123L); + assertThat(w.name()).isEqualTo("Widget-123"); + }); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List rows = reader.findRecent(20).collectList().block(); + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); + assertThat(rows).extracting(ApiLogView::eventType) + .contains("INITIATED", "SUCCESS"); + }); + } + + @Test + void errorPathWritesErrorRow() { + webTestClient.get().uri("/client/widgets/999") + .exchange() + .expectStatus().is5xxServerError(); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List rows = reader.findByEvent("ERROR").collectList().block(); + assertThat(rows).isNotEmpty(); + assertThat(rows).anySatisfy(r -> + assertThat(r.endpoint()).contains("/upstream/widgets/999")); + }); + } + + @Test + void postBodyIsPreservedInPayload() { + Widget body = new Widget(null, "Hyperbolic Cog", "SKU-COG-42", null); + + webTestClient.post().uri("/client/widgets") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .exchange() + .expectStatus().isOk() + .expectBody(Widget.class); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List rows = reader.findByEvent("SUCCESS").collectList().block(); + assertThat(rows).anySatisfy(r -> + assertThat(r.payload()).isNotNull().contains("Hyperbolic Cog")); + }); + } + + @Test + void explicitRequestIdCorrelatesInitiatedAndTerminalRows() { + webTestClient.post().uri("/client/widgets/with-request-id/123") + .exchange() + .expectStatus().isOk(); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + List rows = reader.findByRequestId("demo-fixed-rid").collectList().block(); + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); + assertThat(rows).extracting(ApiLogView::requestId) + .allMatch("demo-fixed-rid"::equals); + }); + } + + @Test + void schemaIsInitializedAndQueryable() { + // If the R2DBC schema initializer didn't run, this would throw "relation + // api_log does not exist". A clean empty Flux is the success signal. + List rows = reader.findRecent(1).collectList().block(); + assertThat(rows).isNotNull(); + } +}