Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}.
*
* <p>{@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.
*
* <p>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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -150,10 +151,24 @@ public <T> Mono<T> patchTyped(String endpoint, Object requestBody, Class<T> 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.
*
* <p>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<ResponseEntity<String>> 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);
}
Expand Down
14 changes: 13 additions & 1 deletion core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -127,12 +128,23 @@ public <T> CompletableFuture<T> 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.
*
* <p>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();
}
Expand Down
Loading