Skip to content

fix(http-client): set application/json on body + use JdkClientHttpRequestFactory for PATCH (v3.0.1)#4

Merged
jlc488 merged 1 commit into
masterfrom
fix/http-client-content-type-on-body
May 23, 2026
Merged

fix(http-client): set application/json on body + use JdkClientHttpRequestFactory for PATCH (v3.0.1)#4
jlc488 merged 1 commit into
masterfrom
fix/http-client-content-type-on-body

Conversation

@jlc488
Copy link
Copy Markdown
Collaborator

@jlc488 jlc488 commented May 23, 2026

Summary

Two real bugs in core's HTTP client utilities — both surfaced by the first end-to-end consumer (devslab-examples `api-log-{jpa,mybatis,r2dbc}-demo` set) calling `postSyncTyped` against a real `@RequestBody`-annotated controller. Plus 65 new end-to-end test cases (4 new IT classes) so any future regression of this class is caught immediately.

Bugs fixed

1. `Content-Type` missing on POST/PUT/PATCH bodies

`RestClient.body(String)` / `WebClient.bodyValue(String)` route the body through `StringHttpMessageConverter`, which writes `Content-Type: text/plain;charset=ISO-8859-1` by default. Any downstream service binding with `@RequestBody Foo` rejected the call as Unsupported Media Type → 415 → propagated as 500 to the test client.

Fix: both `exchange()` methods explicitly set `MediaType.APPLICATION_JSON` when a payload is present.

```diff

  • return spec.body(payload).retrieve();
  • return spec.contentType(MediaType.APPLICATION_JSON).body(payload).retrieve();
    ```

2. `patchSync*` / `patchAsync*` broken end-to-end

`RestApiClientAutoConfiguration` wired `SimpleClientHttpRequestFactory` (backed by `java.net.HttpURLConnection`), whose `setRequestMethod` throws:

```
java.net.ProtocolException: Invalid HTTP method: PATCH
at java.base/java.net.HttpURLConnection.setRequestMethod(...)
```

A long-standing JDK limitation. `patchSync` was a published API that could never actually run.

Fix: swap to `JdkClientHttpRequestFactory` (`java.net.http.HttpClient` backed, Java 11+). All five verbs supported natively; `read-timeout` preserved; `connect-timeout` default left to `HttpClient` (consumers who need tighter swap the bean — it's `@ConditionalOnMissingBean`).

Why these escaped CI before

`RestApiClientUtilRoutingTest` / `ReactiveApiClientUtilRoutingTest` are subclass-recording mocks that intercept `send` / `sendTyped` before `exchange()` runs. They verify "POST was called with these args" but never reach the HTTP layer. Bug 1 is invisible above `exchange()`; bug 2 needs `setRequestMethod` to be called.

The gap was: no test that hit an actual socket or actual `@RequestBody`.

Test coverage added (65 cases, 4 classes)

Class Layer What it pins
`RestApiClientUtilWireIT` MockWebServer wire-level `Content-Type` per body-carrying verb, exact body bytes, UTF-8 encoding (Korean + emoji), 32 KB bodies, HTTP method propagation, no leakage of internal `requestId` to wire headers, async path
`ReactiveApiClientUtilWireIT` Same, for WebClient Reactive mirror of the above
`RestApiClientUtilSpringE2EIT` `@SpringBootTest` (servlet) + `@RequestBody Foo` Real Tomcat round-trip for all 5 verbs, 4xx/5xx propagation, Unicode + nested + null-field bodies, async
`ReactiveApiClientUtilSpringE2EIT` `@SpringBootTest` (reactive) + `@RequestBody Mono` Real reactor-netty round-trip, same coverage

Both E2E ITs pin `spring.main.web-application-type` explicitly because the test classpath has both `spring-boot-starter-web` and `spring-boot-starter-webflux` and the default would pick servlet for the reactive one too.

The E2E ITs also register a no-op `ApiLogWriter` bean — `ApiEventListener` requires the SPI to wire up, but writer implementations live in `:jpa` / `:r2dbc` / `:mybatis`. Documented inline in each test class.

Compatibility

  • No public API changes. All `RestApiClientUtil` / `ReactiveApiClientUtil` method signatures unchanged. 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, every body-carrying call sends `application/json`. If you genuinely need another content type for an outbound call, use Spring's `RestClient` / `WebClient` directly — api-log's wrappers are explicitly JSON-only by design (JSON + JSONB-centric library).
  • `ClientHttpRequestFactory` bean swap. Any consumer that supplied their own factory via `@ConditionalOnMissingBean` continues to win; only the default 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 anyone on 3.0.0 — every consumer calling a body-carrying method against a real Spring service is affected.

Test plan

  • CI green (4 new IT classes added to `:core:test`)
  • Manually re-run devslab-examples PR #62 with `api-log-*:3.0.1` to verify the previously failing `postBodyIsPreservedInPayloadColumn` tests now pass

Downstream actions after merge + v3.0.1 tag

  • devslab-examples PR #62 — bump 3 demos' `api-log-*:3.0.0` → `:3.0.1` (one line per build.gradle.kts × 3 = 3 line edits)
  • Profile README — no badge change needed; the api-log badge uses no version filter so it auto-tracks

…uestFactory 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.
@jlc488 jlc488 merged commit 3c11a7f into master May 23, 2026
1 check passed
jlc488 added a commit that referenced this pull request May 23, 2026
PR #4 fixed two real HTTP-client bugs (Content-Type + PATCH); v3.0.1 is
the recommended upgrade. Update every copy-paste install snippet so users
land on the fixed version, but keep the historical prose ("v3.0.0 splits
the starter into four artifacts...", "that's the v3.0.0 promise...") since
those describe when the multi-module shape was introduced, which didn't
change in 3.0.1.

Sed-replaced patterns:
  <version>3.0.0</version>           -> 3.0.1
  :3.0.0" (Kotlin DSL)               -> :3.0.1"
  :3.0.0' (Groovy DSL)               -> :3.0.1'
  `3.0.0` (the "Replace `X` with..." hint)  -> `3.0.1`

Touched 10 files (README + docs/getting-started/installation + docs/guides/
{jpa,mybatis,r2dbc}-backend, each en + ko). CHANGELOG entries unchanged
(historical records).
jlc488 added a commit to devslab-kr/devslab-examples that referenced this pull request May 23, 2026
… PATCH fixes)

api-log v3.0.1 published to Maven Central
(devslab-kr/api-log#4 + #5 merged, tag pushed, release workflow succeeded
at 16:39Z, all four artifacts indexed). 3.0.1 fixes two bugs in
RestApiClientUtil / ReactiveApiClientUtil that the previous PR #62 CI
runs surfaced:

  1. Content-Type missing on POST/PUT/PATCH body -> upstream returned
     415/500 (the postBodyIsPreservedInPayloadColumn failures across all
     three demos).
  2. PATCH method unsupported because SimpleClientHttpRequestFactory
     wraps java.net.HttpURLConnection (which rejects PATCH).

Bump scope: one line per demo build.gradle.kts (well, two — core +
backend), three demos = 6 lines. No source changes needed — the bugs
were entirely in the starter, the demo code was already calling the
right APIs.

Expecting all 15 IT tests (5 per demo) to pass on this run now that the
starter is fixed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant