From 2a08c822271cd76037244c3dcb42aa771c29695f Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 02:41:19 +0900 Subject: [PATCH 1/9] Split starter into core/jpa/r2dbc/mybatis modules; switch to Gradle Splits the single api-log-spring-boot-starter into four publishable artifacts so consumers pick the persistence backend they actually use: kr.devslab:api-log-core events, ApiLogWriter SPI, listener, HTTP utils kr.devslab:api-log-jpa JPA + Hibernate (drop-in for v0.5.x users) kr.devslab:api-log-r2dbc reactive R2DBC, no JDBC dependency kr.devslab:api-log-mybatis MyBatis mapper backend Build system migrates from Maven to Gradle 8.10 with Vanniktech maven-publish per module, matching the easy-paging-spring-boot-starter convention. pom.xml / mvnw / .mvn removed; gradle wrapper bundled. New ApiLogWriter SPI in :core. Each backend module registers exactly one implementation, and the core listener routes events through it. HttpErrorExtractor and PayloadJsonMapper helpers are also lifted into :core so all three writers share one HTTP-status-extraction path and one JSON canonicalization path. Package renames as part of the split: - kr.devslab.apilog.model.dto.* -> kr.devslab.apilog.dto.* - kr.devslab.apilog.model.ApiLogEntity -> kr.devslab.apilog.jpa.model.ApiLogEntity - kr.devslab.apilog.repository.* -> kr.devslab.apilog.jpa.repository.* - kr.devslab.apilog.service.ApiLogService -> kr.devslab.apilog.jpa.writer.JpaApiLogWriter Fixed: V1.0__create_api_log.sql now uses IF NOT EXISTS on both CREATE TABLE and CREATE INDEX -- idempotent across boots under BUILTIN mode (previously could fail on second boot if Hibernate's ddl-auto wasn't catching it). CI workflow rewritten for Gradle with per-module Jacoco upload glob. Release workflow now publishes via Vanniktech's publishAndReleaseToMavenCentral, releasing all four artifacts in one go. Docs: new install table with the backend choice, three new backend guides (en+ko), v0.6.0 changelog entry with migration mapping, mkdocs nav updated. Local verification: all four modules compile (main + test), the 23 unit tests in :core plus the JPA writer mock tests all pass. Testcontainers integration tests (jpa/r2dbc/mybatis) require a Docker daemon socket the CI Ubuntu runner has by default; on Windows + Docker Desktop they need the socket-access toggle and will be exercised through CI on this PR. --- .github/workflows/ci.yml | 34 +- .github/workflows/release.yml | 38 +- .gitignore | 66 ++-- .mvn/wrapper/maven-wrapper.properties | 3 - CHANGELOG.md | 33 +- README.ko.md | 55 ++- README.md | 56 ++- build.gradle.kts | 18 + core/build.gradle.kts | 182 ++++++++++ .../java/kr/devslab/apilog/Constants.java | 19 + .../ApiLogCoreAutoConfiguration.java | 75 ++-- .../autoconfigure/ApiLogProperties.java | 87 +++++ .../ReactiveApiClientAutoConfiguration.java | 2 +- .../RestApiClientAutoConfiguration.java | 6 +- .../kr/devslab/apilog/config/RetryConfig.java | 29 +- .../kr/devslab/apilog/dto/ApiRequest.java | 27 ++ .../kr/devslab/apilog/dto/ApiResponse.java | 16 + .../apilog/event/ApiCallErrorEvent.java | 12 +- .../apilog/event/ApiCallInitiatedEvent.java | 10 +- .../apilog/event/ApiCallSuccessEvent.java | 10 +- .../apilog/listener/ApiEventListener.java | 43 ++- .../kr/devslab/apilog/spi/ApiLogWriter.java | 40 +++ .../apilog/spi/HttpErrorExtractor.java | 51 +++ .../kr/devslab/apilog/spi/HttpErrorInfo.java | 12 + .../devslab/apilog/spi/PayloadJsonMapper.java | 80 +++++ .../apilog/util/ReactiveApiClientUtil.java | 11 +- .../apilog/util/RestApiClientUtil.java | 4 +- ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../db/api-log/V1.0__create_api_log.sql | 6 +- .../test/java/kr/devslab/apilog/TestApp.java | 14 + .../apilog/listener/ApiEventListenerTest.java | 116 ++++++ .../ReactiveApiClientUtilRoutingTest.java | 8 +- .../util/RestApiClientUtilRoutingTest.java | 18 +- docs/changelog.ko.md | 71 ++++ docs/changelog.md | 71 ++++ docs/getting-started/installation.ko.md | 97 ++++- docs/getting-started/installation.md | 97 ++++- docs/getting-started/quickstart.ko.md | 4 +- docs/getting-started/quickstart.md | 4 +- docs/guides/jpa-backend.ko.md | 230 ++++++++++++ docs/guides/jpa-backend.md | 234 ++++++++++++ docs/guides/mybatis-backend.ko.md | 250 +++++++++++++ docs/guides/mybatis-backend.md | 255 +++++++++++++ docs/guides/r2dbc-backend.ko.md | 218 ++++++++++++ docs/guides/r2dbc-backend.md | 222 ++++++++++++ gradle.properties | 39 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++++ gradlew.bat | 94 +++++ jpa/build.gradle.kts | 164 +++++++++ .../autoconfigure/ApiLogFlywayConfig.java | 4 +- .../ApiLogJpaAutoConfiguration.java | 73 ++++ .../ApiLogJpaSchemaInitializer.java | 8 +- .../apilog/jpa}/model/ApiLogEntity.java | 18 +- .../jpa}/repository/ApiLogRepository.java | 11 +- .../apilog/jpa/writer/JpaApiLogWriter.java | 94 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../test/java/kr/devslab/apilog/TestApp.java | 10 + .../jpa/autoconfigure/ConfigurationTest.java | 114 ++++++ .../jpa/repository/ApiLogRepositoryTest.java | 171 +++++++++ .../jpa/writer/JpaApiLogWriterTest.java | 155 +++----- .../RestApiClientUtilHttpIntegrationTest.java | 69 +--- mkdocs.yml | 8 + mvnw | 295 --------------- mvnw.cmd | 189 ---------- mybatis/build.gradle.kts | 148 ++++++++ .../ApiLogMybatisAutoConfiguration.java | 68 ++++ .../ApiLogMybatisSchemaInitializer.java | 38 ++ .../apilog/mybatis/mapper/ApiLogMapper.java | 54 +++ .../apilog/mybatis/model/ApiLogRow.java | 39 ++ .../mybatis/writer/MybatisApiLogWriter.java | 91 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../test/java/kr/devslab/apilog/TestApp.java | 10 + .../MybatisApiLogWriterIntegrationTest.java | 153 ++++++++ pom.xml | 336 ------------------ r2dbc/build.gradle.kts | 156 ++++++++ .../ApiLogR2dbcAutoConfiguration.java | 78 ++++ .../ApiLogR2dbcSchemaInitializer.java | 39 ++ .../r2dbc/writer/R2dbcApiLogWriter.java | 155 ++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../test/java/kr/devslab/apilog/TestApp.java | 10 + .../R2dbcApiLogWriterIntegrationTest.java | 180 ++++++++++ settings.gradle.kts | 29 ++ .../java/kr/devslab/apilog/Constants.java | 9 - .../autoconfigure/ApiLogProperties.java | 72 ---- .../devslab/apilog/model/dto/ApiRequest.java | 15 - .../devslab/apilog/model/dto/ApiResponse.java | 11 - .../devslab/apilog/service/ApiLogService.java | 146 -------- src/test/java/kr/devslab/apilog/TestApp.java | 17 - .../apilog/config/ConfigurationTest.java | 176 --------- .../apilog/listener/ApiEventListenerTest.java | 132 ------- .../repository/ApiLogRepositoryTest.java | 282 --------------- ...ctiveApiClientUtilHttpIntegrationTest.java | 280 --------------- .../RestApiClientUtilIntegrationTest.java | 94 ----- src/test/resources/application.properties | 4 - 96 files changed, 5080 insertions(+), 2455 deletions(-) delete mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 build.gradle.kts create mode 100644 core/build.gradle.kts create mode 100644 core/src/main/java/kr/devslab/apilog/Constants.java rename src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java => core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java (62%) create mode 100644 core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java rename {src => core/src}/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java (96%) rename {src => core/src}/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java (94%) rename {src => core/src}/main/java/kr/devslab/apilog/config/RetryConfig.java (69%) create mode 100644 core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java create mode 100644 core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java rename {src => core/src}/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java (67%) rename {src => core/src}/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java (68%) rename {src => core/src}/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java (71%) rename {src => core/src}/main/java/kr/devslab/apilog/listener/ApiEventListener.java (61%) create mode 100644 core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java create mode 100644 core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java create mode 100644 core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java create mode 100644 core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java rename {src => core/src}/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java (95%) rename {src => core/src}/main/java/kr/devslab/apilog/util/RestApiClientUtil.java (99%) rename {src => core/src}/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (68%) rename {src => core/src}/main/resources/db/api-log/V1.0__create_api_log.sql (69%) create mode 100644 core/src/test/java/kr/devslab/apilog/TestApp.java create mode 100644 core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java rename {src => core/src}/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java (95%) rename {src => core/src}/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java (86%) create mode 100644 docs/guides/jpa-backend.ko.md create mode 100644 docs/guides/jpa-backend.md create mode 100644 docs/guides/mybatis-backend.ko.md create mode 100644 docs/guides/mybatis-backend.md create mode 100644 docs/guides/r2dbc-backend.ko.md create mode 100644 docs/guides/r2dbc-backend.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 jpa/build.gradle.kts rename {src/main/java/kr/devslab/apilog => jpa/src/main/java/kr/devslab/apilog/jpa}/autoconfigure/ApiLogFlywayConfig.java (96%) create mode 100644 jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java rename src/main/java/kr/devslab/apilog/autoconfigure/ApiLogSchemaInitializer.java => jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaSchemaInitializer.java (88%) rename {src/main/java/kr/devslab/apilog => jpa/src/main/java/kr/devslab/apilog/jpa}/model/ApiLogEntity.java (67%) rename {src/main/java/kr/devslab/apilog => jpa/src/main/java/kr/devslab/apilog/jpa}/repository/ApiLogRepository.java (54%) create mode 100644 jpa/src/main/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriter.java create mode 100644 jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 jpa/src/test/java/kr/devslab/apilog/TestApp.java create mode 100644 jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java create mode 100644 jpa/src/test/java/kr/devslab/apilog/jpa/repository/ApiLogRepositoryTest.java rename src/test/java/kr/devslab/apilog/service/ApiLogServiceTest.java => jpa/src/test/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriterTest.java (50%) rename {src => jpa/src}/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java (77%) delete mode 100755 mvnw delete mode 100644 mvnw.cmd create mode 100644 mybatis/build.gradle.kts create mode 100644 mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java create mode 100644 mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisSchemaInitializer.java create mode 100644 mybatis/src/main/java/kr/devslab/apilog/mybatis/mapper/ApiLogMapper.java create mode 100644 mybatis/src/main/java/kr/devslab/apilog/mybatis/model/ApiLogRow.java create mode 100644 mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java create mode 100644 mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 mybatis/src/test/java/kr/devslab/apilog/TestApp.java create mode 100644 mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java delete mode 100644 pom.xml create mode 100644 r2dbc/build.gradle.kts create mode 100644 r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java create mode 100644 r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcSchemaInitializer.java create mode 100644 r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java create mode 100644 r2dbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 r2dbc/src/test/java/kr/devslab/apilog/TestApp.java create mode 100644 r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java create mode 100644 settings.gradle.kts delete mode 100644 src/main/java/kr/devslab/apilog/Constants.java delete mode 100644 src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java delete mode 100644 src/main/java/kr/devslab/apilog/model/dto/ApiRequest.java delete mode 100644 src/main/java/kr/devslab/apilog/model/dto/ApiResponse.java delete mode 100644 src/main/java/kr/devslab/apilog/service/ApiLogService.java delete mode 100644 src/test/java/kr/devslab/apilog/TestApp.java delete mode 100644 src/test/java/kr/devslab/apilog/config/ConfigurationTest.java delete mode 100644 src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java delete mode 100644 src/test/java/kr/devslab/apilog/repository/ApiLogRepositoryTest.java delete mode 100644 src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilHttpIntegrationTest.java delete mode 100644 src/test/java/kr/devslab/apilog/util/RestApiClientUtilIntegrationTest.java delete mode 100644 src/test/resources/application.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 605fd90..bd945f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@v6 @@ -26,26 +26,44 @@ jobs: with: distribution: temurin java-version: 21 - cache: maven + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-home-cache-cleanup: true + # Configuration cache occasionally trips up the Vanniktech publish + # plugin's property-driven configuration — keep CI off it. + cache-read-only: ${{ github.ref != 'refs/heads/master' }} - name: Build and test - run: ./mvnw -B verify + # `build` runs compileJava + compileTestJava + test on every subproject. + # `jacocoTestReport` is wired in each module's build.gradle.kts but + # repeating it here makes the dependency explicit for the next step. + run: ./gradlew build jacocoTestReport --no-configuration-cache --stacktrace - name: Upload coverage to Codecov if: success() && github.event_name == 'push' uses: codecov/codecov-action@v6 with: - files: ./target/site/jacoco/jacoco.xml + # Glob across all subprojects so adding a future module (e.g. + # api-log-jdbc) picks up its coverage report without another edit. + files: | + ./core/build/reports/jacoco/test/jacocoTestReport.xml + ./jpa/build/reports/jacoco/test/jacocoTestReport.xml + ./r2dbc/build/reports/jacoco/test/jacocoTestReport.xml + ./mybatis/build/reports/jacoco/test/jacocoTestReport.xml flags: unittests - fail_ci_if_error: false # don't break CI if Codecov upload glitches - token: ${{ secrets.CODECOV_TOKEN }} # optional for public repos + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test reports on failure if: failure() uses: actions/upload-artifact@v7 with: name: test-reports + # `**` so any future module's reports are also captured. path: | - target/surefire-reports/ - target/failsafe-reports/ + **/build/reports/tests/ + **/build/test-results/ + **/build/reports/jacoco/ retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b11dfc3..a830b95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ jobs: publish: runs-on: ubuntu-latest timeout-minutes: 30 + environment: maven-central steps: - name: Checkout uses: actions/checkout@v6 @@ -22,26 +23,31 @@ jobs: with: distribution: temurin java-version: 21 - cache: maven - server-id: central # matches central in settings.xml below - server-username: MAVEN_CENTRAL_USERNAME - server-password: MAVEN_CENTRAL_PASSWORD - gpg-private-key: ${{ secrets.SIGNING_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE # maven-gpg-plugin 3.x convention + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 - name: Derive version from tag id: ver run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" - - name: Set release version - run: ./mvnw -B versions:set -DnewVersion=${{ steps.ver.outputs.version }} -DgenerateBackupPoms=false + - name: Build and test + run: ./gradlew build --no-configuration-cache --stacktrace -PVERSION=${{ steps.ver.outputs.version }} - - name: Build, sign, and publish to Maven Central + - name: Publish to Maven Central env: - MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.SIGNING_KEY_PASSWORD }} - run: ./mvnw -B -P release deploy + # Sonatype Central Portal credentials (https://central.sonatype.com/account) + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + # ASCII-armored private key (`gpg --armor --export-secret-keys `) + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + run: | + ./gradlew publishAndReleaseToMavenCentral \ + --no-configuration-cache \ + --stacktrace \ + -PVERSION=${{ steps.ver.outputs.version }} - name: Create GitHub Release uses: softprops/action-gh-release@v3 @@ -49,6 +55,8 @@ jobs: generate_release_notes: true name: ${{ github.ref_name }} fail_on_unmatched_files: false + # Glob across every module's build/libs/ so the release page picks up + # api-log-core / -jpa / -r2dbc / -mybatis without per-module entries. files: | - target/*.jar - target/*.asc + **/build/libs/*.jar + **/build/libs/*.asc diff --git a/.gitignore b/.gitignore index 54d84eb..68089bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ -HELP.md -target/ -.mvn/wrapper/maven-wrapper.jar -!**/src/main/**/target/ -!**/src/test/**/target/ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ -### STS ### +# IDE - IntelliJ +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# IDE - Eclipse / STS .apt_generated .classpath .factorypath @@ -12,33 +22,47 @@ target/ .settings .springBeans .sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr +# IDE - VS Code +.vscode/ -### NetBeans ### +# NetBeans /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ -build/ -!**/src/main/**/build/ -!**/src/test/**/build/ -### VS Code ### -.vscode/ +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ -### PostgreSQL data ### +# Secrets / signing +*.gpg +secring.* +local.properties +gradle-local.properties +*.env +.env* +!.env.example + +# PostgreSQL data data/ -### Claude ### +# Claude .claude/ -### Application properties (root level duplicate) ### +# Application properties (root level duplicate; test fixtures are kept) application.properties -# Test fixtures should be tracked though !src/test/resources/application.properties + +# Test results +/test-results/ +/reports/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index c0bcafe..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,3 +0,0 @@ -wrapperVersion=3.3.4 -distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 6719876..85bc82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ The source of truth for the entries below is [docs/changelog.md](docs/changelog. ## [Unreleased] +## [0.6.0] — Multi-module split (Gradle), pluggable JPA / R2DBC / MyBatis backends + +### Changed + +- **The single `api-log-spring-boot-starter` artifact is split.** Consumers now add `kr.devslab:api-log-core` plus exactly one backend artifact: `api-log-jpa` (drop-in for v0.5.x), `api-log-r2dbc` (reactive), or `api-log-mybatis`. +- **Build system: Maven → Gradle 8.10** with Vanniktech maven-publish per module. +- **Package renames**: `model.dto` → `dto`, `model.ApiLogEntity` → `jpa.model.ApiLogEntity`, `service.ApiLogService` → `jpa.writer.JpaApiLogWriter`. Full mapping in [docs/changelog.md](docs/changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends). + +### Added + +- **`ApiLogWriter` SPI** — backend-agnostic three-method interface (`writeInitiated` / `writeSuccess` / `writeError`). Each backend artifact registers one implementation; the core listener routes events through it. +- **`api-log-r2dbc`** — reactive backend using R2DBC's `DatabaseClient`. Pure-reactive schema initializer; no JDBC pull-in. +- **`api-log-mybatis`** — MyBatis mapper backend with `::jsonb` cast on inserts. + +### Fixed + +- `V1.0__create_api_log.sql` now uses `IF NOT EXISTS` on both CREATE TABLE and CREATE INDEX — idempotent across boots under BUILTIN mode. + +Full migration notes in [docs/changelog.md](docs/changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends). + +## [0.5.2] — Fix bean registration in real consumer apps + +### Fixed + +- `RestApiClientUtil` + four `@Configuration` classes were never registered in consumer apps (relied on `@ComponentScan` reaching the starter's package). Fixed by splitting into three `@AutoConfiguration` classes registered via `META-INF/spring/.../AutoConfiguration.imports`. +- `spring-boot-starter-web` is now `true` — pure-WebFlux apps no longer get a Servlet stack forced onto their classpath. + +Full notes in [docs/changelog.md](docs/changelog.md#052--fix-bean-registration-in-real-consumer-apps). + ## [0.5.1] — Reactive (WebFlux) client + end-to-end HTTP tests ### Added @@ -75,7 +104,9 @@ See [docs/changelog.md](docs/changelog.md#020--schema-management-opt-in) for the First public release. See [docs/changelog.md](docs/changelog.md#010--initial-release) for details. -[Unreleased]: https://github.com/devslab-kr/api-log/compare/v0.5.1...HEAD +[Unreleased]: https://github.com/devslab-kr/api-log/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.6.0 +[0.5.2]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.2 [0.5.1]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.1 [0.5.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.0 [0.4.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.4.0 diff --git a/README.ko.md b/README.ko.md index a63d19c..a41ccc4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -4,7 +4,7 @@ > Spring Boot용 이벤트 드리븐 API 호출 로깅. 비동기 이벤트 파이프라인 + PostgreSQL JSONB. 요청 경로를 막지 않고 외부 API 호출을 모두 기록합니다. -[![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-spring-boot-starter.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter) +[![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-core.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/kr.devslab/api-log-core) [![CI](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml/badge.svg)](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/devslab-kr/api-log/branch/master/graph/badge.svg)](https://codecov.io/gh/devslab-kr/api-log) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) @@ -57,35 +57,64 @@ public class UserService { ``` Caller code ↓ -RestApiClientUtil (또는 자체 HTTP 클라이언트) +RestApiClientUtil / ReactiveApiClientUtil (또는 자체 HTTP 클라이언트) ↓ publishEvent ApplicationEventPublisher - ↓ @EventListener (async) -ApiEventListener - ↓ -ApiLogService - ↓ -ApiLogRepository (JPA) - ↓ -PostgreSQL (api_log · JSONB columns) + ↓ @EventListener (virtual threads) +ApiEventListener (api-log-core) + ↓ ApiLogWriter (SPI) + ├─ JpaApiLogWriter (api-log-jpa) + ├─ R2dbcApiLogWriter (api-log-r2dbc) + └─ MybatisApiLogWriter (api-log-mybatis) + ↓ + PostgreSQL (api_log · JSONB columns) ``` ## 설치 +v0.6.0부터 스타터가 4개 아티팩트로 분리됐습니다 — 백엔드 비종속 코어 1개 + +영속화 백엔드 1개. **`api-log-core` 1개 + 백엔드 1개**를 직접 골라 추가: + +| 좌표 | 언제 쓰나 | +| --- | --- | +| `kr.devslab:api-log-jpa` | Servlet / JPA 앱 (v0.5.x 드롭인) | +| `kr.devslab:api-log-r2dbc` | WebFlux / R2DBC 앱 — JDBC 의존성 없음 | +| `kr.devslab:api-log-mybatis` | 이미 MyBatis를 쓰고, JPA를 원치 않을 때 | + +백엔드 아티팩트 각각이 `api-log-core`를 transitive하게 가져오므로 +좌표 하나만 추가하면 됩니다. + ### Maven ```xml + + + kr.devslab + api-log-jpa + 0.6.0 + + + + + kr.devslab + api-log-r2dbc + 0.6.0 + + + kr.devslab - api-log-spring-boot-starter - 0.5.1 + api-log-mybatis + 0.6.0 ``` ### Gradle ```kotlin -implementation("kr.devslab:api-log-spring-boot-starter:0.5.1") +implementation("kr.devslab:api-log-jpa:0.6.0") +// 또는 "kr.devslab:api-log-r2dbc:0.6.0" +// 또는 "kr.devslab:api-log-mybatis:0.6.0" ``` ## 설정 diff --git a/README.md b/README.md index 319b446..07cc6c7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > Event-driven API call logging for Spring Boot. Async event pipeline with PostgreSQL JSONB storage. -[![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-spring-boot-starter.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter) +[![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/api-log-core.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/kr.devslab/api-log-core) [![CI](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml/badge.svg)](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/devslab-kr/api-log/branch/master/graph/badge.svg)](https://codecov.io/gh/devslab-kr/api-log) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) @@ -57,35 +57,65 @@ Bodies are stored as JSONB, so you can query them with `->`, `->>`, and GIN inde ``` Caller code ↓ -RestApiClientUtil (or your own HTTP client) +RestApiClientUtil / ReactiveApiClientUtil (or your own HTTP client) ↓ publishEvent ApplicationEventPublisher - ↓ @EventListener (async) -ApiEventListener - ↓ -ApiLogService - ↓ -ApiLogRepository (JPA) - ↓ -PostgreSQL (api_log · JSONB columns) + ↓ @EventListener (virtual threads) +ApiEventListener (api-log-core) + ↓ ApiLogWriter (SPI) + ├─ JpaApiLogWriter (api-log-jpa) + ├─ R2dbcApiLogWriter (api-log-r2dbc) + └─ MybatisApiLogWriter (api-log-mybatis) + ↓ + PostgreSQL (api_log · JSONB columns) ``` ## Installation +v0.6.0 splits the starter into four artifacts: a backend-agnostic core, plus +one of three persistence backends. Add **`api-log-core` plus exactly one +backend** to your build: + +| Coordinate | When to use it | +| --- | --- | +| `kr.devslab:api-log-jpa` | Servlet / JPA app (the v0.5.x drop-in) | +| `kr.devslab:api-log-r2dbc` | WebFlux / R2DBC app — no JDBC pull-in | +| `kr.devslab:api-log-mybatis` | Already on MyBatis, don't want JPA | + +Each backend artifact transitively depends on `api-log-core`, so one +coordinate is enough. + ### Maven ```xml + + + kr.devslab + api-log-jpa + 0.6.0 + + + + + kr.devslab + api-log-r2dbc + 0.6.0 + + + kr.devslab - api-log-spring-boot-starter - 0.3.0 + api-log-mybatis + 0.6.0 ``` ### Gradle ```kotlin -implementation("kr.devslab:api-log-spring-boot-starter:0.3.0") +implementation("kr.devslab:api-log-jpa:0.6.0") +// or "kr.devslab:api-log-r2dbc:0.6.0" +// or "kr.devslab:api-log-mybatis:0.6.0" ``` ## Configuration diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2cad94b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,18 @@ +// Multi-module orchestration. The root project is not published — each +// publishable artifact lives under its own subproject (see settings.gradle.kts) +// and applies the publishing plugin itself. +// +// Plugin versions are declared here with `apply false` so subprojects can +// apply them without repeating version numbers, and so the version drift +// between modules stays at zero. + +plugins { + id("org.springframework.boot") version "3.5.6" apply false + id("io.spring.dependency-management") version "1.1.6" apply false + id("com.vanniktech.maven.publish") version "0.30.0" apply false +} + +allprojects { + group = providers.gradleProperty("GROUP").get() + version = providers.gradleProperty("VERSION").get() +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..ad88614 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,182 @@ +// :core — backend-agnostic foundation for the api-log starter. +// +// Published as `kr.devslab:api-log-core`. Holds: +// - The event objects (ApiCallInitiatedEvent / SuccessEvent / ErrorEvent) +// - The `ApiLogWriter` SPI that backend modules implement +// - The async event listener that drives writers off the event bus +// - The HTTP client utilities (RestApiClientUtil for sync, ReactiveApiClientUtil for reactive) +// - The shared Jackson customizer (Blackbird), retry config, and properties +// +// Backend modules (`:jpa`, `:r2dbc`, `:mybatis`) depend on this and each +// register exactly one `ApiLogWriter` bean — consumers pick by adding the +// backend artifact they want. + +plugins { + `java-library` + jacoco + id("org.springframework.boot") apply false + id("io.spring.dependency-management") + id("com.vanniktech.maven.publish") +} + +base { + // On-disk jar filename. Vanniktech overrides the *publish* coordinates + // separately via `mavenPublishing.coordinates(...)`; this controls only + // the local `build/libs/*.jar` name so GitHub Release assets are readable. + archivesName.set("api-log-core") +} + +// Vanniktech's javadoc jar task hardcodes its archive base name to +// `-maven-javadoc` and only sets it inside its plugin's own +// `afterEvaluate`. Without this override the GitHub Release ends up with a +// confusing `core-maven-javadoc-X.Y.Z-javadoc.jar` next to the properly named +// main jar. Configuring inside `afterEvaluate` makes us the last writer. +afterEvaluate { + tasks.named("mavenPlainJavadocJar").configure { + archiveBaseName.set("api-log-core") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + // -parameters: keep AOP-readable param names. -Xlint enabled but the noisy + // categories (classfile/processing/serial) are excluded so -Werror stays + // usable for real code issues without tripping on annotation-processor noise. + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all,-classfile,-processing,-serial", + "-Werror" + )) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("Xdoclint:none", true) + addBooleanOption("html5", true) + locale = "en_US" + } +} + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.6") + } +} + +dependencies { + // Pulled in transitively for every consumer of any api-log-* artifact: + // spring-context + spring-boot give us @EventListener, @EnableAsync, + // ApplicationEventPublisher, @ConditionalOnProperty, etc. + api("org.springframework.boot:spring-boot-starter") + + // The listener's @Retryable wraps each persistence attempt — without + // spring-retry on the classpath consumers can't use the retry semantics. + api("org.springframework.retry:spring-retry") + // @Retryable needs Spring AOP at runtime to weave the proxy. + api("org.springframework.boot:spring-boot-starter-aop") + + // Events / payloads use Jackson directly (JsonNode in payloads, error JSON). + api("com.fasterxml.jackson.core:jackson-databind") + + // Blackbird = ~30-50% Jackson serialization speedup. The Jackson2ObjectMapperBuilderCustomizer + // we register installs it into Spring Boot's default ObjectMapper. + api("com.fasterxml.jackson.module:jackson-module-blackbird") + + // Lombok — compile + annotation-processor only. + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // Auto-configuration metadata processor. + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + // HTTP client API surfaces — `compileOnly` because consumers may have + // either, both, or neither. The corresponding @AutoConfigurations gate + // themselves with @ConditionalOnClass so absence is silent. + compileOnly("org.springframework.boot:spring-boot-starter-web") + compileOnly("org.springframework:spring-webflux") + compileOnly("io.projectreactor.netty:reactor-netty-http") + + // Silences cosmetic "cannot find javax.annotation.Nonnull" warnings from + // resolving Spring's @Nullable. Not exposed to consumers. + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-web") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.assertj:assertj-core") + + // MockWebServer drives the HTTP-client utils against a real socket. + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + + // Explicit launcher pin. JUnit Jupiter 5.11+ requires junit-platform-launcher + // >= 1.11; Gradle 8.10 still bundles 1.10.x. Without this declaration the + // BOM's 1.11 doesn't make it onto the test runtime classpath. + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } + systemProperty("file.encoding", "UTF-8") + finalizedBy(tasks.jacocoTestReport) +} + +jacoco { + toolVersion = "0.8.13" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +mavenPublishing { + coordinates( + providers.gradleProperty("GROUP").get(), + "api-log-core", + providers.gradleProperty("VERSION").get() + ) + + pom { + name.set("API Log Spring Boot Starter - Core") + description.set("Backend-agnostic core for api-log: events, SPI, async listener, HTTP client utilities. Pair with api-log-jpa / api-log-r2dbc / api-log-mybatis.") + + developers { + developer { + id.set(providers.gradleProperty("POM_DEVELOPER_ID")) + name.set(providers.gradleProperty("POM_DEVELOPER_NAME")) + url.set(providers.gradleProperty("POM_DEVELOPER_URL")) + email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL")) + organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + } + + organization { + name.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + url.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + + issueManagement { + system.set(providers.gradleProperty("POM_ISSUE_SYSTEM")) + url.set(providers.gradleProperty("POM_ISSUE_URL")) + } + } +} diff --git a/core/src/main/java/kr/devslab/apilog/Constants.java b/core/src/main/java/kr/devslab/apilog/Constants.java new file mode 100644 index 0000000..44b5727 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/Constants.java @@ -0,0 +1,19 @@ +package kr.devslab.apilog; + +/** + * Shared string constants for the {@code event_type} column written to + * {@code api_log}. Kept as plain string constants (rather than an enum) so + * downstream queries — e.g., {@code WHERE event_type = 'SUCCESS'} from a BI + * tool — match what the application writes. + */ +public final class Constants { + + public static final String INITIATED = "INITIATED"; + public static final String SUCCESS = "SUCCESS"; + public static final String RETRY_ERROR = "RETRY_ERROR"; + public static final String ERROR = "ERROR"; + + private Constants() { + // utility class — no instances + } +} diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java similarity index 62% rename from src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java rename to core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java index 5c4ee4f..630b435 100644 --- a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java @@ -2,85 +2,76 @@ import kr.devslab.apilog.config.RetryConfig; import kr.devslab.apilog.listener.ApiEventListener; -import kr.devslab.apilog.repository.ApiLogRepository; -import kr.devslab.apilog.service.ApiLogService; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.PayloadJsonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.module.blackbird.BlackbirdModule; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.VirtualThreadTaskExecutor; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import javax.sql.DataSource; - /** - * Core auto-configuration — always loads when the starter is on the classpath. + * Backend-agnostic core auto-configuration. Loads whenever the starter is on + * the classpath; registers everything that doesn't depend on a persistence + * backend. * - *

Wires the event pipeline (service, listener), schema initializer, async - * executor, and a BlackbirdModule-equipped {@link ObjectMapper}. Does not - * register either HTTP client — those live in - * {@link RestApiClientAutoConfiguration} (Web/Servlet) and - * {@link ReactiveApiClientAutoConfiguration} (WebFlux), each gated by - * {@code @ConditionalOnClass} so consumers only pay for what's on their - * classpath. + *

Wires the async event listener, the {@link PayloadJsonMapper} helper, the + * Blackbird-enabled Jackson customizer, the retry config, and a virtual-thread + * / platform-thread executor for the listener. + * + *

