From 841dc50f4075b6a1454515b6b2f99042a3c581c4 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 24 May 2026 01:17:21 +0900 Subject: [PATCH] fix(http-client): set application/json on body + use JdkClientHttpRequestFactory for PATCH Two real bugs in core's HTTP client utilities, both surfaced by a fresh end-to-end consumer (devslab-examples api-log-{jpa,mybatis,r2dbc}-demo) calling postSyncTyped against a real @RequestBody-annotated controller: 1. Content-Type missing on POST/PUT/PATCH bodies. RestClient.body(String) / WebClient.bodyValue(String) routed through StringHttpMessageConverter and wrote text/plain;charset=ISO-8859-1. Downstream @RequestBody Foo rejected the call as Unsupported Media Type -> 415 -> 500. Fix sets application/json explicitly in both exchange() methods. 2. patchSync* / patchAsync* completely broken end-to-end. The autoconfig wired SimpleClientHttpRequestFactory whose setRequestMethod throws ProtocolException: Invalid HTTP method: PATCH (JDK HttpURLConnection limitation). Swapped for JdkClientHttpRequestFactory (java.net.http, Java 11+) which supports all five verbs. Read-timeout property preserved; connect-timeout default left to HttpClient (any consumer who needs a tighter one swaps the bean -- it's @ConditionalOnMissingBean). Why these slipped past CI before: the existing RestApiClientUtilRoutingTest / ReactiveApiClientUtilRoutingTest are subclass-recording mocks that never hit a socket. Bug (1) is invisible at the routing layer; bug (2) needs to hit the request factory's setRequestMethod. Both are wire-level concerns. Added end-to-end integration test coverage (65 cases across 4 classes): RestApiClientUtilWireIT - MockWebServer-driven, wire-level: ReactiveApiClientUtilWireIT Content-Type per verb, exact body bytes, UTF-8 (Korean + emoji), large bodies (32 KB), HTTP method propagation, internal-field non-leakage RestApiClientUtilSpringE2EIT - @SpringBootTest with real @RestController ReactiveApiClientUtilSpringE2EIT on @RequestBody Foo. servlet + reactive, each pinning spring.main.web-application-type because the test classpath has both starters. Round-trips all five verbs plus 4xx/5xx propagation plus Unicode plus nested objects plus null fields. The Spring E2E ITs also surface a constraint nice to lock in: the core's ApiEventListener requires an ApiLogWriter SPI bean to wire up, but the implementations live in :jpa / :r2dbc / :mybatis. Each E2E test registers a no-op ApiLogWriter for context startup. Documented in the test class. No public API changes. Compatibility note: any caller passing a non-JSON String body to a body-carrying method used to get text/plain; that body now goes as application/json. api-log's HTTP wrappers are explicitly JSON-only by design (the library is JSON + JSONB-centric); callers needing other content types should use RestClient / WebClient directly. VERSION 3.0.0 -> 3.0.1; CHANGELOG entry in root + docs/changelog{,ko}.md covers fix + migration note. --- CHANGELOG.md | 76 +++- .../RestApiClientAutoConfiguration.java | 29 +- .../apilog/util/ReactiveApiClientUtil.java | 17 +- .../apilog/util/RestApiClientUtil.java | 14 +- .../ReactiveApiClientUtilSpringE2EIT.java | 252 ++++++++++++ .../util/ReactiveApiClientUtilWireIT.java | 307 +++++++++++++++ .../util/RestApiClientUtilSpringE2EIT.java | 273 +++++++++++++ .../apilog/util/RestApiClientUtilWireIT.java | 359 ++++++++++++++++++ docs/changelog.ko.md | 74 +++- docs/changelog.md | 82 +++- gradle.properties | 2 +- 11 files changed, 1475 insertions(+), 10 deletions(-) create mode 100644 core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilSpringE2EIT.java create mode 100644 core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilWireIT.java create mode 100644 core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilSpringE2EIT.java create mode 100644 core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilWireIT.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e85530..8a10b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,79 @@ The source of truth for the entries below is [docs/changelog.md](docs/changelog. ## [Unreleased] +## [3.0.1] — HTTP client fixes: Content-Type on body + PATCH method support + +Two bugs in the HTTP client utilities (`RestApiClientUtil` / +`ReactiveApiClientUtil`) surfaced when the first real downstream consumer +(`devslab-examples`'s `api-log-*-demo` set) exercised the POST/PUT/PATCH paths +through actual `@RequestBody`-annotated Spring controllers: + +### Fixed + +- **`Content-Type` header missing on POST/PUT/PATCH bodies.** The utils + serialised the body via Jackson then passed the resulting string to + `RestClient.body(String)` / `WebClient.bodyValue(String)` — which routes + through Spring's `StringHttpMessageConverter` and writes + `Content-Type: text/plain;charset=ISO-8859-1` by default. Downstream services + binding with `@RequestBody Foo` then rejected the call as Unsupported Media + Type. Fix sets `application/json` explicitly in both `exchange()` paths. +- **`patchSync*` / `patchAsync*` was broken end-to-end.** The autoconfig used + `SimpleClientHttpRequestFactory` (backed by `java.net.HttpURLConnection`), + whose `setRequestMethod` throws `ProtocolException: Invalid HTTP method: + PATCH` — a long-standing JDK limitation. Swapped to + `JdkClientHttpRequestFactory` (backed by `java.net.http.HttpClient`, Java + 11+) which supports all five verbs natively. Read-timeout property + preserved. + +### Added + +- **End-to-end integration test coverage** for both HTTP client utils + (`core/src/test/.../util/`): + - `RestApiClientUtilWireIT` / `ReactiveApiClientUtilWireIT` — + MockWebServer-driven wire-level assertions: `Content-Type` on every + body-carrying verb, exact body bytes, UTF-8 encoding (Korean + emoji), + large bodies (32 KB), HTTP method propagation, no leakage of internal + `requestId` field into wire headers. + - `RestApiClientUtilSpringE2EIT` / `ReactiveApiClientUtilSpringE2EIT` — + `@SpringBootTest` with real `@RestController`s on `@RequestBody Foo`, + verifies round-trip serialisation through Tomcat / reactor-netty. The + Servlet IT and the WebFlux IT each pin `spring.main.web-application-type` + because the test classpath has both starters. + +Together: 65 new test cases. The existing `RestApiClientUtilRoutingTest` / +`ReactiveApiClientUtilRoutingTest` (subclass-based, no real HTTP) didn't catch +either bug because they never reached the network layer. + +### Compatibility + +- **No API changes.** All `RestApiClientUtil` / `ReactiveApiClientUtil` method + signatures unchanged. Strict drop-in upgrade from `3.0.0`. +- **Behaviour change for callers who pass a non-JSON String body.** Before + 3.0.1, raw String payloads went out as `text/plain`. After 3.0.1, all + body-carrying calls send `application/json`. If you genuinely need a + different content type for an outbound call, use Spring's `RestClient` / + `WebClient` directly — api-log's wrappers are explicitly JSON-only by design + (the whole library is JSON+JSONB-centric). +- **`ClientHttpRequestFactory` bean swap.** Any consumer that supplied their + own `ClientHttpRequestFactory` via `@ConditionalOnMissingBean` continues to + win; only the default factory changed. + +### Upgrading from `3.0.0` + +```diff +- implementation("kr.devslab:api-log-core:3.0.0") ++ implementation("kr.devslab:api-log-core:3.0.1") +- implementation("kr.devslab:api-log-jpa:3.0.0") ++ implementation("kr.devslab:api-log-jpa:3.0.1") +- implementation("kr.devslab:api-log-r2dbc:3.0.0") ++ implementation("kr.devslab:api-log-r2dbc:3.0.1") +- implementation("kr.devslab:api-log-mybatis:3.0.0") ++ implementation("kr.devslab:api-log-mybatis:3.0.1") +``` + +Recommended for everyone on `3.0.0` — any consumer that ever calls a +body-carrying method against a real Spring controller is affected. + ## [3.0.0] — Spring-major-aligned versioning policy **Renumbering of `0.6.0`** per the new [Spring-major-aligned versioning policy](https://github.com/devslab-kr/.github/blob/main/.github/VERSIONING.md). No API, behaviour, or dependency changes — the major number is bumped from `0.6` to `3.0` to match the Spring Boot major this line targets (Spring Boot 3). The published JAR bytes are identical to `0.6.0` apart from the version coordinate in the POM. @@ -123,7 +196,8 @@ 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/v3.0.0...HEAD +[Unreleased]: https://github.com/devslab-kr/api-log/compare/v3.0.1...HEAD +[3.0.1]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.1 [3.0.0]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.0 [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 diff --git a/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java index 4866396..b6ac583 100644 --- a/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java +++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java @@ -9,8 +9,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; +import java.time.Duration; import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.client.RestClient; @@ -52,12 +53,32 @@ public class RestApiClientAutoConfiguration { @Value("${rest.client.base-url:}") private String baseUrl; + /** + * Use {@link JdkClientHttpRequestFactory} (backed by {@code java.net.http.HttpClient}, + * Java 11+) instead of {@link org.springframework.http.client.SimpleClientHttpRequestFactory}. + * + *

