fix(http-client): set application/json on body + use JdkClientHttpRequestFactory for PATCH (v3.0.1)#4
Merged
Conversation
…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
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.
jlc488
added a commit
to devslab-kr/devslab-examples
that referenced
this pull request
May 25, 2026
…stcontainers IT (#62) * feat: add api-log demo set (jpa + mybatis + r2dbc) with PostgreSQL Testcontainers IT Covers all three persistence backends of the api-log starter (kr.devslab:api-log-{jpa,mybatis,r2dbc}:3.0.0) so a reader can pick the demo that matches their stack and `./gradlew bootRun` immediately. ## Demos added api-log-jpa-demo/ Spring MVC + JPA + Postgres (blocking) api-log-mybatis-demo/ Spring MVC + MyBatis + Postgres (blocking) api-log-r2dbc-demo/ WebFlux + R2DBC + Postgres (reactive) ## Common design — self-loopback Each demo exposes three controllers in the same app: /upstream/widgets/{id} the "service being called" — returns a fake Widget, with id=999 forcing a 5xx to exercise the error path /client/widgets/{id} calls upstream via RestApiClientUtil (or ReactiveApiClientUtil for r2dbc) so the call gets logged into api_log /client/widgets/with-request-id/{id} same shape but passes an explicit requestId via the core send(HttpMethod, ApiRequest) overload — demonstrates the retry-correlation API /api-log/recent reads the api_log table (top 20 by timestamp DESC) /api-log/by-request/{rid} reads all rows for one requestId (lifecycle: INITIATED → SUCCESS / ERROR) /api-log/by-event/{type} reads all rows for one event type Self-loopback means no external dep — `docker compose up -d db && ./gradlew bootRun && curl localhost:8080/client/widgets/123` is the full demo. Reader can immediately see INITIATED + SUCCESS pairs in /api-log/recent after the async event listener flushes. ## Tests — Testcontainers + @Serviceconnection Each demo ships an integration test (ApiLogLifecycleIT) that spins up postgres:16-alpine via Testcontainers, makes real HTTP self-loopback calls (RestClient.create / TestRestTemplate / WebTestClient depending on the demo's stack), and asserts on the api_log rows that the async listener writes. Awaitility polls past the listener's async window. Five tests per demo: 1. happy GET path → INITIATED + SUCCESS in api_log 2. error path (id=999) → INITIATED + ERROR 3. POST body preserved in api_log.payload (JSONB) 4. explicit requestId correlation via /with-request-id/{id} 5. schema initialized on boot (table exists, query succeeds) 15 IT tests total across the three demos. CI builds them automatically (the workflow's detect step picks up new demos by the presence of build.gradle.kts — no workflow edits needed). ## Backend-specific notes JPA: - spring-boot-starter-data-jpa + api-log-jpa - Reader uses ApiLogRepository (the starter publishes it as a Spring Data repository — extends JpaRepository<ApiLogEntity, Long>) - spring.jpa.hibernate.ddl-auto=none — api-log starter's ApiLogJpaSchemaInitializer creates the api_log table itself (api.log.schema.management=BUILTIN) MyBatis: - mybatis-spring-boot-starter:4.0.1 + api-log-mybatis - Reader uses the bundled ApiLogMapper for findByRequestId, plus a custom ApiLogQueryMapper (xml) for findRecent / findByEvent since the starter's mapper doesn't expose those - @MapperScan scoped to the demo package only (the starter's mapper is registered by its own auto-config — scanning it twice conflicts) R2DBC: - spring-boot-starter-webflux + spring-boot-starter-data-r2dbc + api-log-r2dbc - Reader uses DatabaseClient (not a Spring Data R2DBC repository — the api-log r2dbc backend doesn't ship one, keeping its dep footprint minimal). Cast JSONB columns to ::text so they bind cleanly to String fields on a public ApiLogView record. - Schema initializer is opt-in via api.log.r2dbc.schema.enabled=true (separate property from the JDBC backends because reactive init runs on ConnectionFactory not DataSource — per ApiLogProperties javadoc) - Both r2dbc-postgresql AND jdbc postgresql drivers in the build — the latter is what Testcontainers' @Serviceconnection prefers for rewiring the connection. ## Root README updates Added an "### api-log" section after the ssrf-guard section in both README.md and README.ko.md, with the three new demos linked from a single table. Matches the existing table convention (Maven Central shields.io badge with verbose label) — a follow-up that strips the labels to the default "Maven Central" form (per devslab-examples#59) would touch all rows at once. ## Sibling work in flight devslab-examples#61 adds two more ssrf-guard demos (ssrf-guard-httpclient5-demo + ssrf-guard-native-image-demo) on a parallel branch. The two PRs are independent — they touch different directories. README.md will conflict on the section ordering after both land; whichever merges second rebases. * fix(api-log demos): mark gradlew executable in git index CI failed with `./gradlew: Permission denied` (exit 126) on all three new demos. The agents created the wrappers via `cp -r` on Windows, where the filesystem doesn't track executable bits, so git stored them as 0644. Linux CI runners then refused to execute them. `git update-index --chmod=+x` flips the index mode to 100755 without changing file content. Same fix as #61 used (commit 2dbf4a4). * fix(api-log demos): downgrade to Spring Boot 3.5.6 baseline CI failed with BeanTypeDeductionException -> ClassNotFoundException during Spring context load on all three demo ITs. Root cause: api-log 3.0.0 is built against Spring Boot 3.5.6 per the Spring-major-aligned versioning policy (devslab-kr/.github/.github/VERSIONING.md — lib major = SB major, so api-log 3.x = SB3 line). My demos were on SB4.0.6 and the runtime classpath had api-log's compiled bytecode referencing SB3.5 classes that don't exist in SB4 — gradle resolution upgraded the spring-boot-* artifacts to 4.0.6 but didn't paper over the API drift. Changes per demo (build.gradle.kts): Plugin 4.0.6 -> 3.5.6 Test starters removed SB4-only -webmvc-test / -webflux-test / -resttestclient modules (MockMvc / WebTestClient / TestRestTemplate all ship in plain spring-boot-starter-test on SB3) Testcontainers BOM removed (relying on Spring Boot 3.5.6's managed Testcontainers versions — same approach as easy-paging-postgres-demo + easy-paging-reactive-demo) Testcontainers names testcontainers-postgresql -> postgresql, testcontainers-junit-jupiter -> junit-jupiter (Testcontainers 1.x naming convention, what SB3 BOM pins) MyBatis starter mybatis-spring-boot-starter:4.0.1 -> :3.0.4 (SB3-compatible line; matches what PageHelper + Spring Boot 3 BOM expects) R2DBC extras added testcontainers:r2dbc (the R2DBC @Serviceconnection bridge for SB3 — matches easy-paging-reactive-demo) Unchanged api-log 3.0.0 deps, awaitility 4.2.2, R2DBC + JDBC postgres drivers, dependency-management 1.1.7, JDK 21 toolchain, junit-platform-launcher Changes per IT (imports): PostgreSQLContainer org.testcontainers.postgresql.PostgreSQLContainer -> org.testcontainers.containers.PostgreSQLContainer (Testcontainers 1.x class location) Container generic raw PostgreSQLContainer -> PostgreSQLContainer<?> field type + new PostgreSQLContainer<>("...") (1.x self-typed generic, diamond form) @AutoConfigureWebTestClient (r2dbc only) org.springframework.boot.webtestclient.autoconfigure -> org.springframework.boot.test.autoconfigure.web.reactive TestRestTemplate (mybatis only) org.springframework.boot.resttestclient -> org.springframework.boot.test.web.client @LocalServerPort no change (lives at org.springframework.boot.test.web.server in both SB3.5.6 and SB4 — spec mid-flight said otherwise, agents caught it via compile error) Test logic itself unchanged (5 tests × 3 demos = 15 IT tests). Main- source files (controllers, Widget record, ApiLogReader/View, MyBatis mapper xml, application.yml, docker-compose.yml, READMEs) all source-compatible between SB3 and SB4 — only build pin + a handful of test imports needed to move. Compile verified clean on all 3 demos. Awaiting CI for the runtime path. Follow-up: once api-log ships a 4.x line (the VERSIONING.md policy schedules it for whenever the project decides to add SB4 support), add api-log-{jpa,mybatis,r2dbc}-sb4-demo siblings — same pattern as the easy-paging dual-line setup. * fix(api-log demos): self-loopback URL uses runtime local.server.port The 3 CI test failures (happyGet / postBody / explicitRequestId — all the success-path tests) were a real bug, not a flake: ClientController read `${api-log-demo.upstream-base-url}` which expanded to `http://localhost:8080` at @value injection time. The integration test boots on RANDOM_PORT — e.g. 54321 — so: Test → http://localhost:54321/client/widgets/123 OK ClientController → http://localhost:8080/upstream/widgets/123 CONN REFUSED → 500 errorPath and schemaInit passed for the wrong reason — errorPath asserts an ERROR row shows up regardless of cause (connection-refused counts), and schemaInit never touches HTTP. Fix: don't hardcode the port in config. ClientController now injects Environment and builds the upstream URL at request time from `local.server.port` (which Spring Boot sets for BOTH bootRun and RANDOM_PORT after the embedded server binds). One code path, one behaviour, no test-specific override. Removed `api-log-demo.upstream-base-url` from all 3 application.yml since nothing reads it anymore; comment now points users at ClientController for the "swap upstream URL for production" instruction. All three demos compile clean. The bug existed in the original SB4 version too — only surfaced after the SB3 downgrade got past the ContextLoader exception and tests could actually run. * chore(api-log demos): bump 3.0.0 -> 3.0.1 (HTTP client Content-Type + 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
```
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)
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
Upgrading from 3.0.0
```diff
```
Recommended for anyone on 3.0.0 — every consumer calling a body-carrying method against a real Spring service is affected.
Test plan
Downstream actions after merge + v3.0.1 tag