New posts

This commit is contained in:
2026-11-06 21:44:06 +05:30
parent dc13293046
commit 8da3374155
51 changed files with 2099 additions and 0 deletions
@@ -0,0 +1,38 @@
# groups the HttpServiceProxyRegistry knows about
group apikey -> [ApiKeyClient]
group bearer -> [SecureClient]
group flaky -> [FlakyClient]
group github -> [GitHubClient]
group oauth -> [OAuthClient]
group posts -> [ParamsClient, PostClient]
group resilient -> [ResilientClient]
PostClient bean is a JDK proxy: true
registry.getClient("posts", PostClient.class) is the injected bean: true
# CRUD against the stub (every call is a real HTTP request)
findAll() -> 3 posts
findById(1) -> Post[id=1, userId=1, title=First post, body=Body of the first post]
findByUser(2) -> [3]
findEntity(2) -> status 200 OK, Content-Type application/json
create(...) -> id 101
update(1, ...) -> title 'Updated Title'
delete(1) -> void, no exception
# argument kinds (ParamsClient)
@RequestHeader -> upstream saw X-Correlation-Id=abc-123
@RequestParam(required=false), null -> 3 posts
@RequestParam(defaultValue="2") -> posts [3]
URI argument -> Second post
# what the stub actually received
GET /posts
GET /posts/1
GET /posts?userId=2
GET /posts/2
POST /posts
PUT /posts/1
DELETE /posts/1
GET /echo/headers
GET /posts
GET /posts?userId=2
GET /posts/2
@@ -0,0 +1,15 @@
# A. the keys the article's first version used (spring.http.client.service.group.<g>.base-url)
findAll() -> java.lang.IllegalArgumentException: URI with undefined scheme
# B. the keys Spring Boot 4.1.2 binds (spring.http.serviceclient.<g>.base-url)
findAll() -> OK -> 3
# C. no base URL at all
findAll() -> java.lang.IllegalArgumentException: URI with undefined scheme
# D. a return type that does not match the JSON (array decoded as a single Post)
list() -> org.springframework.web.client.RestClientException: Error while extracting response for type [com.ankurm.hsc.model.Post] and content type [application/json]
root cause class: tools.jackson.databind.exc.MismatchedInputException
# E. @GetMapping on a client interface instead of @GetExchange
createClient(UsesRequestMapping.class).list() -> java.lang.IllegalStateException: Unexpected method invocation: public abstract java.lang.String com.ankurm.hsc.Step02PropertiesAndPitfallsTest$UsesRequestMapping.list()
@@ -0,0 +1,30 @@
# 1. framework defaults (a plain RestClient, no status handler)
findById(99) -> HttpClientErrorException$NotFound
status : 404 NOT_FOUND
message : 404 Not Found: "{"type":"https://stub.example/problems/post-not-found","title":"Post not found","status":404,"detail":"No post with id 99","instance":"/posts/99"}"
content-type: application/problem+json
getResponseBodyAs(ProblemDetail): title='Post not found' detail='No post with id 99' type=https://stub.example/problems/post-not-found
alwaysDown() -> HttpServerErrorException.ServiceUnavailable, status 503 SERVICE_UNAVAILABLE
# 2. a response that never comes: read timeout is 1500ms on the flaky group, /slow takes 3000ms
slow() -> ResourceAccessException, cause java.net.http.HttpTimeoutException
message: I/O error on GET request for "http://127.0.0.1:<port>/slow": Request cancelled
# 3. nobody listening
findAll() -> ResourceAccessException, cause java.net.ConnectException
# 4. Optional<Post> and ResponseEntity<Post> on a 404 with the default handler
findOptional(99) -> NotFound
findEntity(99) -> NotFound
# 5. NotFoundRestClientAdapterDecorator (spring-web 7.0.10) turns a 404 into an empty result
findById(99) -> null
findOptional(99) -> Optional.empty
findEntity(99) -> status 404 NOT_FOUND, body null
# 6. our own defaultStatusHandler on the "posts" group (UpstreamProblemStatusHandler)
findById(99) -> UpstreamNotFoundException: Upstream says not found: No post with id 99
problem.type=https://stub.example/problems/post-not-found instance=/posts/99
create(blank title) -> UpstreamValidationException: Upstream rejected the request: Validation failed
extension 'errors' = [{field=title, message=must not be blank}]
alwaysDown() -> UpstreamUnavailableException: Upstream unavailable (503): Upstream is down for maintenance
@@ -0,0 +1,6 @@
# our service (MockMvc, real HTTP to the stub behind it)
GET /api/posts/1 -> 200 {"id":1,"userId":1,"title":"First post","body":"Body of the first post"}
GET /api/posts/99 -> 404 {"detail":"No post with id 99","instance":"/api/posts/99","status":404,"title":"Not Found"}
POST blank title -> 400 {"detail":"Upstream rejected the request","instance":"/api/posts","status":400,"title":"Bad Request","errors":[{"field":"title","message":"must not be blank"}]}
GET /api/down -> 502 {"detail":"Upstream service error","instance":"/api/down","status":502,"title":"Bad Gateway"}
GET /api/slow -> 504 {"detail":"Upstream service timed out","instance":"/api/slow","status":504,"title":"Gateway Timeout"}
@@ -0,0 +1,24 @@
# 1. RetryTemplate around the call (org.springframework.core.retry, spring-core 7.0.10)
get("tpl", failures=2) -> 'Recovered on call 3'
stub saw 3 calls; listener events: [beforeRetry, onRetryFailure(ServiceUnavailable), beforeRetry, onRetrySuccess]
maxRetries(2) against a dead upstream -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Flaky upstream, call 3","instance":"/flaky/exhaust"}"
stub saw 3 calls
execute() instead of invoke() -> RetryException, retryCount=2, exceptions=3, last=ServiceUnavailable
# 2. @Retryable on the @HttpExchange interface method (bean from the "flaky" group)
get("ann", failures=2) -> 'Recovered on call 3'; stub saw 3 calls
get("ann-dead", failures=10) -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Flaky upstream, call 4","instance":"/flaky/ann-dead"}"
stub saw 4 calls (maxRetries = 3)
# 3. the same retry as a ClientHttpRequestInterceptor on the "resilient" group (idempotent methods only)
get("icpt", failures=2) -> 'Recovered on call 3'; stub saw 3 calls; interceptor attempts so far: 3
POST createUnstable -> HttpServerErrorException$ServiceUnavailable / 503 Service Unavailable: "{"type":"https://stub.example/problems/service-unavailable","title":"Service Unavailable","status":503,"detail":"Created, but the response was lost","instance":"/posts/unstable"}"
stub created the resource 1 time(s): the interceptor did not retry a POST
# 4. the trap: @Retryable on a POST whose response was lost after the server did the work
createUnstable() -> id 102, body 'created 2 times so far'
stub created the resource 2 times for ONE logical call
# 5. Resilience4j 2.4.0 around the same plain client
maxAttempts(3), failures=2 -> 'Recovered on call 3'; stub saw 3 calls
maxAttempts(3) against a dead upstream -> ServiceUnavailable; stub saw 3 calls
@@ -0,0 +1,13 @@
# 1. no credentials (a plain RestClient)
whoAmI() -> 401 Unauthorized, WWW-Authenticate: Bearer realm="stub"
# 2. bearer token added by a ClientHttpRequestInterceptor on the "bearer" group
whoAmI() -> {caller=demo-user, tokenKind=static}
headers the upstream saw: Authorization=Bearer <redacted>, X-Correlation-Id present=true (8 chars)
# 3. API key from spring.http.serviceclient.apikey.default-header.X-Api-Key (no Java at all)
whoAmI() -> {caller=demo-service, auth=api-key}
headers the upstream saw: X-Api-Key=demo-key-123, X-Correlation-Id present=true
# 4. the interceptor is per group: the "apikey" group never sends Authorization
Authorization header on the apikey group: null
@@ -0,0 +1,14 @@
# @ClientRegistrationId("stub") on the interface + OAuth2RestClientHttpServiceGroupConfigurer
call 1: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
call 2: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
call 3: whoAmI() -> {caller=demo-user, tokenKind=oauth2}
# what the stub saw, in order
POST /oauth/token
GET /secure/bearer
GET /secure/bearer
GET /secure/bearer
token requests: 1
# the same interface WITHOUT the group configurer: the annotation is silently ignored
whoAmI() -> 401 UNAUTHORIZED
@@ -0,0 +1,14 @@
# 1. GET: URL, method and the decoded record
findById(1) -> Post[id=1, userId=1, title=Test Title, body=Test body]
# 2. POST: the request body the interface sent
create(...) -> id 101 (request matched: path, method, Content-Type, JSON body)
# 3. a ProblemDetail 404 goes through the real status handler
findById(9) -> UpstreamNotFoundException (handler decoded the body: 'No post with id 9')
# 4. a 500 with no body: the handler falls back instead of crashing
findAll() -> UpstreamUnavailableException: Upstream unavailable (500): (no problem+json body)
# 5. what MockRestServiceServer cannot tell you
timeouts, connection resets, TLS and real serialisation quirks never happen here; see 03 and 05 for the real server.
@@ -0,0 +1,2 @@
# a proxy built from the slice's own RestClient.Builder, which the mock server IS bound to
findById(1) -> Post[id=1, userId=1, title=From the mock, body=b]
@@ -0,0 +1,2 @@
@RestClientTest(PostClient.class), then @Autowired PostClient -> no such bean (null)
MockRestServiceServer bean present -> true
@@ -0,0 +1,2 @@
PostClient from the @ImportHttpServices group is injected: true
server.expect(...) -> IllegalStateException: Unable to use auto-configured MockRestServiceServer since a mock server customizer has not been bound to a RestTemplate or RestClient