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 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 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 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 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