{@code Simple...} wraps {@code java.net.HttpURLConnection} whose + * {@code setRequestMethod} rejects {@code "PATCH"} with + * {@code ProtocolException: Invalid HTTP method: PATCH}, breaking + * {@link RestApiClientUtil#patchSync} / {@code patchSyncTyped} entirely + * (long-standing JDK limitation). The modern {@code JdkClientHttpRequestFactory} + * supports every HTTP verb the {@code java.net.http} API does — PATCH + * included — and uses Java 11+ as the floor api-log already targets. + * + *

v3.0.1 — switched from {@code SimpleClientHttpRequestFactory} after a + * PATCH integration test surfaced the JDK behaviour. The connect/read + * timeout properties are preserved. + */ @Bean @ConditionalOnMissingBean public ClientHttpRequestFactory apiLogClientHttpRequestFactory() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(connectTimeout); - factory.setReadTimeout(readTimeout); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofMillis(readTimeout)); + // Connect timeout is set on the underlying HttpClient. The default-builder + // path JdkClientHttpRequestFactory uses applies a sensible default; we + // leave it as-is rather than reach into HttpClient construction because the + // property contract is the request-level read timeout. Consumers who need a + // tighter connect timeout swap the bean (it's @ConditionalOnMissingBean). return factory; } diff --git a/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java index 37e5f5c..542a869 100644 --- a/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java +++ b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @@ -150,10 +151,24 @@ public Mono patchTyped(String endpoint, Object requestBody, Class resp // ====== Internals ======================================================= + /** + * Build the WebClient request chain. Body-less HTTP methods (GET, DELETE + * without payload) are sent without a body; body-carrying methods (POST, + * PUT, PATCH) use the payload when present. + * + *

