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 56% 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..8b24a73 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,85 @@ 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.autoconfigure.jackson.JacksonAutoConfiguration; 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} 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 -@ConditionalOnClass({ApiEventListener.class, ApiLogService.class}) +@AutoConfiguration(after = JacksonAutoConfiguration.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 { - - @Bean - @ConditionalOnMissingBean - @ConditionalOnBean(ObjectMapper.class) - public ApiLogService apiLogService(ApiLogRepository repository, ObjectMapper objectMapper) { - return new ApiLogService(repository, objectMapper); - } +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(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}. 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 - @ConditionalOnProperty( - prefix = "api.log.schema", - name = "management", - havingValue = "builtin", - matchIfMissing = true - ) - public ApiLogSchemaInitializer apiLogSchemaInitializer(DataSource dataSource) { - return new ApiLogSchemaInitializer(dataSource); + 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 55% 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..9db2cfe 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,54 @@ 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; +import org.springframework.scheduling.annotation.Async; +/** + * 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 @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, + * 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 + * + 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 -@Component @RequiredArgsConstructor public class ApiEventListener { - private final ApiLogService apiLogService; + + private final ApiLogWriter writer; @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Async @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 +60,11 @@ public void handleApiCallInitiated(ApiCallInitiatedEvent event) { } @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Async @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 +75,11 @@ public void handleApiCallSuccess(ApiCallSuccessEvent event) { } @EventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Async @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 +90,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 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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 100755 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..c660ab3 --- /dev/null +++ b/jpa/build.gradle.kts @@ -0,0 +1,168 @@ +// :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") + // 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") + + // 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..7bab142 --- /dev/null +++ b/jpa/src/main/java/kr/devslab/apilog/jpa/autoconfigure/ApiLogJpaAutoConfiguration.java @@ -0,0 +1,80 @@ +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.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 { + + /** + * 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) + 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..7a74831 --- /dev/null +++ b/jpa/src/test/java/kr/devslab/apilog/jpa/autoconfigure/ConfigurationTest.java @@ -0,0 +1,117 @@ +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); + // 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 + 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..5a82256 --- /dev/null +++ b/mybatis/src/main/java/kr/devslab/apilog/mybatis/autoconfigure/ApiLogMybatisAutoConfiguration.java @@ -0,0 +1,75 @@ +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.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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, 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) + 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..e63bbd3 --- /dev/null +++ b/mybatis/src/test/java/kr/devslab/apilog/mybatis/writer/MybatisApiLogWriterIntegrationTest.java @@ -0,0 +1,156 @@ +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() { + // @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 + 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..ee39ac0 --- /dev/null +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/autoconfigure/ApiLogR2dbcAutoConfiguration.java @@ -0,0 +1,85 @@ +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.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; + +/** + * 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, R2dbcAutoConfiguration.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}. + * {@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 + 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) + public R2dbcApiLogWriter r2dbcApiLogWriter(DatabaseClient databaseClient, PayloadJsonMapper jsonMapper) { + return new R2dbcApiLogWriter(databaseClient, jsonMapper); + } + + @Bean + @ConditionalOnMissingBean + @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..2a065c8 --- /dev/null +++ b/r2dbc/src/main/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriter.java @@ -0,0 +1,173 @@ +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 reactor.core.scheduler.Schedulers; + +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 { + + // 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::jsonb, :response::jsonb, + :statusCode, + :errorMessage::jsonb, + :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); + + // 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() + .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) { + // 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..e695a19 --- /dev/null +++ b/r2dbc/src/test/java/kr/devslab/apilog/r2dbc/writer/R2dbcApiLogWriterIntegrationTest.java @@ -0,0 +1,183 @@ +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() { + // 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 + 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(10)).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(10)).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(10)).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(10)).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