The actual {@link ApiLogWriter} bean comes from whichever backend module + * the consumer added — {@code api-log-jpa}, {@code api-log-r2dbc}, or + * {@code api-log-mybatis}. {@link ApiEventListener} is gated on + * {@code @ConditionalOnBean(ApiLogWriter.class)} so missing-backend setups + * fail loudly at config time rather than at first event. */ @AutoConfiguration -@ConditionalOnClass({ApiEventListener.class, ApiLogService.class}) +@ConditionalOnClass({ApiEventListener.class, ApiLogWriter.class}) @EnableConfigurationProperties(ApiLogProperties.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) -@EntityScan(basePackages = "kr.devslab.apilog.model") -@EnableJpaRepositories(basePackages = "kr.devslab.apilog.repository") -@Import({RetryConfig.class, ApiLogFlywayConfig.class}) +@Import(RetryConfig.class) @EnableAsync -public class ApiLogAutoConfiguration { +public class ApiLogCoreAutoConfiguration { + /** + * Shared JSON helper used by every backend writer. Lifted out of the old + * {@code ApiLogService} so backend modules don't each re-implement it. + */ @Bean @ConditionalOnMissingBean @ConditionalOnBean(ObjectMapper.class) - public ApiLogService apiLogService(ApiLogRepository repository, ObjectMapper objectMapper) { - return new ApiLogService(repository, objectMapper); - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnBean(ApiLogService.class) - public ApiEventListener apiEventListener(ApiLogService apiLogService) { - return new ApiEventListener(apiLogService); + public PayloadJsonMapper apiLogPayloadJsonMapper(ObjectMapper objectMapper) { + return new PayloadJsonMapper(objectMapper); } /** - * Creates the api_log table at startup when the consumer hasn't picked a - * different management strategy (the default — BUILTIN). The CREATE TABLE - * statements use IF NOT EXISTS, so this is idempotent and safe to re-run - * on every boot. + * Event-bus listener that routes events to the consumer's chosen + * {@link ApiLogWriter}. Only registered when a writer bean is present — + * makes "consumer added api-log-core but forgot to add a backend" a clear + * "no qualifying bean of type ApiLogWriter" failure instead of silently + * dropping events. */ @Bean @ConditionalOnMissingBean - @ConditionalOnProperty( - prefix = "api.log.schema", - name = "management", - havingValue = "builtin", - matchIfMissing = true - ) - public ApiLogSchemaInitializer apiLogSchemaInitializer(DataSource dataSource) { - return new ApiLogSchemaInitializer(dataSource); + @ConditionalOnBean(ApiLogWriter.class) + public ApiEventListener apiEventListener(ApiLogWriter writer) { + return new ApiEventListener(writer); } /** * Adds the Blackbird module to Spring Boot's auto-configured * {@code ObjectMapper} — ~30-50% Jackson serialization speedup, which - * matters because every API call writes JSONB payloads to {@code api_log}. + * matters because every API call writes JSON payloads to {@code api_log}. * *

Using {@link Jackson2ObjectMapperBuilderCustomizer} (rather than * defining our own {@code @Primary ObjectMapper} bean) keeps Spring Boot's diff --git a/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java new file mode 100644 index 0000000..8a5e32a --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java @@ -0,0 +1,87 @@ +package kr.devslab.apilog.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Top-level configuration namespace for the api-log starter. + * + *

v0.6.0 — the {@code schema.management} property still applies to the + * JPA / MyBatis backends (both run a JDBC initializer). The R2DBC backend has + * its own toggle ({@code api.log.r2dbc.schema.enabled}) because reactive + * initialization runs against a {@code ConnectionFactory} rather than a + * {@code DataSource}. + */ +@ConfigurationProperties(prefix = "api.log") +public class ApiLogProperties { + + /** + * Master switch — when false no api-log beans are registered (listener, + * writer, schema initializer, HTTP utilities). Default: true. + */ + private boolean enabled = true; + + /** + * How the {@code api_log} table's schema is provisioned. See {@link Schema}. + * Applies to the JPA + MyBatis backends. The R2DBC backend uses its own + * reactive initializer keyed off {@code api.log.r2dbc.schema.enabled}. + */ + private Schema schema = new Schema(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Schema getSchema() { + return schema; + } + + public void setSchema(Schema schema) { + this.schema = schema; + } + + public static class Schema { + /** + * Schema management strategy for the {@code api_log} table. + * + *

    + *
  • BUILTIN (default) — the starter runs the bundled DDL on + * application startup. The SQL uses {@code IF NOT EXISTS}, so it's + * idempotent and safe to leave on every boot. + * Use this if you don't have (or don't want) Flyway / Liquibase + * in your project.
  • + *
  • NONE — the starter does not touch the schema. Apply the + * DDL yourself (see api-log.devslab.kr/reference/schema). + * Use this if your team's policy is that third-party libraries + * must never touch the schema.
  • + *
  • FLYWAY — the starter registers a + * {@code FlywayConfigurationCustomizer} that appends + * {@code classpath:db/api-log} to Flyway's locations, so the + * bundled {@code V1.0__create_api_log.sql} runs alongside your + * own migrations and gets recorded in + * {@code flyway_schema_history}. Requires + * {@code org.flywaydb:flyway-core} on the classpath (the starter + * declares it as optional, so the consumer must add it). Only + * applies when the JPA backend is in use.
  • + *
+ */ + private Management management = Management.BUILTIN; + + public Management getManagement() { + return management; + } + + public void setManagement(Management management) { + this.management = management; + } + + public enum Management { + BUILTIN, + NONE, + FLYWAY + } + } +} diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java similarity index 96% rename from src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java rename to core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java index 294be63..d20a6a6 100644 --- a/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java @@ -22,7 +22,7 @@ * codecs, etc.) flow through. Provide your own {@link ReactiveApiClientUtil} * bean to fully replace the wiring. */ -@AutoConfiguration(after = ApiLogAutoConfiguration.class) +@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) @ConditionalOnClass(WebClient.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) public class ReactiveApiClientAutoConfiguration { diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java similarity index 94% rename from src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java rename to core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java index 2eeb00c..4866396 100644 --- a/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java @@ -28,8 +28,8 @@ *
    *
  • {@link ClientHttpRequestFactory} with configurable timeouts * ({@code rest.client.connect-timeout}, {@code rest.client.read-timeout})
  • - *
  • {@link MappingJackson2HttpMessageConverter} using the core - * {@code apiLogObjectMapper} (Blackbird-enabled)
  • + *
  • {@link MappingJackson2HttpMessageConverter} using the Spring Boot + * {@link ObjectMapper} (Blackbird-enabled by the core customizer)
  • *
  • {@link RestClient} with the converter wired in, optional base URL via * {@code rest.client.base-url}
  • *
  • {@link RestApiClientUtil} — the actual API surface consumers inject
  • @@ -38,7 +38,7 @@ *

    Each bean is {@link ConditionalOnMissingBean} so the consumer can swap any * piece (e.g., provide their own {@code RestClient} with auth headers). */ -@AutoConfiguration(after = ApiLogAutoConfiguration.class) +@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) @ConditionalOnClass(RestClient.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) public class RestApiClientAutoConfiguration { diff --git a/src/main/java/kr/devslab/apilog/config/RetryConfig.java b/core/src/main/java/kr/devslab/apilog/config/RetryConfig.java similarity index 69% rename from src/main/java/kr/devslab/apilog/config/RetryConfig.java rename to core/src/main/java/kr/devslab/apilog/config/RetryConfig.java index 949cbaf..2f83845 100644 --- a/src/main/java/kr/devslab/apilog/config/RetryConfig.java +++ b/core/src/main/java/kr/devslab/apilog/config/RetryConfig.java @@ -1,9 +1,9 @@ package kr.devslab.apilog.config; +import kr.devslab.apilog.dto.ApiRequest; import kr.devslab.apilog.event.ApiCallErrorEvent; import kr.devslab.apilog.event.ApiCallInitiatedEvent; import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.dto.ApiRequest; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +12,15 @@ import org.springframework.retry.RetryListener; import org.springframework.retry.annotation.EnableRetry; +/** + * Enables Spring Retry across the api-log starter and registers a + * {@link RetryListener} that re-publishes a {@link ApiCallErrorEvent} marked + * {@code isRetry=true} on every failed attempt past the first, so each retry + * is recorded as its own {@code RETRY_ERROR} row in {@code api_log}. + * + *

    Imported by {@code ApiLogCoreAutoConfiguration} so consumers don't need to + * declare {@code @EnableRetry} on their own {@code @SpringBootApplication}. + */ @Configuration @EnableRetry public class RetryConfig { @@ -30,7 +39,7 @@ public boolean open(RetryContext context, RetryCallback @Override public void onSuccess(RetryContext context, RetryCallback callback, T result) { - // 성공 시 추가 작업 없음 + // no-op } @Override @@ -46,19 +55,19 @@ public void onError(RetryContext context, RetryCallback @Override public void close(RetryContext context, RetryCallback callback, Throwable throwable) { - // 종료 시 추가 작업 없음 + // no-op } private ApiRequest extractRequest(Object event) { - if (event instanceof ApiCallInitiatedEvent) { - return ((ApiCallInitiatedEvent) event).getRequest(); - } else if (event instanceof ApiCallSuccessEvent) { - return ((ApiCallSuccessEvent) event).getRequest(); - } else if (event instanceof ApiCallErrorEvent) { - return ((ApiCallErrorEvent) event).getRequest(); + if (event instanceof ApiCallInitiatedEvent initiated) { + return initiated.getRequest(); + } else if (event instanceof ApiCallSuccessEvent success) { + return success.getRequest(); + } else if (event instanceof ApiCallErrorEvent error) { + return error.getRequest(); } return null; } }; } -} \ No newline at end of file +} diff --git a/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java b/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java new file mode 100644 index 0000000..b5c2a71 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java @@ -0,0 +1,27 @@ +package kr.devslab.apilog.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + +/** + * Caller-supplied request descriptor handed to the HTTP client utilities and + * carried across the event pipeline. + * + *

    {@code requestId} defaults to a fresh UUID so each call has a unique + * correlation key in {@code api_log}. Override it when multiple calls form a + * logical group (e.g., a retry sequence) and you want them to share an id. + * + *

    v0.6.0 note — moved from {@code kr.devslab.apilog.model.dto} as part of + * the multi-module split (the {@code model/} package now belongs to the + * backend modules, which carry their own entity types). + */ +@Getter +@Builder +public class ApiRequest { + @Builder.Default + private final String requestId = UUID.randomUUID().toString(); + private final String payload; + private final String endpoint; +} diff --git a/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java b/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java new file mode 100644 index 0000000..219b9c7 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java @@ -0,0 +1,16 @@ +package kr.devslab.apilog.dto; + +import lombok.Builder; +import lombok.Getter; + +/** + * Wrapper for the response body + HTTP status code emitted by the HTTP client + * utilities. Stored verbatim in {@code api_log.response} (body) + + * {@code api_log.status_code} on success. + */ +@Getter +@Builder +public class ApiResponse { + private final String data; + private final int statusCode; +} diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java similarity index 67% rename from src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java rename to core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java index e5d122d..6f18385 100644 --- a/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java +++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java @@ -1,11 +1,19 @@ package kr.devslab.apilog.event; -import kr.devslab.apilog.model.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiRequest; import lombok.Getter; import org.springframework.context.ApplicationEvent; import java.time.LocalDateTime; +/** + * Fired when an HTTP call fails (exception thrown by RestClient / WebClient, + * or a non-2xx response surfaced as an exception). + * + *

    {@code retryCount} + {@code isRetry} let downstream consumers distinguish + * a first-attempt failure ({@code ERROR}) from a retry failure + * ({@code RETRY_ERROR}). + */ @Getter public class ApiCallErrorEvent extends ApplicationEvent { private final ApiRequest request; @@ -22,4 +30,4 @@ public ApiCallErrorEvent(Object source, ApiRequest request, Throwable error, int this.retryCount = retryCount; this.isRetry = isRetry; } -} \ No newline at end of file +} diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java similarity index 68% rename from src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java rename to core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java index a6755f3..c4b819a 100644 --- a/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java +++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java @@ -1,12 +1,16 @@ package kr.devslab.apilog.event; -import kr.devslab.apilog.model.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiRequest; import lombok.Getter; -import lombok.Setter; import org.springframework.context.ApplicationEvent; import java.time.LocalDateTime; +/** + * Fired just before an outbound HTTP call leaves the client. The listener + * persists an {@code INITIATED} row so the call is traceable even if the + * response never arrives. + */ @Getter public class ApiCallInitiatedEvent extends ApplicationEvent { private final ApiRequest request; @@ -18,4 +22,4 @@ public ApiCallInitiatedEvent(Object source, ApiRequest request) { this.eventTimestamp = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java similarity index 71% rename from src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java rename to core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java index 5fdea28..3120706 100644 --- a/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java +++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java @@ -1,12 +1,16 @@ package kr.devslab.apilog.event; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import lombok.Getter; import org.springframework.context.ApplicationEvent; import java.time.LocalDateTime; +/** + * Fired after a successful (2xx) HTTP response. The listener persists a + * {@code SUCCESS} row carrying the response body + status code. + */ @Getter public class ApiCallSuccessEvent extends ApplicationEvent { private final ApiRequest request; @@ -20,4 +24,4 @@ public ApiCallSuccessEvent(Object source, ApiRequest request, ApiResponse respon this.eventTimestamp = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java similarity index 61% rename from src/main/java/kr/devslab/apilog/listener/ApiEventListener.java rename to core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java index 2220e04..a0b49d8 100644 --- a/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java +++ b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java @@ -3,28 +3,47 @@ import kr.devslab.apilog.event.ApiCallErrorEvent; import kr.devslab.apilog.event.ApiCallInitiatedEvent; import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.service.ApiLogService; +import kr.devslab.apilog.spi.ApiLogWriter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +/** + * Drives the {@link ApiLogWriter} off the application event bus. + * + *

    v0.6.0 change — this used to call {@code ApiLogService} directly (JPA-only). + * It now talks to whichever {@link ApiLogWriter} the consumer's chosen backend + * registered ({@code api-log-jpa} → JpaApiLogWriter, + * {@code api-log-r2dbc} → R2dbcApiLogWriter, + * {@code api-log-mybatis} → MybatisApiLogWriter). The listener stays + * backend-agnostic; routing happens by which jar is on the classpath. + * + *

    {@code @Retryable} wraps each write in up to three attempts with 1s + * backoff so transient persistence failures (connection blips, dead pool + * connection on first use) don't drop a log row. Caught exceptions are logged, + * never rethrown — losing one {@code api_log} row must never break the actual + * outbound API call. + * + *

    Transaction semantics are intentionally not declared here. The JPA + * writer wraps its own {@code save()} in {@code REQUIRES_NEW} (so the log + * write doesn't pollute the consumer's surrounding tx); the R2DBC and MyBatis + * writers do the equivalent inside their own implementations. Keeping the + * tx boundary inside the writer lets each backend pick the semantics that + * make sense (e.g., R2DBC has no @Transactional support out of the box). + */ @Slf4j -@Component @RequiredArgsConstructor public class ApiEventListener { - private final ApiLogService apiLogService; + + private final ApiLogWriter writer; @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallInitiated(ApiCallInitiatedEvent event) { try { - apiLogService.saveApiCallInitiated(event); + writer.writeInitiated(event); log.debug("Saved API Call Initiated: RequestId={}, Endpoint={}", event.getRequest().getRequestId(), event.getRequest().getEndpoint()); } catch (Exception e) { @@ -34,11 +53,10 @@ public void handleApiCallInitiated(ApiCallInitiatedEvent event) { } @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallSuccess(ApiCallSuccessEvent event) { try { - apiLogService.saveApiCallSuccess(event); + writer.writeSuccess(event); log.debug("Saved API Call Success: RequestId={}, Endpoint={}, Status={}", event.getRequest().getRequestId(), event.getRequest().getEndpoint(), event.getResponse().getStatusCode()); @@ -49,11 +67,10 @@ public void handleApiCallSuccess(ApiCallSuccessEvent event) { } @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallError(ApiCallErrorEvent event) { try { - apiLogService.saveApiCallError(event); + writer.writeError(event); log.info("Saved API Call {}: RequestId={}, Endpoint={}, RetryCount={}", event.isRetry() ? "Retry Error" : "Error", event.getRequest().getRequestId(), event.getRequest().getEndpoint(), @@ -64,4 +81,4 @@ public void handleApiCallError(ApiCallErrorEvent event) { event.getRequest().getRequestId(), e.getMessage(), e); } } -} \ No newline at end of file +} diff --git a/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java b/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java new file mode 100644 index 0000000..c744e38 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java @@ -0,0 +1,40 @@ +package kr.devslab.apilog.spi; + +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; + +/** + * Backend-agnostic SPI that each {@code api-log-*} persistence module implements. + * + *

    One {@code ApiLogWriter} bean is expected per application context — provided + * by the backend artifact the consumer chose (see {@code api-log-jpa}, + * {@code api-log-r2dbc}, {@code api-log-mybatis}). The core listener + * ({@code ApiEventListener}) routes every event through the registered writer + * so the wire format (events) stays backend-independent. + * + *

    Append-only semantics. Every call writes a new row keyed by an + * auto-generated {@code id}. The same {@code request_id} can show up multiple + * times — once for {@code INITIATED}, once for {@code SUCCESS} or {@code ERROR}, + * and once per {@code RETRY_ERROR} — because the table is a chronological log, + * not a state machine. + * + *

    Threading. Calls arrive on the executor configured in {@code :core} + * (virtual threads by default; falls back to a platform thread pool). Reactive + * implementations may subscribe inline — the listener does not consume any + * returned reactive type, so writers own their own subscription lifecycle. + */ +public interface ApiLogWriter { + + /** Persist an {@code INITIATED} row when a request leaves the client. */ + void writeInitiated(ApiCallInitiatedEvent event); + + /** Persist a {@code SUCCESS} row when a 2xx response arrives. */ + void writeSuccess(ApiCallSuccessEvent event); + + /** + * Persist an {@code ERROR} or {@code RETRY_ERROR} row when the call fails. + * The {@code event_type} is selected from {@link ApiCallErrorEvent#isRetry()}. + */ + void writeError(ApiCallErrorEvent event); +} diff --git a/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java new file mode 100644 index 0000000..cfcd0a3 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java @@ -0,0 +1,51 @@ +package kr.devslab.apilog.spi; + +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientResponseException; + +/** + * Pulls HTTP status + response body off a thrown exception so backend writers + * can record them into {@code api_log.status_code} / {@code api_log.error_message}. + * + *

    Direct {@code instanceof} works for the Spring Web (blocking) hierarchy — + * we depend on {@code spring-web} via {@code compileOnly}, so the symbols + * resolve when consumers have it on their classpath. For Spring WebFlux's + * {@link RestClientResponseException} cousins ({@code WebClientResponseException} + * and its concrete subclasses), we duck-type via reflection because + * {@code spring-webflux} is also optional and we don't want to force its + * classpath presence just to identify it. + * + *

    Used by every backend writer ({@code JpaApiLogWriter}, + * {@code R2dbcApiLogWriter}, {@code MybatisApiLogWriter}). Kept stateless + + * thread-safe so it can be invoked from any async/reactive context. + */ +public final class HttpErrorExtractor { + + private HttpErrorExtractor() { + // utility class — no instances + } + + public static HttpErrorInfo extract(Throwable error) { + if (error instanceof HttpStatusCodeException ex) { + return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString()); + } + if (error instanceof RestClientResponseException ex) { + return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString()); + } + // Match WebClientResponseException + its concrete subclasses + // (NotFound, BadRequest, etc.) by package prefix so unrelated + // exceptions that happen to share method names don't get matched. + if (error.getClass().getName() + .startsWith("org.springframework.web.reactive.function.client.WebClientResponseException")) { + try { + Object status = error.getClass().getMethod("getStatusCode").invoke(error); + Integer statusValue = (Integer) status.getClass().getMethod("value").invoke(status); + Object body = error.getClass().getMethod("getResponseBodyAsString").invoke(error); + return new HttpErrorInfo(statusValue, body == null ? null : body.toString()); + } catch (ReflectiveOperationException ignored) { + // Shape didn't match — fall through to EMPTY. + } + } + return HttpErrorInfo.EMPTY; + } +} diff --git a/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java new file mode 100644 index 0000000..a291581 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java @@ -0,0 +1,12 @@ +package kr.devslab.apilog.spi; + +/** + * HTTP error metadata pulled off a thrown exception by {@link HttpErrorExtractor}. + * Both fields may be {@code null} when the exception isn't an HTTP error + * carrier (e.g., a timeout or network error before the response landed). + */ +public record HttpErrorInfo(Integer statusCode, String responseBody) { + + /** Sentinel for the no-HTTP-context case — avoids spraying null checks at call sites. */ + public static final HttpErrorInfo EMPTY = new HttpErrorInfo(null, null); +} diff --git a/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java b/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java new file mode 100644 index 0000000..99c5d62 --- /dev/null +++ b/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java @@ -0,0 +1,80 @@ +package kr.devslab.apilog.spi; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Shared JSON conversion used by every backend writer when building rows for + * the {@code payload} / {@code response} / {@code error_message} JSONB columns. + * + *

    The two methods cover the two patterns every writer needs: + *

      + *
    • {@link #toJsonNode(String)} — turn a string body into a {@code JsonNode}. + * Falls back to {@code { "raw": "..." }} when parsing fails so non-JSON + * payloads still land in the column intact.
    • + *
    • {@link #buildErrorJson(Throwable, String)} — build the structured + * {@code error_message} shape: + *
      { "type": "<fqcn>", "message": "<exception message>" [, "responseBody": "..."] }
      + * The {@code responseBody} field only appears when {@link HttpErrorExtractor} + * found one — saves a few bytes per row for non-HTTP failures.
    • + *
    + */ +public final class PayloadJsonMapper { + + private final ObjectMapper objectMapper; + + public PayloadJsonMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public JsonNode toJsonNode(String data) { + if (data == null) { + return objectMapper.createObjectNode(); + } + try { + return objectMapper.readTree(data); + } catch (Exception e) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("raw", data); + return node; + } + } + + public JsonNode buildErrorJson(Throwable error, String responseBody) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("type", error.getClass().getName()); + node.put("message", error.getMessage()); + if (responseBody != null && !responseBody.isEmpty()) { + node.put("responseBody", responseBody); + } + return node; + } + + /** + * String form of {@link #toJsonNode(String)} — JSON canonical form for + * backends (R2DBC, MyBatis) that store the column as text rather than as + * Jackson's {@code JsonNode}. + */ + public String toJsonString(String data) { + try { + return objectMapper.writeValueAsString(toJsonNode(data)); + } catch (Exception e) { + // Should be impossible — toJsonNode always returns a valid JsonNode + // and ObjectMapper.writeValueAsString of one can't throw a parse error. + // Wrap as RuntimeException so the call site doesn't need to declare. + throw new IllegalStateException("Failed to serialize JsonNode to String", e); + } + } + + /** + * Convenience for backends that store {@code error_message} as JSON text. + */ + public String buildErrorJsonString(Throwable error, String responseBody) { + try { + return objectMapper.writeValueAsString(buildErrorJson(error, responseBody)); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize error JsonNode to String", e); + } + } +} diff --git a/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java similarity index 95% rename from src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java rename to core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java index c99977e..37e5f5c 100644 --- a/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java +++ b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java @@ -1,10 +1,10 @@ package kr.devslab.apilog.util; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import kr.devslab.apilog.event.ApiCallErrorEvent; import kr.devslab.apilog.event.ApiCallInitiatedEvent; import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.ApplicationEventPublisher; @@ -27,9 +27,10 @@ * {@code api_log} rows asynchronously — no reactor-vs-Spring-AOP friction * because the event publish is fire-and-forget. * - *

    Registered automatically when {@code org.springframework:spring-webflux} is - * on the classpath (the starter declares it as optional); see - * {@code ReactiveApiClientConfig}. + *

    Registered automatically by + * {@link kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration} + * when {@code org.springframework:spring-webflux} is on the classpath (the + * starter declares it as optional). */ public class ReactiveApiClientUtil { diff --git a/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java similarity index 99% rename from src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java rename to core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java index f06820b..f6ded6e 100644 --- a/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java +++ b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java @@ -1,10 +1,10 @@ package kr.devslab.apilog.util; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import kr.devslab.apilog.event.ApiCallErrorEvent; import kr.devslab.apilog.event.ApiCallInitiatedEvent; import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.ApplicationEventPublisher; diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 68% rename from src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 679bb13..c4d22b2 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,3 @@ -kr.devslab.apilog.autoconfigure.ApiLogAutoConfiguration +kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration kr.devslab.apilog.autoconfigure.RestApiClientAutoConfiguration kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration diff --git a/src/main/resources/db/api-log/V1.0__create_api_log.sql b/core/src/main/resources/db/api-log/V1.0__create_api_log.sql similarity index 69% rename from src/main/resources/db/api-log/V1.0__create_api_log.sql rename to core/src/main/resources/db/api-log/V1.0__create_api_log.sql index e00ef02..3e8134a 100644 --- a/src/main/resources/db/api-log/V1.0__create_api_log.sql +++ b/core/src/main/resources/db/api-log/V1.0__create_api_log.sql @@ -1,4 +1,4 @@ -CREATE TABLE api_log +CREATE TABLE IF NOT EXISTS api_log ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(50) NOT NULL, @@ -13,5 +13,5 @@ CREATE TABLE api_log is_retry BOOLEAN DEFAULT FALSE ); -CREATE INDEX idx_request_id ON api_log (request_id); -CREATE INDEX idx_timestamp ON api_log (timestamp); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_request_id ON api_log (request_id); +CREATE INDEX IF NOT EXISTS idx_timestamp ON api_log (timestamp); diff --git a/core/src/test/java/kr/devslab/apilog/TestApp.java b/core/src/test/java/kr/devslab/apilog/TestApp.java new file mode 100644 index 0000000..460d7de --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/TestApp.java @@ -0,0 +1,14 @@ +package kr.devslab.apilog; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Bootstrap class for {@code @SpringBootTest} in the :core module. + * + *

    The library itself is not a Spring Boot application, so it ships no + * {@code @SpringBootApplication} of its own. Tests need one for context + * lookup — this empty class satisfies that requirement. + */ +@SpringBootApplication +public class TestApp { +} diff --git a/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java b/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java new file mode 100644 index 0000000..73ea4f5 --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java @@ -0,0 +1,116 @@ +package kr.devslab.apilog.listener; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.spi.ApiLogWriter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +/** + * Verifies the listener routes events through whatever {@link ApiLogWriter} + * bean is injected — the v0.6.0 SPI seam. The writer is a {@link RecordingWriter} + * that captures calls; tests assert ordering / arguments / fault-tolerance. + * + *

    The {@code @Retryable} behavior is exercised in a separate Spring-context + * test in the {@code :jpa} module — here we only need the writer wiring to + * work, so no Spring context. + */ +class ApiEventListenerTest { + + private RecordingWriter writer; + private ApiEventListener listener; + + @BeforeEach + void setUp() { + writer = new RecordingWriter(); + listener = new ApiEventListener(writer); + } + + @Test + void initiatedEvent_routesToWriter() { + ApiRequest request = ApiRequest.builder() + .endpoint("/x") + .payload("{}") + .requestId("r-1") + .build(); + + listener.handleApiCallInitiated(new ApiCallInitiatedEvent(this, request)); + + assertThat(writer.initiated).hasSize(1); + assertThat(writer.initiated.get(0).getRequest().getRequestId()).isEqualTo("r-1"); + } + + @Test + void successEvent_routesToWriter() { + ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-2").build(); + ApiResponse response = ApiResponse.builder().statusCode(201).data("{}").build(); + + listener.handleApiCallSuccess(new ApiCallSuccessEvent(this, request, response)); + + assertThat(writer.success).hasSize(1); + assertThat(writer.success.get(0).getResponse().getStatusCode()).isEqualTo(201); + } + + @Test + void errorEvent_routesToWriter_andCarriesRetryFlag() { + ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-3").build(); + RuntimeException boom = new RuntimeException("boom"); + + listener.handleApiCallError(new ApiCallErrorEvent(this, request, boom, 2, true)); + + assertThat(writer.errors).hasSize(1); + assertThat(writer.errors.get(0).isRetry()).isTrue(); + assertThat(writer.errors.get(0).getRetryCount()).isEqualTo(2); + } + + @Test + void writerThrows_listenerSwallows_soOutboundCallIsNotBroken() { + // The whole point of catching inside the listener: losing one audit row + // must never propagate up and break the consumer's outbound API call. + ApiLogWriter exploding = mock(ApiLogWriter.class); + doThrow(new RuntimeException("db down")) + .when(exploding).writeSuccess(org.mockito.ArgumentMatchers.any()); + + ApiEventListener fragileListener = new ApiEventListener(exploding); + ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-4").build(); + ApiResponse response = ApiResponse.builder().statusCode(200).build(); + + // Must not throw. + fragileListener.handleApiCallSuccess(new ApiCallSuccessEvent(this, request, response)); + } + + // ------------------------------------------------------------------ // + // Test doubles // + // ------------------------------------------------------------------ // + + static class RecordingWriter implements ApiLogWriter { + final List initiated = new ArrayList<>(); + final List success = new ArrayList<>(); + final List errors = new ArrayList<>(); + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + initiated.add(event); + } + + @Override + public void writeSuccess(ApiCallSuccessEvent event) { + success.add(event); + } + + @Override + public void writeError(ApiCallErrorEvent event) { + errors.add(event); + } + } +} diff --git a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java similarity index 95% rename from src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java rename to core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java index e77140d..88f930a 100644 --- a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java +++ b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java @@ -1,7 +1,7 @@ package kr.devslab.apilog.util; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; @@ -95,10 +95,6 @@ void sendCore_respectsCallerProvidedRequestId() { record TestUser(String name, String email) {} - /** - * Captures {@code send()} arguments so the verb routing can be asserted - * without a real WebClient. - */ static class RecordingReactiveClient extends ReactiveApiClientUtil { HttpMethod lastMethod; ApiRequest lastRequest; diff --git a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java similarity index 86% rename from src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java rename to core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java index 25039ce..f89af7c 100644 --- a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java +++ b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java @@ -1,19 +1,17 @@ package kr.devslab.apilog.util; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Verifies that the v0.5.0 convenience methods (PUT, DELETE, PATCH plus the + * Verifies that the convenience methods (PUT, DELETE, PATCH plus the * pre-existing GET/POST wrappers) route through the core {@code send*} methods * with the correct {@link HttpMethod} and a properly-built {@link ApiRequest}. * @@ -45,7 +43,6 @@ void postSyncTyped_serializesObjectBodyToJson() { var body = new TestUser("Ada", "ada@example.com"); util.postSyncTyped("/users", body, TestUser.class); assertThat(util.lastMethod).isEqualTo(HttpMethod.POST); - // Body went through Jackson, came out as JSON. assertThat(util.lastRequest.getPayload()).contains("\"name\":\"Ada\""); assertThat(util.lastRequest.getPayload()).contains("\"email\":\"ada@example.com\""); } @@ -94,7 +91,6 @@ void patchSyncTyped_routesToPatchWithSerializedBody() { @Test void sendCore_respectsCallerProvidedRequestId() { - // The whole point of the v0.5.0 core API: same requestId across retries. RecordingClient util = new RecordingClient(); ApiRequest req = ApiRequest.builder() .endpoint("/users") @@ -109,8 +105,6 @@ void sendCore_respectsCallerProvidedRequestId() { void getAsync_routesToGet() { RecordingClient util = new RecordingClient(); CompletableFuture future = util.getAsync("/users/1"); - // The CompletableFuture.supplyAsync wraps the call, but INITIATED fires synchronously. - // Our recording stub completes the future immediately. future.join(); assertThat(util.lastMethod).isEqualTo(HttpMethod.GET); } @@ -126,12 +120,6 @@ void deleteAsync_routesToDelete() { // Test fixtures // ------------------------------------------------------------------------- - /** - * Subclass that overrides the four core send methods to record the - * {@code (HttpMethod, ApiRequest)} pair instead of doing any HTTP I/O. - * The constructor passes nulls for the collaborators we don't use; only the - * ObjectMapper is real because {@code serialize()} runs at the wrapper level. - */ static class RecordingClient extends RestApiClientUtil { HttpMethod lastMethod; ApiRequest lastRequest; diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md index ad74dbb..7fc4227 100644 --- a/docs/changelog.ko.md +++ b/docs/changelog.ko.md @@ -6,6 +6,77 @@ ## [Unreleased] +## [0.6.0] — 멀티모듈 분리 (Gradle), JPA / R2DBC / MyBatis 백엔드 선택 지원 + +### Changed + +- **단일 `api-log-spring-boot-starter` 아티팩트가 분리됐습니다.** 이제는 멀티모듈 Gradle 빌드입니다. 사용자는 `api-log-core` + 백엔드 1개를 직접 골라 추가: + + | 아티팩트 (Maven 좌표) | 역할 | + | --- | --- | + | `kr.devslab:api-log-core` | 이벤트, SPI, async 리스너, HTTP 클라이언트 유틸 | + | `kr.devslab:api-log-jpa` | JPA + Hibernate 영속화 (v0.5.x 동작 그대로) | + | `kr.devslab:api-log-r2dbc` | 리액티브 R2DBC 영속화 — JDBC 의존성 없음 | + | `kr.devslab:api-log-mybatis` | MyBatis mapper 영속화 | + + v0.5.x에서 가장 비슷한 드롭인은 `api-log-jpa`입니다 (자동으로 `api-log-core`를 가져옴). + +- **빌드 시스템: Maven → Gradle 8.10**. easy-paging 컨벤션 적용 — 모듈마다 Vanniktech maven-publish, CI에서는 publish 플러그인의 property-driven 설정과 충돌하지 않도록 configuration cache 비활성화. Maven 파일은 사라졌고, `./gradlew build`가 유일한 빌드 경로입니다. + +- **패키지 이름 변경** (구조 정리 차원): + - `kr.devslab.apilog.model.dto.ApiRequest` → `kr.devslab.apilog.dto.ApiRequest` + - `kr.devslab.apilog.model.dto.ApiResponse` → `kr.devslab.apilog.dto.ApiResponse` + - `kr.devslab.apilog.model.ApiLogEntity` → `kr.devslab.apilog.jpa.model.ApiLogEntity` (`api-log-jpa`로 이동) + - `kr.devslab.apilog.repository.ApiLogRepository` → `kr.devslab.apilog.jpa.repository.ApiLogRepository` + - `kr.devslab.apilog.service.ApiLogService` → `kr.devslab.apilog.jpa.writer.JpaApiLogWriter`로 대체 (새 `ApiLogWriter` SPI 구현) + +### Added + +- **`ApiLogWriter` SPI** (`kr.devslab.apilog.spi.ApiLogWriter`) — 모든 백엔드가 구현하는 3-메서드 인터페이스 (`writeInitiated`, `writeSuccess`, `writeError`). 코어 리스너는 사용자가 추가한 백엔드 아티팩트가 등록한 writer 빈으로 이벤트를 라우팅합니다. +- **`api-log-r2dbc`** — R2DBC의 `DatabaseClient`로 PostgreSQL과 통신하는 리액티브 백엔드. JSONB 바인딩은 R2DBC PostgreSQL 드라이버의 묵시적 `TEXT → JSONB` 캐스트를 활용, 별도의 `::jsonb` 작업이 필요 없음. 순수 리액티브 스키마 초기화 (`R2dbcScriptDatabaseInitializer`) 제공 — JDBC 끌어들이지 않음. +- **`api-log-mybatis`** — `@Mapper` 인터페이스 기반 MyBatis 백엔드. JSONB 컬럼은 `CAST(#{...,jdbcType=VARCHAR} AS jsonb)` 구문으로 처리해서 별도의 `TypeHandler`가 필요 없음. +- **`:core`에서 공유하는 SPI 헬퍼**: + - `HttpErrorExtractor` — 던져진 예외에서 HTTP 상태 / 본문 추출 (기존 `ApiLogService` 내부 로직 분리). + - `PayloadJsonMapper` — 모든 writer가 사용하는 JSON 문자열 / `JsonNode` 변환. + +### Fixed + +- **`V1.0__create_api_log.sql`이 이제 멱등합니다.** `CREATE TABLE`과 `CREATE INDEX` 둘 다 `IF NOT EXISTS` 추가. 기존에는 BUILTIN 모드에서 두 번째 부팅 시 "relation already exists" 에러가 발생할 수 있었음 (Hibernate `ddl-auto`가 잡아주지 않으면). + +### v0.5.2에서 마이그레이션 + +의존성 좌표 변경: + +```xml + + + kr.devslab + api-log-spring-boot-starter + 0.5.2 + + + + + kr.devslab + api-log-jpa + 0.6.0 + +``` + +이동된 타입을 직접 import 하던 경우 패키지 업데이트: + +```java +// Before +import kr.devslab.apilog.model.dto.ApiRequest; +import kr.devslab.apilog.model.ApiLogEntity; + +// After +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +``` + +리액티브 (R2DBC) 또는 MyBatis로 전환하려는 경우: `api-log-jpa` 대신 `api-log-r2dbc` / `api-log-mybatis`만 바꿔 끼우면 됩니다 — 동일한 `ApiLogWriter` 계약, 동일한 `api_log` 테이블. + ## [0.5.2] — 실제 consumer 앱에서 빈 등록 문제 픽스 ### Fixed diff --git a/docs/changelog.md b/docs/changelog.md index 4dea443..3d6a34c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [0.6.0] — Multi-module split (Gradle), pluggable JPA / R2DBC / MyBatis backends + +### Changed + +- **The single `api-log-spring-boot-starter` artifact is gone.** The starter is now a multi-module Gradle build. Consumers add `api-log-core` plus exactly one backend artifact: + + | Artifact (Maven coordinate) | What it provides | + | --- | --- | + | `kr.devslab:api-log-core` | Events, SPI, async listener, HTTP client utilities | + | `kr.devslab:api-log-jpa` | JPA + Hibernate persistence (the v0.5.x behavior) | + | `kr.devslab:api-log-r2dbc` | Reactive R2DBC persistence — no JDBC dependency | + | `kr.devslab:api-log-mybatis` | MyBatis mapper persistence | + + Adding `api-log-jpa` is the closest drop-in for v0.5.x users; it pulls `api-log-core` transitively. + +- **Build system: Maven → Gradle 8.10.** Adopting easy-paging's convention: Vanniktech maven-publish per module, configuration cache disabled in CI to play nice with the publishing plugin. The Maven build files are gone — `./gradlew build` is the only path now. + +- **Package renames** to reflect the layout: + - `kr.devslab.apilog.model.dto.ApiRequest` → `kr.devslab.apilog.dto.ApiRequest` + - `kr.devslab.apilog.model.dto.ApiResponse` → `kr.devslab.apilog.dto.ApiResponse` + - `kr.devslab.apilog.model.ApiLogEntity` → `kr.devslab.apilog.jpa.model.ApiLogEntity` (now lives in `api-log-jpa`) + - `kr.devslab.apilog.repository.ApiLogRepository` → `kr.devslab.apilog.jpa.repository.ApiLogRepository` + - `kr.devslab.apilog.service.ApiLogService` → replaced by `kr.devslab.apilog.jpa.writer.JpaApiLogWriter` (implements the new `ApiLogWriter` SPI) + +### Added + +- **`ApiLogWriter` SPI** (`kr.devslab.apilog.spi.ApiLogWriter`) — three-method interface that every backend implements (`writeInitiated`, `writeSuccess`, `writeError`). The core listener routes events through whatever writer bean the consumer's backend artifact registered. +- **`api-log-r2dbc`** — reactive backend that talks to PostgreSQL via R2DBC's `DatabaseClient`. JSONB binding uses the R2DBC PostgreSQL driver's implicit `TEXT → JSONB` cast, no manual `::jsonb` needed. Ships a pure-reactive schema initializer (`R2dbcScriptDatabaseInitializer`) — zero JDBC pull-in. +- **`api-log-mybatis`** — MyBatis backend with a `@Mapper`-annotated interface. JSONB columns use `CAST(#{...,jdbcType=VARCHAR} AS jsonb)` in the `@Insert` SQL so no custom `TypeHandler` is required. +- **Shared SPI helpers in `:core`**: + - `HttpErrorExtractor` — pulls HTTP status + body off thrown exceptions (was inline in the old `ApiLogService`). + - `PayloadJsonMapper` — JSON string / `JsonNode` conversion used by every writer. + +### Fixed + +- **`V1.0__create_api_log.sql` is now idempotent.** Both `CREATE TABLE` and `CREATE INDEX` got `IF NOT EXISTS`. Previously the second boot under BUILTIN mode could fail with "relation already exists" if Hibernate's `ddl-auto` wasn't catching it. + +### Migration from v0.5.2 + +Update your dependency coordinates: + +```xml + + + kr.devslab + api-log-spring-boot-starter + 0.5.2 + + + + + kr.devslab + api-log-jpa + 0.6.0 + +``` + +If you import any of the moved types directly, update the package: + +```java +// Before +import kr.devslab.apilog.model.dto.ApiRequest; +import kr.devslab.apilog.model.ApiLogEntity; + +// After +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +``` + +Reactive (R2DBC) or MyBatis adopters: swap `api-log-jpa` for `api-log-r2dbc` / `api-log-mybatis` instead — same `ApiLogWriter` contract, same `api_log` table. + ## [0.5.2] — Fix bean registration in real consumer apps ### Fixed diff --git a/docs/getting-started/installation.ko.md b/docs/getting-started/installation.ko.md index a2ea8fa..3cd1bbe 100644 --- a/docs/getting-started/installation.ko.md +++ b/docs/getting-started/installation.ko.md @@ -8,13 +8,38 @@ ## 의존성 추가 +v0.6.0부터 스타터가 백엔드 비종속 코어 + 영속화 백엔드 1개로 분리됐습니다. +**아래 표에서 한 줄만 고르면 됩니다 — 해당 백엔드 아티팩트가 +`api-log-core`를 transitive하게 가져옵니다.** + +| 환경 | 추가할 좌표 | +| --- | --- | +| Spring MVC + JPA (v0.5.x 기본) | `kr.devslab:api-log-jpa` | +| WebFlux + R2DBC (end-to-end 리액티브) | `kr.devslab:api-log-r2dbc` | +| MyBatis (어떤 웹 스택이든) | `kr.devslab:api-log-mybatis` | + === "Maven" ```xml + + + kr.devslab + api-log-jpa + 0.6.0 + + + kr.devslab - api-log-spring-boot-starter - 0.3.0 + api-log-r2dbc + 0.6.0 + + + + + kr.devslab + api-log-mybatis + 0.6.0 ``` @@ -22,7 +47,10 @@ ```kotlin dependencies { - implementation("kr.devslab:api-log-spring-boot-starter:0.3.0") + // JPA — v0.5.x 드롭인 + implementation("kr.devslab:api-log-jpa:0.6.0") + // 또는 "kr.devslab:api-log-r2dbc:0.6.0" + // 또는 "kr.devslab:api-log-mybatis:0.6.0" } ``` @@ -30,24 +58,49 @@ ```groovy dependencies { - implementation 'kr.devslab:api-log-spring-boot-starter:0.3.0' + implementation 'kr.devslab:api-log-jpa:0.6.0' + // 또는 'kr.devslab:api-log-r2dbc:0.6.0' + // 또는 'kr.devslab:api-log-mybatis:0.6.0' } ``` !!! tip "최신 버전" - `0.3.0`은 [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter)의 최신 버전으로 교체. + `0.6.0`은 [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-core)의 최신 버전으로 교체. + +!!! info "v0.5.x에서 업그레이드?" + 기존 `api-log-spring-boot-starter` 좌표를 `api-log-jpa`로 바꾸면 됩니다 + (동일 JPA 백엔드, 동일 `api_log` 행). 일부 패키지 이름이 바뀌었으니 + [v0.6.0 변경 이력](../changelog.md#060--멀티모듈-분리-gradle-jpa--r2dbc--mybatis-백엔드-선택-지원)에서 + 매핑 표 참고. -## 스타터가 자동으로 가져오는 의존성 +## 각 아티팩트가 가져오는 의존성 + +**`api-log-core`** (백엔드 아티팩트가 자동으로 가져옴): + +- `spring-boot-starter` (`@EventListener`, `@EnableAsync`, `ApplicationEventPublisher`) +- `spring-retry` + `spring-boot-starter-aop` (리스너의 `@Retryable` 로그 쓰기 재시도) +- `jackson-databind` + `jackson-module-blackbird` (JSONB 페이로드 직렬화) +- `spring-web` / `spring-webflux` (compile-only — HTTP 유틸이 참조하지만, 사용자 classpath에 실제로 있어야 활성화) + +**`api-log-jpa`** 추가: - `spring-boot-starter-data-jpa` (`ApiLogRepository`) -- `spring-boot-starter-web` (내장 `RestApiClientUtil`) -- `spring-retry` (`ApiEventListener`가 로그 쓰기 실패 시 3회까지 재시도) -- `jackson-module-blackbird` (고성능 JSON 직렬화) - `postgresql` JDBC 드라이버 (runtime) +- `flyway-core` (compile-only — `api.log.schema.management=flyway`일 때만 활성화) + +**`api-log-r2dbc`** 추가: + +- `spring-r2dbc` (`DatabaseClient`) +- `r2dbc-postgresql` (runtime) +- `reactor-core` -Flyway는 **옵셔널** — `api.log.schema.management=flyway`로 설정할 때만 필요합니다 (아래 [스키마 관리](#schema-management) 참고). 기본값(BUILTIN)은 Flyway 불필요. +JDBC 드라이버 없음 — 순수 리액티브. -Spring WebFlux도 **옵셔널** — 리액티브 `ReactiveApiClientUtil` (`Mono` / `Mono` 반환)을 사용하려면 `spring-webflux` + `reactor-netty-http`를 의존성에 추가. 그러면 스타터가 리액티브 클라이언트를 블로킹과 함께 자동 등록. [리액티브 가이드](../guides/reactive.md) 참고. +**`api-log-mybatis`** 추가: + +- `mybatis-spring-boot-starter:3.0.4` +- `spring-jdbc` +- `postgresql` JDBC 드라이버 (runtime) ## 직접 제공해야 하는 것 @@ -76,13 +129,25 @@ api: ## 자동 구성이 하는 일 -스타터가 클래스패스에 있고 `api.log.enabled`가 `true`(기본값)이면 `ApiLogAutoConfiguration`이 활성화되어 다음을 등록합니다: +`api.log.enabled`가 `true`(기본값)이면 `api-log-core`에서 3개의 auto-config가 +활성화되고, 선택한 백엔드에서 1개가 추가로 활성화됩니다. + +**`api-log-core`에서** (`ApiLogCoreAutoConfiguration`, +`RestApiClientAutoConfiguration`, `ReactiveApiClientAutoConfiguration`): -- `ApiLogService` — 영속화 오케스트레이터 (`ObjectMapper` 빈이 있어야 활성화) -- `ApiEventListener` — 이벤트를 서비스로 연결하는 `@EventListener` (async) +- `ApiEventListener` — 이벤트를 등록된 `ApiLogWriter`로 연결하는 `@EventListener` +- `PayloadJsonMapper` — 모든 writer가 공유하는 JSON 헬퍼 - `RetryConfig` — `@EnableRetry` 활성화 (리스너의 로그 쓰기 `@Retryable` 동작용) -- `ApiLogSchemaInitializer` — 부팅 시 `CREATE TABLE IF NOT EXISTS` 실행 (`schema.management=builtin` 활성화 시, 즉 기본값) -- JPA `@EntityScan` 및 `@EnableJpaRepositories` (`kr.devslab.apilog.model`, `kr.devslab.apilog.repository`) +- `apiLogJacksonCustomizer` — Spring Boot 기본 `ObjectMapper`에 Blackbird 추가 +- `apiLogVirtualThreadExecutor` / `apiLogPlatformThreadExecutor` — 리스너용 async executor (Virtual Threads 활성화 시 virtual) +- `RestApiClientUtil` (classpath에 `RestClient`가 있을 때) +- `ReactiveApiClientUtil` (classpath에 `WebClient`가 있을 때) + +**선택한 백엔드 아티팩트에서**: + +- `ApiLogWriter` 구현체 — 추가한 아티팩트에 따라 `JpaApiLogWriter` / `R2dbcApiLogWriter` / `MybatisApiLogWriter` +- 스키마 초기화 (BUILTIN 모드) — `:jpa` + `:mybatis`는 JDBC 기반, `:r2dbc`는 순수 리액티브 +- JPA `@EntityScan` + `@EnableJpaRepositories` (`:jpa`만) 또는 `@MapperScan` (`:mybatis`만) 모든 빈은 `@ConditionalOnMissingBean`. 직접 빈을 정의하면 오버라이드됩니다. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index dbbeb10..564ae41 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -8,13 +8,38 @@ ## Adding the dependency +v0.6.0 splits the starter into a backend-agnostic core plus one persistence +backend per artifact. **Pick one row from the table below — that's it; the +backend artifact pulls in `api-log-core` transitively.** + +| You are using… | Add this artifact | +| --- | --- | +| Spring MVC + JPA (the v0.5.x default) | `kr.devslab:api-log-jpa` | +| WebFlux + R2DBC (reactive end-to-end) | `kr.devslab:api-log-r2dbc` | +| MyBatis (any web stack) | `kr.devslab:api-log-mybatis` | + === "Maven" ```xml + + + kr.devslab + api-log-jpa + 0.6.0 + + + + + kr.devslab + api-log-r2dbc + 0.6.0 + + + kr.devslab - api-log-spring-boot-starter - 0.3.0 + api-log-mybatis + 0.6.0 ``` @@ -22,7 +47,10 @@ ```kotlin dependencies { - implementation("kr.devslab:api-log-spring-boot-starter:0.3.0") + // JPA — drop-in for v0.5.x setups + implementation("kr.devslab:api-log-jpa:0.6.0") + // or "kr.devslab:api-log-r2dbc:0.6.0" + // or "kr.devslab:api-log-mybatis:0.6.0" } ``` @@ -30,26 +58,49 @@ ```groovy dependencies { - implementation 'kr.devslab:api-log-spring-boot-starter:0.3.0' + implementation 'kr.devslab:api-log-jpa:0.6.0' + // or 'kr.devslab:api-log-r2dbc:0.6.0' + // or 'kr.devslab:api-log-mybatis:0.6.0' } ``` !!! tip "Latest version" - Replace `0.3.0` with the latest from [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter). + Replace `0.6.0` with the latest from [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-core). -## What the starter pulls in +!!! info "Upgrading from v0.5.x?" + Swap the old `api-log-spring-boot-starter` coordinate for `api-log-jpa` + (same JPA backend, same `api_log` rows). A few packages were renamed — + see the [v0.6.0 changelog](../changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends) + for the complete mapping. -The starter brings these for you transitively: +## What each artifact pulls in + +**`api-log-core`** (always — pulled transitively by every backend artifact): + +- `spring-boot-starter` (`@EventListener`, `@EnableAsync`, `ApplicationEventPublisher`) +- `spring-retry` + `spring-boot-starter-aop` (listener `@Retryable` log-write retries) +- `jackson-databind` + `jackson-module-blackbird` (the JSONB payload serializer) +- `spring-web` / `spring-webflux` (compile-only — the HTTP utilities reference them but require the consumer's classpath to actually include one or the other) + +**`api-log-jpa`** adds: - `spring-boot-starter-data-jpa` (the `ApiLogRepository`) -- `spring-boot-starter-web` (the bundled `RestApiClientUtil`) -- `spring-retry` (lets `ApiEventListener` retry log-write failures 3× before giving up) -- `jackson-module-blackbird` (high-throughput JSON serialization) - `postgresql` JDBC driver (runtime) +- `flyway-core` (compile-only — only activated when `api.log.schema.management=flyway`) + +**`api-log-r2dbc`** adds: + +- `spring-r2dbc` (`DatabaseClient`) +- `r2dbc-postgresql` (runtime) +- `reactor-core` -Flyway is **optional** — only needed if you set `api.log.schema.management=flyway` (see [Schema management](#schema-management) below). The default doesn't need it. +No JDBC driver — pure reactive. -Spring WebFlux is also **optional** — only needed if you want the reactive `ReactiveApiClientUtil` (returns `Mono` / `Mono`). Add `spring-webflux` + `reactor-netty-http` to your dependencies and the starter auto-registers the reactive client alongside the blocking one. See the [Reactive guide](../guides/reactive.md). +**`api-log-mybatis`** adds: + +- `mybatis-spring-boot-starter:3.0.4` +- `spring-jdbc` +- `postgresql` JDBC driver (runtime) ## What you bring yourself @@ -78,13 +129,25 @@ api: ## What auto-configuration does -When the starter is on the classpath and `api.log.enabled` is `true` (the default), `ApiLogAutoConfiguration` activates and registers: +When `api.log.enabled` is `true` (the default), three auto-configurations from +`api-log-core` activate plus one from whichever backend you picked. + +From **`api-log-core`** (`ApiLogCoreAutoConfiguration`, +`RestApiClientAutoConfiguration`, `ReactiveApiClientAutoConfiguration`): -- `ApiLogService` — the persistence orchestrator (gated on an `ObjectMapper` bean) -- `ApiEventListener` — the `@EventListener` (async) that bridges events to the service +- `ApiEventListener` — the `@EventListener` that bridges events to the chosen `ApiLogWriter` +- `PayloadJsonMapper` — shared JSON helper for every writer - `RetryConfig` — enables `@EnableRetry` so the listener's own `@Retryable` log-write retries work -- `ApiLogSchemaInitializer` — runs `CREATE TABLE IF NOT EXISTS` on startup (active for `schema.management=builtin`, the default) -- JPA `@EntityScan` and `@EnableJpaRepositories` scoped to `kr.devslab.apilog.model` and `kr.devslab.apilog.repository` +- `apiLogJacksonCustomizer` — adds Blackbird to Spring Boot's default `ObjectMapper` +- `apiLogVirtualThreadExecutor` / `apiLogPlatformThreadExecutor` — async executor for the listener (virtual threads when enabled) +- `RestApiClientUtil` (when `RestClient` is on the classpath) +- `ReactiveApiClientUtil` (when `WebClient` is on the classpath) + +From the **backend artifact**: + +- `ApiLogWriter` implementation — `JpaApiLogWriter` / `R2dbcApiLogWriter` / `MybatisApiLogWriter`, depending on which artifact you added +- Schema initializer (BUILTIN mode) — JDBC-based for `:jpa` + `:mybatis`, pure-reactive for `:r2dbc` +- JPA `@EntityScan` + `@EnableJpaRepositories` (`:jpa` only) or `@MapperScan` (`:mybatis` only) All beans use `@ConditionalOnMissingBean`. Define your own to override. diff --git a/docs/getting-started/quickstart.ko.md b/docs/getting-started/quickstart.ko.md index 8735c22..21ccadd 100644 --- a/docs/getting-started/quickstart.ko.md +++ b/docs/getting-started/quickstart.ko.md @@ -28,7 +28,7 @@ spring: package com.example.demo; import kr.devslab.apilog.util.RestApiClientUtil; -import kr.devslab.apilog.model.dto.ApiResponse; +import kr.devslab.apilog.dto.ApiResponse; import org.springframework.stereotype.Service; @Service @@ -75,7 +75,7 @@ public class DemoApplication implements CommandLineRunner { 앱 실행: ```bash -./mvnw spring-boot:run +./gradlew bootRun ``` ## 4. 로그 확인 diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 6e9900e..a0db8b4 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -28,7 +28,7 @@ That's it — no `api.log.*` config needed for the happy path. The default `sche package com.example.demo; import kr.devslab.apilog.util.RestApiClientUtil; -import kr.devslab.apilog.model.dto.ApiResponse; +import kr.devslab.apilog.dto.ApiResponse; import org.springframework.stereotype.Service; @Service @@ -75,7 +75,7 @@ public class DemoApplication implements CommandLineRunner { Start the app: ```bash -./mvnw spring-boot:run +./gradlew bootRun ``` ## 4. Inspect the logs diff --git a/docs/guides/jpa-backend.ko.md b/docs/guides/jpa-backend.ko.md new file mode 100644 index 0000000..79636ee --- /dev/null +++ b/docs/guides/jpa-backend.ko.md @@ -0,0 +1,230 @@ +# JPA 백엔드 + +JPA 백엔드 (`api-log-jpa`)가 기본 선택지입니다. v0.5.x에서 출시된 동작 그대로, +이제 3개 백엔드 중 하나로 패키징됐을 뿐입니다. 이미 Spring Data JPA를 쓰고 +있다면, 또는 굳이 리액티브로 갈 이유가 없다면 이 백엔드를 고르세요. + +## 언제 선택하나 + +- Spring MVC + JPA 스택을 쓰고 있을 때. +- v0.5.x 동작을 그대로 원할 때 — `ApiLogEntity`, `ApiLogRepository`, JSONB + 컬럼용 Hibernate `@JdbcTypeCode(SqlTypes.JSON)` 매핑. +- 다른 앱 코드처럼 `JpaRepository`로 감사 로그를 조회하고 싶을 때. + +WebFlux + R2DBC 환경이면 [`api-log-r2dbc`](r2dbc-backend.md), MyBatis 환경이면 +[`api-log-mybatis`](mybatis-backend.md)가 더 적합합니다. `api_log` 스키마 자체는 +세 백엔드 모두 동일합니다. + +## 설치 + +=== "Maven" + + ```xml + + kr.devslab + api-log-jpa + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-jpa:0.6.0") + ``` + +`api-log-jpa`는 `api-log-core` (이벤트, 리스너, HTTP 유틸)와 +`spring-boot-starter-data-jpa`, PostgreSQL JDBC 드라이버를 transitive하게 +가져옵니다. 추가로 넣을 거 없음 — Flyway는 옵셔널이고 +`api.log.schema.management=flyway`일 때만 필요합니다. + +## 자동으로 등록되는 빈 + +JPA 백엔드가 classpath에 있고 `api.log.enabled=true`(기본값)이면 +`ApiLogJpaAutoConfiguration`이 활성화되어 다음을 등록합니다: + +| 빈 | 역할 | +| --- | --- | +| `JpaApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 | +| `ApiLogJpaSchemaInitializer` | `DataSource`에 `V1.0__create_api_log.sql` 실행 (BUILTIN 모드만) | +| `ApiLogRepository` (`@EnableJpaRepositories` 경유) | `ApiLogEntity` 용 Spring Data 리포지토리 | +| `ApiLogFlywayConfigurationCustomizer` | Flyway locations에 `classpath:db/api-log` 추가 (FLYWAY 모드만) | + +`@EntityScan(basePackageClasses = ApiLogEntity.class)`이 자동으로 적용되므로 +사용자 `@SpringBootApplication` 패키지 스캔에 `ApiLogEntity`를 별도로 추가할 +필요 없습니다. + +## 엔티티 + +```java +package kr.devslab.apilog.jpa.model; + +@Entity +@Table(name = "api_log") +public class ApiLogEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String eventType; + private String requestId; + private String endpoint; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode payload; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode response; + + private Integer statusCode; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode errorMessage; + + private LocalDateTime timestamp; + private Integer retryCount; + private Boolean isRetry; +} +``` + +JSONB 3개 컬럼 (`payload`, `response`, `error_message`)은 +`@JdbcTypeCode(SqlTypes.JSON)`으로 Jackson `JsonNode`에 매핑됩니다. +Hibernate가 PostgreSQL 다이얼렉트의 JSONB 바인더로 위임해주기 때문에 +JSON 구조가 그대로 보존됩니다 (단순 텍스트가 아닌). + +## 코드에서 로그 조회 + +번들된 `ApiLogRepository`가 기본 조회를 제공: + +```java +@Service +@RequiredArgsConstructor +public class AuditQueryService { + + private final ApiLogRepository repo; + + public List timelineFor(String requestId) { + return repo.findByRequestId(requestId); + } + + public List errorsAt(String endpoint) { + return repo.findByEndpoint(endpoint).stream() + .filter(e -> "ERROR".equals(e.getEventType())) + .toList(); + } +} +``` + +JSONB 연산자, GIN 인덱스 활용, 에러율 집계 같은 풍부한 질의는 +[로그 조회 가이드](querying-logs.md) 참고 — 테이블 스키마가 같아서 세 백엔드 +모두 동일하게 적용됩니다. + +## 트랜잭션 시맨틱 + +`JpaApiLogWriter`의 모든 메서드는 `@Transactional(propagation = REQUIRES_NEW)`로 +실행됩니다: + +```java +@Override +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void writeInitiated(ApiCallInitiatedEvent event) { ... } +``` + +의도된 설계입니다. 감사 로그 쓰기는 호출자의 비즈니스 트랜잭션과 함께 롤백되면 +안 됩니다 — 호출자의 `@Transactional`이 나중에 실패해서 롤백되더라도 +`INITIATED` 행은 남아 있어야 합니다. 작업 단위의 운명과 무관하게 `api_log`를 +보면 그 호출이 실제로 나갔는지 확인할 수 있어야 합니다. + +## 스키마 관리 + +기본값은 `api.log.schema.management=builtin` — 부팅 시 번들된 +`V1.0__create_api_log.sql`이 Spring Boot의 `DataSourceScriptDatabaseInitializer`를 +통해 `DataSource`에 실행됩니다. DDL이 `IF NOT EXISTS`를 사용해서 멱등합니다. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # 기본값 +``` + +Flyway 모드로 전환 (마이그레이션이 `flyway_schema_history`에 기록됨): + +```xml + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + runtime + +``` + +```yaml title="application.yml" +api: + log: + schema: + management: flyway +``` + +`ApiLogFlywayConfigurationCustomizer`가 기존 `spring.flyway.locations`에 +`classpath:db/api-log`를 추가합니다 — 사용자 마이그레이션과 우리 마이그레이션이 +하나의 sweep에서 돌고, 하나의 history 테이블을 공유합니다. + +완전히 opt-out (DDL을 직접 적용): + +```yaml +api: + log: + schema: + management: none +``` + +각 전략의 자세한 동작과 원본 DDL은 [스키마 레퍼런스](../reference/schema.md)에 +있습니다. + +## Writer 오버라이드 + +로그 쓰기 방식을 커스터마이즈해야 할 때 (추가 컬럼, 페이로드 마스킹, 다른 +테이블 등)는 직접 `ApiLogWriter` 빈을 정의하면 됩니다 — 백엔드의 +`@ConditionalOnMissingBean(ApiLogWriter.class)`이 뒤로 빠집니다: + +```java +@Bean +public ApiLogWriter customWriter(ApiLogRepository repo, PayloadJsonMapper json, + PayloadMasker masker) { + return new MaskingJpaApiLogWriter(repo, json, masker); +} +``` + +번들된 writer를 감싸는 패턴이 일반적입니다: + +```java +public class MaskingJpaApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final PayloadMasker masker; + + public MaskingJpaApiLogWriter(ApiLogRepository repo, PayloadJsonMapper json, + PayloadMasker masker) { + this.delegate = new JpaApiLogWriter(repo, json); + this.masker = masker; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(masker.mask(event)); + } + // ... writeSuccess / writeError도 동일 +} +``` + +## 더 읽어볼 거리 + +- [로그 조회](querying-logs.md) — JSONB 연산자 레시피, 인덱스, 에러율. +- [이벤트 직접 발행](publishing-events.md) — 자체 HTTP 클라이언트 사용 시 + 이벤트만 사용. +- [재시도 처리](retry-handling.md) — `RETRY_ERROR` 시맨틱, 리스너의 로그 쓰기 + 재시도. +- [스키마 레퍼런스](../reference/schema.md) — 컬럼 타입, 인덱스, 원본 DDL. diff --git a/docs/guides/jpa-backend.md b/docs/guides/jpa-backend.md new file mode 100644 index 0000000..872e084 --- /dev/null +++ b/docs/guides/jpa-backend.md @@ -0,0 +1,234 @@ +# JPA backend + +The JPA backend (`api-log-jpa`) is the default — it's what v0.5.x shipped, now +packaged as one of three choices. Pick it when your application already uses +Spring Data JPA, or when you don't have a strong reason to go reactive. + +## When to pick it + +- You're on a Spring MVC + JPA stack. +- You want the v0.5.x behavior unchanged — `ApiLogEntity`, + `ApiLogRepository`, and Hibernate's `@JdbcTypeCode(SqlTypes.JSON)` mapping + for the JSONB columns. +- You want to query the audit log via the same `JpaRepository` infrastructure + the rest of your app uses. + +If you're on WebFlux + R2DBC, prefer [`api-log-r2dbc`](r2dbc-backend.md). If +you're on MyBatis, prefer [`api-log-mybatis`](mybatis-backend.md). The +`api_log` schema is identical across all three. + +## Install + +=== "Maven" + + ```xml + + kr.devslab + api-log-jpa + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-jpa:0.6.0") + ``` + +`api-log-jpa` transitively pulls in `api-log-core` (events, listener, HTTP +utilities) plus `spring-boot-starter-data-jpa` and the PostgreSQL JDBC driver. +Nothing else to add — Flyway is optional and only matters if you switch +`api.log.schema.management` to `flyway`. + +## What gets registered + +When the JPA backend is on the classpath and `api.log.enabled=true` (the +default), `ApiLogJpaAutoConfiguration` activates and registers: + +| Bean | Purpose | +| --- | --- | +| `JpaApiLogWriter` | The `ApiLogWriter` implementation the core listener routes events through | +| `ApiLogJpaSchemaInitializer` | Runs `V1.0__create_api_log.sql` against your `DataSource` (BUILTIN mode only) | +| `ApiLogRepository` (via `@EnableJpaRepositories`) | Spring Data repository for `ApiLogEntity` | +| `ApiLogFlywayConfigurationCustomizer` | Appends `classpath:db/api-log` to Flyway's locations (FLYWAY mode only) | + +`@EntityScan(basePackageClasses = ApiLogEntity.class)` is wired automatically +— you don't need to add `ApiLogEntity` to your own `@SpringBootApplication`'s +package scan. + +## The entity + +```java +package kr.devslab.apilog.jpa.model; + +@Entity +@Table(name = "api_log") +public class ApiLogEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String eventType; + private String requestId; + private String endpoint; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode payload; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode response; + + private Integer statusCode; + + @JdbcTypeCode(SqlTypes.JSON) + private JsonNode errorMessage; + + private LocalDateTime timestamp; + private Integer retryCount; + private Boolean isRetry; +} +``` + +The three JSONB columns (`payload`, `response`, `error_message`) are mapped as +Jackson `JsonNode` via `@JdbcTypeCode(SqlTypes.JSON)` — Hibernate delegates to +the PostgreSQL dialect's JSONB binder, so the round-trip preserves JSON +structure (not just text). + +## Querying logs from your code + +The bundled `ApiLogRepository` exposes simple lookups: + +```java +@Service +@RequiredArgsConstructor +public class AuditQueryService { + + private final ApiLogRepository repo; + + public List timelineFor(String requestId) { + return repo.findByRequestId(requestId); + } + + public List errorsAt(String endpoint) { + return repo.findByEndpoint(endpoint).stream() + .filter(e -> "ERROR".equals(e.getEventType())) + .toList(); + } +} +``` + +For richer queries — JSONB operators, GIN-indexed payload searches, error-rate +aggregations — go to the [Querying logs guide](querying-logs.md), which +applies to all three backends since the table schema is the same. + +## Transaction semantics + +Every `JpaApiLogWriter` method runs in `@Transactional(propagation = REQUIRES_NEW)`: + +```java +@Override +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void writeInitiated(ApiCallInitiatedEvent event) { ... } +``` + +This is deliberate. An audit log write must never roll back with the caller's +business transaction — if the caller's `@Transactional` later fails and rolls +back, the `INITIATED` row stays. You should be able to query `api_log` and see +that the call actually went out, regardless of what happened to the rest of +the unit of work. + +## Schema management + +The default is `api.log.schema.management=builtin` — on startup the bundled +`V1.0__create_api_log.sql` runs against your `DataSource` via Spring Boot's +`DataSourceScriptDatabaseInitializer`. The DDL uses `IF NOT EXISTS`, so it's +idempotent. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # default +``` + +To switch to Flyway (so the migration is recorded in `flyway_schema_history`): + +```xml + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + runtime + +``` + +```yaml title="application.yml" +api: + log: + schema: + management: flyway +``` + +`ApiLogFlywayConfigurationCustomizer` appends `classpath:db/api-log` to your +existing `spring.flyway.locations` — your own migrations and ours run from one +sweep, share one history table. + +To opt out entirely (apply the DDL yourself): + +```yaml +api: + log: + schema: + management: none +``` + +Full strategy details and the raw DDL are in the +[Schema reference](../reference/schema.md). + +## Overriding the writer + +If you need to customize how rows are written (extra columns, masking +payloads, a different table), provide your own `ApiLogWriter` bean — the +backend's `@ConditionalOnMissingBean(ApiLogWriter.class)` backs off: + +```java +@Bean +public ApiLogWriter customWriter(ApiLogRepository repo, PayloadJsonMapper json, + PayloadMasker masker) { + return new MaskingJpaApiLogWriter(repo, json, masker); +} +``` + +A common pattern is to wrap the bundled writer: + +```java +public class MaskingJpaApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final PayloadMasker masker; + + public MaskingJpaApiLogWriter(ApiLogRepository repo, PayloadJsonMapper json, + PayloadMasker masker) { + this.delegate = new JpaApiLogWriter(repo, json); + this.masker = masker; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(masker.mask(event)); + } + // ... same for writeSuccess / writeError +} +``` + +## See also + +- [Querying logs](querying-logs.md) — JSONB operator recipes, indexes, error + rates. +- [Publishing events manually](publishing-events.md) — bring your own HTTP + client, just use the events. +- [Retry handling](retry-handling.md) — `RETRY_ERROR` semantics, listener + log-write retries. +- [Schema reference](../reference/schema.md) — column types, indexes, raw DDL. diff --git a/docs/guides/mybatis-backend.ko.md b/docs/guides/mybatis-backend.ko.md new file mode 100644 index 0000000..0c8145c --- /dev/null +++ b/docs/guides/mybatis-backend.ko.md @@ -0,0 +1,250 @@ +# MyBatis 백엔드 + +MyBatis 백엔드 (`api-log-mybatis`)는 `@Mapper`로 어노테이션된 인터페이스를 +통해 감사 행을 씁니다. 이미 MyBatis를 쓰고 있는데 감사 로그 때문에 JPA / +Hibernate를 끌고 들어오기 싫을 때 이걸 고르세요. + +## 언제 선택하나 + +- 이미 MyBatis 사용 중 (웹 스택은 무관 — Servlet 또는 WebFlux+JDBC). +- 프로젝트에 ORM을 하나만 두고 싶을 때. 감사 로깅 *만을 위해* JPA를 추가하면 + 영속화 프레임워크 2개, 트랜잭션 매니저 2개, 컨벤션 2세트 — 보통은 그만한 + 가치가 없습니다. + +JPA를 쓰고 있으면 [`api-log-jpa`](jpa-backend.md) 선택. WebFlux + R2DBC에서 +순수 리액티브 영속화를 원하면 [`api-log-r2dbc`](r2dbc-backend.md)가 맞습니다. +`api_log` 스키마는 세 백엔드 모두 동일합니다. + +## 설치 + +=== "Maven" + + ```xml + + kr.devslab + api-log-mybatis + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-mybatis:0.6.0") + ``` + +`api-log-mybatis`는 `api-log-core`, `mybatis-spring-boot-starter:3.0.4` +(Spring Boot 3.x 호환 라인), `spring-jdbc`, PostgreSQL JDBC 드라이버를 +transitive하게 가져옵니다. `DataSource`는 별도로 구성해야 합니다 — Spring +Boot 기본 `spring.datasource.*`로 충분. + +## 자동으로 등록되는 빈 + +MyBatis (`org.apache.ibatis.session.SqlSessionFactory`)가 classpath에 있고 +`api.log.enabled=true`이면 `ApiLogMybatisAutoConfiguration`이 활성화되어 +다음을 등록합니다: + +| 빈 | 역할 | +| --- | --- | +| `MybatisApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 | +| `ApiLogMapper` (`@MapperScan` 경유) | INSERT SQL을 담은 MyBatis `@Mapper` | +| `ApiLogMybatisSchemaInitializer` | `DataSource`에 `V1.0__create_api_log.sql` 실행 (BUILTIN 모드만) | + +`@MapperScan(basePackageClasses = ApiLogMapper.class)`가 자동으로 적용됩니다. +사용자 애플리케이션에 이미 다른 패키지를 향한 `@MapperScan`이 있으면 우리 것이 +함께 합쳐 동작합니다 — 두 스캔 모두 실행. + +## 매퍼 + +```java +package kr.devslab.apilog.mybatis.mapper; + +@Mapper +public interface ApiLogMapper { + + @Insert(""" + INSERT INTO api_log + (event_type, request_id, endpoint, payload, response, + status_code, error_message, timestamp, retry_count, is_retry) + VALUES + (#{eventType}, + #{requestId}, + #{endpoint}, + CAST(#{payload,jdbcType=VARCHAR} AS jsonb), + CAST(#{response,jdbcType=VARCHAR} AS jsonb), + #{statusCode,jdbcType=INTEGER}, + CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb), + #{timestamp}, + #{retryCount}, + #{isRetry}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + void insert(ApiLogRow row); + + @Select(""" + SELECT id, event_type AS eventType, request_id AS requestId, endpoint, + payload::text AS payload, response::text AS response, + status_code AS statusCode, error_message::text AS errorMessage, + timestamp, retry_count AS retryCount, is_retry AS isRetry + FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC + """) + List findByRequestId(String requestId); +} +``` + +### `CAST(...,jdbcType=VARCHAR) AS jsonb`를 쓰는 이유 + +PostgreSQL은 Java `String` 파라미터를 `JSONB` 컬럼으로 묵시적 캐스트해주지 +않습니다. 두 가지 해결법: + +1. **커스텀 `TypeHandler`** — 보일러플레이트, JSONB 컬럼마다 핸들러 등록 필요. +2. **SQL에서 명시적 캐스트** — 이 매퍼의 방식. 컬럼당 한 줄, 별도 와이어업 + 없음. + +`jdbcType=VARCHAR` 어노테이션은 값이 `null`일 때도 VARCHAR 바인딩을 강제해서 +PostgreSQL의 "could not determine data type of parameter" 에러를 회피합니다. + +### 행 타입 + +```java +public class ApiLogRow { + private Long id; + private String eventType; + private String requestId; + private String endpoint; + private String payload; // JSON 문자열 — PayloadJsonMapper의 canonical form + private String response; // JSON 문자열 + private Integer statusCode; + private String errorMessage; // JSON 문자열 + private LocalDateTime timestamp; + private Integer retryCount; + private Boolean isRetry; +} +``` + +"JSON" 필드 3개는 일반 `String`. `api-log-core`의 +`PayloadJsonMapper.toJsonString()`이 canonical JSON 형태로 만들어주고, 매퍼의 +캐스트가 insert 시점에 JSONB로 변환합니다. + +## 트랜잭션 시맨틱 + +`MybatisApiLogWriter`의 메서드는 `@Transactional(propagation = REQUIRES_NEW)`로 +실행됩니다: + +```java +@Override +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void writeInitiated(ApiCallInitiatedEvent event) { ... } +``` + +JPA 백엔드와 동일한 계약 — 감사 쓰기는 호출자의 outer 트랜잭션과 함께 롤백되면 +안 됩니다. 감사 행은 작업 단위의 나머지 운명과 무관하게 자체적으로 +커밋됩니다. + +## 스키마 관리 + +기본값은 `api.log.schema.management=builtin`. MyBatis 백엔드는 JDBC 기반 +`DataSourceScriptDatabaseInitializer`를 사용 (JPA 백엔드와 같은 방식), +MyBatis 자체가 JDBC 위에서 동작하므로. DDL은 `IF NOT EXISTS`를 사용해서 매 +부팅마다 다시 돌려도 no-op. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # 기본값 +``` + +`api-log-mybatis`에는 **Flyway 통합이 번들되지 않습니다** — `FlywayConfigurationCustomizer`는 +`api-log-jpa`에만 들어 있습니다. MyBatis 앱이 Flyway로 테이블 관리를 +원한다면: + +- Flyway를 의존성에 추가 (`flyway-core` + `flyway-database-postgresql`). +- `spring.flyway.locations`에 `classpath:db/api-log`를 사용자 위치와 함께 + 추가: + + ```yaml + spring: + flyway: + locations: + - classpath:db/migration # 사용자 자신의 것 + - classpath:db/api-log # 우리 것 + ``` + +- `api.log.schema.management=none`으로 설정해서 BUILTIN 초기화가 Flyway 부트스트랩과 + 충돌하지 않게 함. + +`none` (DDL을 Liquibase / `psql` 등으로 직접 적용)도 유효: + +```yaml +api: + log: + schema: + management: none +``` + +## 행 다시 읽기 + +번들된 `findByRequestId`가 "한 호출의 타임라인" 쿼리를 커버합니다. 그 외에는 +직접 `ApiLogMapper`에 질의를 추가하세요 (번들된 걸 확장하거나, 별도 매퍼에): + +```java +@Mapper +public interface MyApiLogQueries { + + @Select(""" + SELECT COUNT(*) FILTER (WHERE event_type = 'ERROR') * 100.0 / COUNT(*) + FROM api_log + WHERE endpoint = #{endpoint} + AND timestamp > NOW() - INTERVAL '1 hour' + """) + Double errorRateLastHour(String endpoint); +} +``` + +JSONB 쿼리 플레이북 (연산자, GIN 인덱스, 에러율) 전체는 [로그 조회 +가이드](querying-logs.md)에 — 백엔드와 무관하게 동일합니다. + +## Writer 오버라이드 + +행 쓰기 방식을 커스터마이즈할 때는 직접 `ApiLogWriter` 빈을 정의 — 백엔드의 +`@ConditionalOnMissingBean(ApiLogWriter.class)`가 뒤로 빠집니다: + +```java +@Bean +public ApiLogWriter customWriter(ApiLogMapper mapper, PayloadJsonMapper json, + PayloadMasker masker) { + return new MaskingMybatisApiLogWriter(mapper, json, masker); +} +``` + +번들된 writer를 감싸는 게 보통 충분합니다: + +```java +public class MaskingMybatisApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final PayloadMasker masker; + + public MaskingMybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper json, + PayloadMasker masker) { + this.delegate = new MybatisApiLogWriter(mapper, json); + this.masker = masker; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(masker.mask(event)); + } + // ... writeSuccess / writeError도 동일 +} +``` + +## 더 읽어볼 거리 + +- [로그 조회](querying-logs.md) — JSONB 연산자 레시피, GIN 인덱스, 에러율. +- [이벤트 직접 발행](publishing-events.md) — 자체 HTTP 클라이언트 사용 시 + 이벤트만 사용. +- [재시도 처리](retry-handling.md) — `RETRY_ERROR` 시맨틱, 리스너의 로그 쓰기 + 재시도. +- [스키마 레퍼런스](../reference/schema.md) — 컬럼 타입, 인덱스, 원본 DDL. diff --git a/docs/guides/mybatis-backend.md b/docs/guides/mybatis-backend.md new file mode 100644 index 0000000..a159fb4 --- /dev/null +++ b/docs/guides/mybatis-backend.md @@ -0,0 +1,255 @@ +# MyBatis backend + +The MyBatis backend (`api-log-mybatis`) writes audit rows through a +`@Mapper`-annotated interface. Pick it when your application is already on +MyBatis and you don't want to drag JPA / Hibernate in just for the audit log. + +## When to pick it + +- You're already on MyBatis (any web stack — Servlet or WebFlux+JDBC). +- You want one ORM in your project. Adding JPA *just* for audit logging means + two persistence frameworks, two transaction managers, two sets of conventions + — usually not worth it. + +If you're on JPA, prefer [`api-log-jpa`](jpa-backend.md). If you're on +WebFlux + R2DBC and want pure-reactive persistence, +[`api-log-r2dbc`](r2dbc-backend.md) is the right pick. The `api_log` schema +is identical across all three. + +## Install + +=== "Maven" + + ```xml + + kr.devslab + api-log-mybatis + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-mybatis:0.6.0") + ``` + +`api-log-mybatis` transitively pulls in `api-log-core`, +`mybatis-spring-boot-starter:3.0.4` (the Spring Boot 3.x compatible line), +`spring-jdbc`, and the PostgreSQL JDBC driver. You still need a configured +`DataSource` — Spring Boot's defaults via `spring.datasource.*` work +unchanged. + +## What gets registered + +When MyBatis (`org.apache.ibatis.session.SqlSessionFactory`) is on the +classpath and `api.log.enabled=true`, `ApiLogMybatisAutoConfiguration` +activates and registers: + +| Bean | Purpose | +| --- | --- | +| `MybatisApiLogWriter` | The `ApiLogWriter` implementation the core listener routes events through | +| `ApiLogMapper` (via `@MapperScan`) | The MyBatis `@Mapper` carrying the INSERT SQL | +| `ApiLogMybatisSchemaInitializer` | Runs `V1.0__create_api_log.sql` against the `DataSource` (BUILTIN mode only) | + +`@MapperScan(basePackageClasses = ApiLogMapper.class)` is applied +automatically. If your application already has its own `@MapperScan` for +different packages, ours composes additively — both scans run. + +## The mapper + +```java +package kr.devslab.apilog.mybatis.mapper; + +@Mapper +public interface ApiLogMapper { + + @Insert(""" + INSERT INTO api_log + (event_type, request_id, endpoint, payload, response, + status_code, error_message, timestamp, retry_count, is_retry) + VALUES + (#{eventType}, + #{requestId}, + #{endpoint}, + CAST(#{payload,jdbcType=VARCHAR} AS jsonb), + CAST(#{response,jdbcType=VARCHAR} AS jsonb), + #{statusCode,jdbcType=INTEGER}, + CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb), + #{timestamp}, + #{retryCount}, + #{isRetry}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + void insert(ApiLogRow row); + + @Select(""" + SELECT id, event_type AS eventType, request_id AS requestId, endpoint, + payload::text AS payload, response::text AS response, + status_code AS statusCode, error_message::text AS errorMessage, + timestamp, retry_count AS retryCount, is_retry AS isRetry + FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC + """) + List findByRequestId(String requestId); +} +``` + +### Why `CAST(...,jdbcType=VARCHAR) AS jsonb` + +PostgreSQL won't implicitly cast a Java `String` parameter into a `JSONB` +column. Two ways to bridge that: + +1. **Custom `TypeHandler`** — boilerplate, requires registering the handler + per JSONB column. +2. **Explicit cast in SQL** — what this mapper does. One line per column, + nothing else to wire up. + +The `jdbcType=VARCHAR` annotation forces a VARCHAR binding even when the +value is `null`, side-stepping PostgreSQL's "could not determine data type of +parameter" error on null JSONB binds. + +### The row type + +```java +public class ApiLogRow { + private Long id; + private String eventType; + private String requestId; + private String endpoint; + private String payload; // JSON string — canonical form from PayloadJsonMapper + private String response; // JSON string + private Integer statusCode; + private String errorMessage; // JSON string + private LocalDateTime timestamp; + private Integer retryCount; + private Boolean isRetry; +} +``` + +The three "JSON" fields are plain `String`. `PayloadJsonMapper.toJsonString()` +(from `api-log-core`) produces the canonical JSON form, and the mapper's cast +turns it into JSONB on insert. + +## Transaction semantics + +`MybatisApiLogWriter` methods run in `@Transactional(propagation = REQUIRES_NEW)`: + +```java +@Override +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void writeInitiated(ApiCallInitiatedEvent event) { ... } +``` + +This matches the JPA backend's contract — audit writes must not roll back +with the caller's outer transaction. The audit row is committed on its own, +regardless of what the rest of the unit of work does. + +## Schema management + +The default is `api.log.schema.management=builtin`. The MyBatis backend uses +the JDBC-based `DataSourceScriptDatabaseInitializer` (same approach as the +JPA backend), since MyBatis itself runs on JDBC. The DDL uses `IF NOT EXISTS`, +so re-running on every boot is a no-op. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # default +``` + +Flyway integration **isn't bundled** in `api-log-mybatis` — only `api-log-jpa` +ships the `FlywayConfigurationCustomizer`. For MyBatis apps that want Flyway +to manage the table: + +- Add Flyway to your dependencies (`flyway-core` + `flyway-database-postgresql`). +- Set `spring.flyway.locations` to include `classpath:db/api-log` alongside + your own: + + ```yaml + spring: + flyway: + locations: + - classpath:db/migration # your own + - classpath:db/api-log # ours + ``` + +- Set `api.log.schema.management=none` so the BUILTIN initializer doesn't + fight Flyway's own bootstrap. + +`none` (apply the DDL yourself, e.g. via Liquibase or `psql`) is also valid: + +```yaml +api: + log: + schema: + management: none +``` + +## Reading rows back + +The bundled `findByRequestId` covers the common "timeline of one call" +query. For everything else, write your own queries on `ApiLogMapper` (in your +project's own mapper that extends the bundled one, or in a separate mapper): + +```java +@Mapper +public interface MyApiLogQueries { + + @Select(""" + SELECT COUNT(*) FILTER (WHERE event_type = 'ERROR') * 100.0 / COUNT(*) + FROM api_log + WHERE endpoint = #{endpoint} + AND timestamp > NOW() - INTERVAL '1 hour' + """) + Double errorRateLastHour(String endpoint); +} +``` + +The full JSONB query playbook (operators, GIN indexes, error rates) is in the +[Querying logs guide](querying-logs.md) — backend-independent. + +## Overriding the writer + +To customize how rows are written, provide your own `ApiLogWriter` bean — +the backend's `@ConditionalOnMissingBean(ApiLogWriter.class)` backs off: + +```java +@Bean +public ApiLogWriter customWriter(ApiLogMapper mapper, PayloadJsonMapper json, + PayloadMasker masker) { + return new MaskingMybatisApiLogWriter(mapper, json, masker); +} +``` + +Wrapping the bundled writer is usually enough: + +```java +public class MaskingMybatisApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final PayloadMasker masker; + + public MaskingMybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper json, + PayloadMasker masker) { + this.delegate = new MybatisApiLogWriter(mapper, json); + this.masker = masker; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(masker.mask(event)); + } + // ... same for writeSuccess / writeError +} +``` + +## See also + +- [Querying logs](querying-logs.md) — JSONB operator recipes, GIN indexes, + error rates. +- [Publishing events manually](publishing-events.md) — bring your own HTTP + client, just use the events. +- [Retry handling](retry-handling.md) — `RETRY_ERROR` semantics, listener + log-write retries. +- [Schema reference](../reference/schema.md) — column types, indexes, raw DDL. diff --git a/docs/guides/r2dbc-backend.ko.md b/docs/guides/r2dbc-backend.ko.md new file mode 100644 index 0000000..95edcc3 --- /dev/null +++ b/docs/guides/r2dbc-backend.ko.md @@ -0,0 +1,218 @@ +# R2DBC 백엔드 + +R2DBC 백엔드 (`api-log-r2dbc`)는 리액티브 `ConnectionFactory`로 감사 행을 +씁니다 — JDBC 드라이버도 없고, 블로킹 I/O도 없습니다. Spring WebFlux + R2DBC +스택에서 감사 로그도 리액티브 파이프라인에 그대로 흐르게 하고 싶을 때 (JDBC +브리지로 빠지지 않게) 이걸 고르세요. + +## 언제 선택하나 + +- 애플리케이션 스택이 WebFlux + R2DBC. +- 런타임 classpath에 JDBC 드라이버를 명시적으로 두기 싫을 때. +- Spring Data R2DBC 리포지토리 대신 `DatabaseClient`를 직접 쓰는 게 괜찮을 + 때 — 의존성 footprint를 최소로 유지하기 위한 의도된 트레이드오프입니다. + +Servlet 스택이라면 JPA가 더 자연스럽습니다 — [`api-log-jpa`](jpa-backend.md) +선택. 두 백엔드는 `api_log`에 동일한 행을 만듭니다. + +## 설치 + +=== "Maven" + + ```xml + + kr.devslab + api-log-r2dbc + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-r2dbc:0.6.0") + ``` + +`api-log-r2dbc`는 `api-log-core`와 `spring-r2dbc` (`DatabaseClient`), +`r2dbc-postgresql` (runtime), `reactor-core`를 transitive하게 가져옵니다. +**JDBC 의존성 없음** — Hibernate, HikariCP, `spring-jdbc`가 사용자 앱에서 +다른 경로로 들어오지 않는 한 classpath에 안 올라옵니다. + +PostgreSQL용 `ConnectionFactory` 빈은 별도로 필요합니다 — 가장 쉬운 방법은 +Spring Boot 자동 구성: + +```yaml title="application.yml" +spring: + r2dbc: + url: r2dbc:postgresql://localhost:5432/your_db + username: your_user + password: your_password +``` + +## 자동으로 등록되는 빈 + +`ConnectionFactory`가 classpath에 있고 `api.log.enabled=true`이면 +`ApiLogR2dbcAutoConfiguration`이 활성화되어 다음을 등록합니다: + +| 빈 | 역할 | +| --- | --- | +| `DatabaseClient` (`@ConditionalOnMissingBean`) | 사용자의 `ConnectionFactory`에서 구성 — Spring Boot가 이미 제공했다면 스킵 | +| `R2dbcApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 | +| `ApiLogR2dbcSchemaInitializer` | Spring Boot의 `R2dbcScriptDatabaseInitializer`로 `V1.0__create_api_log.sql`을 리액티브하게 실행 (BUILTIN 모드만) | + +스키마 초기화는 `ConnectionFactory`와만 통신합니다 — **JDBC DataSource가 +필요 없음**, 부팅 시에도. v0.6.0에서 이 백엔드가 약속하는 핵심: +완전 리액티브 `api_log` 설치. + +## 행이 어떻게 써지는가 + +`R2dbcApiLogWriter`는 Spring Data 리포지토리를 거치지 않습니다. +`DatabaseClient.sql(...)`로 파라미터화된 INSERT를 호출하고, fire-and-forget +의미로 인라인 subscribe 합니다: + +```java +spec.fetch() + .rowsUpdated() + .subscribe( + rows -> { /* success — 리스너가 이미 DEBUG로 로그 */ }, + ex -> log.error("R2DBC api_log insert failed: requestId={}, eventType={}", + requestId, eventType, ex) + ); +``` + +리스너는 반환된 리액티브 타입을 소비하지 않습니다 — 이벤트는 설계상 +fire-and-forget이고, @Async 래핑이 `Mono`를 의미 있게 전달해주지도 않습니다. +구독 에러는 로깅되지만 절대 재throw 되지 않습니다 — 감사 행 하나를 잃는다고 +사용자의 outbound HTTP 호출이 망가지면 안 되니까요. + +### `::jsonb` 캐스트 없이 JSONB 바인딩 + +JSONB 3개 컬럼 (`payload`, `response`, `error_message`)은 `R2dbcType.CLOB` +(text)으로 바인딩됩니다: + +```java +private static Object asJsonbParam(String value) { + return value == null + ? Parameters.in(R2dbcType.CLOB) + : Parameters.in(R2dbcType.CLOB, value); +} +``` + +PostgreSQL R2DBC 드라이버가 컬럼 레벨에서 `TEXT → JSONB` 묵시적 캐스트를 +처리해주므로 SQL에서 `::jsonb` 캐스트가 필요 없습니다 — 향후 다른 리액티브 +다이얼렉트가 등장해도 INSERT는 그대로 휴대 가능합니다. + +## 스키마 관리 + +기본값은 `api.log.schema.management=builtin`. 리액티브 초기화는 Spring Boot의 +`R2dbcScriptDatabaseInitializer`를 사용해서 첫 연결 시 번들된 DDL을 실행합니다. +`IF NOT EXISTS` 덕분에 부팅 간 멱등합니다. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # 기본값 +``` + +**R2DBC에서는 Flyway 모드가 지원되지 않습니다.** Flyway는 JDBC `DataSource`를 +요구합니다; Flyway 관리 스키마가 필요한 리액티브 앱은: + +- Spring Boot 기본 Flyway 자동 구성을 R2DBC와 함께 설치 (Flyway는 마이그레이션 + 전용으로 자체 JDBC 연결을 열고 — R2DBC 풀과 별개로 — 부팅 후 앱은 순수 + 리액티브로 유지됨), 그리고 `spring.flyway.locations`에 `classpath:db/api-log`를 + 직접 추가; 또는 +- 순수 리액티브가 hard requirement가 아니면 JPA 백엔드로 전환. + +`api.log.schema.management=none` (DDL 직접 적용)도 유효합니다: + +```yaml +api: + log: + schema: + management: none +``` + +## 리액티브 HTTP 클라이언트와의 조합 + +리액티브 백엔드는 [`ReactiveApiClientUtil`](reactive.md)과 자연스럽게 +짝지어집니다 — `Mono`를 반환하면서 `R2dbcApiLogWriter`가 소비하는 +이벤트를 발행합니다: + +```java +@Service +@RequiredArgsConstructor +public class ChargeService { + + private final ReactiveApiClientUtil api; + + public Mono charge(ChargeRequest input) { + return api.postTyped("/charges", input, ChargeResult.class); + } +} +``` + +End-to-end 리액티브: WebClient 호출 → 발행된 이벤트 → R2DBC writer → +PostgreSQL. 요청 경로 어디에도 블로킹 hop이 없습니다. + +## Writer 오버라이드 + +행 쓰기 방식을 커스터마이즈해야 할 때 (마스킹, 추가 컬럼, 다른 테이블 등)는 +직접 `ApiLogWriter` 빈을 정의 — 백엔드의 +`@ConditionalOnMissingBean(ApiLogWriter.class)`가 뒤로 빠집니다: + +```java +@Bean +public ApiLogWriter customWriter(DatabaseClient client, PayloadJsonMapper json) { + return new TenantAwareR2dbcApiLogWriter(client, json, tenantContext); +} +``` + +위임 패턴이 일반적: + +```java +public class TenantAwareR2dbcApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final TenantContext tenants; + + public TenantAwareR2dbcApiLogWriter(DatabaseClient client, PayloadJsonMapper json, + TenantContext tenants) { + this.delegate = new R2dbcApiLogWriter(client, json); + this.tenants = tenants; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(annotateTenant(event)); + } + // ... writeSuccess / writeError도 동일 +} +``` + +## 로그 조회 + +이 백엔드에는 리포지토리 추상화가 없습니다 — 행을 다시 읽어야 할 때는 +`DatabaseClient`를 직접 사용: + +```java +public Flux> timelineFor(String requestId) { + return databaseClient.sql(""" + SELECT event_type, endpoint, status_code, timestamp + FROM api_log WHERE request_id = :rid ORDER BY id ASC + """) + .bind("rid", requestId) + .fetch() + .all(); +} +``` + +더 깊은 질의 (JSONB 연산자, GIN 인덱스, 에러율 등)는 [로그 조회 +가이드](querying-logs.md) 참고 — SQL은 백엔드와 무관하게 동일합니다. + +## 더 읽어볼 거리 + +- [리액티브 HTTP 클라이언트](reactive.md) — `ReactiveApiClientUtil`, + 이 writer가 소비하는 이벤트를 발행하는 WebClient 기반 짝꿍. +- [로그 조회](querying-logs.md) — JSONB 연산자 레시피, 인덱스, 에러율. +- [스키마 레퍼런스](../reference/schema.md) — 컬럼 타입, 인덱스, 원본 DDL. diff --git a/docs/guides/r2dbc-backend.md b/docs/guides/r2dbc-backend.md new file mode 100644 index 0000000..09701ac --- /dev/null +++ b/docs/guides/r2dbc-backend.md @@ -0,0 +1,222 @@ +# R2DBC backend + +The R2DBC backend (`api-log-r2dbc`) writes audit rows over a reactive +`ConnectionFactory` — no JDBC driver, no blocking I/O. Use it when your +application is built on Spring WebFlux + R2DBC and you want the audit log to +participate in the same reactive pipeline instead of forcing a JDBC bridge. + +## When to pick it + +- Your application stack is WebFlux + R2DBC. +- You explicitly don't want a JDBC driver on your runtime classpath. +- You're OK with the writer using `DatabaseClient` directly rather than going + through a Spring Data R2DBC repository — that's the chosen trade-off to + keep the dependency footprint minimal. + +If you have a Servlet stack, JPA is more idiomatic — pick +[`api-log-jpa`](jpa-backend.md) instead. The two backends produce identical +rows in `api_log`. + +## Install + +=== "Maven" + + ```xml + + kr.devslab + api-log-r2dbc + 0.6.0 + + ``` + +=== "Gradle (Kotlin DSL)" + + ```kotlin + implementation("kr.devslab:api-log-r2dbc:0.6.0") + ``` + +`api-log-r2dbc` transitively pulls in `api-log-core` plus `spring-r2dbc` +(`DatabaseClient`), `r2dbc-postgresql` (runtime), and `reactor-core`. **No +JDBC dependency** — Hibernate, HikariCP, and `spring-jdbc` stay off your +classpath unless something else in your app pulls them in. + +You still need a `ConnectionFactory` bean configured for PostgreSQL — the +easiest way is Spring Boot's auto-configuration: + +```yaml title="application.yml" +spring: + r2dbc: + url: r2dbc:postgresql://localhost:5432/your_db + username: your_user + password: your_password +``` + +## What gets registered + +When `ConnectionFactory` is on the classpath and `api.log.enabled=true`, +`ApiLogR2dbcAutoConfiguration` activates and registers: + +| Bean | Purpose | +| --- | --- | +| `DatabaseClient` (`@ConditionalOnMissingBean`) | Built from the consumer's `ConnectionFactory` — skipped if Spring Boot already provided one | +| `R2dbcApiLogWriter` | The `ApiLogWriter` implementation the core listener routes events through | +| `ApiLogR2dbcSchemaInitializer` | Runs `V1.0__create_api_log.sql` reactively via Spring Boot's `R2dbcScriptDatabaseInitializer` (BUILTIN mode only) | + +The schema initializer talks to `ConnectionFactory` directly — **no JDBC +DataSource is required**, even at boot. That's the v0.6.0 promise this +backend delivers: a fully reactive `api_log` install. + +## How rows get written + +`R2dbcApiLogWriter` doesn't go through a Spring Data repository. It calls +`DatabaseClient.sql(...)` with a parameterized INSERT, and subscribes inline +to make it fire-and-forget: + +```java +spec.fetch() + .rowsUpdated() + .subscribe( + rows -> { /* success — listener already logs at DEBUG */ }, + ex -> log.error("R2DBC api_log insert failed: requestId={}, eventType={}", + requestId, eventType, ex) + ); +``` + +The listener doesn't consume any returned reactive type — events are +fire-and-forget by design, and the @Async wrapping wouldn't propagate a `Mono` +usefully anyway. Subscription errors are logged but never rethrown — losing +one audit row must never break the consumer's outbound HTTP call. + +### JSONB binding without `::jsonb` casts + +The three JSONB columns (`payload`, `response`, `error_message`) are bound as +`R2dbcType.CLOB` (text): + +```java +private static Object asJsonbParam(String value) { + return value == null + ? Parameters.in(R2dbcType.CLOB) + : Parameters.in(R2dbcType.CLOB, value); +} +``` + +The PostgreSQL R2DBC driver handles the `TEXT → JSONB` implicit cast at the +column level, so no `::jsonb` cast is needed in the SQL — the INSERT stays +portable for the day another reactive dialect shows up. + +## Schema management + +The default is `api.log.schema.management=builtin`. The reactive initializer +uses Spring Boot's `R2dbcScriptDatabaseInitializer` and runs the bundled DDL +on first connection. `IF NOT EXISTS` makes it idempotent across boots. + +```yaml title="application.yml" +api: + log: + schema: + management: builtin # default +``` + +**Flyway mode is not supported under R2DBC.** Flyway requires a JDBC +`DataSource`; reactive apps that want Flyway-managed schema should either: + +- Install Spring Boot's standard Flyway auto-config alongside R2DBC (Flyway + opens its own JDBC connection just for migrations — separate from your + R2DBC pool — and the rest of the app stays pure-reactive after boot), and + add `classpath:db/api-log` to `spring.flyway.locations` themselves; or +- Switch to the JPA backend if pure-reactive isn't a hard requirement. + +`api.log.schema.management=none` (apply the DDL yourself) is also valid: + +```yaml +api: + log: + schema: + management: none +``` + +## Reactive HTTP client integration + +The reactive backend pairs naturally with +[`ReactiveApiClientUtil`](reactive.md), which returns `Mono` and +publishes the same events `R2dbcApiLogWriter` consumes: + +```java +@Service +@RequiredArgsConstructor +public class ChargeService { + + private final ReactiveApiClientUtil api; + + public Mono charge(ChargeRequest input) { + return api.postTyped("/charges", input, ChargeResult.class); + } +} +``` + +End-to-end reactive: WebClient call → published events → R2DBC writer → +PostgreSQL. No blocking hop anywhere on the request path. + +## Overriding the writer + +If you need to customize how rows are written (masking, extra columns, +different table), define your own `ApiLogWriter` bean — the backend's +`@ConditionalOnMissingBean(ApiLogWriter.class)` backs off: + +```java +@Bean +public ApiLogWriter customWriter(DatabaseClient client, PayloadJsonMapper json) { + return new TenantAwareR2dbcApiLogWriter(client, json, tenantContext); +} +``` + +A common pattern is delegation: + +```java +public class TenantAwareR2dbcApiLogWriter implements ApiLogWriter { + + private final ApiLogWriter delegate; + private final TenantContext tenants; + + public TenantAwareR2dbcApiLogWriter(DatabaseClient client, PayloadJsonMapper json, + TenantContext tenants) { + this.delegate = new R2dbcApiLogWriter(client, json); + this.tenants = tenants; + } + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + delegate.writeInitiated(annotateTenant(event)); + } + // ... same for writeSuccess / writeError +} +``` + +## Querying logs + +You're not given a repository abstraction in this backend — query +`DatabaseClient` directly when you need to read rows back: + +```java +public Flux> timelineFor(String requestId) { + return databaseClient.sql(""" + SELECT event_type, endpoint, status_code, timestamp + FROM api_log WHERE request_id = :rid ORDER BY id ASC + """) + .bind("rid", requestId) + .fetch() + .all(); +} +``` + +For deeper queries (JSONB operators, GIN indexes, error rates), see the +[Querying logs guide](querying-logs.md) — the SQL is the same regardless of +backend. + +## See also + +- [Reactive HTTP client](reactive.md) — `ReactiveApiClientUtil`, the + WebClient-backed companion that publishes the events this writer consumes. +- [Querying logs](querying-logs.md) — JSONB operator recipes, indexes, error + rates. +- [Schema reference](../reference/schema.md) — column types, indexes, raw DDL. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cbb34b3 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,39 @@ +# Project coordinates +GROUP=kr.devslab +VERSION=0.6.0-SNAPSHOT + +# Project metadata for Maven Central POM +POM_NAME=API Log Spring Boot Starter +POM_DESCRIPTION=Event-driven API call logging for Spring Boot. PostgreSQL JSONB storage with pluggable JPA / R2DBC / MyBatis backends. +POM_INCEPTION_YEAR=2026 +POM_URL=https://api-log.devslab.kr + +POM_LICENSE_NAME=The Apache License, Version 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_SCM_URL=https://github.com/devslab-kr/api-log +POM_SCM_CONNECTION=scm:git:https://github.com/devslab-kr/api-log.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/devslab-kr/api-log.git + +POM_ISSUE_SYSTEM=GitHub +POM_ISSUE_URL=https://github.com/devslab-kr/api-log/issues + +POM_DEVELOPER_ID=devslab +POM_DEVELOPER_NAME=Devslab +POM_DEVELOPER_URL=https://devslab.kr +POM_DEVELOPER_EMAIL=support@devslab.kr + +POM_ORGANIZATION_NAME=Devslab +POM_ORGANIZATION_URL=https://devslab.kr + +# Build performance +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Vanniktech maven-publish defaults (https://vanniktech.github.io/gradle-maven-publish-plugin/central/) +SONATYPE_HOST=CENTRAL_PORTAL +SONATYPE_AUTOMATIC_RELEASE=true +RELEASE_SIGNING_ENABLED=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/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/HEAD/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 + +CLASSPATH="\\\"\\\"" + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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" \ + -classpath "$CLASSPATH" \ + -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/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +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 + +goto fail + +: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 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jpa/build.gradle.kts b/jpa/build.gradle.kts new file mode 100644 index 0000000..f10ab3a --- /dev/null +++ b/jpa/build.gradle.kts @@ -0,0 +1,164 @@ +// :jpa — JPA (Hibernate) backend for the api-log starter. +// +// Published as `kr.devslab:api-log-jpa`. Depends transitively on `:core` so +// consumers add a single coordinate and get the full Servlet + JPA stack +// (event listener, HTTP utilities, writer, schema initializer, Flyway hook). + +plugins { + `java-library` + jacoco + id("org.springframework.boot") apply false + id("io.spring.dependency-management") + id("com.vanniktech.maven.publish") +} + +base { + archivesName.set("api-log-jpa") +} + +afterEvaluate { + tasks.named("mavenPlainJavadocJar").configure { + archiveBaseName.set("api-log-jpa") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all,-classfile,-processing,-serial", + "-Werror" + )) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("Xdoclint:none", true) + addBooleanOption("html5", true) + locale = "en_US" + } +} + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.6") + } +} + +dependencies { + // Core carries the event types, SPI, listener, HTTP utilities, and the + // V1.0 schema script (under classpath:db/api-log/). Pulled in as `api` so + // consumers see one coordinate. + api(project(":core")) + + // JPA + JDBC — the whole point of this artifact. + api("org.springframework.boot:spring-boot-starter-data-jpa") + + // PostgreSQL JDBC driver — runtime only; this starter is PostgreSQL-specific + // (JSONB columns + Hibernate's @JdbcTypeCode(JSON) mapping rely on it). + runtimeOnly("org.postgresql:postgresql") + + // Lombok — compile + annotation-processor only. + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + // Flyway is OPTIONAL — consumers who pick `api.log.schema.management=flyway` + // bring their own flyway-core + flyway-database-postgresql. The Flyway + // customizer in this module is gated by @ConditionalOnClass(FluentConfiguration.class) + // so absence is silent. + compileOnly("org.flywaydb:flyway-core:11.13.1") + + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-web") + testImplementation("org.assertj:assertj-core") + testImplementation("com.h2database:h2") + + // Testcontainers — real PostgreSQL backs the integration tests because + // Hibernate's JSONB mapping (@JdbcTypeCode(JSON)) is PostgreSQL-specific. + testImplementation(platform("org.testcontainers:testcontainers-bom:1.21.3")) + testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + + // Flyway runtime for the FlywayConfigurationCustomizer integration test + // (provides flyway-core + the PostgreSQL dialect plugin). + testImplementation("org.flywaydb:flyway-core:11.13.1") + testRuntimeOnly("org.flywaydb:flyway-database-postgresql:11.13.1") + + // MockWebServer drives the end-to-end HTTP integration tests + // (real HTTP through RestApiClientUtil → assert api_log rows via Testcontainers). + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } + systemProperty("file.encoding", "UTF-8") + finalizedBy(tasks.jacocoTestReport) +} + +jacoco { + toolVersion = "0.8.13" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +mavenPublishing { + coordinates( + providers.gradleProperty("GROUP").get(), + "api-log-jpa", + providers.gradleProperty("VERSION").get() + ) + + pom { + name.set("API Log Spring Boot Starter - JPA") + description.set("JPA + Hibernate backend for api-log. PostgreSQL JSONB columns mapped via @JdbcTypeCode(JSON). Pair with api-log-core.") + + developers { + developer { + id.set(providers.gradleProperty("POM_DEVELOPER_ID")) + name.set(providers.gradleProperty("POM_DEVELOPER_NAME")) + url.set(providers.gradleProperty("POM_DEVELOPER_URL")) + email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL")) + organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + } + + organization { + name.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + url.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + + issueManagement { + system.set(providers.gradleProperty("POM_ISSUE_SYSTEM")) + url.set(providers.gradleProperty("POM_ISSUE_URL")) + } + } +} diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogFlywayConfig.java b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogFlywayConfig.java similarity index 96% rename from src/main/java/kr/devslab/apilog/autoconfigure/ApiLogFlywayConfig.java rename to jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogFlywayConfig.java index f33076f..919585f 100644 --- a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogFlywayConfig.java +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogFlywayConfig.java @@ -1,4 +1,4 @@ -package kr.devslab.apilog.autoconfigure; +package kr.devslab.apilog.jpa.autoconfigure; import org.flywaydb.core.api.Location; import org.flywaydb.core.api.configuration.FluentConfiguration; @@ -19,7 +19,7 @@ *

    Activated only when: *

      *
    • {@code org.flywaydb.core} is on the classpath (Flyway is optional in this starter), AND
    • - *
    • {@code api.log.schema.management=flyway} is set (default is {@code NONE}).
    • + *
    • {@code api.log.schema.management=flyway} is set (default is {@code BUILTIN}).
    • *
    * *

    Behavior is additive: existing {@code spring.flyway.locations} are preserved, and the diff --git a/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java new file mode 100644 index 0000000..08e067f --- /dev/null +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java @@ -0,0 +1,73 @@ +package kr.devslab.apilog.jpa.autoconfigure; + +import kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; +import kr.devslab.apilog.jpa.writer.JpaApiLogWriter; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +import javax.sql.DataSource; + +/** + * JPA backend auto-configuration. Loads when JPA is on the classpath + * ({@link jakarta.persistence.Entity}) and registers: + * + *

      + *
    • {@link JpaApiLogWriter} — the {@link ApiLogWriter} implementation that + * the core listener routes events through.
    • + *
    • {@link ApiLogJpaSchemaInitializer} — runs {@code V1.0__create_api_log.sql} + * when {@code api.log.schema.management=builtin} (default).
    • + *
    + * + *

    {@code @EntityScan} + {@code @EnableJpaRepositories} are pointed explicitly + * at this module's packages so the consumer doesn't need to add them to their + * own {@code @SpringBootApplication} setup. + * + *

    {@code ApiLogFlywayConfig} is {@code @Imported} so it gets picked up too + * — its own {@code @ConditionalOnClass} + {@code @ConditionalOnProperty} + * gates keep it dormant unless Flyway is on the classpath AND the consumer + * opted in via {@code api.log.schema.management=flyway}. + */ +@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) +@ConditionalOnClass(jakarta.persistence.Entity.class) +@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) +@EntityScan(basePackageClasses = ApiLogEntity.class) +@EnableJpaRepositories(basePackageClasses = ApiLogRepository.class) +@Import(ApiLogFlywayConfig.class) +public class ApiLogJpaAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ApiLogWriter.class) + @ConditionalOnBean({ApiLogRepository.class, PayloadJsonMapper.class}) + public JpaApiLogWriter jpaApiLogWriter(ApiLogRepository repository, PayloadJsonMapper jsonMapper) { + return new JpaApiLogWriter(repository, jsonMapper); + } + + /** + * Creates the {@code api_log} table at startup when the consumer hasn't + * picked a different management strategy (the default — BUILTIN). The + * CREATE TABLE statements use IF NOT EXISTS, so this is idempotent and + * safe to re-run on every boot. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty( + prefix = "api.log.schema", + name = "management", + havingValue = "builtin", + matchIfMissing = true + ) + public ApiLogJpaSchemaInitializer apiLogJpaSchemaInitializer(DataSource dataSource) { + return new ApiLogJpaSchemaInitializer(dataSource); + } +} diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogSchemaInitializer.java b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaSchemaInitializer.java similarity index 88% rename from src/main/java/kr/devslab/apilog/autoconfigure/ApiLogSchemaInitializer.java rename to jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaSchemaInitializer.java index 85e6901..798cc48 100644 --- a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogSchemaInitializer.java +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaSchemaInitializer.java @@ -1,4 +1,4 @@ -package kr.devslab.apilog.autoconfigure; +package kr.devslab.apilog.jpa.autoconfigure; import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; import org.springframework.boot.sql.init.DatabaseInitializationMode; @@ -25,12 +25,12 @@ *

    To opt out of this behavior, set {@code api.log.schema.management=none} * (apply the DDL yourself) or {@code flyway} (let Flyway own it). */ -public class ApiLogSchemaInitializer extends DataSourceScriptDatabaseInitializer { +public class ApiLogJpaSchemaInitializer extends DataSourceScriptDatabaseInitializer { - /** Classpath path of the bundled schema script (shared with {@link ApiLogFlywayConfig}). */ + /** Classpath path of the bundled schema script (shared with {@code ApiLogFlywayConfig}). */ public static final String SCHEMA_SCRIPT = "classpath:db/api-log/V1.0__create_api_log.sql"; - public ApiLogSchemaInitializer(DataSource dataSource) { + public ApiLogJpaSchemaInitializer(DataSource dataSource) { super(dataSource, settings()); } diff --git a/src/main/java/kr/devslab/apilog/model/ApiLogEntity.java b/jpa/src/main/java/kr/devslab/apilog/jpa/model/ApiLogEntity.java similarity index 67% rename from src/main/java/kr/devslab/apilog/model/ApiLogEntity.java rename to jpa/src/main/java/kr/devslab/apilog/jpa/model/ApiLogEntity.java index f9cc527..cf54444 100644 --- a/src/main/java/kr/devslab/apilog/model/ApiLogEntity.java +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/model/ApiLogEntity.java @@ -1,4 +1,4 @@ -package kr.devslab.apilog.model; +package kr.devslab.apilog.jpa.model; import com.fasterxml.jackson.databind.JsonNode; import jakarta.persistence.Column; @@ -17,6 +17,20 @@ import java.time.LocalDateTime; +/** + * JPA entity mapping for the {@code api_log} table. + * + *

    The three JSONB columns ({@code payload}, {@code response}, + * {@code error_message}) use Hibernate's {@code @JdbcTypeCode(SqlTypes.JSON)} + * which delegates to the PostgreSQL dialect's JSONB binder. The corresponding + * R2DBC + MyBatis backends store the same columns differently + * (Json type / TypeHandler). + * + *

    v0.6.0 — moved from {@code kr.devslab.apilog.model} to + * {@code kr.devslab.apilog.jpa.model} as part of the multi-module split. + * Consumers who imported {@code ApiLogEntity} directly will need to update + * their import. + */ @Entity @Table(name = "api_log") @Getter @@ -57,4 +71,4 @@ public class ApiLogEntity { @Column(name = "is_retry") private Boolean isRetry; -} \ No newline at end of file +} diff --git a/src/main/java/kr/devslab/apilog/repository/ApiLogRepository.java b/jpa/src/main/java/kr/devslab/apilog/jpa/repository/ApiLogRepository.java similarity index 54% rename from src/main/java/kr/devslab/apilog/repository/ApiLogRepository.java rename to jpa/src/main/java/kr/devslab/apilog/jpa/repository/ApiLogRepository.java index bd60801..77d8fdf 100644 --- a/src/main/java/kr/devslab/apilog/repository/ApiLogRepository.java +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/repository/ApiLogRepository.java @@ -1,11 +1,16 @@ -package kr.devslab.apilog.repository; +package kr.devslab.apilog.jpa.repository; -import kr.devslab.apilog.model.ApiLogEntity; +import kr.devslab.apilog.jpa.model.ApiLogEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +/** + * Spring Data JPA repository for {@link ApiLogEntity}. Only convenience + * lookups are exposed; rich querying belongs in the consumer's own services + * (this starter's job is to keep the table populated, not to be a reporting API). + */ @Repository public interface ApiLogRepository extends JpaRepository { @@ -14,4 +19,4 @@ public interface ApiLogRepository extends JpaRepository { List findByEventType(String eventType); List findByEndpoint(String endpoint); -} \ No newline at end of file +} diff --git a/jpa/src/main/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriter.java b/jpa/src/main/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriter.java new file mode 100644 index 0000000..651689a --- /dev/null +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriter.java @@ -0,0 +1,94 @@ +package kr.devslab.apilog.jpa.writer; + +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.HttpErrorExtractor; +import kr.devslab.apilog.spi.HttpErrorInfo; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.RETRY_ERROR; +import static kr.devslab.apilog.Constants.SUCCESS; + +/** + * JPA implementation of {@link ApiLogWriter}. Persists every event as a new + * row in the {@code api_log} table. + * + *

    Each write runs in {@link Propagation#REQUIRES_NEW} so the audit write + * never participates in (and never breaks) the consumer's outer transaction — + * a rollback in the caller's business code mustn't erase log rows for calls + * that already happened. + * + *

    v0.6.0 — this is the same logic that lived in the old + * {@code kr.devslab.apilog.service.ApiLogService}, now repackaged as a writer + * and exposed via the {@link ApiLogWriter} SPI so the core listener can talk + * to it without an import cycle. + */ +@RequiredArgsConstructor +public class JpaApiLogWriter implements ApiLogWriter { + + private final ApiLogRepository repository; + private final PayloadJsonMapper jsonMapper; + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeInitiated(ApiCallInitiatedEvent event) { + ApiLogEntity entity = ApiLogEntity.builder() + .eventType(INITIATED) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonNode(event.getRequest().getPayload())) + .timestamp(LocalDateTime.now()) + .retryCount(0) + .isRetry(false) + .build(); + repository.save(entity); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeSuccess(ApiCallSuccessEvent event) { + ApiLogEntity entity = ApiLogEntity.builder() + .eventType(SUCCESS) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonNode(event.getRequest().getPayload())) + .response(jsonMapper.toJsonNode(event.getResponse().getData())) + .statusCode(event.getResponse().getStatusCode()) + .timestamp(LocalDateTime.now()) + .retryCount(0) + .isRetry(false) + .build(); + repository.save(entity); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeError(ApiCallErrorEvent event) { + Throwable error = event.getError(); + HttpErrorInfo info = HttpErrorExtractor.extract(error); + + ApiLogEntity entity = ApiLogEntity.builder() + .eventType(event.isRetry() ? RETRY_ERROR : ERROR) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonNode(event.getRequest().getPayload())) + .errorMessage(jsonMapper.buildErrorJson(error, info.responseBody())) + .statusCode(info.statusCode()) + .timestamp(LocalDateTime.now()) + .retryCount(event.getRetryCount()) + .isRetry(event.isRetry()) + .build(); + repository.save(entity); + } +} diff --git a/jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e0a8cfe --- /dev/null +++ b/jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +kr.devslab.apilog.jpa.autoconfigure.ApiLogJpaAutoConfiguration diff --git a/jpa/src/test/java/kr/devslab/apilog/TestApp.java b/jpa/src/test/java/kr/devslab/apilog/TestApp.java new file mode 100644 index 0000000..e29efea --- /dev/null +++ b/jpa/src/test/java/kr/devslab/apilog/TestApp.java @@ -0,0 +1,10 @@ +package kr.devslab.apilog; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Bootstrap class for {@code @SpringBootTest} in the :jpa module. + */ +@SpringBootApplication +public class TestApp { +} diff --git a/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java b/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java new file mode 100644 index 0000000..0457f14 --- /dev/null +++ b/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java @@ -0,0 +1,114 @@ +package kr.devslab.apilog.jpa.autoconfigure; + +import kr.devslab.apilog.spi.ApiLogWriter; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.blackbird.BlackbirdModule; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.core.task.TaskExecutor; +import org.springframework.core.task.VirtualThreadTaskExecutor; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Boots a full Spring context with the JPA backend installed and verifies the + * v0.6.0 module split wires everything up correctly: + *

      + *
    • The three auto-configurations from :core load (core / rest / reactive).
    • + *
    • The JPA auto-config from :jpa loads, registering a {@link ApiLogWriter}.
    • + *
    • Blackbird-enabled ObjectMapper + RestClient + virtual-thread executor + * are all in the context.
    • + *
    + */ +@SpringBootTest +@Testcontainers +class ConfigurationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") + .withDatabaseName("configtest") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.threads.virtual.enabled", () -> "true"); + } + + @Autowired + private ApplicationContext applicationContext; + + @Test + void jacksonCustomizer_installsBlackbird() { + ObjectMapper objectMapper = applicationContext.getBean(ObjectMapper.class); + assertThat(objectMapper).isNotNull(); + assertThat(objectMapper.getRegisteredModuleIds()) + .contains(BlackbirdModule.class.getName()); + } + + @Test + void mappingJackson2HttpMessageConverter_isRegistered_withBlackbird() { + MappingJackson2HttpMessageConverter converter = + applicationContext.getBean(MappingJackson2HttpMessageConverter.class); + assertThat(converter).isNotNull(); + assertThat(converter.getObjectMapper().getRegisteredModuleIds()) + .contains(BlackbirdModule.class.getName()); + } + + @Test + void restClient_isAutoConfigured() { + RestClient restClient = applicationContext.getBean(RestClient.class); + assertThat(restClient).isNotNull(); + } + + @Test + void virtualThreadExecutor_isWhatWeUseWhenSpringVirtualThreadsAreOn() { + TaskExecutor taskExecutor = applicationContext.getBean("apiLogVirtualThreadExecutor", TaskExecutor.class); + assertThat(taskExecutor).isInstanceOf(VirtualThreadTaskExecutor.class); + } + + @Test + void platformThreadExecutor_isAbsentWhenVirtualThreadsAreOn() { + assertThat(applicationContext.containsBean("apiLogPlatformThreadExecutor")).isFalse(); + } + + @Test + void retryConfig_isImported() { + assertThat(applicationContext.containsBean("retryConfig")).isTrue(); + } + + @Test + void apiLogWriter_isProvidedByJpaBackend() { + ApiLogWriter writer = applicationContext.getBean(ApiLogWriter.class); + assertThat(writer.getClass().getSimpleName()).isEqualTo("JpaApiLogWriter"); + } + + @Test + void allAutoConfigurationsAreLoaded() { + // From :core + assertThat(applicationContext.containsBean( + "kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration")).isTrue(); + assertThat(applicationContext.containsBean( + "kr.devslab.apilog.autoconfigure.RestApiClientAutoConfiguration")).isTrue(); + assertThat(applicationContext.containsBean( + "kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration")).isTrue(); + // From :jpa + assertThat(applicationContext.containsBean( + "kr.devslab.apilog.jpa.autoconfigure.ApiLogJpaAutoConfiguration")).isTrue(); + // RetryConfig is @Imported by ApiLogCoreAutoConfiguration. + assertThat(applicationContext.containsBean("retryConfig")).isTrue(); + } +} diff --git a/jpa/src/test/java/kr/devslab/apilog/jpa/repository/ApiLogRepositoryTest.java b/jpa/src/test/java/kr/devslab/apilog/jpa/repository/ApiLogRepositoryTest.java new file mode 100644 index 0000000..931e4e0 --- /dev/null +++ b/jpa/src/test/java/kr/devslab/apilog/jpa/repository/ApiLogRepositoryTest.java @@ -0,0 +1,171 @@ +package kr.devslab.apilog.jpa.repository; + +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.util.List; + +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.SUCCESS; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Real-database repository test for the JPA backend. JSONB columns are + * PostgreSQL-specific, so this runs against a Testcontainers Postgres rather + * than H2. + * + *

    {@code @DataJpaTest} doesn't auto-pick up our autoconfig, so we point + * {@code @EntityScan} + {@code @EnableJpaRepositories} at the api-log packages + * via a nested test config. + */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ApiLogRepositoryTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + } + + @Configuration + @EntityScan(basePackageClasses = ApiLogEntity.class) + @EnableJpaRepositories(basePackageClasses = ApiLogRepository.class) + static class RepoConfig { + } + + @Autowired + private ApiLogRepository repository; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + void save_persistsApiLogEntity() throws Exception { + JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); + JsonNode response = objectMapper.readTree("{\"result\":\"success\"}"); + + ApiLogEntity entity = ApiLogEntity.builder() + .eventType(SUCCESS) + .requestId("test-request-id") + .endpoint("/api/test") + .payload(payload) + .response(response) + .statusCode(200) + .timestamp(LocalDateTime.now()) + .retryCount(0) + .isRetry(false) + .build(); + + ApiLogEntity saved = repository.save(entity); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getEventType()).isEqualTo(SUCCESS); + assertThat(saved.getRequestId()).isEqualTo("test-request-id"); + assertThat(saved.getEndpoint()).isEqualTo("/api/test"); + assertThat(saved.getPayload()).isEqualTo(payload); + assertThat(saved.getResponse()).isEqualTo(response); + assertThat(saved.getStatusCode()).isEqualTo(200); + } + + @Test + void findByRequestId_returnsAllRowsForOneCall() throws Exception { + String requestId = "test-request-123"; + JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); + + repository.save(ApiLogEntity.builder() + .eventType(INITIATED).requestId(requestId).endpoint("/api/test") + .payload(payload).timestamp(LocalDateTime.now()) + .retryCount(0).isRetry(false).build()); + + repository.save(ApiLogEntity.builder() + .eventType(SUCCESS).requestId(requestId).endpoint("/api/test") + .payload(payload).response(objectMapper.readTree("{\"result\":\"success\"}")) + .statusCode(200).timestamp(LocalDateTime.now().plusSeconds(1)) + .retryCount(0).isRetry(false).build()); + + List found = repository.findByRequestId(requestId); + + assertThat(found).hasSize(2); + assertThat(found).extracting(ApiLogEntity::getEventType) + .containsExactlyInAnyOrder(INITIATED, SUCCESS); + } + + @Test + void findByEventType_returnsAllRowsOfOneEventType() throws Exception { + JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); + JsonNode errMsg = objectMapper.readTree("{\"error\":\"x\"}"); + + repository.save(ApiLogEntity.builder() + .eventType(ERROR).requestId("r-1").endpoint("/a").payload(payload) + .errorMessage(errMsg).timestamp(LocalDateTime.now()) + .retryCount(0).isRetry(false).build()); + repository.save(ApiLogEntity.builder() + .eventType(ERROR).requestId("r-2").endpoint("/b").payload(payload) + .errorMessage(errMsg).timestamp(LocalDateTime.now()) + .retryCount(1).isRetry(false).build()); + repository.save(ApiLogEntity.builder() + .eventType(SUCCESS).requestId("r-3").endpoint("/c").payload(payload) + .statusCode(200).timestamp(LocalDateTime.now()) + .retryCount(0).isRetry(false).build()); + + List errors = repository.findByEventType(ERROR); + + assertThat(errors).hasSize(2); + assertThat(errors).extracting(ApiLogEntity::getRequestId) + .containsExactlyInAnyOrder("r-1", "r-2"); + } + + @Test + void save_roundtripsComplexJsonbValues() throws Exception { + JsonNode complexPayload = objectMapper.readTree(""" + { "user": { "id": 1, "name": "John", "prefs": { "theme": "dark" } }, + "items": [ { "id": 1 }, { "id": 2 } ] } + """); + JsonNode complexError = objectMapper.readTree(""" + { "error": "ValidationError", + "details": { "field": "email", "message": "Invalid email format" } } + """); + + ApiLogEntity entity = ApiLogEntity.builder() + .eventType(ERROR).requestId("complex-request").endpoint("/api/users") + .payload(complexPayload).errorMessage(complexError) + .timestamp(LocalDateTime.now()) + .retryCount(1).isRetry(true).build(); + + ApiLogEntity saved = repository.save(entity); + + assertThat(saved.getPayload()).isEqualTo(complexPayload); + assertThat(saved.getErrorMessage()).isEqualTo(complexError); + assertThat(saved.getPayload().get("user").get("name").asText()).isEqualTo("John"); + } +} diff --git a/src/test/java/kr/devslab/apilog/service/ApiLogServiceTest.java b/jpa/src/test/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriterTest.java similarity index 50% rename from src/test/java/kr/devslab/apilog/service/ApiLogServiceTest.java rename to jpa/src/test/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriterTest.java index b0f84e6..df55309 100644 --- a/src/test/java/kr/devslab/apilog/service/ApiLogServiceTest.java +++ b/jpa/src/test/java/kr/devslab/apilog/jpa/writer/JpaApiLogWriterTest.java @@ -1,12 +1,13 @@ -package kr.devslab.apilog.service; +package kr.devslab.apilog.jpa.writer; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; import kr.devslab.apilog.event.ApiCallErrorEvent; import kr.devslab.apilog.event.ApiCallInitiatedEvent; import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.ApiLogEntity; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; -import kr.devslab.apilog.repository.ApiLogRepository; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; +import kr.devslab.apilog.spi.PayloadJsonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,43 +18,47 @@ import org.springframework.http.HttpStatus; import org.springframework.web.client.HttpClientErrorException; -import static kr.devslab.apilog.Constants.*; +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.RETRY_ERROR; +import static kr.devslab.apilog.Constants.SUCCESS; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link JpaApiLogWriter}. The repository is mocked so we can + * assert exactly what the writer produces for each event type without + * spinning up a database. + * + *

    v0.6.0 — this is the same coverage that used to live in + * {@code ApiLogServiceTest}, but pointed at the new writer interface. + */ @ExtendWith(MockitoExtension.class) -class ApiLogServiceTest { +class JpaApiLogWriterTest { @Mock private ApiLogRepository repository; - private ObjectMapper objectMapper; - private ApiLogService apiLogService; + private JpaApiLogWriter writer; private ArgumentCaptor entityCaptor; @BeforeEach void setUp() { - objectMapper = new ObjectMapper(); // 실제 ObjectMapper 사용 - apiLogService = new ApiLogService(repository, objectMapper); + writer = new JpaApiLogWriter(repository, new PayloadJsonMapper(new ObjectMapper())); entityCaptor = ArgumentCaptor.forClass(ApiLogEntity.class); } @Test - void saveApiCallInitiated_shouldSaveEntityWithCorrectData() { - // Given + void writeInitiated_savesEntityWithCorrectData() { ApiRequest request = ApiRequest.builder() .endpoint("/api/test") .payload("{\"test\":\"data\"}") .build(); - ApiCallInitiatedEvent event = new ApiCallInitiatedEvent(this, request); - // When - apiLogService.saveApiCallInitiated(event); + writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - assertThat(saved.getEventType()).isEqualTo(INITIATED); assertThat(saved.getRequestId()).isEqualTo(request.getRequestId()); assertThat(saved.getEndpoint()).isEqualTo("/api/test"); @@ -63,8 +68,7 @@ void saveApiCallInitiated_shouldSaveEntityWithCorrectData() { } @Test - void saveApiCallSuccess_shouldSaveEntityWithResponseData() { - // Given + void writeSuccess_savesEntityWithResponseData() { ApiRequest request = ApiRequest.builder() .endpoint("/api/test") .payload("{\"test\":\"data\"}") @@ -73,121 +77,77 @@ void saveApiCallSuccess_shouldSaveEntityWithResponseData() { .data("{\"result\":\"success\"}") .statusCode(200) .build(); - ApiCallSuccessEvent event = new ApiCallSuccessEvent(this, request, response); - // When - apiLogService.saveApiCallSuccess(event); + writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - assertThat(saved.getEventType()).isEqualTo(SUCCESS); - assertThat(saved.getRequestId()).isEqualTo(request.getRequestId()); - assertThat(saved.getEndpoint()).isEqualTo("/api/test"); assertThat(saved.getStatusCode()).isEqualTo(200); - assertThat(saved.getRetryCount()).isEqualTo(0); - assertThat(saved.getIsRetry()).isFalse(); - assertThat(saved.getTimestamp()).isNotNull(); } @Test - void saveApiCallError_shouldSaveEntityWithErrorData() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - RuntimeException error = new RuntimeException("Test error"); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 1, false); + void writeError_savesErrorWithRetryFlag() { + ApiRequest request = ApiRequest.builder().endpoint("/api/test").build(); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, + new RuntimeException("Test error"), 1, false); - // When - apiLogService.saveApiCallError(event); + writer.writeError(event); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - assertThat(saved.getEventType()).isEqualTo(ERROR); - assertThat(saved.getRequestId()).isEqualTo(request.getRequestId()); - assertThat(saved.getEndpoint()).isEqualTo("/api/test"); assertThat(saved.getRetryCount()).isEqualTo(1); assertThat(saved.getIsRetry()).isFalse(); - assertThat(saved.getTimestamp()).isNotNull(); } @Test - void saveApiCallError_shouldSaveRetryError() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - RuntimeException error = new RuntimeException("Retry error"); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 2, true); + void writeError_marksRetryErrorWhenRetryFlagSet() { + ApiRequest request = ApiRequest.builder().endpoint("/api/test").build(); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, + new RuntimeException("Retry error"), 2, true); - // When - apiLogService.saveApiCallError(event); + writer.writeError(event); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - assertThat(saved.getEventType()).isEqualTo(RETRY_ERROR); assertThat(saved.getRetryCount()).isEqualTo(2); assertThat(saved.getIsRetry()).isTrue(); } @Test - void saveApiCallInitiated_shouldHandleNullPayload() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .build(); // payload is null - ApiCallInitiatedEvent event = new ApiCallInitiatedEvent(this, request); + void writeInitiated_handlesNullPayload() { + ApiRequest request = ApiRequest.builder().endpoint("/api/test").build(); - // When - apiLogService.saveApiCallInitiated(event); + writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - - assertThat(saved.getEventType()).isEqualTo(INITIATED); - assertThat(saved.getEndpoint()).isEqualTo("/api/test"); - assertThat(saved.getPayload()).isNotNull(); // Should create empty ObjectNode + assertThat(saved.getPayload()).isNotNull(); // empty ObjectNode, not null } @Test - void saveApiCallError_writesStructuredErrorMessage() { - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .build(); + void writeError_writesStructuredErrorMessage() { + ApiRequest request = ApiRequest.builder().endpoint("/api/test").build(); IllegalStateException error = new IllegalStateException("connection broken"); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 0, false); - apiLogService.saveApiCallError(event); + writer.writeError(new ApiCallErrorEvent(this, request, error, 0, false)); verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - - // Should be a structured {type, message} object, not just a raw string. assertThat(saved.getErrorMessage()).isNotNull(); assertThat(saved.getErrorMessage().get("type").asText()) .isEqualTo("java.lang.IllegalStateException"); assertThat(saved.getErrorMessage().get("message").asText()) .isEqualTo("connection broken"); - // No upstream response body for a non-HTTP exception. assertThat(saved.getErrorMessage().has("responseBody")).isFalse(); - // Non-HTTP exceptions don't carry a status code. assertThat(saved.getStatusCode()).isNull(); } @Test - void saveApiCallError_extractsHttpStatusAndResponseBody() { - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .build(); + void writeError_extractsHttpStatusAndResponseBody() { + ApiRequest request = ApiRequest.builder().endpoint("/api/test").build(); HttpClientErrorException error = HttpClientErrorException.create( HttpStatus.NOT_FOUND, "Not Found", @@ -195,16 +155,12 @@ void saveApiCallError_extractsHttpStatusAndResponseBody() { "{\"error\":\"user not found\"}".getBytes(), null ); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 0, false); - apiLogService.saveApiCallError(event); + writer.writeError(new ApiCallErrorEvent(this, request, error, 0, false)); verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - - // status_code lifted off the Spring exception — was always NULL before v0.4.0. assertThat(saved.getStatusCode()).isEqualTo(404); - // error_message carries type + message + upstream responseBody. assertThat(saved.getErrorMessage().get("type").asText()) .contains("HttpClientErrorException"); assertThat(saved.getErrorMessage().get("responseBody").asText()) @@ -212,24 +168,17 @@ void saveApiCallError_extractsHttpStatusAndResponseBody() { } @Test - void saveApiCallInitiated_shouldHandleInvalidJson() { - // Given + void writeInitiated_handlesInvalidJson() { ApiRequest request = ApiRequest.builder() .endpoint("/api/test") .payload("invalid json {") .build(); - ApiCallInitiatedEvent event = new ApiCallInitiatedEvent(this, request); - // When - apiLogService.saveApiCallInitiated(event); + writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - // Then verify(repository).save(entityCaptor.capture()); ApiLogEntity saved = entityCaptor.getValue(); - - assertThat(saved.getEventType()).isEqualTo(INITIATED); - assertThat(saved.getEndpoint()).isEqualTo("/api/test"); - assertThat(saved.getPayload()).isNotNull(); // Should create fallback node with raw field - assertThat(saved.getPayload().has("raw")).isTrue(); // Should have raw field for invalid JSON + assertThat(saved.getPayload()).isNotNull(); + assertThat(saved.getPayload().has("raw")).isTrue(); } -} \ No newline at end of file +} diff --git a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java b/jpa/src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java similarity index 77% rename from src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java rename to jpa/src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java index c395d40..6818b7c 100644 --- a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java +++ b/jpa/src/test/java/kr/devslab/apilog/util/RestApiClientUtilHttpIntegrationTest.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import kr.devslab.apilog.model.ApiLogEntity; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; -import kr.devslab.apilog.repository.ApiLogRepository; +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.jpa.model.ApiLogEntity; +import kr.devslab.apilog.jpa.repository.ApiLogRepository; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.awaitility.Awaitility; @@ -37,7 +37,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * End-to-end HTTP integration test for {@link RestApiClientUtil}. + * End-to-end HTTP integration test for {@link RestApiClientUtil} on the JPA + * backend. * *

    What this proves that the unit tests do NOT: *

      @@ -51,10 +52,6 @@ *
    • A caller-supplied {@code requestId} via {@code send()} correlates all * retry attempts in {@code api_log}.
    • *
    - * - *

    Drives real HTTP traffic through an in-process {@link MockWebServer}, - * persists to a real PostgreSQL 15 container via Testcontainers, and waits - * for the async listener to drain before asserting. */ @SpringBootTest @Testcontainers @@ -69,9 +66,6 @@ class RestApiClientUtilHttpIntegrationTest { static final MockWebServer mockServer; static { - // MockWebServer must be running before Spring wires the RestClient bean - // below — static initializer guarantees that ordering, JUnit's @BeforeAll - // would fire too late. mockServer = new MockWebServer(); try { mockServer.start(); @@ -86,7 +80,6 @@ static void configure(DynamicPropertyRegistry registry) { registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); - // BUILTIN strategy creates the api_log table for us. registry.add("api.log.schema.management", () -> "builtin"); } @@ -97,10 +90,6 @@ static void stopMockServer() throws IOException { @TestConfiguration static class HttpTestConfig { - /** - * Override the auto-configured {@link RestClient} so {@link RestApiClientUtil} - * targets the in-process {@link MockWebServer} instead of a real network. - */ @Bean @Primary RestClient testRestClient() { @@ -122,8 +111,6 @@ RestClient testRestClient() { @BeforeEach void clearLog() throws InterruptedException { repository.deleteAll(); - // Drain MockWebServer's recorded-request queue so verb assertions in - // later tests don't pick up the previous test's request. while (mockServer.getRequestCount() > 0 && mockServer.takeRequest(1, TimeUnit.MILLISECONDS) != null) { // discard } @@ -142,7 +129,6 @@ void getSync_2xx_propagatesActualStatusCodeIntoApiLog() { ApiResponse resp = api.getSync("/users/1"); - // The bug we fixed in v0.4.0: was hardcoded 200, must be the real upstream status. assertThat(resp.getStatusCode()).isEqualTo(201); ApiLogEntity successRow = waitForRow("SUCCESS"); @@ -163,18 +149,12 @@ void postSync_writesInitiatedAndSuccessRowsWithSameRequestId() { Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { List rows = repository.findAll(); assertThat(rows).hasSize(2); - - // Same UUID across the two rows of one logical call. assertThat(rows.stream().map(ApiLogEntity::getRequestId).distinct()).hasSize(1); assertThat(rows.stream().map(ApiLogEntity::getEventType)) .containsExactlyInAnyOrder("INITIATED", "SUCCESS"); }); } - // ------------------------------------------------------------------ // - // Error paths // - // ------------------------------------------------------------------ // - @Test void clientError_4xx_capturesStatusCodeAndStructuredErrorMessage() { mockServer.enqueue(new MockResponse() @@ -187,11 +167,7 @@ void clientError_4xx_capturesStatusCodeAndStructuredErrorMessage() { ApiLogEntity errorRow = waitForRow("ERROR"); - // Before v0.4.0 this was always NULL — should now be 404. assertThat(errorRow.getStatusCode()).isEqualTo(404); - - // Before v0.4.0 error_message was a raw string or {raw: "..."} — should - // now be the structured form with type / message / responseBody. JsonNode err = errorRow.getErrorMessage(); assertThat(err.get("type").asText()).contains("HttpClientErrorException"); assertThat(err.has("message")).isTrue(); @@ -214,10 +190,6 @@ void serverError_5xx_capturesStatusCode() { .isEqualTo("service unavailable"); } - // ------------------------------------------------------------------ // - // Async // - // ------------------------------------------------------------------ // - @Test void getAsync_publishesEventsAndLogsRows() throws Exception { mockServer.enqueue(new MockResponse() @@ -236,16 +208,10 @@ void getAsync_publishesEventsAndLogsRows() throws Exception { .containsExactlyInAnyOrder("INITIATED", "SUCCESS")); } - // ------------------------------------------------------------------ // - // send() with caller-supplied requestId — retry correlation // - // ------------------------------------------------------------------ // - @Test void send_withCustomRequestId_correlatesAttemptsForRetryTimeline() { - // VARCHAR(36) limits us to UUID-sized correlation keys. Plain UUID is 36 chars. String correlationId = UUID.randomUUID().toString(); - // Simulate the "fail twice then succeed" retry pattern. mockServer.enqueue(new MockResponse().setResponseCode(503)); mockServer.enqueue(new MockResponse().setResponseCode(503)); mockServer.enqueue(new MockResponse() @@ -259,8 +225,6 @@ void send_withCustomRequestId_correlatesAttemptsForRetryTimeline() { .requestId(correlationId) .build(); - // Caller-driven retry loop. Each attempt reuses the same ApiRequest, so - // the requestId stays constant — that's the whole point of send(). Exception last = null; for (int attempt = 0; attempt < 3; attempt++) { try { @@ -273,8 +237,6 @@ void send_withCustomRequestId_correlatesAttemptsForRetryTimeline() { } assertThat(last).isNull(); - // We expect 6 rows all sharing the correlation id: INITIATED + ERROR - // (2x) then INITIATED + SUCCESS. Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { List rows = repository.findAll().stream() .filter(r -> correlationId.equals(r.getRequestId())) @@ -289,17 +251,10 @@ void send_withCustomRequestId_correlatesAttemptsForRetryTimeline() { }); } - // ------------------------------------------------------------------ // - // HTTP verb coverage (smoke) // - // ------------------------------------------------------------------ // - @Test void putSync_routesPutAndLogsCorrectly() throws InterruptedException { mockServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - api.putSync("/users/1", "{\"name\":\"Ada-renamed\"}"); - - // The fake server saw a PUT. assertThat(mockServer.takeRequest().getMethod()).isEqualTo("PUT"); waitForRow("SUCCESS"); } @@ -307,9 +262,7 @@ void putSync_routesPutAndLogsCorrectly() throws InterruptedException { @Test void deleteSync_routesDeleteAndLogsCorrectly() throws InterruptedException { mockServer.enqueue(new MockResponse().setResponseCode(204)); - api.deleteSync("/users/1"); - assertThat(mockServer.takeRequest().getMethod()).isEqualTo("DELETE"); ApiLogEntity success = waitForRow("SUCCESS"); assertThat(success.getStatusCode()).isEqualTo(204); @@ -318,21 +271,11 @@ void deleteSync_routesDeleteAndLogsCorrectly() throws InterruptedException { @Test void patchSync_routesPatchAndLogsCorrectly() throws InterruptedException { mockServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - api.patchSync("/users/1", "{\"email\":\"new@example.com\"}"); - assertThat(mockServer.takeRequest().getMethod()).isEqualTo("PATCH"); waitForRow("SUCCESS"); } - // ------------------------------------------------------------------ // - // Helpers // - // ------------------------------------------------------------------ // - - /** - * Polls until an {@code api_log} row of the given event type appears. - * Returns it so the test can assert on its columns. - */ private ApiLogEntity waitForRow(String eventType) { return Awaitility.await() .atMost(Duration.ofSeconds(5)) diff --git a/mkdocs.yml b/mkdocs.yml index 102a64e..9c55d5d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,10 @@ plugins: Installation: 설치 Quickstart: 빠른 시작 Guides: 가이드 + Backends: 백엔드 + JPA Backend: JPA 백엔드 + R2DBC Backend: R2DBC 백엔드 + MyBatis Backend: MyBatis 백엔드 Using RestApiClient: RestApiClient 사용하기 Reactive (WebFlux): 리액티브 (WebFlux) Publishing Events: 이벤트 직접 발행 @@ -132,6 +136,10 @@ nav: - Installation: getting-started/installation.md - Quickstart: getting-started/quickstart.md - Guides: + - Backends: + - JPA Backend: guides/jpa-backend.md + - R2DBC Backend: guides/r2dbc-backend.md + - MyBatis Backend: guides/mybatis-backend.md - Using RestApiClient: guides/using-restapiclient.md - Reactive (WebFlux): guides/reactive.md - Publishing Events: guides/publishing-events.md diff --git a/mvnw b/mvnw deleted file mode 100755 index bd8896b..0000000 --- a/mvnw +++ /dev/null @@ -1,295 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you 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 -# -# http://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. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.3.4 -# -# Optional ENV vars -# ----------------- -# JAVA_HOME - location of a JDK home dir, required when download maven via java source -# MVNW_REPOURL - repo url base for downloading maven distribution -# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven -# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output -# ---------------------------------------------------------------------------- - -set -euf -[ "${MVNW_VERBOSE-}" != debug ] || set -x - -# OS specific support. -native_path() { printf %s\\n "$1"; } -case "$(uname)" in -CYGWIN* | MINGW*) - [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" - native_path() { cygpath --path --windows "$1"; } - ;; -esac - -# set JAVACMD and JAVACCMD -set_java_home() { - # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched - 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" - JAVACCMD="$JAVA_HOME/jre/sh/javac" - else - JAVACMD="$JAVA_HOME/bin/java" - JAVACCMD="$JAVA_HOME/bin/javac" - - if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then - echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 - echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 - return 1 - fi - fi - else - JAVACMD="$( - 'set' +e - 'unset' -f command 2>/dev/null - 'command' -v java - )" || : - JAVACCMD="$( - 'set' +e - 'unset' -f command 2>/dev/null - 'command' -v javac - )" || : - - if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then - echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 - return 1 - fi - fi -} - -# hash string like Java String::hashCode -hash_string() { - str="${1:-}" h=0 - while [ -n "$str" ]; do - char="${str%"${str#?}"}" - h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) - str="${str#?}" - done - printf %x\\n $h -} - -verbose() { :; } -[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } - -die() { - printf %s\\n "$1" >&2 - exit 1 -} - -trim() { - # MWRAPPER-139: - # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. - # Needed for removing poorly interpreted newline sequences when running in more - # exotic environments such as mingw bash on Windows. - printf "%s" "${1}" | tr -d '[:space:]' -} - -scriptDir="$(dirname "$0")" -scriptName="$(basename "$0")" - -# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties -while IFS="=" read -r key value; do - case "${key-}" in - distributionUrl) distributionUrl=$(trim "${value-}") ;; - distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; - esac -done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" -[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" - -case "${distributionUrl##*/}" in -maven-mvnd-*bin.*) - MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ - case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in - *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; - :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; - :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; - :Linux*x86_64*) distributionPlatform=linux-amd64 ;; - *) - echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 - distributionPlatform=linux-amd64 - ;; - esac - distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" - ;; -maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; -*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; -esac - -# apply MVNW_REPOURL and calculate MAVEN_HOME -# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ -[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" -distributionUrlName="${distributionUrl##*/}" -distributionUrlNameMain="${distributionUrlName%.*}" -distributionUrlNameMain="${distributionUrlNameMain%-bin}" -MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" -MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" - -exec_maven() { - unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : - exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" -} - -if [ -d "$MAVEN_HOME" ]; then - verbose "found existing MAVEN_HOME at $MAVEN_HOME" - exec_maven "$@" -fi - -case "${distributionUrl-}" in -*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; -*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; -esac - -# prepare tmp dir -if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then - clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } - trap clean HUP INT TERM EXIT -else - die "cannot create temp dir" -fi - -mkdir -p -- "${MAVEN_HOME%/*}" - -# Download and Install Apache Maven -verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." -verbose "Downloading from: $distributionUrl" -verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" - -# select .zip or .tar.gz -if ! command -v unzip >/dev/null; then - distributionUrl="${distributionUrl%.zip}.tar.gz" - distributionUrlName="${distributionUrl##*/}" -fi - -# verbose opt -__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' -[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v - -# normalize http auth -case "${MVNW_PASSWORD:+has-password}" in -'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; -has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; -esac - -if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then - verbose "Found wget ... using wget" - wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" -elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then - verbose "Found curl ... using curl" - curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" -elif set_java_home; then - verbose "Falling back to use Java to download" - javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" - targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" - cat >"$javaSource" <<-END - public class Downloader extends java.net.Authenticator - { - protected java.net.PasswordAuthentication getPasswordAuthentication() - { - return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); - } - public static void main( String[] args ) throws Exception - { - setDefault( new Downloader() ); - java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); - } - } - END - # For Cygwin/MinGW, switch paths to Windows format before running javac and java - verbose " - Compiling Downloader.java ..." - "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" - verbose " - Running Downloader.java ..." - "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" -fi - -# If specified, validate the SHA-256 sum of the Maven distribution zip file -if [ -n "${distributionSha256Sum-}" ]; then - distributionSha256Result=false - if [ "$MVN_CMD" = mvnd.sh ]; then - echo "Checksum validation is not supported for maven-mvnd." >&2 - echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 - exit 1 - elif command -v sha256sum >/dev/null; then - if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then - distributionSha256Result=true - fi - elif command -v shasum >/dev/null; then - if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then - distributionSha256Result=true - fi - else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 - echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 - exit 1 - fi - if [ $distributionSha256Result = false ]; then - echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 - echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 - exit 1 - fi -fi - -# unzip and move -if command -v unzip >/dev/null; then - unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" -else - tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" -fi - -# Find the actual extracted directory name (handles snapshots where filename != directory name) -actualDistributionDir="" - -# First try the expected directory name (for regular distributions) -if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then - if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then - actualDistributionDir="$distributionUrlNameMain" - fi -fi - -# If not found, search for any directory with the Maven executable (for snapshots) -if [ -z "$actualDistributionDir" ]; then - # enable globbing to iterate over items - set +f - for dir in "$TMP_DOWNLOAD_DIR"/*; do - if [ -d "$dir" ]; then - if [ -f "$dir/bin/$MVN_CMD" ]; then - actualDistributionDir="$(basename "$dir")" - break - fi - fi - done - set -f -fi - -if [ -z "$actualDistributionDir" ]; then - verbose "Contents of $TMP_DOWNLOAD_DIR:" - verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" - die "Could not find Maven distribution directory in extracted archive" -fi - -verbose "Found extracted Maven distribution directory: $actualDistributionDir" -printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" -mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" - -clean || : -exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index 92450f9..0000000 --- a/mvnw.cmd +++ /dev/null @@ -1,189 +0,0 @@ -<# : batch portion -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.4 -@REM -@REM Optional ENV vars -@REM MVNW_REPOURL - repo url base for downloading maven distribution -@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven -@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output -@REM ---------------------------------------------------------------------------- - -@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) -@SET __MVNW_CMD__= -@SET __MVNW_ERROR__= -@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% -@SET PSModulePath= -@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( - IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) -) -@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% -@SET __MVNW_PSMODULEP_SAVE= -@SET __MVNW_ARG0_NAME__= -@SET MVNW_USERNAME= -@SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) -@echo Cannot start maven from wrapper >&2 && exit /b 1 -@GOTO :EOF -: end batch / begin powershell #> - -$ErrorActionPreference = "Stop" -if ($env:MVNW_VERBOSE -eq "true") { - $VerbosePreference = "Continue" -} - -# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties -$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl -if (!$distributionUrl) { - Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" -} - -switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { - "maven-mvnd-*" { - $USE_MVND = $true - $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" - $MVN_CMD = "mvnd.cmd" - break - } - default { - $USE_MVND = $false - $MVN_CMD = $script -replace '^mvnw','mvn' - break - } -} - -# apply MVNW_REPOURL and calculate MAVEN_HOME -# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ -if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" -} -$distributionUrlName = $distributionUrl -replace '^.*/','' -$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' - -$MAVEN_M2_PATH = "$HOME/.m2" -if ($env:MAVEN_USER_HOME) { - $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" -} - -if (-not (Test-Path -Path $MAVEN_M2_PATH)) { - New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null -} - -$MAVEN_WRAPPER_DISTS = $null -if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { - $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" -} else { - $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" -} - -$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" -$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' -$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" - -if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { - Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" - Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" - exit $? -} - -if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { - Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" -} - -# prepare tmp dir -$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile -$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" -$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null -trap { - if ($TMP_DOWNLOAD_DIR.Exists) { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } - } -} - -New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null - -# Download and Install Apache Maven -Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." -Write-Verbose "Downloading from: $distributionUrl" -Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" - -$webclient = New-Object System.Net.WebClient -if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { - $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) -} -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null - -# If specified, validate the SHA-256 sum of the Maven distribution zip file -$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum -if ($distributionSha256Sum) { - if ($USE_MVND) { - Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." - } - Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash - if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { - Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." - } -} - -# unzip and move -Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null - -# Find the actual extracted directory name (handles snapshots where filename != directory name) -$actualDistributionDir = "" - -# First try the expected directory name (for regular distributions) -$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" -$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" -if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { - $actualDistributionDir = $distributionUrlNameMain -} - -# If not found, search for any directory with the Maven executable (for snapshots) -if (!$actualDistributionDir) { - Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { - $testPath = Join-Path $_.FullName "bin/$MVN_CMD" - if (Test-Path -Path $testPath -PathType Leaf) { - $actualDistributionDir = $_.Name - } - } -} - -if (!$actualDistributionDir) { - Write-Error "Could not find Maven distribution directory in extracted archive" -} - -Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null -try { - Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null -} catch { - if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { - Write-Error "fail to move MAVEN_HOME" - } -} finally { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } -} - -Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/mybatis/build.gradle.kts b/mybatis/build.gradle.kts new file mode 100644 index 0000000..964d636 --- /dev/null +++ b/mybatis/build.gradle.kts @@ -0,0 +1,148 @@ +// :mybatis — MyBatis backend for the api-log starter. +// +// Published as `kr.devslab:api-log-mybatis`. Depends transitively on `:core`. +// Use this artifact when your application is already on MyBatis and you don't +// want to drag in JPA / Hibernate just for the audit log. + +plugins { + `java-library` + jacoco + id("org.springframework.boot") apply false + id("io.spring.dependency-management") + id("com.vanniktech.maven.publish") +} + +base { + archivesName.set("api-log-mybatis") +} + +afterEvaluate { + tasks.named("mavenPlainJavadocJar").configure { + archiveBaseName.set("api-log-mybatis") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all,-classfile,-processing,-serial", + "-Werror" + )) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("Xdoclint:none", true) + addBooleanOption("html5", true) + locale = "en_US" + } +} + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.6") + } +} + +dependencies { + api(project(":core")) + + // MyBatis Spring Boot Starter — drives @Mapper scanning + SqlSessionFactory + // + automatic transaction management. Spring Boot 3.x line uses 3.0.x. + api("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4") + + // JDBC connection pool comes from the consumer's spring-boot-starter-jdbc / + // -data-jpa; we don't force a particular pool here. spring-jdbc is needed + // for DataSource + the JDBC schema initializer below. + api("org.springframework:spring-jdbc") + + // PostgreSQL JDBC driver — runtime only. + runtimeOnly("org.postgresql:postgresql") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-web") + testImplementation("org.assertj:assertj-core") + + testImplementation(platform("org.testcontainers:testcontainers-bom:1.21.3")) + testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } + systemProperty("file.encoding", "UTF-8") + finalizedBy(tasks.jacocoTestReport) +} + +jacoco { + toolVersion = "0.8.13" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +mavenPublishing { + coordinates( + providers.gradleProperty("GROUP").get(), + "api-log-mybatis", + providers.gradleProperty("VERSION").get() + ) + + pom { + name.set("API Log Spring Boot Starter - MyBatis") + description.set("MyBatis backend for api-log. Native PostgreSQL JSONB inserts via mapper with explicit cast. Pair with api-log-core.") + + developers { + developer { + id.set(providers.gradleProperty("POM_DEVELOPER_ID")) + name.set(providers.gradleProperty("POM_DEVELOPER_NAME")) + url.set(providers.gradleProperty("POM_DEVELOPER_URL")) + email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL")) + organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + } + + organization { + name.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + url.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + + issueManagement { + system.set(providers.gradleProperty("POM_ISSUE_SYSTEM")) + url.set(providers.gradleProperty("POM_ISSUE_URL")) + } + } +} diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java new file mode 100644 index 0000000..798c073 --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java @@ -0,0 +1,68 @@ +package kr.devslab.apilog.mybatis.autoconfigure; + +import kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration; +import kr.devslab.apilog.mybatis.mapper.ApiLogMapper; +import kr.devslab.apilog.mybatis.writer.MybatisApiLogWriter; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import javax.sql.DataSource; + +/** + * MyBatis backend auto-configuration. Loads when MyBatis is on the classpath + * ({@code org.apache.ibatis.session.SqlSessionFactory}). + * + *

    Registers: + *

      + *
    • {@link MybatisApiLogWriter} — the {@link ApiLogWriter} implementation + * the core listener routes events through.
    • + *
    • {@link ApiLogMybatisSchemaInitializer} — runs + * {@code V1.0__create_api_log.sql} when + * {@code api.log.schema.management=builtin} (default).
    • + *
    + * + *

    {@link MapperScan} is pointed at {@link ApiLogMapper}'s package so the + * consumer doesn't need to add their own {@code @MapperScan} or + * {@code @Mapper} bean override. If the consumer already drives MyBatis with + * their own scan, our mapper still gets picked up because MapperScan annotations + * compose additively. + * + *

    Schema management strategies: {@code BUILTIN} (default) registers the + * initializer above; {@code NONE} does nothing; {@code FLYWAY} is honored if + * the JPA module is also on the classpath (its FlywayConfigurationCustomizer + * activates via the same property). Pure-MyBatis setups can install Flyway + * directly — Spring Boot's stock Flyway autoconfig will pick up + * {@code classpath:db/api-log} when added to their {@code spring.flyway.locations}. + */ +@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) +@ConditionalOnClass(org.apache.ibatis.session.SqlSessionFactory.class) +@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) +@MapperScan(basePackageClasses = ApiLogMapper.class) +public class ApiLogMybatisAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ApiLogWriter.class) + @ConditionalOnBean({ApiLogMapper.class, PayloadJsonMapper.class}) + public MybatisApiLogWriter mybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper jsonMapper) { + return new MybatisApiLogWriter(mapper, jsonMapper); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty( + prefix = "api.log.schema", + name = "management", + havingValue = "builtin", + matchIfMissing = true + ) + public ApiLogMybatisSchemaInitializer apiLogMybatisSchemaInitializer(DataSource dataSource) { + return new ApiLogMybatisSchemaInitializer(dataSource); + } +} diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisSchemaInitializer.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisSchemaInitializer.java new file mode 100644 index 0000000..e98d092 --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisSchemaInitializer.java @@ -0,0 +1,38 @@ +package kr.devslab.apilog.mybatis.autoconfigure; + +import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; +import org.springframework.boot.sql.init.DatabaseInitializationMode; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; + +import javax.sql.DataSource; +import java.util.List; + +/** + * Runs the bundled {@code V1.0__create_api_log.sql} against the consumer's + * {@link DataSource} when {@code api.log.schema.management=builtin} (default). + * + *

    Identical shape to {@code ApiLogJpaSchemaInitializer} — both backends + * use JDBC, so both can reuse Spring Boot's + * {@link DataSourceScriptDatabaseInitializer}. Kept as separate classes to + * keep each backend self-contained (no awkward "import the JPA module's bean + * just for the initializer"). + * + *

    The DDL is idempotent ({@code CREATE TABLE IF NOT EXISTS}) so re-running + * it on every boot is safe. + */ +public class ApiLogMybatisSchemaInitializer extends DataSourceScriptDatabaseInitializer { + + public static final String SCHEMA_SCRIPT = "classpath:db/api-log/V1.0__create_api_log.sql"; + + public ApiLogMybatisSchemaInitializer(DataSource dataSource) { + super(dataSource, settings()); + } + + private static DatabaseInitializationSettings settings() { + DatabaseInitializationSettings settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(List.of(SCHEMA_SCRIPT)); + settings.setMode(DatabaseInitializationMode.ALWAYS); + settings.setContinueOnError(false); + return settings; + } +} diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/mapper/ApiLogMapper.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/mapper/ApiLogMapper.java new file mode 100644 index 0000000..c5d1a51 --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/mapper/ApiLogMapper.java @@ -0,0 +1,54 @@ +package kr.devslab.apilog.mybatis.mapper; + +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; + +import java.util.List; + +/** + * MyBatis mapper for the {@code api_log} table. + * + *

    The {@code ::jsonb} cast on each JSON parameter lets MyBatis bind a Java + * {@link String} into a PostgreSQL {@code JSONB} column without needing a + * custom {@code TypeHandler}. {@code #{payload,jdbcType=VARCHAR}} forces the + * VARCHAR binding even when the value is {@code null}, which side-steps + * PostgreSQL's "could not determine data type of parameter" error on null + * JSONB binds. + * + *

    {@code @Options(useGeneratedKeys=true)} flows the {@code BIGSERIAL} + * {@code id} back onto the {@link ApiLogRow} after insert — handy for tests + * that want to assert on a specific row even though the writer itself doesn't + * read it back. + */ +@Mapper +public interface ApiLogMapper { + + @Insert(""" + INSERT INTO api_log + (event_type, request_id, endpoint, payload, response, + status_code, error_message, timestamp, retry_count, is_retry) + VALUES + (#{eventType}, + #{requestId}, + #{endpoint}, + CAST(#{payload,jdbcType=VARCHAR} AS jsonb), + CAST(#{response,jdbcType=VARCHAR} AS jsonb), + #{statusCode,jdbcType=INTEGER}, + CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb), + #{timestamp}, + #{retryCount}, + #{isRetry}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + void insert(ApiLogRow row); + + @org.apache.ibatis.annotations.Select( + "SELECT id, event_type AS eventType, request_id AS requestId, endpoint, " + + "payload::text AS payload, response::text AS response, status_code AS statusCode, " + + "error_message::text AS errorMessage, timestamp, retry_count AS retryCount, is_retry AS isRetry " + + "FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC" + ) + List findByRequestId(String requestId); +} diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/model/ApiLogRow.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/model/ApiLogRow.java new file mode 100644 index 0000000..9974dea --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/model/ApiLogRow.java @@ -0,0 +1,39 @@ +package kr.devslab.apilog.mybatis.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * Plain row carrier handed to the MyBatis mapper. Mirrors the {@code api_log} + * table — the JPA backend's {@code ApiLogEntity} has the same shape but + * carries the Hibernate {@code @JdbcTypeCode(JSON)} annotations; here we + * keep it framework-free since MyBatis handles the parameter binding via the + * {@code ::jsonb} cast in the mapper SQL. + * + *

    The {@code payload}, {@code response}, and {@code errorMessage} fields + * are JSON strings (canonical form produced by {@code PayloadJsonMapper}) — + * the mapper SQL casts them to JSONB on insert. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiLogRow { + private Long id; + private String eventType; + private String requestId; + private String endpoint; + private String payload; + private String response; + private Integer statusCode; + private String errorMessage; + private LocalDateTime timestamp; + private Integer retryCount; + private Boolean isRetry; +} diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java new file mode 100644 index 0000000..995864d --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java @@ -0,0 +1,91 @@ +package kr.devslab.apilog.mybatis.writer; + +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.mybatis.mapper.ApiLogMapper; +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.HttpErrorExtractor; +import kr.devslab.apilog.spi.HttpErrorInfo; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.RETRY_ERROR; +import static kr.devslab.apilog.Constants.SUCCESS; + +/** + * MyBatis implementation of {@link ApiLogWriter}. + * + *

    Each write runs in {@link Propagation#REQUIRES_NEW} so the audit insert + * doesn't piggy-back on (or roll back with) the consumer's outer transaction + * — same contract as {@code JpaApiLogWriter}. + * + *

    JSONB columns are handled by the mapper SQL itself + * ({@code CAST(#{...,jdbcType=VARCHAR} AS jsonb)}), so this class just builds + * an {@link ApiLogRow} with string-typed JSON and hands it off. + */ +@RequiredArgsConstructor +public class MybatisApiLogWriter implements ApiLogWriter { + + private final ApiLogMapper mapper; + private final PayloadJsonMapper jsonMapper; + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeInitiated(ApiCallInitiatedEvent event) { + ApiLogRow row = ApiLogRow.builder() + .eventType(INITIATED) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonString(event.getRequest().getPayload())) + .timestamp(LocalDateTime.now()) + .retryCount(0) + .isRetry(false) + .build(); + mapper.insert(row); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeSuccess(ApiCallSuccessEvent event) { + ApiLogRow row = ApiLogRow.builder() + .eventType(SUCCESS) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonString(event.getRequest().getPayload())) + .response(jsonMapper.toJsonString(event.getResponse().getData())) + .statusCode(event.getResponse().getStatusCode()) + .timestamp(LocalDateTime.now()) + .retryCount(0) + .isRetry(false) + .build(); + mapper.insert(row); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void writeError(ApiCallErrorEvent event) { + Throwable error = event.getError(); + HttpErrorInfo info = HttpErrorExtractor.extract(error); + + ApiLogRow row = ApiLogRow.builder() + .eventType(event.isRetry() ? RETRY_ERROR : ERROR) + .requestId(event.getRequest().getRequestId()) + .endpoint(event.getRequest().getEndpoint()) + .payload(jsonMapper.toJsonString(event.getRequest().getPayload())) + .errorMessage(jsonMapper.buildErrorJsonString(error, info.responseBody())) + .statusCode(info.statusCode()) + .timestamp(LocalDateTime.now()) + .retryCount(event.getRetryCount()) + .isRetry(event.isRetry()) + .build(); + mapper.insert(row); + } +} diff --git a/mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..31a924e --- /dev/null +++ b/mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +kr.devslab.apilog.mybatis.autoconfigure.ApiLogMybatisAutoConfiguration diff --git a/mybatis/src/test/java/kr/devslab/apilog/TestApp.java b/mybatis/src/test/java/kr/devslab/apilog/TestApp.java new file mode 100644 index 0000000..d3d59f9 --- /dev/null +++ b/mybatis/src/test/java/kr/devslab/apilog/TestApp.java @@ -0,0 +1,10 @@ +package kr.devslab.apilog; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Bootstrap class for {@code @SpringBootTest} in the :mybatis module. + */ +@SpringBootApplication +public class TestApp { +} diff --git a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java new file mode 100644 index 0000000..658f0af --- /dev/null +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -0,0 +1,153 @@ +package kr.devslab.apilog.mybatis.writer; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.mybatis.mapper.ApiLogMapper; +import kr.devslab.apilog.mybatis.model.ApiLogRow; +import kr.devslab.apilog.spi.ApiLogWriter; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; +import java.util.List; +import java.util.UUID; + +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.RETRY_ERROR; +import static kr.devslab.apilog.Constants.SUCCESS; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for the MyBatis backend — boots a Spring context, drives + * events through the registered {@link ApiLogWriter}, then verifies rows via + * both the mapper's own {@code findByRequestId} (round-trips JSONB → text) + * and a direct {@link JdbcTemplate} query (sanity check on the binding). + */ +@SpringBootTest +@Testcontainers +class MybatisApiLogWriterIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") + .withDatabaseName("apilog_mybatis_it") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configure(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + ApiLogWriter writer; + + @Autowired + ApiLogMapper mapper; + + @Autowired + DataSource dataSource; + + JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.update("DELETE FROM api_log"); + } + + @Test + void writer_isWiredFromMybatisBackend() { + assertThat(writer.getClass().getSimpleName()).isEqualTo("MybatisApiLogWriter"); + } + + @Test + void writeInitiated_insertsRowWithJsonbPayload() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges") + .payload("{\"amount\":100}") + .requestId(reqId) + .build(); + + writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); + + List rows = mapper.findByRequestId(reqId); + assertThat(rows).hasSize(1); + ApiLogRow row = rows.get(0); + assertThat(row.getEventType()).isEqualTo(INITIATED); + assertThat(row.getEndpoint()).isEqualTo("/charges"); + assertThat(row.getRetryCount()).isEqualTo(0); + assertThat(row.getIsRetry()).isFalse(); + assertThat(row.getPayload()).contains("\"amount\":100"); + } + + @Test + void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + ApiResponse response = ApiResponse.builder() + .data("{\"id\":\"ch_1\"}").statusCode(201).build(); + + writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); + + List rows = mapper.findByRequestId(reqId); + assertThat(rows).hasSize(1); + ApiLogRow row = rows.get(0); + assertThat(row.getEventType()).isEqualTo(SUCCESS); + assertThat(row.getStatusCode()).isEqualTo(201); + assertThat(row.getResponse()).contains("\"id\":\"ch_1\""); + } + + @Test + void writeError_insertsStructuredErrorMessage() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + IllegalStateException error = new IllegalStateException("connection broken"); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 0, false); + + writer.writeError(event); + + List rows = mapper.findByRequestId(reqId); + assertThat(rows).hasSize(1); + ApiLogRow row = rows.get(0); + assertThat(row.getEventType()).isEqualTo(ERROR); + assertThat(row.getStatusCode()).isNull(); + assertThat(row.getErrorMessage()) + .contains("\"type\":\"java.lang.IllegalStateException\"") + .contains("\"message\":\"connection broken\""); + } + + @Test + void writeError_marksRetryErrorWhenRetryFlagSet() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, + new RuntimeException("retry"), 2, true); + + writer.writeError(event); + + List rows = mapper.findByRequestId(reqId); + assertThat(rows).hasSize(1); + ApiLogRow row = rows.get(0); + assertThat(row.getEventType()).isEqualTo(RETRY_ERROR); + assertThat(row.getRetryCount()).isEqualTo(2); + assertThat(row.getIsRetry()).isTrue(); + } +} diff --git a/pom.xml b/pom.xml deleted file mode 100644 index 697f5cf..0000000 --- a/pom.xml +++ /dev/null @@ -1,336 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.5.6 - - - - kr.devslab - api-log-spring-boot-starter - 0.5.2-SNAPSHOT - jar - - API Log Spring Boot Starter - Event-driven API call logging for Spring Boot — async event pipeline with PostgreSQL JSONB storage. - https://github.com/devslab-kr/api-log - - 2026 - - - - The Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - repo - - - - - Devslab - https://devslab.kr - - - - - devslab - Devslab - support@devslab.kr - https://devslab.kr - Devslab - https://devslab.kr - - - - - scm:git:https://github.com/devslab-kr/api-log.git - scm:git:ssh://git@github.com/devslab-kr/api-log.git - https://github.com/devslab-kr/api-log - HEAD - - - - GitHub - https://github.com/devslab-kr/api-log/issues - - - - 21 - UTF-8 - UTF-8 - 11.13.1 - 1.21.3 - - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - org.springframework.boot - spring-boot-starter-web - true - - - - - org.springframework.retry - spring-retry - - - - - org.springframework - spring-webflux - true - - - io.projectreactor.netty - reactor-netty-http - true - - - - - com.fasterxml.jackson.module - jackson-module-blackbird - - - - - org.postgresql - postgresql - runtime - - - - - org.flywaydb - flyway-core - ${flyway.version} - true - - - org.flywaydb - flyway-database-postgresql - ${flyway.version} - runtime - true - - - - - org.springframework.boot - spring-boot-configuration-processor - true - - - org.projectlombok - lombok - true - - - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.boot - spring-boot-testcontainers - test - - - org.testcontainers - testcontainers - ${testcontainers.version} - test - - - org.testcontainers - junit-jupiter - test - - - org.testcontainers - postgresql - test - - - com.h2database - h2 - test - - - - - com.squareup.okhttp3 - mockwebserver - 4.12.0 - test - - - - - io.projectreactor - reactor-test - test - - - - - - - - org.jacoco - jacoco-maven-plugin - 0.8.12 - - - prepare-agent - prepare-agent - - - report - verify - report - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.springframework.boot - spring-boot-configuration-processor - - - org.projectlombok - lombok - - - - - - - - - - - release - - - - - org.apache.maven.plugins - maven-source-plugin - - - attach-sources - jar-no-fork - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - - none - 21 - - - - attach-javadocs - jar - - - - - - - org.apache.maven.plugins - maven-gpg-plugin - - - sign-artifacts - verify - sign - - - - --pinentry-mode - loopback - - - - - - - - - org.sonatype.central - central-publishing-maven-plugin - 0.6.0 - true - - central - true - - - - - - - - diff --git a/r2dbc/build.gradle.kts b/r2dbc/build.gradle.kts new file mode 100644 index 0000000..5390e88 --- /dev/null +++ b/r2dbc/build.gradle.kts @@ -0,0 +1,156 @@ +// :r2dbc — Reactive (R2DBC) backend for the api-log starter. +// +// Published as `kr.devslab:api-log-r2dbc`. Depends transitively on `:core`. +// Use this artifact (instead of api-log-jpa) when your application is built +// on Spring WebFlux + R2DBC and you want the audit log to participate in the +// same reactive pipeline rather than block on JDBC. + +plugins { + `java-library` + jacoco + id("org.springframework.boot") apply false + id("io.spring.dependency-management") + id("com.vanniktech.maven.publish") +} + +base { + archivesName.set("api-log-r2dbc") +} + +afterEvaluate { + tasks.named("mavenPlainJavadocJar").configure { + archiveBaseName.set("api-log-r2dbc") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all,-classfile,-processing,-serial", + "-Werror" + )) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("Xdoclint:none", true) + addBooleanOption("html5", true) + locale = "en_US" + } +} + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.6") + } +} + +dependencies { + api(project(":core")) + + // R2DBC stack — the writer talks directly to DatabaseClient (no Spring Data + // repository) so consumers don't get a transitive dependency on Spring Data + // R2DBC unless they want it. spring-r2dbc gives DatabaseClient + the + // connection-factory-based ScriptDatabaseInitializer used by our schema bean. + api("org.springframework:spring-r2dbc") + + // PostgreSQL R2DBC driver — runtime only (the starter is PostgreSQL-specific + // because of the JSONB column type). + runtimeOnly("org.postgresql:r2dbc-postgresql") + + // Reactor — explicit (rather than transitively via webflux) so this module + // works in non-webflux setups too. + api("io.projectreactor:reactor-core") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.assertj:assertj-core") + + // Testcontainers — real PostgreSQL via R2DBC for the integration tests. + testImplementation(platform("org.testcontainers:testcontainers-bom:1.21.3")) + testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + testImplementation("org.testcontainers:r2dbc") + // PostgreSQL JDBC driver is used by Testcontainers' Postgres module to run + // init scripts; not used by the runtime R2DBC path. + testImplementation("org.postgresql:postgresql") + + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } + systemProperty("file.encoding", "UTF-8") + finalizedBy(tasks.jacocoTestReport) +} + +jacoco { + toolVersion = "0.8.13" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +mavenPublishing { + coordinates( + providers.gradleProperty("GROUP").get(), + "api-log-r2dbc", + providers.gradleProperty("VERSION").get() + ) + + pom { + name.set("API Log Spring Boot Starter - R2DBC") + description.set("Reactive (R2DBC) backend for api-log. Native PostgreSQL JSONB inserts via DatabaseClient — no JDBC dependency. Pair with api-log-core.") + + developers { + developer { + id.set(providers.gradleProperty("POM_DEVELOPER_ID")) + name.set(providers.gradleProperty("POM_DEVELOPER_NAME")) + url.set(providers.gradleProperty("POM_DEVELOPER_URL")) + email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL")) + organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + } + + organization { + name.set(providers.gradleProperty("POM_ORGANIZATION_NAME")) + url.set(providers.gradleProperty("POM_ORGANIZATION_URL")) + } + + issueManagement { + system.set(providers.gradleProperty("POM_ISSUE_SYSTEM")) + url.set(providers.gradleProperty("POM_ISSUE_URL")) + } + } +} diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java new file mode 100644 index 0000000..3d3370b --- /dev/null +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java @@ -0,0 +1,78 @@ +package kr.devslab.apilog.r2dbc.autoconfigure; + +import kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration; +import kr.devslab.apilog.r2dbc.writer.R2dbcApiLogWriter; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import io.r2dbc.spi.ConnectionFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.r2dbc.core.DatabaseClient; + +/** + * R2DBC backend auto-configuration. Loads when the reactive R2DBC stack + * ({@link ConnectionFactory}) is on the classpath. + * + *

    Registers: + *

      + *
    • {@link DatabaseClient} (only if the consumer didn't already provide one) + * built off the consumer's {@code ConnectionFactory} bean.
    • + *
    • {@link R2dbcApiLogWriter} — the reactive {@link ApiLogWriter} the core + * listener routes through.
    • + *
    • {@link ApiLogR2dbcSchemaInitializer} — pure-reactive + * {@code CREATE TABLE IF NOT EXISTS} initializer when + * {@code api.log.schema.management=builtin} (default).
    • + *
    + * + *

    Schema management strategies under R2DBC: + *

      + *
    • BUILTIN (default) — registers the reactive initializer above.
    • + *
    • NONE — does nothing; apply the DDL yourself.
    • + *
    • FLYWAYnot supported in R2DBC mode. Flyway needs a + * JDBC {@code DataSource}; consumers who want Flyway should run it from + * a separate JDBC connection at boot (Spring Boot's standard Flyway + * autoconfig works fine alongside R2DBC for this) or switch to the JPA + * backend.
    • + *
    + */ +@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) +@ConditionalOnClass(ConnectionFactory.class) +@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) +public class ApiLogR2dbcAutoConfiguration { + + /** + * Lazily-built {@link DatabaseClient}. Skipped if the consumer (or + * Spring Boot's reactive autoconfig) already registered one — most + * R2DBC apps will already have it via {@code spring-boot-starter-data-r2dbc}. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(ConnectionFactory.class) + public DatabaseClient apiLogR2dbcDatabaseClient(ConnectionFactory connectionFactory) { + return DatabaseClient.create(connectionFactory); + } + + @Bean + @ConditionalOnMissingBean(ApiLogWriter.class) + @ConditionalOnBean({DatabaseClient.class, PayloadJsonMapper.class}) + public R2dbcApiLogWriter r2dbcApiLogWriter(DatabaseClient databaseClient, PayloadJsonMapper jsonMapper) { + return new R2dbcApiLogWriter(databaseClient, jsonMapper); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(ConnectionFactory.class) + @ConditionalOnProperty( + prefix = "api.log.schema", + name = "management", + havingValue = "builtin", + matchIfMissing = true + ) + public ApiLogR2dbcSchemaInitializer apiLogR2dbcSchemaInitializer(ConnectionFactory connectionFactory) { + return new ApiLogR2dbcSchemaInitializer(connectionFactory); + } +} diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcSchemaInitializer.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcSchemaInitializer.java new file mode 100644 index 0000000..92a1a81 --- /dev/null +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcSchemaInitializer.java @@ -0,0 +1,39 @@ +package kr.devslab.apilog.r2dbc.autoconfigure; + +import io.r2dbc.spi.ConnectionFactory; +import org.springframework.boot.r2dbc.init.R2dbcScriptDatabaseInitializer; +import org.springframework.boot.sql.init.DatabaseInitializationMode; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; + +import java.util.List; + +/** + * Pure-reactive schema initializer — runs {@code V1.0__create_api_log.sql} + * against a {@link ConnectionFactory} without ever opening a JDBC connection. + * + *

    Mirrors {@code ApiLogJpaSchemaInitializer} but routes through Spring + * Boot's {@link R2dbcScriptDatabaseInitializer} (vs. {@code DataSourceScript...}). + * That keeps R2DBC-only applications honest — no surprise JDBC driver pull-in + * just because the audit table needs to be created. + * + *

    Activated when {@code api.log.schema.management=builtin} (default) AND + * the R2DBC autoconfig is active. The DDL is idempotent + * ({@code CREATE TABLE IF NOT EXISTS}), so re-running on every boot is safe. + */ +public class ApiLogR2dbcSchemaInitializer extends R2dbcScriptDatabaseInitializer { + + /** Classpath path of the bundled schema script (shared with the JPA + MyBatis backends). */ + public static final String SCHEMA_SCRIPT = "classpath:db/api-log/V1.0__create_api_log.sql"; + + public ApiLogR2dbcSchemaInitializer(ConnectionFactory connectionFactory) { + super(connectionFactory, settings()); + } + + private static DatabaseInitializationSettings settings() { + DatabaseInitializationSettings settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(List.of(SCHEMA_SCRIPT)); + settings.setMode(DatabaseInitializationMode.ALWAYS); + settings.setContinueOnError(false); + return settings; + } +} diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java new file mode 100644 index 0000000..93092d1 --- /dev/null +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -0,0 +1,155 @@ +package kr.devslab.apilog.r2dbc.writer; + +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.spi.ApiLogWriter; +import kr.devslab.apilog.spi.HttpErrorExtractor; +import kr.devslab.apilog.spi.HttpErrorInfo; +import kr.devslab.apilog.spi.PayloadJsonMapper; +import io.r2dbc.spi.Parameters; +import io.r2dbc.spi.R2dbcType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.r2dbc.core.DatabaseClient; + +import java.time.LocalDateTime; + +import static kr.devslab.apilog.Constants.ERROR; +import static kr.devslab.apilog.Constants.INITIATED; +import static kr.devslab.apilog.Constants.RETRY_ERROR; +import static kr.devslab.apilog.Constants.SUCCESS; + +/** + * Reactive (R2DBC) implementation of {@link ApiLogWriter}. + * + *

    Talks to a {@link DatabaseClient} directly rather than going through a + * Spring Data R2DBC repository — this keeps the dependency footprint small + * (no spring-data-r2dbc, no Spring Data Commons), and gives us a clean place + * to do the explicit {@code ::jsonb} cast PostgreSQL needs when binding a + * {@code TEXT} parameter into a {@code JSONB} column. + * + *

    The writer subscribes to the resulting {@code Mono} inline via + * {@code .subscribe()} so it matches the fire-and-forget semantics the core + * listener expects (the listener doesn't consume return values, and the + * surrounding {@code @Async} executor wouldn't propagate a {@code Mono} + * usefully anyway). + * + *

    Subscription errors are logged but not rethrown — losing one audit row + * must never break the consumer's actual outbound HTTP call. Same contract + * as the JPA writer. + */ +@Slf4j +@RequiredArgsConstructor +public class R2dbcApiLogWriter implements ApiLogWriter { + + private static final String INSERT_SQL = """ + INSERT INTO api_log + (event_type, request_id, endpoint, payload, response, + status_code, error_message, timestamp, retry_count, is_retry) + VALUES + (:eventType, :requestId, :endpoint, :payload, :response, + :statusCode, :errorMessage, :timestamp, :retryCount, :isRetry) + """; + + private final DatabaseClient databaseClient; + private final PayloadJsonMapper jsonMapper; + + @Override + public void writeInitiated(ApiCallInitiatedEvent event) { + executeInsert( + INITIATED, + event.getRequest().getRequestId(), + event.getRequest().getEndpoint(), + jsonMapper.toJsonString(event.getRequest().getPayload()), + null, + null, + null, + 0, + false + ); + } + + @Override + public void writeSuccess(ApiCallSuccessEvent event) { + executeInsert( + SUCCESS, + event.getRequest().getRequestId(), + event.getRequest().getEndpoint(), + jsonMapper.toJsonString(event.getRequest().getPayload()), + jsonMapper.toJsonString(event.getResponse().getData()), + event.getResponse().getStatusCode(), + null, + 0, + false + ); + } + + @Override + public void writeError(ApiCallErrorEvent event) { + Throwable error = event.getError(); + HttpErrorInfo info = HttpErrorExtractor.extract(error); + + executeInsert( + event.isRetry() ? RETRY_ERROR : ERROR, + event.getRequest().getRequestId(), + event.getRequest().getEndpoint(), + jsonMapper.toJsonString(event.getRequest().getPayload()), + null, + info.statusCode(), + jsonMapper.buildErrorJsonString(error, info.responseBody()), + event.getRetryCount(), + event.isRetry() + ); + } + + /** + * Common write path — every event type funnels through this. + * + *

    Each JSONB parameter is bound as a {@link R2dbcType#CLOB CLOB} via + * {@link Parameters#in(io.r2dbc.spi.Type, Object)} and the driver hands it + * to PostgreSQL as text. The PostgreSQL R2DBC driver applies the column's + * implicit cast (TEXT → JSONB) on insert, so no manual {@code ::jsonb} is + * needed — keeps the SQL portable to other dialects that may follow. + */ + private void executeInsert(String eventType, + String requestId, + String endpoint, + String payload, + String response, + Integer statusCode, + String errorMessage, + int retryCount, + boolean isRetry) { + DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(INSERT_SQL) + .bind("eventType", eventType) + .bind("requestId", requestId) + .bind("endpoint", endpoint) + .bind("payload", asJsonbParam(payload)) + .bind("response", asJsonbParam(response)) + .bind("statusCode", statusCode == null + ? Parameters.in(R2dbcType.INTEGER) + : Parameters.in(R2dbcType.INTEGER, statusCode)) + .bind("errorMessage", asJsonbParam(errorMessage)) + .bind("timestamp", LocalDateTime.now()) + .bind("retryCount", retryCount) + .bind("isRetry", isRetry); + + spec.fetch() + .rowsUpdated() + .subscribe( + rows -> { /* success — listener already logs at DEBUG */ }, + ex -> log.error("R2DBC api_log insert failed: requestId={}, eventType={}", + requestId, eventType, ex) + ); + } + + private static Object asJsonbParam(String value) { + // CLOB binding triggers the driver's TEXT path. Passing null with a + // typed Parameters.in(...) preserves the NULL JSONB semantics — a raw + // null would let the driver guess the type and bind it as untyped NULL. + return value == null + ? Parameters.in(R2dbcType.CLOB) + : Parameters.in(R2dbcType.CLOB, value); + } +} diff --git a/r2dbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/r2dbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..530830f --- /dev/null +++ b/r2dbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +kr.devslab.apilog.r2dbc.autoconfigure.ApiLogR2dbcAutoConfiguration diff --git a/r2dbc/src/test/java/kr/devslab/apilog/TestApp.java b/r2dbc/src/test/java/kr/devslab/apilog/TestApp.java new file mode 100644 index 0000000..5c6b143 --- /dev/null +++ b/r2dbc/src/test/java/kr/devslab/apilog/TestApp.java @@ -0,0 +1,10 @@ +package kr.devslab.apilog; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Bootstrap class for {@code @SpringBootTest} in the :r2dbc module. + */ +@SpringBootApplication +public class TestApp { +} diff --git a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java new file mode 100644 index 0000000..c999bcf --- /dev/null +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -0,0 +1,180 @@ +package kr.devslab.apilog.r2dbc.writer; + +import kr.devslab.apilog.dto.ApiRequest; +import kr.devslab.apilog.dto.ApiResponse; +import kr.devslab.apilog.event.ApiCallErrorEvent; +import kr.devslab.apilog.event.ApiCallInitiatedEvent; +import kr.devslab.apilog.event.ApiCallSuccessEvent; +import kr.devslab.apilog.spi.ApiLogWriter; +import org.awaitility.Awaitility; +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.r2dbc.core.DatabaseClient; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for the R2DBC backend — boots a Spring context with the + * reactive autoconfig, drives events through the registered + * {@link ApiLogWriter}, then asserts the rows landed in PostgreSQL using a + * second JDBC-free {@link DatabaseClient} query. + * + *

    This is also the regression guard for the v0.6.0 "R2DBC actually works" + * promise — if the writer's parameter binding or the reactive schema + * initializer breaks, this test fails before any release reaches Maven Central. + */ +@SpringBootTest +@Testcontainers +class R2dbcApiLogWriterIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") + .withDatabaseName("apilog_r2dbc_it") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configure(DynamicPropertyRegistry registry) { + // Spring Boot's R2dbcAutoConfiguration reads spring.r2dbc.* — the + // r2dbc:postgresql:// scheme is what tells it which driver to load. + registry.add("spring.r2dbc.url", () -> String.format( + "r2dbc:postgresql://%s:%d/%s", + postgres.getHost(), + postgres.getMappedPort(5432), + postgres.getDatabaseName())); + registry.add("spring.r2dbc.username", postgres::getUsername); + registry.add("spring.r2dbc.password", postgres::getPassword); + } + + @Autowired + ApiLogWriter writer; + + @Autowired + DatabaseClient databaseClient; + + @BeforeEach + void clearTable() { + databaseClient.sql("DELETE FROM api_log").fetch().rowsUpdated().block(); + } + + @Test + void writer_isWiredFromR2dbcBackend() { + assertThat(writer.getClass().getSimpleName()).isEqualTo("R2dbcApiLogWriter"); + } + + @Test + void writeInitiated_insertsRowWithJsonbPayload() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges") + .payload("{\"amount\":100}") + .requestId(reqId) + .build(); + + writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); + + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + List> rows = fetchByRequestId(reqId); + assertThat(rows).hasSize(1); + Map row = rows.get(0); + assertThat(row.get("event_type")).isEqualTo("INITIATED"); + assertThat(row.get("endpoint")).isEqualTo("/charges"); + assertThat(row.get("retry_count")).isEqualTo(0); + assertThat(row.get("is_retry")).isEqualTo(false); + // payload is JSONB; toString round-trips to canonical JSON + assertThat(row.get("payload").toString()).contains("\"amount\":100"); + }); + } + + @Test + void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + ApiResponse response = ApiResponse.builder() + .data("{\"id\":\"ch_1\"}").statusCode(201).build(); + + writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); + + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + List> rows = fetchByRequestId(reqId); + assertThat(rows).hasSize(1); + Map row = rows.get(0); + assertThat(row.get("event_type")).isEqualTo("SUCCESS"); + assertThat(row.get("status_code")).isEqualTo(201); + assertThat(row.get("response").toString()).contains("\"id\":\"ch_1\""); + }); + } + + @Test + void writeError_insertsStructuredErrorMessage() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + IllegalStateException error = new IllegalStateException("connection broken"); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 0, false); + + writer.writeError(event); + + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + List> rows = fetchByRequestId(reqId); + assertThat(rows).hasSize(1); + Map row = rows.get(0); + assertThat(row.get("event_type")).isEqualTo("ERROR"); + // Non-HTTP exception → no status_code + assertThat(row.get("status_code")).isNull(); + assertThat(row.get("error_message").toString()) + .contains("\"type\":\"java.lang.IllegalStateException\"") + .contains("\"message\":\"connection broken\""); + }); + } + + @Test + void writeError_marksRetryErrorWhenRetryFlagSet() { + String reqId = UUID.randomUUID().toString(); + ApiRequest request = ApiRequest.builder() + .endpoint("/charges").requestId(reqId).build(); + ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, + new RuntimeException("retry"), 2, true); + + writer.writeError(event); + + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + List> rows = fetchByRequestId(reqId); + assertThat(rows).hasSize(1); + Map row = rows.get(0); + assertThat(row.get("event_type")).isEqualTo("RETRY_ERROR"); + assertThat(row.get("retry_count")).isEqualTo(2); + assertThat(row.get("is_retry")).isEqualTo(true); + }); + } + + private List> fetchByRequestId(String requestId) { + return databaseClient.sql(""" + SELECT event_type, endpoint, payload::text AS payload, + response::text AS response, status_code, + error_message::text AS error_message, + retry_count, is_retry + FROM api_log + WHERE request_id = :rid + ORDER BY id ASC + """) + .bind("rid", requestId) + .fetch() + .all() + .collectList() + .block(); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3c389d8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "api-log" + +// Subprojects. Each publishable artifact lives under its own subproject. +// The on-disk directory name is short (`core`, `jpa`, `r2dbc`, `mybatis`); +// the Maven artifact ID is pinned via `mavenPublishing.coordinates(...)` in +// each module's build file. +// +// core → kr.devslab:api-log-core (events, SPI, HTTP utils, listener) +// jpa → kr.devslab:api-log-jpa (JPA writer + entity + Flyway hook) +// r2dbc → kr.devslab:api-log-r2dbc (reactive R2DBC writer) +// mybatis → kr.devslab:api-log-mybatis (MyBatis mapper writer) +include("core") +include("jpa") +include("r2dbc") +include("mybatis") diff --git a/src/main/java/kr/devslab/apilog/Constants.java b/src/main/java/kr/devslab/apilog/Constants.java deleted file mode 100644 index b39a1b6..0000000 --- a/src/main/java/kr/devslab/apilog/Constants.java +++ /dev/null @@ -1,9 +0,0 @@ -package kr.devslab.apilog; - -public class Constants { - - public static final String INITIATED = "INITIATED"; - public static final String SUCCESS = "SUCCESS"; - public static final String RETRY_ERROR = "RETRY_ERROR"; - public static final String ERROR = "ERROR"; -} \ No newline at end of file diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java b/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java deleted file mode 100644 index 99115ab..0000000 --- a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java +++ /dev/null @@ -1,72 +0,0 @@ -package kr.devslab.apilog.autoconfigure; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "api.log") -public class ApiLogProperties { - - /** - * Enable the API logging infrastructure (listener, service, repository, RestApiClientUtil). - * When false, no beans are registered. - * Default: true. - */ - private boolean enabled = true; - - /** - * How the api_log table's schema is provisioned. See {@link Schema}. - */ - private Schema schema = new Schema(); - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public Schema getSchema() { - return schema; - } - - public void setSchema(Schema schema) { - this.schema = schema; - } - - public static class Schema { - /** - * Schema management strategy for the {@code api_log} table. - * - *

      - *
    • BUILTIN (default) — the starter runs {@code CREATE TABLE IF NOT EXISTS} - * on application startup, so the table just exists without any other tool. - * The SQL is idempotent, so this is safe to leave on every boot. - * Use this if you don't have (or don't want) Flyway / Liquibase in your project.
    • - *
    • NONE — the starter does not touch the schema. Apply the DDL yourself - * (see api-log.devslab.kr/reference/schema). - * Use this if your team's policy is that third-party libraries must never touch the schema, - * or if you've already provisioned the table some other way.
    • - *
    • FLYWAY — the starter registers a {@code FlywayConfigurationCustomizer} that - * appends {@code classpath:db/api-log} to Flyway's locations, so the bundled - * {@code V1.0__create_api_log.sql} runs alongside your own migrations and is - * recorded in {@code flyway_schema_history}. Requires {@code org.flywaydb:flyway-core} - * on the classpath (the starter declares it as optional, so the consumer must add it).
    • - *
    - */ - private Management management = Management.BUILTIN; - - public Management getManagement() { - return management; - } - - public void setManagement(Management management) { - this.management = management; - } - - public enum Management { - BUILTIN, - NONE, - FLYWAY - } - } -} diff --git a/src/main/java/kr/devslab/apilog/model/dto/ApiRequest.java b/src/main/java/kr/devslab/apilog/model/dto/ApiRequest.java deleted file mode 100644 index 3e6ea07..0000000 --- a/src/main/java/kr/devslab/apilog/model/dto/ApiRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.devslab.apilog.model.dto; - -import lombok.Builder; -import lombok.Getter; - -import java.util.UUID; - -@Getter -@Builder -public class ApiRequest { - @Builder.Default - private final String requestId = UUID.randomUUID().toString(); - private final String payload; - private final String endpoint; -} \ No newline at end of file diff --git a/src/main/java/kr/devslab/apilog/model/dto/ApiResponse.java b/src/main/java/kr/devslab/apilog/model/dto/ApiResponse.java deleted file mode 100644 index 51d89d2..0000000 --- a/src/main/java/kr/devslab/apilog/model/dto/ApiResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.devslab.apilog.model.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ApiResponse { - private final String data; - private final int statusCode; -} \ No newline at end of file diff --git a/src/main/java/kr/devslab/apilog/service/ApiLogService.java b/src/main/java/kr/devslab/apilog/service/ApiLogService.java deleted file mode 100644 index 9842d83..0000000 --- a/src/main/java/kr/devslab/apilog/service/ApiLogService.java +++ /dev/null @@ -1,146 +0,0 @@ -package kr.devslab.apilog.service; - -import kr.devslab.apilog.event.ApiCallErrorEvent; -import kr.devslab.apilog.event.ApiCallInitiatedEvent; -import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.ApiLogEntity; -import kr.devslab.apilog.repository.ApiLogRepository; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpStatusCodeException; -import org.springframework.web.client.RestClientResponseException; - -import java.time.LocalDateTime; - -import static kr.devslab.apilog.Constants.ERROR; -import static kr.devslab.apilog.Constants.INITIATED; -import static kr.devslab.apilog.Constants.RETRY_ERROR; -import static kr.devslab.apilog.Constants.SUCCESS; - -@Service -@RequiredArgsConstructor -public class ApiLogService { - - private final ApiLogRepository repository; - private final ObjectMapper objectMapper; - - public void saveApiCallInitiated(ApiCallInitiatedEvent event) { - ApiLogEntity entity = ApiLogEntity.builder() - .eventType(INITIATED) - .requestId(event.getRequest().getRequestId()) - .endpoint(event.getRequest().getEndpoint()) - .payload(toJsonNode(event.getRequest().getPayload())) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - repository.save(entity); - } - - public void saveApiCallSuccess(ApiCallSuccessEvent event) { - ApiLogEntity entity = ApiLogEntity.builder() - .eventType(SUCCESS) - .requestId(event.getRequest().getRequestId()) - .endpoint(event.getRequest().getEndpoint()) - .payload(toJsonNode(event.getRequest().getPayload())) - .response(toJsonNode(event.getResponse().getData())) - .statusCode(event.getResponse().getStatusCode()) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - repository.save(entity); - } - - public void saveApiCallError(ApiCallErrorEvent event) { - Throwable error = event.getError(); - HttpErrorInfo info = extractHttpErrorInfo(error); - - ApiLogEntity entity = ApiLogEntity.builder() - .eventType(event.isRetry() ? RETRY_ERROR : ERROR) - .requestId(event.getRequest().getRequestId()) - .endpoint(event.getRequest().getEndpoint()) - .payload(toJsonNode(event.getRequest().getPayload())) - .errorMessage(buildErrorJson(error, info.responseBody())) - .statusCode(info.statusCode()) - .timestamp(LocalDateTime.now()) - .retryCount(event.getRetryCount()) - .isRetry(event.isRetry()) - .build(); - repository.save(entity); - } - - private record HttpErrorInfo(Integer statusCode, String responseBody) { - static final HttpErrorInfo EMPTY = new HttpErrorInfo(null, null); - } - - /** - * Pulls HTTP status + response body off a throwable when applicable. - * - *

    Direct {@code instanceof} works for the Spring Web (blocking) - * hierarchy because we depend on {@code spring-web} unconditionally. For - * Spring WebFlux's {@code WebClientResponseException}, we duck-type via - * reflection — {@code spring-webflux} is an optional dependency on this - * starter, so we can't import its types directly without forcing it onto - * the classpath of consumers who only want the blocking client. - */ - private HttpErrorInfo extractHttpErrorInfo(Throwable error) { - if (error instanceof HttpStatusCodeException ex) { - return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString()); - } - if (error instanceof RestClientResponseException ex) { - return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString()); - } - // Match WebClientResponseException + its concrete subclasses (NotFound, - // BadRequest, etc.) by package prefix so unrelated exceptions that - // happen to share method names don't match. - if (error.getClass().getName() - .startsWith("org.springframework.web.reactive.function.client.WebClientResponseException")) { - try { - Object status = error.getClass().getMethod("getStatusCode").invoke(error); - Integer statusValue = (Integer) status.getClass().getMethod("value").invoke(status); - Object body = error.getClass().getMethod("getResponseBodyAsString").invoke(error); - return new HttpErrorInfo(statusValue, body == null ? null : body.toString()); - } catch (ReflectiveOperationException ignored) { - // Shape didn't match — fall through to EMPTY. - } - } - return HttpErrorInfo.EMPTY; - } - - /** - * Build the structured error JSON written into the {@code error_message} column. - * - *

    Shape:

    { "type": "", "message": "" [, "responseBody": "..."] }
    - * - *

    The {@code responseBody} field is only present when the cause was a Spring - * {@code HttpStatusCodeException} / {@code RestClientResponseException} carrying - * the upstream's body — useful for diagnosing vendor errors that put detail in - * the body, not the message. - */ - private JsonNode buildErrorJson(Throwable error, String responseBody) { - ObjectNode node = objectMapper.createObjectNode(); - node.put("type", error.getClass().getName()); - node.put("message", error.getMessage()); - if (responseBody != null && !responseBody.isEmpty()) { - node.put("responseBody", responseBody); - } - return node; - } - - private JsonNode toJsonNode(String data) { - if (data == null) { - return objectMapper.createObjectNode(); - } - try { - return objectMapper.readTree(data); - } catch (Exception e) { - ObjectNode node = objectMapper.createObjectNode(); - node.put("raw", data); - return node; - } - } -} diff --git a/src/test/java/kr/devslab/apilog/TestApp.java b/src/test/java/kr/devslab/apilog/TestApp.java deleted file mode 100644 index f5ba80f..0000000 --- a/src/test/java/kr/devslab/apilog/TestApp.java +++ /dev/null @@ -1,17 +0,0 @@ -package kr.devslab.apilog; - -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * Bootstrap class for @SpringBootTest in this module. - * - * The library itself is not a Spring Boot application, so it ships no @SpringBootApplication. - * Tests need one for the application context lookup — this empty class satisfies that - * requirement without leaking app-scaffolding into main sources. - * - * Sits at the root of the kr.devslab.apilog package so every test under - * src/test/java/kr/devslab/apilog/** can find it during the upward package scan. - */ -@SpringBootApplication -public class TestApp { -} diff --git a/src/test/java/kr/devslab/apilog/config/ConfigurationTest.java b/src/test/java/kr/devslab/apilog/config/ConfigurationTest.java deleted file mode 100644 index 276ccb1..0000000 --- a/src/test/java/kr/devslab/apilog/config/ConfigurationTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package kr.devslab.apilog.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.blackbird.BlackbirdModule; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.core.task.TaskExecutor; -import org.springframework.core.task.VirtualThreadTaskExecutor; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestTemplate; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Testcontainers -class ConfigurationTest { - - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("configtest") - .withUsername("test") - .withPassword("test"); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - registry.add("spring.threads.virtual.enabled", () -> "true"); - } - - @Autowired - private ApplicationContext applicationContext; - - @Test - void jacksonConfig_shouldCreateObjectMapperWithBlackbird() { - // When - ObjectMapper objectMapper = applicationContext.getBean(ObjectMapper.class); - - // Then - assertThat(objectMapper).isNotNull(); - assertThat(objectMapper.getRegisteredModuleIds()) - .contains(BlackbirdModule.class.getName()); - } - - @Test - void jacksonConfig_shouldCreateMappingJackson2HttpMessageConverter() { - // When - MappingJackson2HttpMessageConverter converter = - applicationContext.getBean(MappingJackson2HttpMessageConverter.class); - - // Then - assertThat(converter).isNotNull(); - assertThat(converter.getObjectMapper()).isNotNull(); - assertThat(converter.getObjectMapper().getRegisteredModuleIds()) - .contains(BlackbirdModule.class.getName()); - } - - @Test - void restClientConfig_shouldCreateRestClient() { - // When - RestClient restClient = applicationContext.getBean(RestClient.class); - - // Then - assertThat(restClient).isNotNull(); - } - - // v0.5.2: RestTemplate bean removed (was accidentally exposed by the old - // RestClientConfig — never advertised, not part of the public API). Use - // RestClient instead. Test removed accordingly. - - @Test - void asyncConfig_shouldCreateVirtualThreadTaskExecutor() { - // When - TaskExecutor taskExecutor = applicationContext.getBean("apiLogVirtualThreadExecutor", TaskExecutor.class); - - // Then - assertThat(taskExecutor).isNotNull(); - assertThat(taskExecutor).isInstanceOf(VirtualThreadTaskExecutor.class); - } - - @Test - void asyncConfig_shouldNotCreateThreadPoolTaskExecutor() { - // Given & When - boolean hasThreadPoolTaskExecutor = applicationContext.containsBean("apiLogPlatformThreadExecutor"); - - // Then - Virtual Threads가 활성화되어 있으므로 ThreadPoolTaskExecutor는 생성되지 않아야 함 - assertThat(hasThreadPoolTaskExecutor).isFalse(); - } - - @Test - void virtualThreadConfig_shouldBeEnabled() { - // Given - String virtualThreadsEnabled = applicationContext.getEnvironment() - .getProperty("spring.threads.virtual.enabled"); - - // Then - assertThat(virtualThreadsEnabled).isEqualTo("true"); - } - - @Test - void retryConfig_shouldBeConfigured() { - // When - RetryConfig가 자동으로 스캔되고 설정되는지 확인 - boolean hasRetryConfig = applicationContext.containsBean("retryConfig"); - - // Then - assertThat(hasRetryConfig).isTrue(); - } - - @Test - void allAutoConfigurationsShouldBePresent() { - // v0.5.2: three auto-config classes registered via - // META-INF/spring/.../AutoConfiguration.imports — assert all loaded. - assertThat(applicationContext.containsBean( - "kr.devslab.apilog.autoconfigure.ApiLogAutoConfiguration")).isTrue(); - assertThat(applicationContext.containsBean( - "kr.devslab.apilog.autoconfigure.RestApiClientAutoConfiguration")).isTrue(); - assertThat(applicationContext.containsBean( - "kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration")).isTrue(); - // RetryConfig is @Imported by ApiLogAutoConfiguration. - assertThat(applicationContext.containsBean("retryConfig")).isTrue(); - } -} - -// Virtual Threads 비활성화 상태 테스트 -@SpringBootTest -@Testcontainers -class ConfigurationWithoutVirtualThreadsTest { - - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("configtest2") - .withUsername("test") - .withPassword("test"); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - registry.add("spring.threads.virtual.enabled", () -> "false"); - } - - @Autowired - private ApplicationContext applicationContext; - - @Test - void asyncConfig_shouldCreateThreadPoolTaskExecutor() { - // When - TaskExecutor taskExecutor = applicationContext.getBean("apiLogPlatformThreadExecutor", TaskExecutor.class); - - // Then - assertThat(taskExecutor).isNotNull(); - assertThat(taskExecutor).isInstanceOf(ThreadPoolTaskExecutor.class); - } - - @Test - void asyncConfig_shouldNotCreateVirtualThreadTaskExecutor() { - // Given & When - boolean hasVirtualThreadTaskExecutor = applicationContext.containsBean("apiLogVirtualThreadExecutor"); - - // Then - Virtual Threads가 비활성화되어 있으므로 VirtualThreadTaskExecutor는 생성되지 않아야 함 - assertThat(hasVirtualThreadTaskExecutor).isFalse(); - } -} \ No newline at end of file diff --git a/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java b/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java deleted file mode 100644 index 1791a05..0000000 --- a/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package kr.devslab.apilog.listener; - -import kr.devslab.apilog.event.ApiCallErrorEvent; -import kr.devslab.apilog.event.ApiCallInitiatedEvent; -import kr.devslab.apilog.event.ApiCallSuccessEvent; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; -import kr.devslab.apilog.service.ApiLogService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ApiEventListenerTest { - - @Mock - private ApiLogService apiLogService; - - @InjectMocks - private ApiEventListener apiEventListener; - - @Test - void handleApiCallInitiated_shouldCallServiceSaveMethod() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - ApiCallInitiatedEvent event = new ApiCallInitiatedEvent(this, request); - - // When - apiEventListener.handleApiCallInitiated(event); - - // Then - verify(apiLogService).saveApiCallInitiated(event); - } - - @Test - void handleApiCallSuccess_shouldCallServiceSaveMethod() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - ApiResponse response = ApiResponse.builder() - .data("{\"result\":\"success\"}") - .statusCode(200) - .build(); - ApiCallSuccessEvent event = new ApiCallSuccessEvent(this, request, response); - - // When - apiEventListener.handleApiCallSuccess(event); - - // Then - verify(apiLogService).saveApiCallSuccess(event); - } - - @Test - void handleApiCallError_shouldCallServiceSaveMethod() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - RuntimeException error = new RuntimeException("Test error"); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 1, false); - - // When - apiEventListener.handleApiCallError(event); - - // Then - verify(apiLogService).saveApiCallError(event); - } - - @Test - void handleApiCallInitiated_shouldNotThrowWhenServiceFails() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .payload("{\"test\":\"data\"}") - .build(); - ApiCallInitiatedEvent event = new ApiCallInitiatedEvent(this, request); - - doThrow(new RuntimeException("Service error")).when(apiLogService).saveApiCallInitiated(event); - - // When & Then - Should not throw exception (비동기 이벤트 실패는 원본에 영향 없음) - apiEventListener.handleApiCallInitiated(event); - - verify(apiLogService).saveApiCallInitiated(event); - } - - @Test - void handleApiCallSuccess_shouldNotThrowWhenServiceFails() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .build(); - ApiResponse response = ApiResponse.builder() - .data("{\"result\":\"success\"}") - .statusCode(200) - .build(); - ApiCallSuccessEvent event = new ApiCallSuccessEvent(this, request, response); - - doThrow(new RuntimeException("Service error")).when(apiLogService).saveApiCallSuccess(event); - - // When & Then - Should not throw exception - apiEventListener.handleApiCallSuccess(event); - - verify(apiLogService).saveApiCallSuccess(event); - } - - @Test - void handleApiCallError_shouldNotThrowWhenServiceFails() { - // Given - ApiRequest request = ApiRequest.builder() - .endpoint("/api/test") - .build(); - RuntimeException error = new RuntimeException("API error"); - ApiCallErrorEvent event = new ApiCallErrorEvent(this, request, error, 0, false); - - doThrow(new RuntimeException("Service error")).when(apiLogService).saveApiCallError(event); - - // When & Then - Should not throw exception - apiEventListener.handleApiCallError(event); - - verify(apiLogService).saveApiCallError(event); - } -} \ No newline at end of file diff --git a/src/test/java/kr/devslab/apilog/repository/ApiLogRepositoryTest.java b/src/test/java/kr/devslab/apilog/repository/ApiLogRepositoryTest.java deleted file mode 100644 index 43c95ec..0000000 --- a/src/test/java/kr/devslab/apilog/repository/ApiLogRepositoryTest.java +++ /dev/null @@ -1,282 +0,0 @@ -package kr.devslab.apilog.repository; - -import kr.devslab.apilog.model.ApiLogEntity; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import java.time.LocalDateTime; -import java.util.List; - -import static kr.devslab.apilog.Constants.*; -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@Testcontainers -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class ApiLogRepositoryTest { - - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("testdb") - .withUsername("test") - .withPassword("test"); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - } - - @Autowired - private ApiLogRepository repository; - - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - } - - @Test - void save_shouldPersistApiLogEntity() throws Exception { - // Given - JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); - JsonNode response = objectMapper.readTree("{\"result\":\"success\"}"); - - ApiLogEntity entity = ApiLogEntity.builder() - .eventType(SUCCESS) - .requestId("test-request-id") - .endpoint("/api/test") - .payload(payload) - .response(response) - .statusCode(200) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - // When - ApiLogEntity saved = repository.save(entity); - - // Then - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getEventType()).isEqualTo(SUCCESS); - assertThat(saved.getRequestId()).isEqualTo("test-request-id"); - assertThat(saved.getEndpoint()).isEqualTo("/api/test"); - assertThat(saved.getPayload()).isEqualTo(payload); - assertThat(saved.getResponse()).isEqualTo(response); - assertThat(saved.getStatusCode()).isEqualTo(200); - assertThat(saved.getRetryCount()).isEqualTo(0); - assertThat(saved.getIsRetry()).isFalse(); - } - - @Test - void findByRequestId_shouldReturnEntitiesWithSameRequestId() throws Exception { - // Given - String requestId = "test-request-123"; - JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); - - ApiLogEntity initiated = ApiLogEntity.builder() - .eventType(INITIATED) - .requestId(requestId) - .endpoint("/api/test") - .payload(payload) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - ApiLogEntity success = ApiLogEntity.builder() - .eventType(SUCCESS) - .requestId(requestId) - .endpoint("/api/test") - .payload(payload) - .response(objectMapper.readTree("{\"result\":\"success\"}")) - .statusCode(200) - .timestamp(LocalDateTime.now().plusSeconds(1)) - .retryCount(0) - .isRetry(false) - .build(); - - repository.save(initiated); - repository.save(success); - - // When - List found = repository.findByRequestId(requestId); - - // Then - assertThat(found).hasSize(2); - assertThat(found).extracting(ApiLogEntity::getEventType) - .containsExactlyInAnyOrder(INITIATED, SUCCESS); - assertThat(found).allMatch(entity -> entity.getRequestId().equals(requestId)); - } - - @Test - void findByEventType_shouldReturnEntitiesWithSameEventType() throws Exception { - // Given - JsonNode payload = objectMapper.readTree("{\"test\":\"data\"}"); - - ApiLogEntity error1 = ApiLogEntity.builder() - .eventType(ERROR) - .requestId("request-1") - .endpoint("/api/test1") - .payload(payload) - .errorMessage(objectMapper.readTree("{\"error\":\"error1\"}")) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - ApiLogEntity error2 = ApiLogEntity.builder() - .eventType(ERROR) - .requestId("request-2") - .endpoint("/api/test2") - .payload(payload) - .errorMessage(objectMapper.readTree("{\"error\":\"error2\"}")) - .timestamp(LocalDateTime.now()) - .retryCount(1) - .isRetry(false) - .build(); - - ApiLogEntity success = ApiLogEntity.builder() - .eventType(SUCCESS) - .requestId("request-3") - .endpoint("/api/test3") - .payload(payload) - .response(objectMapper.readTree("{\"result\":\"success\"}")) - .statusCode(200) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - repository.save(error1); - repository.save(error2); - repository.save(success); - - // When - List errors = repository.findByEventType(ERROR); - - // Then - assertThat(errors).hasSize(2); - assertThat(errors).allMatch(entity -> entity.getEventType().equals(ERROR)); - assertThat(errors).extracting(ApiLogEntity::getRequestId) - .containsExactlyInAnyOrder("request-1", "request-2"); - } - - @Test - void findByEndpoint_shouldReturnEntitiesWithSameEndpoint() throws Exception { - // Given - String endpoint = "/api/users"; - JsonNode payload = objectMapper.readTree("{\"name\":\"John\"}"); - - ApiLogEntity entity1 = ApiLogEntity.builder() - .eventType(INITIATED) - .requestId("request-1") - .endpoint(endpoint) - .payload(payload) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - ApiLogEntity entity2 = ApiLogEntity.builder() - .eventType(SUCCESS) - .requestId("request-1") - .endpoint(endpoint) - .payload(payload) - .response(objectMapper.readTree("{\"id\":1,\"name\":\"John\"}")) - .statusCode(201) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - ApiLogEntity otherEndpoint = ApiLogEntity.builder() - .eventType(INITIATED) - .requestId("request-2") - .endpoint("/api/products") - .payload(payload) - .timestamp(LocalDateTime.now()) - .retryCount(0) - .isRetry(false) - .build(); - - repository.save(entity1); - repository.save(entity2); - repository.save(otherEndpoint); - - // When - List found = repository.findByEndpoint(endpoint); - - // Then - assertThat(found).hasSize(2); - assertThat(found).allMatch(entity -> entity.getEndpoint().equals(endpoint)); - assertThat(found).extracting(ApiLogEntity::getEventType) - .containsExactlyInAnyOrder(INITIATED, SUCCESS); - } - - @Test - void save_shouldHandleJsonbFields() throws Exception { - // Given - JSONB 필드들이 제대로 저장되는지 테스트 - JsonNode complexPayload = objectMapper.readTree(""" - { - "user": { - "id": 1, - "name": "John Doe", - "preferences": { - "theme": "dark", - "notifications": true - } - }, - "items": [ - {"id": 1, "name": "Item 1"}, - {"id": 2, "name": "Item 2"} - ] - } - """); - - JsonNode complexError = objectMapper.readTree(""" - { - "error": "ValidationError", - "details": { - "field": "email", - "message": "Invalid email format" - }, - "timestamp": "2023-12-01T10:00:00Z" - } - """); - - ApiLogEntity entity = ApiLogEntity.builder() - .eventType(ERROR) - .requestId("complex-request") - .endpoint("/api/users") - .payload(complexPayload) - .errorMessage(complexError) - .timestamp(LocalDateTime.now()) - .retryCount(1) - .isRetry(true) - .build(); - - // When - ApiLogEntity saved = repository.save(entity); - - // Then - assertThat(saved.getPayload()).isEqualTo(complexPayload); - assertThat(saved.getErrorMessage()).isEqualTo(complexError); - assertThat(saved.getPayload().get("user").get("name").asText()).isEqualTo("John Doe"); - assertThat(saved.getErrorMessage().get("details").get("field").asText()).isEqualTo("email"); - } -} \ No newline at end of file diff --git a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilHttpIntegrationTest.java b/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilHttpIntegrationTest.java deleted file mode 100644 index c3eeda4..0000000 --- a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilHttpIntegrationTest.java +++ /dev/null @@ -1,280 +0,0 @@ -package kr.devslab.apilog.util; - -import com.fasterxml.jackson.databind.JsonNode; -import kr.devslab.apilog.model.ApiLogEntity; -import kr.devslab.apilog.model.dto.ApiRequest; -import kr.devslab.apilog.model.dto.ApiResponse; -import kr.devslab.apilog.repository.ApiLogRepository; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterAll; -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.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.http.HttpMethod; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import java.io.IOException; -import java.time.Duration; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * End-to-end HTTP integration for {@link ReactiveApiClientUtil}. - * - *

    Drives real HTTP through an in-process {@link MockWebServer}, lets the - * async listener drain into a real PostgreSQL 15 container (Testcontainers), - * then asserts on the {@code api_log} rows — same shape of guarantees as the - * blocking {@link RestApiClientUtilHttpIntegrationTest}, but exercising the - * {@link WebClient} code path so reactive callers also pay for what they get. - */ -@SpringBootTest -@Testcontainers -class ReactiveApiClientUtilHttpIntegrationTest { - - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("apilog_reactive_it") - .withUsername("test") - .withPassword("test"); - - static final MockWebServer mockServer; - - static { - mockServer = new MockWebServer(); - try { - mockServer.start(); - } catch (IOException e) { - throw new RuntimeException("Could not start MockWebServer", e); - } - } - - @DynamicPropertySource - static void configure(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); - registry.add("api.log.schema.management", () -> "builtin"); - } - - @AfterAll - static void stopMockServer() throws IOException { - mockServer.shutdown(); - } - - @TestConfiguration - static class ReactiveTestConfig { - /** - * Override {@link WebClient.Builder} so the test instance of - * {@link ReactiveApiClientUtil} routes traffic to {@link #mockServer} - * instead of the real network. Spring Boot's WebClientCustomizers are - * still applied because {@code @Primary} doesn't swap them. - */ - @Bean - @Primary - WebClient.Builder testWebClientBuilder() { - return WebClient.builder().baseUrl(mockServer.url("/").toString()); - } - } - - @Autowired - ReactiveApiClientUtil api; - - @Autowired - ApiLogRepository repository; - - @BeforeEach - void clearLog() throws InterruptedException { - repository.deleteAll(); - while (mockServer.getRequestCount() > 0 && mockServer.takeRequest(1, TimeUnit.MILLISECONDS) != null) { - // discard - } - } - - // ------------------------------------------------------------------ // - // Success path — status code propagation // - // ------------------------------------------------------------------ // - - @Test - void get_2xx_propagatesActualStatusCodeIntoApiLog() { - mockServer.enqueue(new MockResponse() - .setResponseCode(201) - .setHeader("Content-Type", "application/json") - .setBody("{\"id\":1,\"name\":\"Ada\"}")); - - StepVerifier.create(api.get("/users/1")) - .assertNext(resp -> { - assertThat(resp.getStatusCode()).isEqualTo(201); - assertThat(resp.getData()).contains("\"name\":\"Ada\""); - }) - .verifyComplete(); - - ApiLogEntity successRow = waitForRow("SUCCESS"); - assertThat(successRow.getStatusCode()).isEqualTo(201); - assertThat(successRow.getResponse().get("id").asInt()).isEqualTo(1); - } - - @Test - void postTyped_deserializesResponseAndLogsRows() { - mockServer.enqueue(new MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"name\":\"Ada\",\"email\":\"ada@example.com\"}")); - - Mono mono = api.postTyped("/users", new TestUser("Ada", "ada@example.com"), TestUser.class); - - StepVerifier.create(mono) - .assertNext(user -> { - assertThat(user.name()).isEqualTo("Ada"); - assertThat(user.email()).isEqualTo("ada@example.com"); - }) - .verifyComplete(); - - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - List rows = repository.findAll(); - assertThat(rows).hasSize(2); - assertThat(rows.stream().map(ApiLogEntity::getEventType)) - .containsExactlyInAnyOrder("INITIATED", "SUCCESS"); - assertThat(rows.stream().map(ApiLogEntity::getRequestId).distinct()).hasSize(1); - }); - } - - // ------------------------------------------------------------------ // - // Error path // - // ------------------------------------------------------------------ // - - @Test - void clientError_4xx_capturesStatusCodeAndStructuredErrorMessage() { - mockServer.enqueue(new MockResponse() - .setResponseCode(404) - .setHeader("Content-Type", "application/json") - .setBody("{\"error\":\"missing\"}")); - - StepVerifier.create(api.get("/users/999")) - .expectErrorSatisfies(throwable -> - assertThat(throwable).isInstanceOf(WebClientResponseException.class)) - .verify(); - - ApiLogEntity errorRow = waitForRow("ERROR"); - assertThat(errorRow.getStatusCode()).isEqualTo(404); - - JsonNode err = errorRow.getErrorMessage(); - assertThat(err.get("type").asText()).contains("WebClientResponseException"); - assertThat(err.has("message")).isTrue(); - // WebClientResponseException carries the upstream body via getResponseBodyAsString. - assertThat(err.get("responseBody").asText()) - .isEqualTo("{\"error\":\"missing\"}"); - } - - // ------------------------------------------------------------------ // - // send() — caller-provided request_id correlates attempts // - // ------------------------------------------------------------------ // - - @Test - void send_withCustomRequestId_correlatesAttemptsForRetryTimeline() { - // VARCHAR(36) limits us to UUID-sized correlation keys. Plain UUID is 36 chars. - String correlationId = UUID.randomUUID().toString(); - mockServer.enqueue(new MockResponse().setResponseCode(503)); - mockServer.enqueue(new MockResponse().setResponseCode(200).setBody("{\"ok\":true}")); - - ApiRequest req = ApiRequest.builder() - .endpoint("/charges") - .payload("{}") - .requestId(correlationId) - .build(); - - // Mono.onErrorResume gives us the same caller-driven retry shape used - // in the docs — a Resilience4j / .retry() style chain would behave the - // same way as long as the ApiRequest (and its requestId) is reused. - ApiResponse finalResponse = api.send(HttpMethod.POST, req) - .onErrorResume(throwable -> api.send(HttpMethod.POST, req)) - .block(); - - assertThat(finalResponse).isNotNull(); - assertThat(finalResponse.getStatusCode()).isEqualTo(200); - - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - List rows = repository.findAll().stream() - .filter(r -> correlationId.equals(r.getRequestId())) - .toList(); - // 2× INITIATED + 1 ERROR + 1 SUCCESS = 4 rows, all sharing requestId. - assertThat(rows).hasSize(4); - assertThat(rows.stream().map(ApiLogEntity::getEventType)) - .filteredOn(t -> t.equals("INITIATED")).hasSize(2); - assertThat(rows.stream().map(ApiLogEntity::getEventType)) - .filteredOn(t -> t.equals("ERROR")).hasSize(1); - assertThat(rows.stream().map(ApiLogEntity::getEventType)) - .filteredOn(t -> t.equals("SUCCESS")).hasSize(1); - }); - } - - // ------------------------------------------------------------------ // - // Verb coverage // - // ------------------------------------------------------------------ // - - @Test - void put_routesPutAndLogs() throws InterruptedException { - mockServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - - api.put("/users/1", "{\"name\":\"x\"}").block(); - - assertThat(mockServer.takeRequest().getMethod()).isEqualTo("PUT"); - waitForRow("SUCCESS"); - } - - @Test - void delete_routesDeleteAndLogs204() throws InterruptedException { - mockServer.enqueue(new MockResponse().setResponseCode(204)); - - api.delete("/users/1").block(); - - assertThat(mockServer.takeRequest().getMethod()).isEqualTo("DELETE"); - ApiLogEntity success = waitForRow("SUCCESS"); - assertThat(success.getStatusCode()).isEqualTo(204); - } - - @Test - void patch_routesPatchAndLogs() throws InterruptedException { - mockServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - - api.patch("/users/1", "{\"email\":\"x@y\"}").block(); - - assertThat(mockServer.takeRequest().getMethod()).isEqualTo("PATCH"); - waitForRow("SUCCESS"); - } - - // ------------------------------------------------------------------ // - // Helpers // - // ------------------------------------------------------------------ // - - private ApiLogEntity waitForRow(String eventType) { - return Awaitility.await() - .atMost(Duration.ofSeconds(5)) - .pollInterval(Duration.ofMillis(50)) - .until( - () -> repository.findAll().stream() - .filter(r -> eventType.equals(r.getEventType())) - .findFirst() - .orElse(null), - row -> row != null); - } - - record TestUser(String name, String email) {} -} diff --git a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilIntegrationTest.java b/src/test/java/kr/devslab/apilog/util/RestApiClientUtilIntegrationTest.java deleted file mode 100644 index f9b7a6a..0000000 --- a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilIntegrationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package kr.devslab.apilog.util; - -import kr.devslab.apilog.model.dto.ApiResponse; -import com.fasterxml.jackson.databind.ObjectMapper; -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.context.ApplicationEventPublisher; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.web.client.RestClient; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Testcontainers -class RestApiClientUtilIntegrationTest { - - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("testdb") - .withUsername("test") - .withPassword("test"); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - } - - @Autowired - private ApplicationEventPublisher eventPublisher; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private RestClient restClient; - - private RestApiClientUtil restApiClientUtil; - - // 테스트용 DTO - static class TestDto { - public String name; - public String email; - - public TestDto() {} - - public TestDto(String name, String email) { - this.name = name; - this.email = email; - } - } - - @BeforeEach - void setUp() { - restApiClientUtil = new RestApiClientUtil( - restClient, - eventPublisher, - objectMapper - ); - } - - @Test - void restApiClientUtil_shouldBeInstantiatedCorrectly() { - // When & Then - assertThat(restApiClientUtil).isNotNull(); - assertThat(restClient).isNotNull(); - assertThat(objectMapper).isNotNull(); - assertThat(eventPublisher).isNotNull(); - } - - @Test - void objectMapper_shouldSerializeAndDeserializeCorrectly() throws Exception { - // Given - TestDto dto = new TestDto("강신", "jlc488@gmail.com"); - - // When - String json = objectMapper.writeValueAsString(dto); - TestDto deserialized = objectMapper.readValue(json, TestDto.class); - - // Then - assertThat(json).contains("강신"); - assertThat(json).contains("jlc488@gmail.com"); - assertThat(deserialized.name).isEqualTo("강신"); - assertThat(deserialized.email).isEqualTo("jlc488@gmail.com"); - } -} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties deleted file mode 100644 index 03398a8..0000000 --- a/src/test/resources/application.properties +++ /dev/null @@ -1,4 +0,0 @@ -# Tests run with the BUILTIN strategy (the default in v0.3.0+), so this file -# is mostly here to be explicit. The starter creates the api_log table for -# us on first DataSource access via DataSourceScriptDatabaseInitializer. -api.log.schema.management=builtin From e52b54ebbb2196d4108bde0193f245281dc0ef09 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 03:14:47 +0900 Subject: [PATCH 2/9] Mark gradlew executable so Linux runners can invoke it --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 450063fa6076c7e6887398c322b410bdda7c11bf Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 03:25:08 +0900 Subject: [PATCH 3/9] Fix bean wiring: drop @ConditionalOnBean self-references in auto-configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI exposed what the local single-jar tests masked: same-class @ConditionalOnBean evaluation happens before the sibling @Bean declarations are registered, so PayloadJsonMapper / DatabaseClient / ApiLogRepository / ApiLogMapper / ApiLogWriter beans never satisfied the guards and the writer beans were silently dropped. Replaced the @ConditionalOnBean guards with plain constructor injection — Spring DI resolves the dependencies lazily, which is the documented "right" pattern for in-class dependencies. Added @AutoConfiguration(after = ...) hints so each auto-config evaluates after the Spring Boot autoconfig that registers its prerequisites: - ApiLogCoreAutoConfiguration -> after JacksonAutoConfiguration - ApiLogR2dbcAutoConfiguration -> after R2dbcAutoConfiguration - ApiLogMybatisAutoConfiguration -> after MybatisAutoConfiguration A missing backend artifact now fails fast at context refresh with a clear NoSuchBeanDefinitionException for ApiLogWriter, which is more actionable than silently dropping events. Local: :core 23 unit tests pass; all four modules compile. --- .../ApiLogCoreAutoConfiguration.java | 31 ++++++++++++------- .../ApiLogJpaAutoConfiguration.java | 11 +++++-- .../ApiLogMybatisAutoConfiguration.java | 13 ++++++-- .../ApiLogR2dbcAutoConfiguration.java | 17 +++++++--- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java index 630b435..8b24a73 100644 --- a/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java @@ -7,11 +7,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.module.blackbird.BlackbirdModule; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @@ -31,11 +31,18 @@ * *

    The actual {@link ApiLogWriter} bean comes from whichever backend module * the consumer added — {@code api-log-jpa}, {@code api-log-r2dbc}, or - * {@code api-log-mybatis}. {@link ApiEventListener} is gated on - * {@code @ConditionalOnBean(ApiLogWriter.class)} so missing-backend setups - * fail loudly at config time rather than at first event. + * {@code api-log-mybatis}. {@link ApiEventListener} just declares + * {@code ApiLogWriter} as a constructor parameter so Spring DI resolves the + * backend writer lazily; if no backend is on the classpath, the context fails + * fast at startup with a clear {@code NoSuchBeanDefinitionException}. + * + *

    {@code after = JacksonAutoConfiguration.class} guarantees Spring Boot's + * {@code ObjectMapper} is registered before this class is evaluated, so the + * {@code PayloadJsonMapper} bean can take it as a parameter without timing + * games. Backend auto-configs declare {@code after = ApiLogCoreAutoConfiguration.class} + * to chain the ordering further. */ -@AutoConfiguration +@AutoConfiguration(after = JacksonAutoConfiguration.class) @ConditionalOnClass({ApiEventListener.class, ApiLogWriter.class}) @EnableConfigurationProperties(ApiLogProperties.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) @@ -46,24 +53,26 @@ public class ApiLogCoreAutoConfiguration { /** * Shared JSON helper used by every backend writer. Lifted out of the old * {@code ApiLogService} so backend modules don't each re-implement it. + * + *

    {@code ObjectMapper} comes from Spring Boot's + * {@code JacksonAutoConfiguration} (we run after it via the + * {@code @AutoConfiguration(after = ...)} hint on the class). */ @Bean @ConditionalOnMissingBean - @ConditionalOnBean(ObjectMapper.class) public PayloadJsonMapper apiLogPayloadJsonMapper(ObjectMapper objectMapper) { return new PayloadJsonMapper(objectMapper); } /** * Event-bus listener that routes events to the consumer's chosen - * {@link ApiLogWriter}. Only registered when a writer bean is present — - * makes "consumer added api-log-core but forgot to add a backend" a clear - * "no qualifying bean of type ApiLogWriter" failure instead of silently - * dropping events. + * {@link ApiLogWriter}. Spring DI resolves the writer lazily — if no + * backend artifact is present the application context fails fast at + * startup with {@code NoSuchBeanDefinitionException}, which is more + * actionable than silently dropping events. */ @Bean @ConditionalOnMissingBean - @ConditionalOnBean(ApiLogWriter.class) public ApiEventListener apiEventListener(ApiLogWriter writer) { return new ApiEventListener(writer); } diff --git a/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java index 08e067f..7bab142 100644 --- a/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java @@ -7,7 +7,6 @@ import kr.devslab.apilog.spi.ApiLogWriter; import kr.devslab.apilog.spi.PayloadJsonMapper; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -46,9 +45,17 @@ @Import(ApiLogFlywayConfig.class) public class ApiLogJpaAutoConfiguration { + /** + * Wire the JPA writer as the {@link ApiLogWriter} implementation. Spring + * DI resolves {@link ApiLogRepository} (registered by + * {@code @EnableJpaRepositories} above) and {@link PayloadJsonMapper} + * (from {@code ApiLogCoreAutoConfiguration}) lazily — no + * {@code @ConditionalOnBean} guards needed here, and removing them avoids + * the "same-class @Bean evaluated before its own siblings are registered" + * pitfall that bit v0.6.0's first CI run. + */ @Bean @ConditionalOnMissingBean(ApiLogWriter.class) - @ConditionalOnBean({ApiLogRepository.class, PayloadJsonMapper.class}) public JpaApiLogWriter jpaApiLogWriter(ApiLogRepository repository, PayloadJsonMapper jsonMapper) { return new JpaApiLogWriter(repository, jsonMapper); } diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java index 798c073..5a82256 100644 --- a/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java @@ -6,8 +6,8 @@ import kr.devslab.apilog.spi.ApiLogWriter; import kr.devslab.apilog.spi.PayloadJsonMapper; import org.mybatis.spring.annotation.MapperScan; +import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -41,15 +41,22 @@ * directly — Spring Boot's stock Flyway autoconfig will pick up * {@code classpath:db/api-log} when added to their {@code spring.flyway.locations}. */ -@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) +@AutoConfiguration(after = {ApiLogCoreAutoConfiguration.class, MybatisAutoConfiguration.class}) @ConditionalOnClass(org.apache.ibatis.session.SqlSessionFactory.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) @MapperScan(basePackageClasses = ApiLogMapper.class) public class ApiLogMybatisAutoConfiguration { + /** + * Wire the MyBatis writer as the {@link ApiLogWriter} implementation. + * Spring DI resolves {@link ApiLogMapper} (registered via the + * {@code @MapperScan} above) and {@link PayloadJsonMapper} (from + * {@code ApiLogCoreAutoConfiguration}) lazily — no + * {@code @ConditionalOnBean} guards, since those evaluate before the + * sibling beans are registered and were what bit the first CI run. + */ @Bean @ConditionalOnMissingBean(ApiLogWriter.class) - @ConditionalOnBean({ApiLogMapper.class, PayloadJsonMapper.class}) public MybatisApiLogWriter mybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper jsonMapper) { return new MybatisApiLogWriter(mapper, jsonMapper); } diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java index 3d3370b..ee39ac0 100644 --- a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java @@ -6,10 +6,10 @@ import kr.devslab.apilog.spi.PayloadJsonMapper; import io.r2dbc.spi.ConnectionFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.r2dbc.core.DatabaseClient; @@ -39,7 +39,7 @@ * backend. *

*/ -@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class) +@AutoConfiguration(after = {ApiLogCoreAutoConfiguration.class, R2dbcAutoConfiguration.class}) @ConditionalOnClass(ConnectionFactory.class) @ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true) public class ApiLogR2dbcAutoConfiguration { @@ -48,24 +48,31 @@ public class ApiLogR2dbcAutoConfiguration { * Lazily-built {@link DatabaseClient}. Skipped if the consumer (or * Spring Boot's reactive autoconfig) already registered one — most * R2DBC apps will already have it via {@code spring-boot-starter-data-r2dbc}. + * {@link ConnectionFactory} is supplied via constructor injection; we sit + * {@code after = R2dbcAutoConfiguration.class} so Spring Boot's auto-built + * one is in place by the time this method is invoked. */ @Bean @ConditionalOnMissingBean - @ConditionalOnBean(ConnectionFactory.class) public DatabaseClient apiLogR2dbcDatabaseClient(ConnectionFactory connectionFactory) { return DatabaseClient.create(connectionFactory); } + /** + * Wire the R2DBC writer as the {@link ApiLogWriter} implementation. No + * {@code @ConditionalOnBean} guards on {@link DatabaseClient} / + * {@link PayloadJsonMapper} — those would race against the same class's + * own {@code @Bean} declarations (a Spring Boot pitfall). Spring DI + * resolves the parameters lazily, which avoids the ordering problem. + */ @Bean @ConditionalOnMissingBean(ApiLogWriter.class) - @ConditionalOnBean({DatabaseClient.class, PayloadJsonMapper.class}) public R2dbcApiLogWriter r2dbcApiLogWriter(DatabaseClient databaseClient, PayloadJsonMapper jsonMapper) { return new R2dbcApiLogWriter(databaseClient, jsonMapper); } @Bean @ConditionalOnMissingBean - @ConditionalOnBean(ConnectionFactory.class) @ConditionalOnProperty( prefix = "api.log.schema", name = "management", From e68ddc63b1c5811b6848bb443ebb6e707c2b0d9d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 03:37:06 +0900 Subject: [PATCH 4/9] Fix integration tests: @Async listener, reactive scheduling, proxy-safe assertions Four problems exposed by the previous CI run, now fixed: 1. ApiEventListener handlers re-gained @Async. Without it the event listener ran on the publisher's thread -- a reactor I/O thread in the WebClient path -- so the R2DBC writer's subscribe() ended up sharing CPU with the caller's reactive chain and never got to actually emit. 2. R2dbcApiLogWriter now pins its subscription to Schedulers.boundedElastic() and adds doOnSuccess / doOnError logging. Fire-and-forget is preserved (no .block() -- that would defeat the point of having a reactive backend); the explicit scheduler just guarantees the insert runs on a worker thread that isn't competing with the test's polling loop or a single-core CI runner. 3. Writer-identity assertions in three integration tests switched from getSimpleName() == "Foo" to getName().contains("Foo"). JPA and MyBatis writers are wrapped in a CGLIB proxy because of @Transactional, so the runtime class name is `Foo$$SpringCGLIB$$0` -- the substring check survives the proxy. 4. :jpa test deps now include spring-boot-starter-webflux, so ConfigurationTest.allAutoConfigurationsAreLoaded() can verify ReactiveApiClientAutoConfiguration also activates -- matches the reality of a mixed Servlet+WebFlux consumer. Local: :core 23 unit tests still pass; all four modules compile. --- .../apilog/listener/ApiEventListener.java | 19 +++++++++++++----- jpa/build.gradle.kts | 4 ++++ .../jpa/autoconfigure/ConfigurationTest.java | 5 ++++- .../MybatisApiLogWriterIntegrationTest.java | 5 ++++- .../r2dbc/writer/R2dbcApiLogWriter.java | 20 ++++++++++++++----- .../R2dbcApiLogWriterIntegrationTest.java | 5 ++++- 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java index a0b49d8..9db2cfe 100644 --- a/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java +++ b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java @@ -9,6 +9,7 @@ import org.springframework.context.event.EventListener; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; /** * Drives the {@link ApiLogWriter} off the application event bus. @@ -20,6 +21,12 @@ * {@code api-log-mybatis} → MybatisApiLogWriter). The listener stays * backend-agnostic; routing happens by which jar is on the classpath. * + *

{@code @Async} so the persistence hop runs on the executor configured in + * {@code :core} (virtual-thread by default, platform-thread pool as fallback) + * — the HTTP caller never waits for a {@code api_log} write. The R2DBC writer + * also bridges its reactive {@code Mono} to a blocking call inside this + * executor thread; nothing on the request path blocks. + * *

{@code @Retryable} wraps each write in up to three attempts with 1s * backoff so transient persistence failures (connection blips, dead pool * connection on first use) don't drop a log row. Caught exceptions are logged, @@ -27,11 +34,10 @@ * outbound API call. * *

Transaction semantics are intentionally not declared here. The JPA - * writer wraps its own {@code save()} in {@code REQUIRES_NEW} (so the log - * write doesn't pollute the consumer's surrounding tx); the R2DBC and MyBatis - * writers do the equivalent inside their own implementations. Keeping the - * tx boundary inside the writer lets each backend pick the semantics that - * make sense (e.g., R2DBC has no @Transactional support out of the box). + * + MyBatis writers wrap their own writes in {@code REQUIRES_NEW} (so the log + * write doesn't pollute the consumer's surrounding tx). The R2DBC writer + * relies on the driver's auto-commit. Keeping the tx boundary inside the + * writer lets each backend pick the semantics that make sense. */ @Slf4j @RequiredArgsConstructor @@ -40,6 +46,7 @@ public class ApiEventListener { private final ApiLogWriter writer; @EventListener + @Async @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallInitiated(ApiCallInitiatedEvent event) { try { @@ -53,6 +60,7 @@ public void handleApiCallInitiated(ApiCallInitiatedEvent event) { } @EventListener + @Async @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallSuccess(ApiCallSuccessEvent event) { try { @@ -67,6 +75,7 @@ public void handleApiCallSuccess(ApiCallSuccessEvent event) { } @EventListener + @Async @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000)) public void handleApiCallError(ApiCallErrorEvent event) { try { diff --git a/jpa/build.gradle.kts b/jpa/build.gradle.kts index f10ab3a..c660ab3 100644 --- a/jpa/build.gradle.kts +++ b/jpa/build.gradle.kts @@ -82,6 +82,10 @@ dependencies { // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-web") + // Lets the ConfigurationTest assert that ReactiveApiClientAutoConfiguration + // also activates when WebClient is on the classpath — same shape as a real + // mixed Servlet+WebFlux consumer. + testImplementation("org.springframework.boot:spring-boot-starter-webflux") testImplementation("org.assertj:assertj-core") testImplementation("com.h2database:h2") diff --git a/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java b/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java index 0457f14..7a74831 100644 --- a/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java +++ b/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java @@ -93,7 +93,10 @@ void retryConfig_isImported() { @Test void apiLogWriter_isProvidedByJpaBackend() { ApiLogWriter writer = applicationContext.getBean(ApiLogWriter.class); - assertThat(writer.getClass().getSimpleName()).isEqualTo("JpaApiLogWriter"); + // Spring AOP wraps the writer in a CGLIB proxy because of @Transactional, + // so the runtime class name is `JpaApiLogWriter$$SpringCGLIB$$0`. + // Substring check on the FQN survives the proxy wrapping. + assertThat(writer.getClass().getName()).contains("JpaApiLogWriter"); } @Test diff --git a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java index 658f0af..84d8fba 100644 --- a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -71,7 +71,10 @@ void setUp() { @Test void writer_isWiredFromMybatisBackend() { - assertThat(writer.getClass().getSimpleName()).isEqualTo("MybatisApiLogWriter"); + // @Transactional on the writer makes Spring AOP wrap it in a CGLIB + // proxy whose simple name is `MybatisApiLogWriter$$SpringCGLIB$$0`. + // Substring on the FQN survives the proxy. + assertThat(writer.getClass().getName()).contains("MybatisApiLogWriter"); } @Test diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java index 93092d1..1754692 100644 --- a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.r2dbc.core.DatabaseClient; +import reactor.core.scheduler.Schedulers; import java.time.LocalDateTime; @@ -135,13 +136,22 @@ private void executeInsert(String eventType, .bind("retryCount", retryCount) .bind("isRetry", isRetry); + // Fire-and-forget — the whole point of this backend is to NOT block + // the caller's reactor thread. Pinning the subscription to + // boundedElastic guarantees the insert actually runs on a worker thread + // (without it the chain can starve when the caller hands control + // straight back to a CPU-bound test loop or a single-core CI runner — + // which is what bit the v0.6.0 first integration run). spec.fetch() .rowsUpdated() - .subscribe( - rows -> { /* success — listener already logs at DEBUG */ }, - ex -> log.error("R2DBC api_log insert failed: requestId={}, eventType={}", - requestId, eventType, ex) - ); + .subscribeOn(Schedulers.boundedElastic()) + .doOnSuccess(rows -> log.debug( + "R2DBC api_log insert ok: requestId={}, eventType={}, rows={}", + requestId, eventType, rows)) + .doOnError(ex -> log.error( + "R2DBC api_log insert failed: requestId={}, eventType={}", + requestId, eventType, ex)) + .subscribe(); } private static Object asJsonbParam(String value) { diff --git a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java index c999bcf..0aed9c1 100644 --- a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -71,7 +71,10 @@ void clearTable() { @Test void writer_isWiredFromR2dbcBackend() { - assertThat(writer.getClass().getSimpleName()).isEqualTo("R2dbcApiLogWriter"); + // The R2DBC writer has no @Transactional today so it's not proxied, + // but matching the JPA/MyBatis tests' substring approach keeps it + // proxy-safe if that ever changes. + assertThat(writer.getClass().getName()).contains("R2dbcApiLogWriter"); } @Test From 7fa0911515d49bf33beb3ef68cafc7371e8c64c6 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 03:46:14 +0900 Subject: [PATCH 5/9] Diagnostics: drop mybatis @Transactional, INFO logs + 15s await for r2dbc CI is still red on the r2dbc + mybatis integration tests; both report 0 rows inserted but no error. This commit narrows the two hypotheses so the next CI run will pinpoint each one separately. MyBatis: temporarily remove @Transactional(REQUIRES_NEW) from MybatisApiLogWriter. mybatis-spring's SpringManagedTransaction then runs the mapper call against the connection's auto-commit, so the insert lands immediately and is visible to subsequent reads. If the four mybatis integration tests turn green, the wrapping transaction wasn't committing for some reason and a follow-up commit will reinstate REQUIRES_NEW with the actual fix. If they stay red, the problem is elsewhere (mapper wiring, JDBC connection pool, etc.). R2DBC: bump the Awaitility timeouts in R2dbcApiLogWriterIntegrationTest from 5s to 15s (CI runners can be slow on first Reactor scheduler use), and raise the writer's doOnSubscribe / doOnSuccess / doOnError logs from DEBUG to INFO so the CI run reveals whether the chain ever subscribed in the first place. If neither the "subscribe" nor the "insert ok"/"insert failed" line shows, the .subscribe() call wasn't triggering the chain and we need to look at subscriber lifecycle (probably Disposable GC) -- if "subscribe" lands but "insert ok" doesn't, the boundedElastic worker is starved and we need a different scheduler hand-off. --- .../mybatis/writer/MybatisApiLogWriter.java | 29 +++++++++++++++---- .../r2dbc/writer/R2dbcApiLogWriter.java | 10 ++++++- .../R2dbcApiLogWriterIntegrationTest.java | 8 ++--- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java index 995864d..8a8df6f 100644 --- a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java @@ -10,8 +10,6 @@ import kr.devslab.apilog.spi.HttpErrorInfo; import kr.devslab.apilog.spi.PayloadJsonMapper; import lombok.RequiredArgsConstructor; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -38,7 +36,14 @@ public class MybatisApiLogWriter implements ApiLogWriter { private final PayloadJsonMapper jsonMapper; @Override - @Transactional(propagation = Propagation.REQUIRES_NEW) + // @Transactional(REQUIRES_NEW) intentionally removed in this commit to + // confirm CI hypothesis: the previous run inserted nothing, suggesting + // the wrapping transaction wasn't committing. With it gone, mybatis-spring + // runs the mapper call under SpringManagedTransaction in no-tx mode, which + // means the JDBC connection's auto-commit is used and each insert is + // visible immediately. If this turns the four mybatis integration tests + // green, the transaction wiring is what's broken and a follow-up commit + // will reinstate REQUIRES_NEW with the actual fix. public void writeInitiated(ApiCallInitiatedEvent event) { ApiLogRow row = ApiLogRow.builder() .eventType(INITIATED) @@ -53,7 +58,14 @@ public void writeInitiated(ApiCallInitiatedEvent event) { } @Override - @Transactional(propagation = Propagation.REQUIRES_NEW) + // @Transactional(REQUIRES_NEW) intentionally removed in this commit to + // confirm CI hypothesis: the previous run inserted nothing, suggesting + // the wrapping transaction wasn't committing. With it gone, mybatis-spring + // runs the mapper call under SpringManagedTransaction in no-tx mode, which + // means the JDBC connection's auto-commit is used and each insert is + // visible immediately. If this turns the four mybatis integration tests + // green, the transaction wiring is what's broken and a follow-up commit + // will reinstate REQUIRES_NEW with the actual fix. public void writeSuccess(ApiCallSuccessEvent event) { ApiLogRow row = ApiLogRow.builder() .eventType(SUCCESS) @@ -70,7 +82,14 @@ public void writeSuccess(ApiCallSuccessEvent event) { } @Override - @Transactional(propagation = Propagation.REQUIRES_NEW) + // @Transactional(REQUIRES_NEW) intentionally removed in this commit to + // confirm CI hypothesis: the previous run inserted nothing, suggesting + // the wrapping transaction wasn't committing. With it gone, mybatis-spring + // runs the mapper call under SpringManagedTransaction in no-tx mode, which + // means the JDBC connection's auto-commit is used and each insert is + // visible immediately. If this turns the four mybatis integration tests + // green, the transaction wiring is what's broken and a follow-up commit + // will reinstate REQUIRES_NEW with the actual fix. public void writeError(ApiCallErrorEvent event) { Throwable error = event.getError(); HttpErrorInfo info = HttpErrorExtractor.extract(error); diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java index 1754692..57d813c 100644 --- a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -142,10 +142,18 @@ private void executeInsert(String eventType, // (without it the chain can starve when the caller hands control // straight back to a CPU-bound test loop or a single-core CI runner — // which is what bit the v0.6.0 first integration run). + // + // INFO-level diagnostics (not DEBUG) so the CI run can prove whether + // the chain actually emitted. If the row never lands and neither log + // line shows up, `.subscribe()` wasn't triggering the chain at all + // and we know to look at the subscriber lifecycle. spec.fetch() .rowsUpdated() .subscribeOn(Schedulers.boundedElastic()) - .doOnSuccess(rows -> log.debug( + .doOnSubscribe(s -> log.info( + "R2DBC api_log subscribe: requestId={}, eventType={}", + requestId, eventType)) + .doOnSuccess(rows -> log.info( "R2DBC api_log insert ok: requestId={}, eventType={}, rows={}", requestId, eventType, rows)) .doOnError(ex -> log.error( diff --git a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java index 0aed9c1..0fc4547 100644 --- a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -88,7 +88,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -111,7 +111,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -131,7 +131,7 @@ void writeError_insertsStructuredErrorMessage() { writer.writeError(event); - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -154,7 +154,7 @@ void writeError_marksRetryErrorWhenRetryFlagSet() { writer.writeError(event); - Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); From 3ec91adbff2abfa2355aeb25929d709ac49bc087 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 03:54:31 +0900 Subject: [PATCH 6/9] Diagnostics 2: surface application logs in CI, log mybatis insert lifecycle Previous run's diagnostic output was invisible because every module's test task had showStandardStreams = false -- application log lines never reached the Gradle console. Flipped to true (temporary, will go back to false once the integration tests are stable). Also added INFO-level "MyBatis insert X / X done" pairs around each mapper.insert() call so the next CI run can prove whether the insert ran and whether mapper.insert() returned without throwing. The intriguing data point from the previous run -- writeError_marksRetry PASSED, writer_isWiredFromMybatisBackend PASSED, but the other three FAILED with empty rows -- suggests the inserts that ran did commit (@Transactional is off in this commit, so it's straight auto-commit JDBC). The mystery is why the same code path produces a row for one test and zero rows for the others. --- core/build.gradle.kts | 2 +- jpa/build.gradle.kts | 2 +- mybatis/build.gradle.kts | 2 +- .../mybatis/writer/MybatisApiLogWriter.java | 30 +++++++++---------- r2dbc/build.gradle.kts | 2 +- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ad88614..0f47f82 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -128,7 +128,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = false + showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/jpa/build.gradle.kts b/jpa/build.gradle.kts index c660ab3..cf9025c 100644 --- a/jpa/build.gradle.kts +++ b/jpa/build.gradle.kts @@ -114,7 +114,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = false + showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/mybatis/build.gradle.kts b/mybatis/build.gradle.kts index 964d636..a39a1ac 100644 --- a/mybatis/build.gradle.kts +++ b/mybatis/build.gradle.kts @@ -94,7 +94,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = false + showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java index 8a8df6f..49d495f 100644 --- a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java @@ -10,6 +10,7 @@ import kr.devslab.apilog.spi.HttpErrorInfo; import kr.devslab.apilog.spi.PayloadJsonMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; @@ -29,6 +30,7 @@ * ({@code CAST(#{...,jdbcType=VARCHAR} AS jsonb)}), so this class just builds * an {@link ApiLogRow} with string-typed JSON and hands it off. */ +@Slf4j @RequiredArgsConstructor public class MybatisApiLogWriter implements ApiLogWriter { @@ -54,18 +56,14 @@ public void writeInitiated(ApiCallInitiatedEvent event) { .retryCount(0) .isRetry(false) .build(); + log.info("MyBatis insert INITIATED: requestId={}, endpoint={}", + row.getRequestId(), row.getEndpoint()); mapper.insert(row); + log.info("MyBatis insert INITIATED done: requestId={}, id={}", + row.getRequestId(), row.getId()); } @Override - // @Transactional(REQUIRES_NEW) intentionally removed in this commit to - // confirm CI hypothesis: the previous run inserted nothing, suggesting - // the wrapping transaction wasn't committing. With it gone, mybatis-spring - // runs the mapper call under SpringManagedTransaction in no-tx mode, which - // means the JDBC connection's auto-commit is used and each insert is - // visible immediately. If this turns the four mybatis integration tests - // green, the transaction wiring is what's broken and a follow-up commit - // will reinstate REQUIRES_NEW with the actual fix. public void writeSuccess(ApiCallSuccessEvent event) { ApiLogRow row = ApiLogRow.builder() .eventType(SUCCESS) @@ -78,18 +76,14 @@ public void writeSuccess(ApiCallSuccessEvent event) { .retryCount(0) .isRetry(false) .build(); + log.info("MyBatis insert SUCCESS: requestId={}, status={}", + row.getRequestId(), row.getStatusCode()); mapper.insert(row); + log.info("MyBatis insert SUCCESS done: requestId={}, id={}", + row.getRequestId(), row.getId()); } @Override - // @Transactional(REQUIRES_NEW) intentionally removed in this commit to - // confirm CI hypothesis: the previous run inserted nothing, suggesting - // the wrapping transaction wasn't committing. With it gone, mybatis-spring - // runs the mapper call under SpringManagedTransaction in no-tx mode, which - // means the JDBC connection's auto-commit is used and each insert is - // visible immediately. If this turns the four mybatis integration tests - // green, the transaction wiring is what's broken and a follow-up commit - // will reinstate REQUIRES_NEW with the actual fix. public void writeError(ApiCallErrorEvent event) { Throwable error = event.getError(); HttpErrorInfo info = HttpErrorExtractor.extract(error); @@ -105,6 +99,10 @@ public void writeError(ApiCallErrorEvent event) { .retryCount(event.getRetryCount()) .isRetry(event.isRetry()) .build(); + log.info("MyBatis insert {}: requestId={}, isRetry={}", + row.getEventType(), row.getRequestId(), row.getIsRetry()); mapper.insert(row); + log.info("MyBatis insert {} done: requestId={}, id={}", + row.getEventType(), row.getRequestId(), row.getId()); } } diff --git a/r2dbc/build.gradle.kts b/r2dbc/build.gradle.kts index 5390e88..089a504 100644 --- a/r2dbc/build.gradle.kts +++ b/r2dbc/build.gradle.kts @@ -102,7 +102,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = false + showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) From 446e5c5e4bf6babdaf80e4816d73eb29bf389a2c Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 04:02:29 +0900 Subject: [PATCH 7/9] Fix R2DBC INSERT: explicit ::jsonb casts; diagnose mybatis SELECT visibility R2DBC: root cause was a wrong assumption in v0.6.0's R2dbcApiLogWriter - I'd documented "PostgreSQL R2DBC driver does the TEXT -> JSONB implicit cast", but it actually doesn't. r2dbc-postgresql binds CLOB params as text and the server rejects the insert with column "payload" is of type jsonb but expression is of type text Fixed by adding explicit `::jsonb` casts on payload / response / error_message in the INSERT, matching what the mybatis backend's mapper already does. No codec change; the bind path stays R2dbcType.CLOB. MyBatis: previous diagnostics confirmed mapper.insert() returned without throwing and the row got a BIGSERIAL id, yet mapper.findByRequestId() came back empty for three of the four tests - the fourth ("retry") passed identically. That points at a SELECT visibility issue, not a write failure. Added diagFindByRequestId() that runs the mapper's SELECT alongside two raw JdbcTemplate counts (by request_id and total rows) so the next run will tell whether (a) mapper-only blindness (mybatis session/cache issue) or (b) global emptiness (something is rolling back / deleting between the writer call and the assertion). --- .../MybatisApiLogWriterIntegrationTest.java | 32 ++++++++++++++++--- .../r2dbc/writer/R2dbcApiLogWriter.java | 12 +++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java index 84d8fba..a55af63 100644 --- a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -66,7 +66,29 @@ static void configure(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { jdbcTemplate = new JdbcTemplate(dataSource); - jdbcTemplate.update("DELETE FROM api_log"); + int deleted = jdbcTemplate.update("DELETE FROM api_log"); + int remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM api_log", Integer.class); + System.out.println("[diag] BeforeEach: DELETE removed=" + deleted + ", remaining=" + remaining); + } + + /** + * Diagnostic helper: returns the mapper's view of the table AND a + * parallel JdbcTemplate view, so the next CI run can tell whether the + * mapper is missing rows the raw JDBC sees (mybatis-spring session + * isolation) or both views are genuinely empty (commit/visibility issue). + */ + private List diagFindByRequestId(String reqId) { + List mapperRows = mapper.findByRequestId(reqId); + Integer jdbcCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM api_log WHERE request_id = ?", + Integer.class, reqId); + Integer jdbcTotal = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM api_log", Integer.class); + System.out.println("[diag] reqId=" + reqId + + " mapperRows=" + mapperRows.size() + + " jdbcRows=" + jdbcCount + + " jdbcTotal=" + jdbcTotal); + return mapperRows; } @Test @@ -88,7 +110,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - List rows = mapper.findByRequestId(reqId); + List rows = diagFindByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(INITIATED); @@ -108,7 +130,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); - List rows = mapper.findByRequestId(reqId); + List rows = diagFindByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(SUCCESS); @@ -126,7 +148,7 @@ void writeError_insertsStructuredErrorMessage() { writer.writeError(event); - List rows = mapper.findByRequestId(reqId); + List rows = diagFindByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(ERROR); @@ -146,7 +168,7 @@ void writeError_marksRetryErrorWhenRetryFlagSet() { writer.writeError(event); - List rows = mapper.findByRequestId(reqId); + List rows = diagFindByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(RETRY_ERROR); diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java index 57d813c..025ddf2 100644 --- a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -44,13 +44,21 @@ @RequiredArgsConstructor public class R2dbcApiLogWriter implements ApiLogWriter { + // PostgreSQL won't implicitly cast TEXT -> JSONB the way I'd hoped -- + // r2dbc-postgresql binds CLOB params as text and the server rejects the + // insert with "column 'payload' is of type jsonb but expression is of + // type text". Explicit `::jsonb` on every JSONB column fixes it without + // requiring a custom codec or the r2dbc-postgresql Json type. private static final String INSERT_SQL = """ INSERT INTO api_log (event_type, request_id, endpoint, payload, response, status_code, error_message, timestamp, retry_count, is_retry) VALUES - (:eventType, :requestId, :endpoint, :payload, :response, - :statusCode, :errorMessage, :timestamp, :retryCount, :isRetry) + (:eventType, :requestId, :endpoint, + :payload::jsonb, :response::jsonb, + :statusCode, + :errorMessage::jsonb, + :timestamp, :retryCount, :isRetry) """; private final DatabaseClient databaseClient; From eaab4fd626affcade5ae0a5a31e3ca3b8ab0f714 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 04:08:35 +0900 Subject: [PATCH 8/9] Tests: match PostgreSQL JSONB canonical form (space after colon) The R2DBC SQL fix from the previous commit produced visibly correct INSERTs (r2dbc subscribe + insert ok rows=1; mybatis mapperRows=1 jdbcRows=1 jdbcTotal=1), but every JSON-content assertion failed with shapes like Expecting actual: "{\"amount\": 100}" to contain: "\"amount\":100" PostgreSQL stores JSONB in canonical form (one space after each colon) and the `column::text` SELECT returns that canonical form. The tests were written against the input shape (no space) rather than the stored shape (with space) -- false alarm. Updated eight assertions across the r2dbc and mybatis integration tests to expect the canonical form. The JPA backend doesn't need the same change because Hibernate maps payload as JsonNode and the test asserts via .get("user").get("name") rather than substring on the serialized text. This should be the last red light. The four R2DBC integration tests and three mybatis tests that were failing on the payload/response/ error_message substring should now all pass on their already- inserted rows. --- .../writer/MybatisApiLogWriterIntegrationTest.java | 8 ++++---- .../r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java index a55af63..b2ca5c5 100644 --- a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -117,7 +117,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { assertThat(row.getEndpoint()).isEqualTo("/charges"); assertThat(row.getRetryCount()).isEqualTo(0); assertThat(row.getIsRetry()).isFalse(); - assertThat(row.getPayload()).contains("\"amount\":100"); + assertThat(row.getPayload()).contains("\"amount\": 100"); } @Test @@ -135,7 +135,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(SUCCESS); assertThat(row.getStatusCode()).isEqualTo(201); - assertThat(row.getResponse()).contains("\"id\":\"ch_1\""); + assertThat(row.getResponse()).contains("\"id\": \"ch_1\""); } @Test @@ -154,8 +154,8 @@ void writeError_insertsStructuredErrorMessage() { assertThat(row.getEventType()).isEqualTo(ERROR); assertThat(row.getStatusCode()).isNull(); assertThat(row.getErrorMessage()) - .contains("\"type\":\"java.lang.IllegalStateException\"") - .contains("\"message\":\"connection broken\""); + .contains("\"type\": \"java.lang.IllegalStateException\"") + .contains("\"message\": \"connection broken\""); } @Test diff --git a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java index 0fc4547..432c9db 100644 --- a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -97,7 +97,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { assertThat(row.get("retry_count")).isEqualTo(0); assertThat(row.get("is_retry")).isEqualTo(false); // payload is JSONB; toString round-trips to canonical JSON - assertThat(row.get("payload").toString()).contains("\"amount\":100"); + assertThat(row.get("payload").toString()).contains("\"amount\": 100"); }); } @@ -117,7 +117,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { Map row = rows.get(0); assertThat(row.get("event_type")).isEqualTo("SUCCESS"); assertThat(row.get("status_code")).isEqualTo(201); - assertThat(row.get("response").toString()).contains("\"id\":\"ch_1\""); + assertThat(row.get("response").toString()).contains("\"id\": \"ch_1\""); }); } @@ -139,8 +139,8 @@ void writeError_insertsStructuredErrorMessage() { // Non-HTTP exception → no status_code assertThat(row.get("status_code")).isNull(); assertThat(row.get("error_message").toString()) - .contains("\"type\":\"java.lang.IllegalStateException\"") - .contains("\"message\":\"connection broken\""); + .contains("\"type\": \"java.lang.IllegalStateException\"") + .contains("\"message\": \"connection broken\""); }); } From 0a87203ca1218f3f418e0c449edd295596594d52 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 21 May 2026 04:14:59 +0900 Subject: [PATCH 9/9] Cleanup: restore production hygiene now that CI is green The previous five iterations sprinkled diagnostic logging and loosened a few production knobs to get to the root cause. Now that all 44 tests pass on CI we undo the temporary noise: - showStandardStreams = false in all four modules' test config (Gradle console no longer dumps the entire Spring Boot startup). - MybatisApiLogWriter regains @Transactional(REQUIRES_NEW). The earlier hypothesis that the wrapping transaction was the failure cause turned out to be wrong -- the JSON canonical-form mismatch was the actual cause -- so the matching contract with JpaApiLogWriter (audit log committed regardless of caller tx rollback) goes back in. - MybatisApiLogWriter loses the INFO-level "insert / insert done" log pairs. - R2dbcApiLogWriter loses the doOnSubscribe line and downgrades doOnSuccess from INFO to DEBUG; doOnError stays at ERROR so a real production-time insert failure remains visible. - MyBatis integration test drops the diagFindByRequestId helper + BeforeEach println; calls mapper.findByRequestId directly. - R2DBC integration test settles its Awaitility timeout at 10s (down from 15s diagnostic margin, still 2x the original 5s for CI cold-start headroom). This is the commit that goes into v0.6.0 -- no temporary debugging artifacts. Pushing to confirm CI stays green before merging. --- core/build.gradle.kts | 2 +- jpa/build.gradle.kts | 2 +- mybatis/build.gradle.kts | 2 +- .../mybatis/writer/MybatisApiLogWriter.java | 27 +++------------- .../MybatisApiLogWriterIntegrationTest.java | 32 +++---------------- r2dbc/build.gradle.kts | 2 +- .../r2dbc/writer/R2dbcApiLogWriter.java | 10 +----- .../R2dbcApiLogWriterIntegrationTest.java | 8 ++--- 8 files changed, 19 insertions(+), 66 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 0f47f82..ad88614 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -128,7 +128,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging + showStandardStreams = false } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/jpa/build.gradle.kts b/jpa/build.gradle.kts index cf9025c..c660ab3 100644 --- a/jpa/build.gradle.kts +++ b/jpa/build.gradle.kts @@ -114,7 +114,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging + showStandardStreams = false } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/mybatis/build.gradle.kts b/mybatis/build.gradle.kts index a39a1ac..964d636 100644 --- a/mybatis/build.gradle.kts +++ b/mybatis/build.gradle.kts @@ -94,7 +94,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging + showStandardStreams = false } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java index 49d495f..995864d 100644 --- a/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriter.java @@ -10,7 +10,8 @@ import kr.devslab.apilog.spi.HttpErrorInfo; import kr.devslab.apilog.spi.PayloadJsonMapper; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -30,7 +31,6 @@ * ({@code CAST(#{...,jdbcType=VARCHAR} AS jsonb)}), so this class just builds * an {@link ApiLogRow} with string-typed JSON and hands it off. */ -@Slf4j @RequiredArgsConstructor public class MybatisApiLogWriter implements ApiLogWriter { @@ -38,14 +38,7 @@ public class MybatisApiLogWriter implements ApiLogWriter { private final PayloadJsonMapper jsonMapper; @Override - // @Transactional(REQUIRES_NEW) intentionally removed in this commit to - // confirm CI hypothesis: the previous run inserted nothing, suggesting - // the wrapping transaction wasn't committing. With it gone, mybatis-spring - // runs the mapper call under SpringManagedTransaction in no-tx mode, which - // means the JDBC connection's auto-commit is used and each insert is - // visible immediately. If this turns the four mybatis integration tests - // green, the transaction wiring is what's broken and a follow-up commit - // will reinstate REQUIRES_NEW with the actual fix. + @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeInitiated(ApiCallInitiatedEvent event) { ApiLogRow row = ApiLogRow.builder() .eventType(INITIATED) @@ -56,14 +49,11 @@ public void writeInitiated(ApiCallInitiatedEvent event) { .retryCount(0) .isRetry(false) .build(); - log.info("MyBatis insert INITIATED: requestId={}, endpoint={}", - row.getRequestId(), row.getEndpoint()); mapper.insert(row); - log.info("MyBatis insert INITIATED done: requestId={}, id={}", - row.getRequestId(), row.getId()); } @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeSuccess(ApiCallSuccessEvent event) { ApiLogRow row = ApiLogRow.builder() .eventType(SUCCESS) @@ -76,14 +66,11 @@ public void writeSuccess(ApiCallSuccessEvent event) { .retryCount(0) .isRetry(false) .build(); - log.info("MyBatis insert SUCCESS: requestId={}, status={}", - row.getRequestId(), row.getStatusCode()); mapper.insert(row); - log.info("MyBatis insert SUCCESS done: requestId={}, id={}", - row.getRequestId(), row.getId()); } @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeError(ApiCallErrorEvent event) { Throwable error = event.getError(); HttpErrorInfo info = HttpErrorExtractor.extract(error); @@ -99,10 +86,6 @@ public void writeError(ApiCallErrorEvent event) { .retryCount(event.getRetryCount()) .isRetry(event.isRetry()) .build(); - log.info("MyBatis insert {}: requestId={}, isRetry={}", - row.getEventType(), row.getRequestId(), row.getIsRetry()); mapper.insert(row); - log.info("MyBatis insert {} done: requestId={}, id={}", - row.getEventType(), row.getRequestId(), row.getId()); } } diff --git a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java index b2ca5c5..e63bbd3 100644 --- a/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -66,29 +66,7 @@ static void configure(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { jdbcTemplate = new JdbcTemplate(dataSource); - int deleted = jdbcTemplate.update("DELETE FROM api_log"); - int remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM api_log", Integer.class); - System.out.println("[diag] BeforeEach: DELETE removed=" + deleted + ", remaining=" + remaining); - } - - /** - * Diagnostic helper: returns the mapper's view of the table AND a - * parallel JdbcTemplate view, so the next CI run can tell whether the - * mapper is missing rows the raw JDBC sees (mybatis-spring session - * isolation) or both views are genuinely empty (commit/visibility issue). - */ - private List diagFindByRequestId(String reqId) { - List mapperRows = mapper.findByRequestId(reqId); - Integer jdbcCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM api_log WHERE request_id = ?", - Integer.class, reqId); - Integer jdbcTotal = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM api_log", Integer.class); - System.out.println("[diag] reqId=" + reqId - + " mapperRows=" + mapperRows.size() - + " jdbcRows=" + jdbcCount - + " jdbcTotal=" + jdbcTotal); - return mapperRows; + jdbcTemplate.update("DELETE FROM api_log"); } @Test @@ -110,7 +88,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - List rows = diagFindByRequestId(reqId); + List rows = mapper.findByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(INITIATED); @@ -130,7 +108,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); - List rows = diagFindByRequestId(reqId); + List rows = mapper.findByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(SUCCESS); @@ -148,7 +126,7 @@ void writeError_insertsStructuredErrorMessage() { writer.writeError(event); - List rows = diagFindByRequestId(reqId); + List rows = mapper.findByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(ERROR); @@ -168,7 +146,7 @@ void writeError_marksRetryErrorWhenRetryFlagSet() { writer.writeError(event); - List rows = diagFindByRequestId(reqId); + List rows = mapper.findByRequestId(reqId); assertThat(rows).hasSize(1); ApiLogRow row = rows.get(0); assertThat(row.getEventType()).isEqualTo(RETRY_ERROR); diff --git a/r2dbc/build.gradle.kts b/r2dbc/build.gradle.kts index 089a504..5390e88 100644 --- a/r2dbc/build.gradle.kts +++ b/r2dbc/build.gradle.kts @@ -102,7 +102,7 @@ tasks.test { testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - showStandardStreams = true // temporary: surface application logs in CI for the v0.6.0 integration-test debugging + showStandardStreams = false } systemProperty("file.encoding", "UTF-8") finalizedBy(tasks.jacocoTestReport) diff --git a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java index 025ddf2..2a065c8 100644 --- a/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -150,18 +150,10 @@ private void executeInsert(String eventType, // (without it the chain can starve when the caller hands control // straight back to a CPU-bound test loop or a single-core CI runner — // which is what bit the v0.6.0 first integration run). - // - // INFO-level diagnostics (not DEBUG) so the CI run can prove whether - // the chain actually emitted. If the row never lands and neither log - // line shows up, `.subscribe()` wasn't triggering the chain at all - // and we know to look at the subscriber lifecycle. spec.fetch() .rowsUpdated() .subscribeOn(Schedulers.boundedElastic()) - .doOnSubscribe(s -> log.info( - "R2DBC api_log subscribe: requestId={}, eventType={}", - requestId, eventType)) - .doOnSuccess(rows -> log.info( + .doOnSuccess(rows -> log.debug( "R2DBC api_log insert ok: requestId={}, eventType={}, rows={}", requestId, eventType, rows)) .doOnError(ex -> log.error( diff --git a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java index 432c9db..e695a19 100644 --- a/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -88,7 +88,7 @@ void writeInitiated_insertsRowWithJsonbPayload() { writer.writeInitiated(new ApiCallInitiatedEvent(this, request)); - Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -111,7 +111,7 @@ void writeSuccess_insertsRowWithResponseJsonAndStatusCode() { writer.writeSuccess(new ApiCallSuccessEvent(this, request, response)); - Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -131,7 +131,7 @@ void writeError_insertsStructuredErrorMessage() { writer.writeError(event); - Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0); @@ -154,7 +154,7 @@ void writeError_marksRetryErrorWhenRetryFlagSet() { writer.writeError(event); - Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> { + Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { List> rows = fetchByRequestId(reqId); assertThat(rows).hasSize(1); Map row = rows.get(0);