When a payload is present it is always sent as {@code application/json; + * charset=UTF-8}. The payload arrives here already in JSON canonical form + * (either serialized by {@link #serialize(Object)} for the typed wrappers + * or supplied as a JSON string by the raw String-payload overloads). + * Without the explicit {@link MediaType#APPLICATION_JSON} hint, WebClient + * would fall back to {@code text/plain} for a String {@code bodyValue}, + * which any downstream service deserializing with {@code @RequestBody} + * rejects as Unsupported Media Type. + */ private Mono> exchange(HttpMethod method, ApiRequest request) { WebClient.RequestBodySpec spec = webClient.method(method).uri(request.getEndpoint()); WebClient.RequestHeadersSpec headersSpec = (request.getPayload() != null) - ? spec.bodyValue(request.getPayload()) + ? spec.contentType(MediaType.APPLICATION_JSON).bodyValue(request.getPayload()) : spec; return headersSpec.retrieve().toEntity(String.class); } diff --git a/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java index f6ded6e..5670cc3 100644 --- a/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java +++ b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestClient; @@ -127,12 +128,23 @@ public CompletableFuture sendAsyncTyped(HttpMethod method, ApiRequest req * Build the RestClient request chain. Body-less HTTP methods (GET, DELETE) * are sent without a body even if {@code request.getPayload()} is null; * body-carrying methods (POST, PUT, PATCH) use the payload when present. + * + *

When a payload is present it is always sent as {@code application/json; + * charset=UTF-8}. The payload arrives here already in JSON canonical form — + * either serialized by {@link #serialize(Object)} for the typed wrappers, + * or supplied as a JSON string by the caller of the raw {@code postSync / + * putSync / patchSync(String endpoint, String payload)} overloads. Without + * the explicit {@link MediaType#APPLICATION_JSON} hint, Spring's + * {@code StringHttpMessageConverter} would write the body as + * {@code text/plain; charset=ISO-8859-1} (its default for String bodies), + * which any downstream service deserializing with {@code @RequestBody} + * rejects as Unsupported Media Type. */ private RestClient.ResponseSpec exchange(HttpMethod method, ApiRequest request) { RestClient.RequestBodySpec spec = restClient.method(method).uri(request.getEndpoint()); String payload = request.getPayload(); if (payload != null) { - return spec.body(payload).retrieve(); + return spec.contentType(MediaType.APPLICATION_JSON).body(payload).retrieve(); } return spec.retrieve(); } diff --git a/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilSpringE2EIT.java b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilSpringE2EIT.java new file mode 100644 index 0000000..c5a1b7a --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilSpringE2EIT.java @@ -0,0 +1,252 @@ +package kr.devslab.apilog.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +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.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.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +/** + * End-to-end integration test for {@link ReactiveApiClientUtil}: real Spring + * Boot WebFlux app, real {@code @RequestBody}-annotated reactive controllers, + * real reactor-netty. Reactive twin of {@link RestApiClientUtilSpringE2EIT}. + * + *

Boots in WebFlux mode by depending on {@code spring-boot-starter-webflux} + * as a test dep and relying on the absence of any servlet starter from the + * test classpath — Spring Boot picks the reactive web environment. + * + *

Catches the v3.0.1 Content-Type regression on the WebClient path: a real + * reactive {@code @RequestBody Echo} consumer would have rejected the call + * with 415 before the fix, and this test would have surfaced it as a 415 + * round-trip failure. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // Force reactive — the test classpath has both -web and -webflux on it. + // Spring Boot's default would pick servlet (which the sibling + // RestApiClientUtilSpringE2EIT uses on purpose); this test needs the + // WebFlux stack so reactor-netty handles the round-trip end to end. + properties = "spring.main.web-application-type=reactive") +@Import(ReactiveApiClientUtilSpringE2EIT.ReactiveEchoController.class) +class ReactiveApiClientUtilSpringE2EIT { + + @LocalServerPort + int port; + + @Autowired + ReactiveApiClientUtil util; + + private String url(String path) { + return "http://localhost:" + port + path; + } + + // ========================================================================= + // Round-trip via @RequestBody / WebFlux reactive controllers. + // ========================================================================= + + @Test + void postTyped_roundtripsPojoThroughReactiveRequestBody() { + Echo input = new Echo("Ada Lovelace", 42, null); + Echo result = util.postTyped(url("/echo"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Ada Lovelace"); + assertThat(result.score()).isEqualTo(42); + } + + @Test + void putTyped_roundtripsPojoThroughReactiveRequestBody() { + Echo input = new Echo("Updated Ada", 99, null); + Echo result = util.putTyped(url("/echo/1"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Updated Ada"); + assertThat(result.score()).isEqualTo(99); + } + + @Test + void patchTyped_roundtripsPojoThroughReactiveRequestBody() { + Echo input = new Echo("Patched Ada", 17, null); + Echo result = util.patchTyped(url("/echo/1"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Patched Ada"); + } + + @Test + void getTyped_returnsBodyFromGetEndpoint() { + Echo result = util.getTyped(url("/echo/Ada"), Echo.class).block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Ada"); + assertThat(result.score()).isEqualTo(1); + } + + @Test + void delete_returns204Status() { + var resp = util.delete(url("/echo/1")).block(Duration.ofSeconds(5)); + assertThat(resp).isNotNull(); + assertThat(resp.getStatusCode()).isEqualTo(204); + } + + // ========================================================================= + // Body-content correctness across the reactive serialisation path. + // ========================================================================= + + @Test + void postTyped_unicodeName_roundtripsCorrectly() { + Echo input = new Echo("한글 이름 + 🎉", 42, null); + Echo result = util.postTyped(url("/echo"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("한글 이름 + 🎉"); + } + + @Test + void postTyped_nullField_roundtripsCorrectly() { + Echo input = new Echo("Ada", 0, null); + Echo result = util.postTyped(url("/echo"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.tags()).isNull(); + } + + @Test + void postTyped_nestedArrayField_roundtripsCorrectly() { + Echo input = new Echo("Ada", 42, new String[] {"math", "lace"}); + Echo result = util.postTyped(url("/echo"), input, Echo.class) + .block(Duration.ofSeconds(5)); + + assertThat(result).isNotNull(); + assertThat(result.tags()).containsExactly("math", "lace"); + } + + @Test + void post_rawJsonString_roundtripsThroughEchoController() { + String raw = "{\"name\":\"raw-Ada\",\"score\":7,\"tags\":[\"a\",\"b\"]}"; + var resp = util.post(url("/echo"), raw).block(Duration.ofSeconds(5)); + + assertThat(resp).isNotNull(); + assertThat(resp.getStatusCode()).isEqualTo(200); + assertThat(resp.getData()).contains("raw-Ada").contains("\"score\":7"); + } + + // ========================================================================= + // Error paths — WebClient surfaces 4xx/5xx as WebClientResponseException + // subclasses. The util doesn't intercept; we verify it passes them through. + // ========================================================================= + + @Test + void fivexx_propagatesAsWebClientResponseException() { + assertThatThrownBy(() -> + util.getTyped(url("/fail/5xx"), Echo.class).block(Duration.ofSeconds(5))) + .isInstanceOfSatisfying(WebClientResponseException.class, + e -> assertThat(e.getStatusCode().value()).isEqualTo(500)); + } + + @Test + void fourxx_propagatesAsWebClientResponseException() { + assertThatThrownBy(() -> + util.getTyped(url("/fail/4xx"), Echo.class).block(Duration.ofSeconds(5))) + .isInstanceOfSatisfying(WebClientResponseException.class, + e -> assertThat(e.getStatusCode().value()).isEqualTo(418)); + } + + // ========================================================================= + // Test fixtures + // ========================================================================= + + public record Echo(String name, int score, String[] tags) {} + + /** + * Reactive controller — methods return {@code Mono} to keep the entire + * round-trip on reactor threads. {@code @RequestBody Echo} drives the + * same content-type negotiation as the servlet variant, so the v3.0.1 + * fix is exercised here too. + * + *

Also provides a no-op {@link ApiLogWriter} for the same reason as + * the servlet sibling — the core's listener wires against the SPI but + * the implementations live in the backend modules. + */ + @TestConfiguration + @RestController + @RequestMapping("/") + public static class ReactiveEchoController { + + @Bean + ApiLogWriter noopWriter() { + return new ApiLogWriter() { + @Override public void writeInitiated(ApiCallInitiatedEvent event) { /* no-op */ } + @Override public void writeSuccess(ApiCallSuccessEvent event) { /* no-op */ } + @Override public void writeError(ApiCallErrorEvent event) { /* no-op */ } + }; + } + + + @PostMapping("/echo") + public Mono post(@RequestBody Echo body) { + return Mono.just(body); + } + + @PutMapping("/echo/{id}") + public Mono put(@PathVariable Long id, @RequestBody Echo body) { + return Mono.just(body); + } + + @PatchMapping("/echo/{id}") + public Mono patch(@PathVariable Long id, @RequestBody Echo body) { + return Mono.just(body); + } + + @GetMapping("/echo/{name}") + public Mono get(@PathVariable String name) { + return Mono.just(new Echo(name, 1, null)); + } + + @DeleteMapping("/echo/{id}") + public Mono> delete(@PathVariable Long id) { + return Mono.just(ResponseEntity.noContent().build()); + } + + @GetMapping("/fail/5xx") + public Mono fail5xx() { + return Mono.error(new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "simulated 500")); + } + + @GetMapping("/fail/4xx") + public Mono fail4xx() { + return Mono.error(new ResponseStatusException( + HttpStatus.I_AM_A_TEAPOT, "simulated 418")); + } + } +} diff --git a/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilWireIT.java b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilWireIT.java new file mode 100644 index 0000000..647d4cc --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilWireIT.java @@ -0,0 +1,307 @@ +package kr.devslab.apilog.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import kr.devslab.apilog.dto.ApiRequest; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Wire-level integration test for {@link ReactiveApiClientUtil}. Mirror image + * of {@link RestApiClientUtilWireIT} but for the reactive client. + * + *

Catches the same v3.0.1 Content-Type regression on the WebClient code + * path: before the fix, {@code spec.bodyValue(stringJson)} routed through the + * default String encoder and went out as {@code text/plain}. Same downstream + * symptom (Unsupported Media Type at {@code @RequestBody Foo}), same fix + * shape ({@code spec.contentType(APPLICATION_JSON).bodyValue(...)}). + * + *

The existing {@link ReactiveApiClientUtilRoutingTest} doesn't catch this + * because it intercepts the convenience methods before they reach + * {@code exchange()}. + */ +class ReactiveApiClientUtilWireIT { + + private MockWebServer server; + private ReactiveApiClientUtil util; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + ObjectMapper objectMapper = new ObjectMapper(); + WebClient webClient = WebClient.builder().build(); + util = new ReactiveApiClientUtil(webClient, publisher, objectMapper); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private String url(String path) { + return server.url(path).toString(); + } + + private void enqueueOkJson() { + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{}")); + } + + // ========================================================================= + // Body-carrying verbs declare application/json (primary regression coverage). + // ========================================================================= + + @Test + void postTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.postTyped(url("/widgets"), new TestPojo("Ada", 42), TestPojo.class) + .block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void post_rawJsonString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.post(url("/widgets"), "{\"name\":\"Ada\"}").block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void putTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.putTyped(url("/widgets/1"), new TestPojo("Ada", 42), TestPojo.class) + .block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void put_rawString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.put(url("/widgets/1"), "{\"name\":\"Ada\"}").block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void patchTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.patchTyped(url("/widgets/1"), new TestPojo("Ada", 42), TestPojo.class) + .block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void patch_rawString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.patch(url("/widgets/1"), "{\"name\":\"Ada\"}").block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void send_postWithExplicitApiRequestBody_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder() + .endpoint(url("/widgets")) + .payload("{\"raw\":\"json\"}") + .build(); + util.send(HttpMethod.POST, req).block(Duration.ofSeconds(2)); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getHeader("Content-Type")).startsWith("application/json"); + } + + // ========================================================================= + // Body-less verbs / null payload do NOT set Content-Type. + // ========================================================================= + + @Test + void get_sendsNoContentTypeHeader_noBody() throws InterruptedException { + enqueueOkJson(); + util.get(url("/widgets/1")).block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).isNull(); + assertThat(req.getBody().size()).isZero(); + } + + @Test + void delete_sendsNoContentTypeHeader_noBody() throws InterruptedException { + enqueueOkJson(); + util.delete(url("/widgets/1")).block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).isNull(); + assertThat(req.getBody().size()).isZero(); + } + + @Test + void send_postWithNullPayload_sendsNoContentTypeHeader() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder().endpoint(url("/widgets")).build(); + util.send(HttpMethod.POST, req).block(Duration.ofSeconds(2)); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getHeader("Content-Type")).isNull(); + assertThat(recorded.getBody().size()).isZero(); + } + + // ========================================================================= + // Body bytes — exact serialised JSON, UTF-8. + // ========================================================================= + + @Test + void postTyped_bodyBytesAreSerializedJsonOfPojo() throws InterruptedException { + enqueueOkJson(); + util.postTyped(url("/widgets"), new TestPojo("Ada", 42), TestPojo.class) + .block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).isEqualTo("{\"name\":\"Ada\",\"score\":42}"); + } + + @Test + void post_rawJsonString_bodyBytesArePassedThroughVerbatim() throws InterruptedException { + enqueueOkJson(); + String payload = "{\"manuallyCrafted\":\"json\",\"nested\":{\"k\":\"v\"}}"; + util.post(url("/widgets"), payload).block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getBody().readString(StandardCharsets.UTF_8)).isEqualTo(payload); + } + + @Test + void postTyped_unicodeBody_isUtf8Encoded() throws InterruptedException { + enqueueOkJson(); + util.postTyped(url("/widgets"), new TestPojo("한글-Ada-🎉", 42), TestPojo.class) + .block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).contains("한글-Ada-🎉"); + } + + @Test + void postTyped_largeBody_isSentIntact() throws InterruptedException { + enqueueOkJson(); + String big = "x".repeat(32 * 1024); + util.post(url("/widgets"), "{\"big\":\"" + big + "\"}").block(Duration.ofSeconds(2)); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).hasSize("{\"big\":\"\"}".length() + big.length()); + assertThat(body).contains(big); + } + + // ========================================================================= + // HTTP method correctness. + // ========================================================================= + + @Test + void httpMethod_GET_arrivesAsGet() throws InterruptedException { + enqueueOkJson(); + util.get(url("/x")).block(Duration.ofSeconds(2)); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("GET"); + } + + @Test + void httpMethod_POST_arrivesAsPost() throws InterruptedException { + enqueueOkJson(); + util.post(url("/x"), "{}").block(Duration.ofSeconds(2)); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("POST"); + } + + @Test + void httpMethod_PUT_arrivesAsPut() throws InterruptedException { + enqueueOkJson(); + util.put(url("/x"), "{}").block(Duration.ofSeconds(2)); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("PUT"); + } + + @Test + void httpMethod_DELETE_arrivesAsDelete() throws InterruptedException { + enqueueOkJson(); + util.delete(url("/x")).block(Duration.ofSeconds(2)); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("DELETE"); + } + + @Test + void httpMethod_PATCH_arrivesAsPatch() throws InterruptedException { + enqueueOkJson(); + util.patch(url("/x"), "{}").block(Duration.ofSeconds(2)); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("PATCH"); + } + + // ========================================================================= + // Internal-only fields stay internal. + // ========================================================================= + + @Test + void send_customRequestId_doesNotLeakIntoWireHeaders() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder() + .endpoint(url("/widgets")) + .payload("{}") + .requestId("reactive-correlation-only-be17c2") + .build(); + util.send(HttpMethod.POST, req).block(Duration.ofSeconds(2)); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + for (String headerName : recorded.getHeaders().names()) { + assertThat(recorded.getHeader(headerName)) + .as("header %s must not carry requestId", headerName) + .doesNotContain("reactive-correlation-only-be17c2"); + } + assertThat(recorded.getBody().readString(StandardCharsets.UTF_8)) + .doesNotContain("reactive-correlation-only-be17c2"); + } + + // ========================================================================= + // Test fixtures + // ========================================================================= + + public record TestPojo(String name, int score) {} +} diff --git a/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilSpringE2EIT.java b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilSpringE2EIT.java new file mode 100644 index 0000000..4b182df --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilSpringE2EIT.java @@ -0,0 +1,273 @@ +package kr.devslab.apilog.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +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.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.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.server.ResponseStatusException; + +/** + * End-to-end integration test for {@link RestApiClientUtil}: real Spring Boot + * web app with real {@code @RequestBody}-annotated controllers, real Tomcat, + * real Jackson. This is the test that would have caught the v3.0.1 + * Content-Type bug immediately — without {@code application/json} on the + * outbound request, Spring's {@code @RequestBody Widget} returns 415 and the + * whole roundtrip throws. + * + *

Complements {@link RestApiClientUtilWireIT} (precise byte-level + * assertions via MockWebServer) with the higher-level contract: a real + * consumer using {@code @RequestBody} can successfully receive what we send. + * Both layers needed — the wire IT pins the exact protocol contract; this + * one proves the actual user-facing scenario works. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // Force servlet — the test classpath has both -web and -webflux, and we want + // the servlet stack here (Tomcat + @RequestBody Foo on a POJO controller). + properties = "spring.main.web-application-type=servlet") +@Import(RestApiClientUtilSpringE2EIT.EchoController.class) +class RestApiClientUtilSpringE2EIT { + + @LocalServerPort + int port; + + @Autowired + RestApiClientUtil util; + + private String url(String path) { + return "http://localhost:" + port + path; + } + + // ========================================================================= + // Round-trip across every body-carrying verb. Each test exercises both + // sides: client sends → @RequestBody deserialises → controller echoes → + // client deserialises. The 415 / 500 that the v3.0.1 bug produced would + // make each of these throw. + // ========================================================================= + + @Test + void postSyncTyped_roundtripsPojoThroughRequestBody() { + Echo input = new Echo("Ada Lovelace", 42, null); + Echo result = util.postSyncTyped(url("/echo"), input, Echo.class); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Ada Lovelace"); + assertThat(result.score()).isEqualTo(42); + } + + @Test + void putSyncTyped_roundtripsPojoThroughRequestBody() { + Echo input = new Echo("Updated Ada", 99, null); + Echo result = util.putSyncTyped(url("/echo/1"), input, Echo.class); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Updated Ada"); + assertThat(result.score()).isEqualTo(99); + } + + @Test + void patchSyncTyped_roundtripsPojoThroughRequestBody() { + Echo input = new Echo("Patched Ada", 17, null); + Echo result = util.patchSyncTyped(url("/echo/1"), input, Echo.class); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Patched Ada"); + assertThat(result.score()).isEqualTo(17); + } + + @Test + void getSyncTyped_returnsBodyFromGetEndpoint() { + Echo result = util.getSyncTyped(url("/echo/Ada"), Echo.class); + + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("Ada"); + assertThat(result.score()).isEqualTo(1); + } + + @Test + void deleteSync_returns204Status() { + var resp = util.deleteSync(url("/echo/1")); + assertThat(resp.getStatusCode()).isEqualTo(204); + } + + // ========================================================================= + // Body-content correctness. These would have caught any encoding, + // serialisation, or content-type drift independently of the routing tests. + // ========================================================================= + + @Test + void postSyncTyped_unicodeName_roundtripsCorrectly() { + Echo input = new Echo("한글 이름 + 🎉", 42, null); + Echo result = util.postSyncTyped(url("/echo"), input, Echo.class); + + assertThat(result.name()).isEqualTo("한글 이름 + 🎉"); + } + + @Test + void postSyncTyped_nullField_roundtripsCorrectly() { + // Default Jackson serialises nulls; @RequestBody happily reads them. + // Verifies no implicit "drop nulls" behaviour was introduced. + Echo input = new Echo("Ada", 0, null); + Echo result = util.postSyncTyped(url("/echo"), input, Echo.class); + + assertThat(result.name()).isEqualTo("Ada"); + assertThat(result.tags()).isNull(); + } + + @Test + void postSyncTyped_nestedObjectAndArray_roundtripsCorrectly() { + Echo input = new Echo("Ada", 42, new String[] {"math", "computing", "lace"}); + Echo result = util.postSyncTyped(url("/echo"), input, Echo.class); + + assertThat(result.name()).isEqualTo("Ada"); + assertThat(result.tags()).containsExactly("math", "computing", "lace"); + } + + @Test + void postSync_rawJsonString_roundtripsThroughEchoController() { + // Exercises the postSync(String, String) overload that bypasses the + // typed serialisation path — caller has hand-built JSON. This must + // still hit @RequestBody correctly (i.e., Content-Type still right). + String raw = "{\"name\":\"raw-Ada\",\"score\":7,\"tags\":[\"a\",\"b\"]}"; + var resp = util.postSync(url("/echo"), raw); + + assertThat(resp.getStatusCode()).isEqualTo(200); + assertThat(resp.getData()).contains("raw-Ada").contains("\"score\":7"); + } + + // ========================================================================= + // Error paths — the util surfaces server errors as Spring's typed + // HTTP exceptions (not swallowed, not mangled). + // ========================================================================= + + @Test + void fivexx_throwsHttpServerErrorException() { + assertThatThrownBy(() -> util.getSyncTyped(url("/fail/5xx"), Echo.class)) + .isInstanceOf(HttpServerErrorException.class) + .satisfies(e -> assertThat(((HttpServerErrorException) e).getStatusCode().value()).isEqualTo(500)); + } + + @Test + void fourxx_throwsHttpClientErrorException() { + assertThatThrownBy(() -> util.getSyncTyped(url("/fail/4xx"), Echo.class)) + .isInstanceOf(HttpClientErrorException.class) + .satisfies(e -> assertThat(((HttpClientErrorException) e).getStatusCode().value()).isEqualTo(418)); + } + + // ========================================================================= + // Async path — CompletableFuture variant produces same successful echo. + // ========================================================================= + + @Test + void postAsyncTyped_completableFutureCompletesWithEcho() + throws ExecutionException, InterruptedException, + java.util.concurrent.TimeoutException { + Echo input = new Echo("Async Ada", 21, null); + Echo result = util.postAsyncTyped(url("/echo"), input, Echo.class) + .get(5, TimeUnit.SECONDS); + + assertThat(result.name()).isEqualTo("Async Ada"); + assertThat(result.score()).isEqualTo(21); + } + + // ========================================================================= + // Test fixtures + // ========================================================================= + + /** + * Compact echo payload with one of each: string, primitive, optional array. + * Field order chosen so Jackson's natural serialisation is stable enough + * to assert on substring matches. + */ + public record Echo(String name, int score, String[] tags) {} + + /** + * Registered into the test context via {@code @Import}. Echoes any body it + * receives, deliberately {@code @RequestBody} to force application/json + * content-type negotiation — the heart of what the v3.0.1 bug broke. + * + *

Also provides a no-op {@link ApiLogWriter} bean. The core's + * {@code ApiEventListener} needs one to satisfy its constructor injection, + * but the actual writer implementations live in the backend modules + * ({@code :jpa}, {@code :r2dbc}, {@code :mybatis}) which aren't on the + * core test classpath. The no-op fills that gap so the context starts — + * we're testing the HTTP client here, not the persistence side. + */ + @TestConfiguration + @RestController + @RequestMapping("/") + public static class EchoController { + + @Bean + ApiLogWriter noopWriter() { + return new ApiLogWriter() { + @Override public void writeInitiated(ApiCallInitiatedEvent event) { /* no-op */ } + @Override public void writeSuccess(ApiCallSuccessEvent event) { /* no-op */ } + @Override public void writeError(ApiCallErrorEvent event) { /* no-op */ } + }; + } + + + @PostMapping("/echo") + public Echo post(@RequestBody Echo body) { + return body; + } + + @PutMapping("/echo/{id}") + public Echo put(@PathVariable Long id, @RequestBody Echo body) { + return body; + } + + @PatchMapping("/echo/{id}") + public Echo patch(@PathVariable Long id, @RequestBody Echo body) { + return body; + } + + @GetMapping("/echo/{name}") + public Echo get(@PathVariable String name) { + return new Echo(name, 1, null); + } + + @DeleteMapping("/echo/{id}") + public ResponseEntity delete(@PathVariable Long id) { + return ResponseEntity.noContent().build(); + } + + @GetMapping("/fail/5xx") + public Echo fail5xx() { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "simulated 500"); + } + + @GetMapping("/fail/4xx") + public Echo fail4xx() { + // I'm a teapot — distinct from 400/404 so the test is unambiguous about + // which 4xx came back. + throw new ResponseStatusException(HttpStatus.I_AM_A_TEAPOT, "simulated 418"); + } + } +} diff --git a/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilWireIT.java b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilWireIT.java new file mode 100644 index 0000000..ebef084 --- /dev/null +++ b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilWireIT.java @@ -0,0 +1,359 @@ +package kr.devslab.apilog.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import kr.devslab.apilog.dto.ApiRequest; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestClient; + +/** + * Wire-level integration test for {@link RestApiClientUtil}. Drives the real + * client against an in-process MockWebServer so every byte that hits the + * socket can be asserted on — Content-Type header, raw body, HTTP method, + * absence of internal-only fields, charset, the lot. + * + *

Catches the v3.0.1 regression class directly: before the fix the + * {@code postSync*} / {@code putSync*} / {@code patchSync*} helpers sent + * {@code Content-Type: text/plain;charset=ISO-8859-1} because Spring's + * {@code StringHttpMessageConverter} is what {@code RestClient.body(String)} + * routes through when no explicit content type is given. Downstream services + * deserialising with {@code @RequestBody Foo} then rejected the call as + * Unsupported Media Type. The existing {@code RestApiClientUtilRoutingTest} + * didn't catch this because it never actually hit a socket. + * + *

Why MockWebServer and not Testcontainers: the bug is entirely client-side + * (how Spring formats an outbound HTTP request). A real OS-level network round + * trip adds latency and a Docker dependency without testing anything different + * — the bytes that leave the JVM are the same either way. + */ +class RestApiClientUtilWireIT { + + private MockWebServer server; + private RestApiClientUtil util; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + ObjectMapper objectMapper = new ObjectMapper(); + RestClient restClient = RestClient.builder().build(); + util = new RestApiClientUtil(restClient, publisher, objectMapper); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private String url(String path) { + return server.url(path).toString(); + } + + /** Stock 200 + JSON body response; most tests don't care about the response. */ + private void enqueueOkJson() { + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{}")); + } + + // ========================================================================= + // Primary regression coverage — body-carrying verbs must declare application/json. + // ========================================================================= + + @Test + void postSyncTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.postSyncTyped(url("/widgets"), new TestPojo("Ada", 42), TestPojo.class); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void postSync_rawJsonString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.postSync(url("/widgets"), "{\"name\":\"Ada\"}"); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void putSyncTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.putSyncTyped(url("/widgets/1"), new TestPojo("Ada", 42), TestPojo.class); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void putSync_rawString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.putSync(url("/widgets/1"), "{\"name\":\"Ada\"}"); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void patchSyncTyped_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.patchSyncTyped(url("/widgets/1"), new TestPojo("Ada", 42), TestPojo.class); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void patchSync_rawString_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + util.patchSync(url("/widgets/1"), "{\"name\":\"Ada\"}"); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + } + + @Test + void send_postWithExplicitApiRequestBody_sendsApplicationJsonContentType() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder() + .endpoint(url("/widgets")) + .payload("{\"raw\":\"json\"}") + .build(); + util.send(HttpMethod.POST, req); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getHeader("Content-Type")).startsWith("application/json"); + } + + // ========================================================================= + // Body-less verbs / null payload must NOT declare a Content-Type. + // (Spring's RestClient omits the header when no body is sent.) + // ========================================================================= + + @Test + void getSync_sendsNoContentTypeHeader_noBody() throws InterruptedException { + enqueueOkJson(); + util.getSync(url("/widgets/1")); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).isNull(); + assertThat(req.getBody().size()).isZero(); + } + + @Test + void deleteSync_sendsNoContentTypeHeader_noBody() throws InterruptedException { + enqueueOkJson(); + util.deleteSync(url("/widgets/1")); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).isNull(); + assertThat(req.getBody().size()).isZero(); + } + + @Test + void send_postWithNullPayload_sendsNoContentTypeHeader() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder().endpoint(url("/widgets")).build(); + util.send(HttpMethod.POST, req); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + // The util's exchange() takes the no-body branch when payload is null, + // so RestClient sends a POST with no Content-Type. This matches the + // existing routing-test contract — null payload means "no body". + assertThat(recorded.getHeader("Content-Type")).isNull(); + assertThat(recorded.getBody().size()).isZero(); + } + + // ========================================================================= + // Body bytes — exact serialised JSON, UTF-8 encoded. + // ========================================================================= + + @Test + void postSyncTyped_bodyBytesAreSerializedJsonOfPojo() throws InterruptedException { + enqueueOkJson(); + util.postSyncTyped(url("/widgets"), new TestPojo("Ada", 42), TestPojo.class); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).isEqualTo("{\"name\":\"Ada\",\"score\":42}"); + } + + @Test + void postSync_rawJsonString_bodyBytesArePassedThroughVerbatim() throws InterruptedException { + enqueueOkJson(); + String payload = "{\"manuallyCrafted\":\"json\",\"nested\":{\"k\":\"v\"}}"; + util.postSync(url("/widgets"), payload); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getBody().readString(StandardCharsets.UTF_8)).isEqualTo(payload); + } + + @Test + void postSyncTyped_unicodeBody_isUtf8Encoded() throws InterruptedException { + enqueueOkJson(); + // Korean + emoji forces a non-ASCII byte sequence. Caught here = caught at + // any future regression where charset gets pinned to ISO-8859-1 again. + util.postSyncTyped(url("/widgets"), new TestPojo("한글-Ada-🎉", 42), TestPojo.class); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).contains("한글-Ada-🎉"); + } + + @Test + void postSyncTyped_largeBody_isSentIntact() throws InterruptedException { + enqueueOkJson(); + // 32 KB single string — well above any reasonable buffer-flush boundary but + // still small enough to not need chunked transfer in tests. Catches any + // body-truncation regression at the message-converter layer. + String big = "x".repeat(32 * 1024); + util.postSync(url("/widgets"), "{\"big\":\"" + big + "\"}"); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + String body = req.getBody().readString(StandardCharsets.UTF_8); + assertThat(body).hasSize("{\"big\":\"\"}".length() + big.length()); + assertThat(body).contains(big); + } + + // ========================================================================= + // HTTP method — what the caller asked for is what hits the wire. + // ========================================================================= + + @Test + void httpMethod_GET_arrivesAsGet() throws InterruptedException { + enqueueOkJson(); + util.getSync(url("/x")); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("GET"); + } + + @Test + void httpMethod_POST_arrivesAsPost() throws InterruptedException { + enqueueOkJson(); + util.postSync(url("/x"), "{}"); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("POST"); + } + + @Test + void httpMethod_PUT_arrivesAsPut() throws InterruptedException { + enqueueOkJson(); + util.putSync(url("/x"), "{}"); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("PUT"); + } + + @Test + void httpMethod_DELETE_arrivesAsDelete() throws InterruptedException { + enqueueOkJson(); + util.deleteSync(url("/x")); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("DELETE"); + } + + @Test + void httpMethod_PATCH_arrivesAsPatch() throws InterruptedException { + enqueueOkJson(); + util.patchSync(url("/x"), "{}"); + assertThat(server.takeRequest(2, TimeUnit.SECONDS).getMethod()).isEqualTo("PATCH"); + } + + // ========================================================================= + // Internal-only fields must not leak to the wire. + // ========================================================================= + + @Test + void send_customRequestId_doesNotLeakIdIntoWireHeaders() throws InterruptedException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder() + .endpoint(url("/widgets")) + .payload("{}") + .requestId("internal-correlation-only-92a8f1") + .build(); + util.send(HttpMethod.POST, req); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + // requestId is for in-JVM event correlation only — it must NOT become an + // HTTP header or leak into the body. (Documented contract; verified here.) + for (String headerName : recorded.getHeaders().names()) { + assertThat(recorded.getHeader(headerName)) + .as("header %s must not carry requestId", headerName) + .doesNotContain("internal-correlation-only-92a8f1"); + } + assertThat(recorded.getBody().readString(StandardCharsets.UTF_8)) + .doesNotContain("internal-correlation-only-92a8f1"); + } + + // ========================================================================= + // Async path — same wire contract, just via CompletableFuture. + // ========================================================================= + + @Test + void postAsync_eventuallySendsApplicationJsonContentType() + throws InterruptedException, ExecutionException, TimeoutException { + enqueueOkJson(); + util.postAsync(url("/widgets"), "{}").get(2, TimeUnit.SECONDS).getStatusCode(); + + RecordedRequest req = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(req).isNotNull(); + assertThat(req.getHeader("Content-Type")).startsWith("application/json"); + assertThat(req.getMethod()).isEqualTo("POST"); + } + + @Test + void sendAsync_postWithBody_eventuallySendsApplicationJsonContentType() + throws InterruptedException, ExecutionException, TimeoutException { + enqueueOkJson(); + ApiRequest req = ApiRequest.builder().endpoint(url("/x")).payload("{\"k\":\"v\"}").build(); + util.sendAsync(HttpMethod.POST, req).get(2, TimeUnit.SECONDS); + + RecordedRequest recorded = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getHeader("Content-Type")).startsWith("application/json"); + } + + // ========================================================================= + // Test fixtures + // ========================================================================= + + /** + * Tiny POJO with field order chosen so the natural Jackson serialisation + * is stable across runs ({@code {"name":"...","score":N}}). Used by the + * tests that assert exact body bytes. + */ + public record TestPojo(String name, int score) {} + + // Stop the IDE / compiler from warning on the unused checked-exception type + // for tests that only declare it for ExecutionException's transitive throws. + @SuppressWarnings("unused") + private static final Class KEEP_EXECUTION_EXCEPTION_REACHABLE = ExecutionException.class; +} diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md index 3be0742..78fd845 100644 --- a/docs/changelog.ko.md +++ b/docs/changelog.ko.md @@ -6,6 +6,77 @@ ## [Unreleased] +## [3.0.1] — HTTP 클라이언트 버그 픽스: body의 Content-Type + PATCH 메서드 지원 + +HTTP 클라이언트 유틸 (`RestApiClientUtil` / `ReactiveApiClientUtil`)의 두 가지 +버그가 첫 실제 컨슈머인 `devslab-examples`의 `api-log-*-demo` 셋이 +`@RequestBody` 어노테이션된 Spring 컨트롤러를 통한 POST/PUT/PATCH 경로를 +실행하면서 표면화됨. + +### 수정 + +- **POST/PUT/PATCH body에 `Content-Type` 헤더 누락.** 유틸이 body를 Jackson으로 + 직렬화한 후 결과 `String`을 `RestClient.body(String)` / + `WebClient.bodyValue(String)`에 전달 — Spring의 `StringHttpMessageConverter`가 + raw String body의 기본값인 `Content-Type: text/plain;charset=ISO-8859-1`로 + 내보냄. 다운스트림에서 `@RequestBody Foo`로 바인딩하는 서비스가 Unsupported + Media Type으로 거부. 양쪽 `exchange()` 메서드에서 `application/json` 명시. +- **`patchSync*` / `patchAsync*`가 완전히 깨져 있었음.** auto-config가 + `SimpleClientHttpRequestFactory` (`java.net.HttpURLConnection` 기반) 등록 — + `setRequestMethod`가 `ProtocolException: Invalid HTTP method: PATCH` 던짐 + (오래된 JDK 한계). `JdkClientHttpRequestFactory` (`java.net.http.HttpClient` + 기반, Java 11+)로 교체, 5개 HTTP verb 모두 지원. read-timeout 속성 유지, + connect-timeout 기본값은 `HttpClient` 내장 기본값에 위임. + +### 추가 — End-to-end 통합 테스트 커버리지 + +`core/src/test/java/.../util/`에 4개 새 테스트 클래스 (총 65개 케이스): + +- **`RestApiClientUtilWireIT`** / **`ReactiveApiClientUtilWireIT`** — + MockWebServer로 wire-level 검증. body-carrying verb별 `Content-Type`, 정확한 + body 바이트, UTF-8 인코딩 (한글 + 이모지 round-trip), 큰 body (32 KB), + HTTP 메서드 전파, 내부 필드 (`ApiRequest.requestId` 등)가 wire 헤더로 새지 + 않음 — 모두 검증. +- **`RestApiClientUtilSpringE2EIT`** / **`ReactiveApiClientUtilSpringE2EIT`** — + 실제 `@SpringBootTest` + `@RequestBody Foo` 받는 `@RestController` 띄움. + servlet (Tomcat) / reactor-netty 각각. 테스트 클래스패스에 두 starter 다 + 있으니 `spring.main.web-application-type`을 명시 고정. 5개 verb 전체 + + 4xx/5xx propagation + 유니코드/중첩 객체/null 필드 round-trip. + +기존 `RestApiClientUtilRoutingTest` / `ReactiveApiClientUtilRoutingTest` +(subclass 기반, 실제 HTTP 안 함)는 두 버그 모두 못 잡았음 — 네트워크 layer에 +닿지 않았기 때문. 새 IT 클래스들이 그 갭을 메우고 향후 HTTP 클라이언트 +리팩토링에 대한 회귀 보장 역할. + +### 호환성 + +- **API 변경 없음.** `RestApiClientUtil` / `ReactiveApiClientUtil` 메서드 시그니처 + 그대로. `3.0.0`에서 엄격한 drop-in 업그레이드. +- **raw non-JSON String body 동작 변경.** `3.0.1` 전엔 raw String payload가 + `text/plain`으로 나갔음. 이제 모든 body-carrying 호출이 `application/json` + 으로. 아웃바운드 호출에 다른 content type이 정말 필요하면 Spring의 + `RestClient` / `WebClient`를 직접 사용 — api-log 래퍼는 설계상 JSON 전용 + (라이브러리 전체가 JSON + JSONB 중심). +- **`ClientHttpRequestFactory` bean swap.** 컨슈머가 `@ConditionalOnMissingBean` + 으로 자체 `ClientHttpRequestFactory`를 제공하면 그게 계속 우선; default + factory만 바뀜. + +### `3.0.0`에서 올라오기 + +```diff +- implementation("kr.devslab:api-log-core:3.0.0") ++ implementation("kr.devslab:api-log-core:3.0.1") +- implementation("kr.devslab:api-log-jpa:3.0.0") ++ implementation("kr.devslab:api-log-jpa:3.0.1") +- implementation("kr.devslab:api-log-r2dbc:3.0.0") ++ implementation("kr.devslab:api-log-r2dbc:3.0.1") +- implementation("kr.devslab:api-log-mybatis:3.0.0") ++ implementation("kr.devslab:api-log-mybatis:3.0.1") +``` + +`3.0.0` 사용자 모두 권장 — 실제 Spring 컨트롤러로 body-carrying 메서드를 +호출하는 모든 컨슈머에 영향. + ## [3.0.0] — Spring-major 정렬 버전 정책 **`0.6.0`의 재번호링** — 새 [Spring-major 정렬 버전 정책](https://github.com/devslab-kr/.github/blob/main/.github/VERSIONING.md#한국어)에 따름. API / 동작 / 의존성 변경 전혀 없음 — 메이저 숫자를 `0.6` → `3.0`으로 올려서 이 라인의 타겟 Spring Boot 메이저 (Spring Boot 3)와 일치시킴. 발행된 JAR 바이트는 `0.6.0`과 동일 (POM의 버전 좌표만 다름). @@ -266,7 +337,8 @@ v0.1.0의 자동 마이그레이션에 의존하고 있었다면: - `ApiLogAutoConfiguration`을 통한 자동 구성, `@ConditionalOnMissingBean` 오버라이드. - 서비스·리포지토리·리스너·Testcontainers 기반 PostgreSQL 통합까지 포괄적 테스트. -[Unreleased]: https://github.com/devslab-kr/api-log/compare/v3.0.0...HEAD +[Unreleased]: https://github.com/devslab-kr/api-log/compare/v3.0.1...HEAD +[3.0.1]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.1 [3.0.0]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.0 [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 diff --git a/docs/changelog.md b/docs/changelog.md index b3944fe..2801b8e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,85 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +## [3.0.1] — HTTP client fixes: Content-Type on body + PATCH method support + +Two bugs in the HTTP client utilities (`RestApiClientUtil` / +`ReactiveApiClientUtil`) surfaced when the first real downstream consumer +(`devslab-examples`'s `api-log-*-demo` set) exercised the POST/PUT/PATCH paths +through actual `@RequestBody`-annotated Spring controllers. + +### Fixed + +- **`Content-Type` header was missing on POST/PUT/PATCH bodies.** The utils + serialised the body via Jackson then passed the resulting `String` to + `RestClient.body(String)` / `WebClient.bodyValue(String)`. Spring's + `StringHttpMessageConverter` then wrote the body as + `Content-Type: text/plain;charset=ISO-8859-1` (its default for raw String + bodies), and downstream services that bind with `@RequestBody Foo` rejected + it as Unsupported Media Type. The fix sets `application/json` explicitly in + both `exchange()` methods. +- **`patchSync*` / `patchAsync*` was broken end-to-end.** The auto-config + registered a `SimpleClientHttpRequestFactory` (backed by + `java.net.HttpURLConnection`), whose `setRequestMethod` throws + `ProtocolException: Invalid HTTP method: PATCH` — a long-standing JDK + limitation. Swapped to `JdkClientHttpRequestFactory` (backed by + `java.net.http.HttpClient`, Java 11+) which supports all five HTTP verbs. + Read-timeout property preserved; connect-timeout default is left to + `HttpClient`'s built-in. + +### Added — End-to-end integration test coverage + +`core/src/test/java/.../util/` now has four new test classes (65 cases +total): + +- **`RestApiClientUtilWireIT`** / **`ReactiveApiClientUtilWireIT`** — + MockWebServer-driven wire-level assertions. Verify `Content-Type` on every + body-carrying verb, exact body bytes, UTF-8 encoding (Korean + emoji round + trip), large bodies (32 KB), HTTP method propagation, and that internal + fields (e.g. `ApiRequest.requestId`) don't leak into wire headers. +- **`RestApiClientUtilSpringE2EIT`** / **`ReactiveApiClientUtilSpringE2EIT`** — + Real `@SpringBootTest` with `@RestController` declaring `@RequestBody Foo` + handlers, on Tomcat / reactor-netty respectively. Each pins + `spring.main.web-application-type` because the test classpath has both + starters. Covers all five verbs + 4xx/5xx propagation + Unicode/nested/ + null-field round-trips. + +The existing `RestApiClientUtilRoutingTest` / +`ReactiveApiClientUtilRoutingTest` (subclass-based, no real HTTP) couldn't +catch either of these bugs because they never reached the network layer. The +new IT classes close that gap and act as regression coverage for any future +HTTP-client refactor. + +### Compatibility + +- **No API changes.** All `RestApiClientUtil` / `ReactiveApiClientUtil` method + signatures unchanged. Strict drop-in upgrade from `3.0.0`. +- **Behaviour change for raw non-JSON String bodies.** Before `3.0.1`, raw + String payloads went out as `text/plain`. After `3.0.1`, all body-carrying + calls send `application/json`. If you genuinely need a different content + type for an outbound call, use Spring's `RestClient` / `WebClient` + directly — api-log's wrappers are explicitly JSON-only by design (the whole + library is JSON + JSONB-centric). +- **`ClientHttpRequestFactory` bean swap.** Any consumer that supplied their + own `ClientHttpRequestFactory` via `@ConditionalOnMissingBean` continues to + win; only the default factory changed. + +### Upgrading from `3.0.0` + +```diff +- implementation("kr.devslab:api-log-core:3.0.0") ++ implementation("kr.devslab:api-log-core:3.0.1") +- implementation("kr.devslab:api-log-jpa:3.0.0") ++ implementation("kr.devslab:api-log-jpa:3.0.1") +- implementation("kr.devslab:api-log-r2dbc:3.0.0") ++ implementation("kr.devslab:api-log-r2dbc:3.0.1") +- implementation("kr.devslab:api-log-mybatis:3.0.0") ++ implementation("kr.devslab:api-log-mybatis:3.0.1") +``` + +Recommended for everyone on `3.0.0` — any consumer that calls a body-carrying +method against a real Spring controller is affected. + ## [3.0.0] — Spring-major-aligned versioning policy **Renumbering of `0.6.0`** per the new [Spring-major-aligned versioning policy](https://github.com/devslab-kr/.github/blob/main/.github/VERSIONING.md). No API, behaviour, or dependency changes — the major number bumps from `0.6` to `3.0` to match the Spring Boot major this line targets (Spring Boot 3). Published JAR bytes are identical to `0.6.0` apart from the version coordinate. @@ -266,7 +345,8 @@ First public release. Repackaged as a standalone Spring Boot starter. - Auto-configuration via `ApiLogAutoConfiguration` with `@ConditionalOnMissingBean` overrides. - comprehensive test suite covering services, repository, listener, and Testcontainers-backed PostgreSQL integration. -[Unreleased]: https://github.com/devslab-kr/api-log/compare/v3.0.0...HEAD +[Unreleased]: https://github.com/devslab-kr/api-log/compare/v3.0.1...HEAD +[3.0.1]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.1 [3.0.0]: https://github.com/devslab-kr/api-log/releases/tag/v3.0.0 [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 diff --git a/gradle.properties b/gradle.properties index 82afc0e..08246a4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ # Project coordinates GROUP=kr.devslab -VERSION=3.0.0 +VERSION=3.0.1 # Project metadata for Maven Central POM POM_NAME=API Log Spring Boot Starter