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
+1
View File
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates |
|---|---|---|
| [`http-service-clients/`](http-service-clients) | [Spring Boot 4 HTTP Service Clients: @HttpExchange Interfaces with RestClient (Complete Guide)](https://ankurm.com/spring-boot-4-http-service-clients/) | `@HttpExchange` interfaces registered with `@ImportHttpServices` and configured under `spring.http.serviceclient.<group>.*` (the keys the first version of the post gave are silently ignored), run against a local stub upstream: `defaultStatusHandler` with ProblemDetail decoding, `NotFoundRestClientAdapterDecorator` for `Optional` on a 404, Framework 7 `@Retryable` on the interface vs `RetryTemplate` vs an interceptor vs Resilience4j (call counts side by side, and a retried POST created twice), bearer, API-key and OAuth2 client-credentials auth (`@ClientRegistrationId` ignored without its group configurer), `MockRestServiceServer`, and the `@RestClientTest` slice where a group client is not bound to the mock |
| [`pdf-invoice/`](pdf-invoice) | [Generate PDF Invoices in Spring Boot: OpenPDF vs iText vs Flying Saucer (HTML to PDF)](https://ankurm.com/spring-boot-generate-pdf-invoice-openpdf-itext-flying-saucer/) | one invoice rendered by OpenPDF 3.0.5, iText Core 9.8.0 (AGPL 3 or commercial) and Flying Saucer 10.7.0 from a Thymeleaf template, with embedded Noto Sans Devanagari: none of the three shapes Marathi/Hindi conjuncts by default (checked by rendering to PNG against a libraqm reference), OpenPDF's `GlyphLayoutManager` does but breaks text extraction until `ActualText` is on, OpenPDF's static `LayoutProcessor` is JVM-global and breaks other writers, Apache FOP on the classpath did not help, streamed vs buffered failure (`200` + curl exit 18 vs `500`), font subsetting, indicative per-invoice timings |
| [`filter-interceptor-aop/`](filter-interceptor-aop) | [Filter vs HandlerInterceptor vs AOP in Spring Boot 4: Which Runs When](https://ankurm.com/spring-boot-filter-vs-handlerinterceptor-vs-aop-which-runs-when/) | the real order of a servlet filter, a `HandlerInterceptor` and an `@Aspect` printed from a running Tomcat and from MockMvc, the callbacks that still fire (and the ones that do not) after an exception, a 404, a `preHandle` that returns false and an async request, `afterCompletion` seeing `ex=null` after `@ControllerAdvice`, a header added in `postHandle` silently lost, a request body read twice, `ContentCachingResponseWrapper` returning an empty 200, Spring Security 7's chain sitting at order -100, a filter bean running twice, the Framework 7 path-matching removals read with `javap`, and the three self-invocation-style cases an aspect never sees |
| [`excel-export/`](excel-export) | [Export Excel and CSV from Spring Boot: Streaming 1 Million Rows with Apache POI SXSSF](https://ankurm.com/spring-boot-export-excel-csv-streaming-poi-sxssf/) | XSSF vs SXSSF under a fixed 512 MB heap (XSSF out of memory between 50,000 and 100,000 rows and at 1,000,000 rows even with 3 GB), 1,000,000 rows streamed with `StreamingResponseBody` under a 64 MB heap, sheet rolling at 1,048,576 rows, `compressTempFiles` cutting the spill from 475 MB to 56 MB, OpenCSV swallowing write errors, and an SXSSF file LibreOffice will not open |
+38
View File
@@ -0,0 +1,38 @@
# http-service-clients
Companion module for [**Spring Boot 4 HTTP Service Clients: @HttpExchange Interfaces with RestClient (Complete Guide)**](https://ankurm.com/spring-boot-4-http-service-clients/)
on ankurm.com. Declarative `@HttpExchange` interfaces registered with `@ImportHttpServices`, configured
through `spring.http.serviceclient.<group>.*`, with error handling, retries, authentication and tests.
Everything runs offline: the tests start a small stub upstream (`stub/StubServer`, JDK `HttpServer`) on a
random port and the clients call it over real HTTP. Nothing talks to jsonplaceholder.typicode.com.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.2 |
| Spring Framework | 7.0.10 |
| Spring Security | 7.1.2 (managed by Boot) |
| Resilience4j (retry comparison only) | 2.4.0 |
| JDK | 25 (LTS) |
## Run
```bash
export JAVA_HOME=/path/to/jdk-25
./scripts/run-all.sh # mvn test; rewrites output/*.txt (11 tests)
./scripts/run-stub.sh # optional: the stub on port 18601, to poke with curl
```
## Files
| Path | What |
|---|---|
| `src/main/java/.../client/` | the `@HttpExchange` interfaces (posts, params, flaky, resilient, bearer, api key, OAuth2, GitHub) |
| `src/main/java/.../config/HttpClientsConfig.java` | `@ImportHttpServices` groups and `RestClientHttpServiceGroupConfigurer` beans |
| `src/main/java/.../error/` | status handler, custom exceptions, `@RestControllerAdvice` |
| `src/main/java/.../stub/StubServer.java` | the local upstream |
| `src/main/resources/application.yml` | base URLs, timeouts, default headers, OAuth2 registration |
| `src/test/java/.../Step01..Step09*Test.java` | one test per article section; each writes its transcript |
| `output/01..09c-*.txt` | captured output quoted in the article |
@@ -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
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>http-service-clients</artifactId>
<version>1.0.0</version>
<name>http-service-clients</name>
<description>HTTP Service Clients (@HttpExchange + RestClient) on Spring Boot 4.1: groups, errors, retries, auth, testing</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Boot 4 split HTTP client support out of the web starters; this is what brings RestClient.Builder
auto-configuration and the spring.http.serviceclient.* properties support. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-retry</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerates every transcript in output/. The tests start their own stub upstream on a random port,
# so nothing here needs the network once the dependencies are in ~/.m2.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f output/*.txt
mvn -B -q test
echo "Regenerated: $(ls output | wc -l) files in output/"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Starts the stub upstream on a fixed port so you can poke it with curl:
# ./scripts/run-stub.sh 18601 & then curl -i http://127.0.0.1:18601/posts/99
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q -DskipTests compile
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
exec java -cp "target/classes:$(cat target/cp.txt)" com.ankurm.hsc.stub.StubServer "${1:-18601}"
@@ -0,0 +1,8 @@
package com.ankurm.hsc;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Marker application class; the module is exercised by its tests, which print the transcripts in output/. */
@SpringBootApplication
public class Application {
}
@@ -0,0 +1,16 @@
package com.ankurm.hsc.client;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import java.util.Map;
@HttpExchange
public interface ApiKeyClient {
@GetExchange("/secure/apikey")
Map<String, String> whoAmI();
@GetExchange("/echo/headers")
Map<String, String> echoHeaders();
}
@@ -0,0 +1,32 @@
package com.ankurm.hsc.client;
import com.ankurm.hsc.model.Post;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
/** Endpoints that fail on purpose, to exercise retries. */
@HttpExchange
public interface FlakyClient {
/** Retried by Spring Framework 7's @Retryable (needs @EnableResilientMethods): 3 retries, 20 ms apart. */
@Retryable(includes = HttpServerErrorException.class, maxRetries = 3, delay = 20)
@GetExchange("/flaky/{key}")
Post get(@PathVariable String key, @RequestParam int failures);
@GetExchange("/always-503")
String alwaysDown();
@GetExchange("/slow")
String slow();
/** DO NOT copy this: retrying a POST whose response was lost creates the resource twice. */
@Retryable(includes = HttpServerErrorException.class, maxRetries = 3, delay = 20)
@PostExchange("/posts/unstable")
Post createUnstable(@RequestBody Post post);
}
@@ -0,0 +1,24 @@
package com.ankurm.hsc.client;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import java.util.List;
/** The second API from the first version of the article. Registered as a group but never called here (offline). */
@HttpExchange
public interface GitHubClient {
record Repository(Long id, String name, String full_name, String description) {
}
record Milestone(int number, String title, String state) {
}
@GetExchange("/users/{username}/repos")
List<Repository> getRepositories(@PathVariable String username);
@GetExchange("/repos/{owner}/{repo}/milestones")
List<Milestone> getMilestones(@PathVariable String owner, @PathVariable String repo);
}
@@ -0,0 +1,16 @@
package com.ankurm.hsc.client;
import org.springframework.security.oauth2.client.annotation.ClientRegistrationId;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import java.util.Map;
/** The token comes from the Spring Security client registration named "stub", not from this interface. */
@HttpExchange
@ClientRegistrationId("stub")
public interface OAuthClient {
@GetExchange("/secure/bearer")
Map<String, String> whoAmI();
}
@@ -0,0 +1,29 @@
package com.ankurm.hsc.client;
import com.ankurm.hsc.model.Post;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import java.net.URI;
import java.util.List;
import java.util.Map;
/** One method per kind of argument the first version of the article listed, each run against the stub. */
@HttpExchange
public interface ParamsClient {
@GetExchange("/echo/headers")
Map<String, String> withHeader(@RequestHeader("X-Correlation-Id") String correlationId);
@GetExchange("/posts")
List<Post> optionalParam(@RequestParam(required = false) Integer userId);
@GetExchange("/posts")
List<Post> defaultedParam(@RequestParam(defaultValue = "2") Integer userId);
/** A java.net.URI argument replaces the whole URL, base URL included. */
@GetExchange
Post byUri(URI uri);
}
@@ -0,0 +1,45 @@
package com.ankurm.hsc.client;
import com.ankurm.hsc.model.Post;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.DeleteExchange;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.web.service.annotation.PutExchange;
import java.util.List;
import java.util.Optional;
/** The interface from the original article, pointed at the local stub instead of jsonplaceholder. */
@HttpExchange("/posts")
public interface PostClient {
@GetExchange
List<Post> findAll();
@GetExchange
List<Post> findByUser(@RequestParam Integer userId);
@GetExchange("/{id}")
Post findById(@PathVariable Integer id);
@GetExchange("/{id}")
ResponseEntity<Post> findEntity(@PathVariable Integer id);
/** Used only to show that Optional does NOT turn a 404 into Optional.empty(). */
@GetExchange("/{id}")
Optional<Post> findOptional(@PathVariable Integer id);
@PostExchange
Post create(@RequestBody Post post);
@PutExchange("/{id}")
Post update(@PathVariable Integer id, @RequestBody Post post);
@DeleteExchange("/{id}")
void delete(@PathVariable Integer id);
}
@@ -0,0 +1,20 @@
package com.ankurm.hsc.client;
import com.ankurm.hsc.model.Post;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
/** No annotations here: retry comes from the RetryingInterceptor installed on the "resilient" group. */
@HttpExchange
public interface ResilientClient {
@GetExchange("/flaky/{key}")
Post get(@PathVariable String key, @RequestParam int failures);
@PostExchange("/posts/unstable")
Post createUnstable(@RequestBody Post post);
}
@@ -0,0 +1,17 @@
package com.ankurm.hsc.client;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import java.util.Map;
/** Calls an endpoint that wants "Authorization: Bearer ...". The interface knows nothing about the token. */
@HttpExchange
public interface SecureClient {
@GetExchange("/secure/bearer")
Map<String, String> whoAmI();
@GetExchange("/echo/headers")
Map<String, String> echoHeaders();
}
@@ -0,0 +1,26 @@
package com.ankurm.hsc.config;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
import java.util.function.Supplier;
/** Adds "Authorization: Bearer <token>" to every request, asking the supplier each time so tokens can rotate. */
public class BearerTokenInterceptor implements ClientHttpRequestInterceptor {
private final Supplier<String> tokens;
public BearerTokenInterceptor(Supplier<String> tokens) {
this.tokens = tokens;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
request.getHeaders().setBearerAuth(tokens.get());
return execution.execute(request, body);
}
}
@@ -0,0 +1,22 @@
package com.ankurm.hsc.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Putting spring-security-oauth2-client on the classpath also secures THIS application's own endpoints
* with Boot's default chain (everything needs a login). The demo controller is not what this module is
* about, so it is opened up here; a real service would configure its own rules.
*/
@Configuration(proxyBeanMethods = false)
class DemoSecurityConfig {
@Bean
SecurityFilterChain open(HttpSecurity http) {
http.authorizeHttpRequests(a -> a.anyRequest().permitAll()).csrf(c -> c.disable());
return http.build();
}
}
@@ -0,0 +1,113 @@
package com.ankurm.hsc.config;
import com.ankurm.hsc.client.ApiKeyClient;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.GitHubClient;
import com.ankurm.hsc.client.OAuthClient;
import com.ankurm.hsc.client.ParamsClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.client.ResilientClient;
import com.ankurm.hsc.client.SecureClient;
import com.ankurm.hsc.error.UpstreamProblemStatusHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.ClientCredentialsOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import org.springframework.web.service.registry.ImportHttpServices;
import java.io.IOException;
import java.time.Duration;
import java.util.UUID;
/**
* Five groups, one interface each. Base URLs and timeouts live in application.yml under
* spring.http.serviceclient.<group>; everything that needs code lives here.
*/
@Configuration(proxyBeanMethods = false)
@EnableResilientMethods
@ImportHttpServices(group = "posts", types = {PostClient.class, ParamsClient.class})
@ImportHttpServices(group = "github", types = GitHubClient.class)
@ImportHttpServices(group = "flaky", types = FlakyClient.class)
@ImportHttpServices(group = "resilient", types = ResilientClient.class)
@ImportHttpServices(group = "bearer", types = SecureClient.class)
@ImportHttpServices(group = "apikey", types = ApiKeyClient.class)
@ImportHttpServices(group = "oauth", types = OAuthClient.class)
public class HttpClientsConfig {
/** Error handling for the "posts" group: 404/422/5xx become our own exception types. */
@Bean
RestClientHttpServiceGroupConfigurer postsErrorHandling() {
return groups -> groups.filterByName("posts").forEachClient((group, builder) ->
builder.defaultStatusHandler(
status -> status.isError(), new UpstreamProblemStatusHandler()));
}
/** HTTP-level retry for the "resilient" group, shared with tests so they can read the attempt counter. */
@Bean
RetryingInterceptor retryingInterceptor(
@Value("${demo.retry.max-retries:3}") long maxRetries,
@Value("${demo.retry.delay:20ms}") Duration delay) {
return new RetryingInterceptor(maxRetries, delay);
}
/** Programmatic per-group configuration: default headers and a request-logging interceptor for "github". */
@Bean
RestClientHttpServiceGroupConfigurer githubDefaults() {
return groups -> groups.filterByName("github").forEachClient((group, builder) -> builder
.defaultHeader("Accept", "application/vnd.github.v3+json")
.defaultHeader("X-GitHub-Api-Version", "2022-11-28")
.requestInterceptor((HttpRequest request, byte[] body, ClientHttpRequestExecution execution) -> {
System.out.println("Calling: " + request.getURI());
return execution.execute(request, body);
}));
}
@Bean
RestClientHttpServiceGroupConfigurer resilientGroup(RetryingInterceptor interceptor) {
return groups -> groups.filterByName("resilient").forEachClient((group, builder) ->
builder.requestInterceptor(interceptor));
}
/** Static bearer token for the "bearer" group; a Supplier so a real app can fetch or rotate it. */
@Bean
RestClientHttpServiceGroupConfigurer bearerAuth(@Value("${demo.bearer-token}") String token) {
return groups -> groups.filterByName("bearer").forEachClient((group, builder) ->
builder.requestInterceptor(new BearerTokenInterceptor(() -> token)));
}
/** A correlation id on every request of every group, plus a log line, to show interceptors run for all groups. */
@Bean
RestClientHttpServiceGroupConfigurer correlationId() {
return groups -> groups.filterByName("apikey", "bearer").forEachClient((group, builder) ->
builder.requestInterceptor((HttpRequest request, byte[] body, ClientHttpRequestExecution execution) -> {
request.getHeaders().set("X-Correlation-Id", UUID.randomUUID().toString().substring(0, 8));
return execution.execute(request, body);
}));
}
// ---------------------------------------------------------------- OAuth2 client credentials
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clients) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
manager.setAuthorizedClientProvider(new ClientCredentialsOAuth2AuthorizedClientProvider());
return manager;
}
/** Only the group named in @ClientRegistrationId on the interface gets a token attached. */
@Bean
RestClientHttpServiceGroupConfigurer oauthGroups(OAuth2AuthorizedClientManager manager) {
return OAuth2RestClientHttpServiceGroupConfigurer.from(manager);
}
}
@@ -0,0 +1,69 @@
package com.ankurm.hsc.config;
import com.ankurm.hsc.error.UpstreamUnavailableException;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Retries idempotent requests (GET, HEAD, PUT, DELETE, OPTIONS) at the HTTP level, on 5xx responses
* and on I/O errors, using Spring Framework 7's own RetryTemplate. Because it sits in the RestClient's
* interceptor chain, every interface in the group gets it without an annotation.
*/
public class RetryingInterceptor implements ClientHttpRequestInterceptor {
private final RetryTemplate retryTemplate;
private final AtomicInteger attempts = new AtomicInteger();
public RetryingInterceptor(long maxRetries, Duration delay) {
this.retryTemplate = new RetryTemplate(RetryPolicy.builder()
.maxRetries(maxRetries)
.delay(delay)
.includes(IOException.class, UpstreamUnavailableException.class)
.build());
}
public int attempts() {
return attempts.get();
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
if (!isIdempotent(request.getMethod())) {
return execution.execute(request, body);
}
try {
return retryTemplate.execute(() -> {
attempts.incrementAndGet();
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().is5xxServerError()) {
int status = response.getStatusCode().value();
response.close();
throw new UpstreamUnavailableException(status, "retrying " + request.getMethod() + " " + request.getURI().getPath());
}
return response;
});
} catch (RetryException e) {
// retries exhausted: surface the last failure, not the wrapper
Throwable last = e.getLastException();
if (last instanceof IOException io) {
throw io;
}
throw (RuntimeException) last;
}
}
private static boolean isIdempotent(HttpMethod m) {
return !HttpMethod.POST.equals(m) && !HttpMethod.PATCH.equals(m);
}
}
@@ -0,0 +1,43 @@
package com.ankurm.hsc.error;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.model.Post;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/** A thin controller in front of the clients, so the advice has something to translate. */
@RestController
class UpstreamController {
private final PostClient posts;
private final FlakyClient flaky;
UpstreamController(PostClient posts, FlakyClient flaky) {
this.posts = posts;
this.flaky = flaky;
}
@GetMapping("/api/posts/{id}")
Post post(@PathVariable Integer id) {
return posts.findById(id);
}
@PostMapping("/api/posts")
Post create(@RequestBody Post post) {
return posts.create(post);
}
@GetMapping("/api/down")
String down() {
return flaky.alwaysDown();
}
@GetMapping("/api/slow")
String slow() {
return flaky.slow();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hsc.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
/**
* Translates what the HTTP clients throw into what THIS service tells ITS callers.
* The upstream's own status is not passed through: a 503 from them is a 502 from us.
*/
@RestControllerAdvice
public class UpstreamExceptionAdvice {
@ExceptionHandler(UpstreamNotFoundException.class)
ProblemDetail notFound(UpstreamNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.problem().getDetail());
}
@ExceptionHandler(UpstreamValidationException.class)
ProblemDetail invalid(UpstreamValidationException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Upstream rejected the request");
pd.setProperty("errors", ex.properties().get("errors"));
return pd;
}
@ExceptionHandler({HttpServerErrorException.class, UpstreamUnavailableException.class})
ProblemDetail badGateway(RuntimeException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, "Upstream service error");
}
@ExceptionHandler(ResourceAccessException.class)
ProblemDetail timeout(ResourceAccessException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.GATEWAY_TIMEOUT, "Upstream service timed out");
}
}
@@ -0,0 +1,17 @@
package com.ankurm.hsc.error;
import org.springframework.http.ProblemDetail;
public class UpstreamNotFoundException extends RuntimeException {
private final transient ProblemDetail problem;
public UpstreamNotFoundException(ProblemDetail problem) {
super("Upstream says not found: " + problem.getDetail());
this.problem = problem;
}
public ProblemDetail problem() {
return problem;
}
}
@@ -0,0 +1,46 @@
package com.ankurm.hsc.error;
import org.springframework.http.HttpRequest;
import org.springframework.http.ProblemDetail;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.RestClient;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import java.io.IOException;
/**
* Turns an error response into one of our own exceptions, reading the RFC 9457 body when there is one.
* Registered with RestClient.Builder#defaultStatusHandler, so it runs for every method of every
* interface in the group.
*/
public class UpstreamProblemStatusHandler implements RestClient.ResponseSpec.ErrorHandler {
private final JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter();
@Override
public void handle(HttpRequest request, ClientHttpResponse response) throws IOException {
int status = response.getStatusCode().value();
ProblemDetail problem = readProblem(response, status);
if (status == 404) {
throw new UpstreamNotFoundException(problem);
}
if (status == 422) {
throw new UpstreamValidationException(problem);
}
if (response.getStatusCode().is5xxServerError()) {
throw new UpstreamUnavailableException(status, problem.getDetail());
}
// anything else (401, 403, 409 ...): fall back to a generic failure carrying the body
throw new IllegalStateException("Unexpected upstream status " + status + ": " + problem.getDetail());
}
private ProblemDetail readProblem(ClientHttpResponse response, int status) throws IOException {
try {
return (ProblemDetail) converter.read(ProblemDetail.class, response);
} catch (RuntimeException e) {
ProblemDetail fallback = ProblemDetail.forStatus(status);
fallback.setDetail("(no problem+json body)");
return fallback;
}
}
}
@@ -0,0 +1,16 @@
package com.ankurm.hsc.error;
/** A 5xx from the upstream. Retryable by definition, which is why it is its own type. */
public class UpstreamUnavailableException extends RuntimeException {
private final int status;
public UpstreamUnavailableException(int status, String detail) {
super("Upstream unavailable (" + status + "): " + detail);
this.status = status;
}
public int status() {
return status;
}
}
@@ -0,0 +1,24 @@
package com.ankurm.hsc.error;
import org.springframework.http.ProblemDetail;
import java.util.Map;
/** A 422 from the upstream, with the machine-readable "errors" extension kept. */
public class UpstreamValidationException extends RuntimeException {
private final transient ProblemDetail problem;
public UpstreamValidationException(ProblemDetail problem) {
super("Upstream rejected the request: " + problem.getTitle());
this.problem = problem;
}
public ProblemDetail problem() {
return problem;
}
public Map<String, Object> properties() {
return problem.getProperties() == null ? Map.of() : problem.getProperties();
}
}
@@ -0,0 +1,4 @@
package com.ankurm.hsc.model;
public record Post(Integer id, Integer userId, String title, String body) {
}
@@ -0,0 +1,320 @@
package com.ankurm.hsc.stub;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A tiny "upstream API" on the JDK's built-in HttpServer, so every example runs offline.
* It plays the part jsonplaceholder.typicode.com plays in the original article, plus the awkward
* behaviours the article needs: ProblemDetail errors, a flaky endpoint, secured endpoints and a
* client_credentials token endpoint.
*/
public final class StubServer implements AutoCloseable {
public static final String API_KEY = "demo-key-123";
public static final String STATIC_TOKEN = "s3cr3t-token";
public static final String CLIENT_ID = "demo-client";
public static final String CLIENT_SECRET = "demo-secret";
private static final JsonMapper JSON = JsonMapper.builder().build();
private static StubServer shared;
private final HttpServer server;
private final List<String> log = Collections.synchronizedList(new ArrayList<>());
private final Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
private final AtomicInteger tokenSerial = new AtomicInteger();
private StubServer(int port) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/", this::handle);
server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(8));
server.start();
}
/** One server per JVM, on a random free port. */
public static synchronized StubServer shared() {
if (shared == null) {
try {
shared = new StubServer(0);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
return shared;
}
public static StubServer start(int port) throws IOException {
return new StubServer(port);
}
public String url() {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Every request the stub has seen, as "METHOD path" lines. */
public List<String> requests() {
synchronized (log) {
return List.copyOf(log);
}
}
public int count(String key) {
AtomicInteger c = counters.get(key);
return c == null ? 0 : c.get();
}
public void reset() {
log.clear();
counters.clear();
tokenSerial.set(0);
}
@Override
public void close() {
server.stop(0);
}
// ------------------------------------------------------------------ routing
private void handle(HttpExchange ex) throws IOException {
URI uri = ex.getRequestURI();
String path = uri.getPath();
String method = ex.getRequestMethod();
log.add(method + " " + path + (uri.getQuery() == null ? "" : "?" + uri.getQuery()));
String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
try {
if (path.equals("/posts") && method.equals("GET")) {
listPosts(ex, uri.getQuery());
} else if (path.equals("/posts") && method.equals("POST")) {
createPost(ex, body);
} else if (path.equals("/posts/unstable") && method.equals("POST")) {
unstableCreate(ex);
} else if (path.startsWith("/posts/")) {
postById(ex, method, path.substring("/posts/".length()), body);
} else if (path.startsWith("/flaky/")) {
flaky(ex, path.substring("/flaky/".length()), uri.getQuery());
} else if (path.equals("/always-503")) {
problem(ex, 503, "Service Unavailable", "Upstream is down for maintenance", path, null);
} else if (path.equals("/slow")) {
sleep(3000);
json(ex, 200, "{\"ok\":true}");
} else if (path.equals("/secure/bearer")) {
secureBearer(ex);
} else if (path.equals("/secure/apikey")) {
secureApiKey(ex);
} else if (path.equals("/echo/headers")) {
echoHeaders(ex);
} else if (path.equals("/oauth/token") && method.equals("POST")) {
token(ex, body);
} else {
problem(ex, 404, "Not Found", "No route for " + path, path, null);
}
} catch (RuntimeException e) {
problem(ex, 500, "Stub failure", String.valueOf(e), path, null);
} finally {
ex.close();
}
}
// ------------------------------------------------------------------ posts
private static final String[][] POSTS = {
{"1", "1", "First post", "Body of the first post"},
{"2", "1", "Second post", "Body of the second post"},
{"3", "2", "Third post", "Body of the third post"},
};
private static String postJson(String id, String userId, String title, String body) {
return "{\"id\":" + id + ",\"userId\":" + userId + ",\"title\":" + JSON.writeValueAsString(title)
+ ",\"body\":" + JSON.writeValueAsString(body) + "}";
}
private void listPosts(HttpExchange ex, String query) throws IOException {
String userFilter = null;
if (query != null && query.startsWith("userId=")) {
userFilter = query.substring("userId=".length());
}
List<String> items = new ArrayList<>();
for (String[] p : POSTS) {
if (userFilter == null || userFilter.equals(p[1])) {
items.add(postJson(p[0], p[1], p[2], p[3]));
}
}
json(ex, 200, "[" + String.join(",", items) + "]");
}
private void postById(HttpExchange ex, String method, String id, String body) throws IOException {
String[] found = null;
for (String[] p : POSTS) {
if (p[0].equals(id)) {
found = p;
}
}
if (found == null) {
problem(ex, 404, "Post not found", "No post with id " + id, "/posts/" + id, null);
return;
}
switch (method) {
case "GET" -> json(ex, 200, postJson(found[0], found[1], found[2], found[3]));
case "PUT" -> {
JsonNode in = JSON.readTree(body);
json(ex, 200, postJson(id, in.path("userId").asString("1"),
in.path("title").asString(""), in.path("body").asString("")));
}
case "DELETE" -> {
ex.sendResponseHeaders(204, -1);
}
default -> problem(ex, 405, "Method Not Allowed", method, "/posts/" + id, null);
}
}
private void createPost(HttpExchange ex, String body) throws IOException {
JsonNode in = JSON.readTree(body);
String title = in.path("title").asString("");
if (title.isBlank()) {
ObjectNode extra = JSON.createObjectNode();
var errors = extra.putArray("errors");
errors.addObject().put("field", "title").put("message", "must not be blank");
problem(ex, 422, "Validation failed", "1 field is invalid", "/posts", extra);
return;
}
json(ex, 201, postJson("101", in.path("userId").asString("1"), title, in.path("body").asString("")));
}
/** Creates the resource, then answers 503 on the first call: a response lost after the work was done. */
private void unstableCreate(HttpExchange ex) throws IOException {
int created = counter("unstable-created").incrementAndGet();
if (created == 1) {
problem(ex, 503, "Service Unavailable", "Created, but the response was lost", "/posts/unstable", null);
} else {
json(ex, 201, postJson(String.valueOf(100 + created), "1", "unstable", "created " + created + " times so far"));
}
}
// ------------------------------------------------------------------ flaky
/** GET /flaky/{key}?failures=N answers 503 for the first N calls per key, then 200. */
private void flaky(HttpExchange ex, String key, String query) throws IOException {
int failures = 2;
if (query != null && query.startsWith("failures=")) {
failures = Integer.parseInt(query.substring("failures=".length()));
}
int call = counter("flaky-" + key).incrementAndGet();
if (call <= failures) {
ex.getResponseHeaders().add("Retry-After", "0");
problem(ex, 503, "Service Unavailable", "Flaky upstream, call " + call, "/flaky/" + key, null);
} else {
json(ex, 200, postJson("1", "1", "Recovered on call " + call, "after " + failures + " failures"));
}
}
// ------------------------------------------------------------------ security
private void secureBearer(HttpExchange ex) throws IOException {
String auth = ex.getRequestHeaders().getFirst("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String token = auth.substring("Bearer ".length());
if (token.equals(STATIC_TOKEN) || token.startsWith("oauth-token-")) {
json(ex, 200, "{\"caller\":\"demo-user\",\"tokenKind\":\""
+ (token.startsWith("oauth-") ? "oauth2" : "static") + "\"}");
return;
}
}
ex.getResponseHeaders().add("WWW-Authenticate", "Bearer realm=\"stub\"");
problem(ex, 401, "Unauthorized", "Missing or invalid bearer token", "/secure/bearer", null);
}
private void secureApiKey(HttpExchange ex) throws IOException {
if (API_KEY.equals(ex.getRequestHeaders().getFirst("X-Api-Key"))) {
json(ex, 200, "{\"caller\":\"demo-service\",\"auth\":\"api-key\"}");
} else {
problem(ex, 401, "Unauthorized", "Missing or invalid X-Api-Key", "/secure/apikey", null);
}
}
private void echoHeaders(HttpExchange ex) throws IOException {
ObjectNode out = JSON.createObjectNode();
Headers h = ex.getRequestHeaders();
for (String name : List.of("Accept", "X-Api-Key", "X-Correlation-Id", "Authorization", "X-Client")) {
String v = h.getFirst(name);
if (v != null) {
out.put(name, name.equals("Authorization") ? v.replaceAll("(Bearer |Basic ).*", "$1<redacted>") : v);
}
}
json(ex, 200, JSON.writeValueAsString(out));
}
private void token(HttpExchange ex, String form) throws IOException {
String auth = ex.getRequestHeaders().getFirst("Authorization");
String expected = "Basic " + Base64.getEncoder()
.encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8));
if (!expected.equals(auth) || !form.contains("grant_type=client_credentials")) {
json(ex, 401, "{\"error\":\"invalid_client\"}");
return;
}
int n = tokenSerial.incrementAndGet();
counter("token-requests").incrementAndGet();
json(ex, 200, "{\"access_token\":\"oauth-token-" + n + "\",\"token_type\":\"Bearer\",\"expires_in\":3600}");
}
// ------------------------------------------------------------------ helpers
private AtomicInteger counter(String key) {
return counters.computeIfAbsent(key, k -> new AtomicInteger());
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void json(HttpExchange ex, int status, String body) throws IOException {
write(ex, status, "application/json", body);
}
private static void problem(HttpExchange ex, int status, String title, String detail, String instance,
ObjectNode extra) throws IOException {
ObjectNode n = JSON.createObjectNode();
n.put("type", "https://stub.example/problems/" + title.toLowerCase().replace(' ', '-'));
n.put("title", title);
n.put("status", status);
n.put("detail", detail);
n.put("instance", instance);
if (extra != null) {
extra.properties().forEach(e -> n.set(e.getKey(), e.getValue()));
}
write(ex, status, "application/problem+json", JSON.writeValueAsString(n));
}
private static void write(HttpExchange ex, int status, String type, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", type);
ex.sendResponseHeaders(status, bytes.length);
ex.getResponseBody().write(bytes);
}
public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 18601;
StubServer s = start(port);
System.out.println("Stub upstream listening on " + s.url());
}
}
@@ -0,0 +1,45 @@
# The stub upstream. Tests override stub.url with the random port of the in-process stub.
stub:
url: http://127.0.0.1:18601
demo:
bearer-token: s3cr3t-token
spring:
http:
clients: # defaults for every group
connect-timeout: 2s
read-timeout: 5s
serviceclient: # one entry per group registered with @ImportHttpServices
posts:
base-url: ${stub.url}
default-header:
X-Client: hsc-demo
github: # a second API with its own base URL; never called in the tests
base-url: https://api.github.com
read-timeout: 5s
flaky:
base-url: ${stub.url}
read-timeout: 1500ms # per-group override of spring.http.clients.read-timeout
resilient:
base-url: ${stub.url}
bearer:
base-url: ${stub.url}
apikey:
base-url: ${stub.url}
default-header:
X-Api-Key: demo-key-123
oauth:
base-url: ${stub.url}
security:
oauth2:
client:
registration:
stub:
provider: stub
client-id: demo-client
client-secret: demo-secret
authorization-grant-type: client_credentials
provider:
stub:
token-uri: ${stub.url}/oauth/token
@@ -0,0 +1,70 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.ParamsClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import java.util.List;
import java.util.TreeSet;
import static org.assertj.core.api.Assertions.assertThat;
class Step01RegistryAndCrudTest extends StubBackedTest {
@Autowired
PostClient posts;
@Autowired
HttpServiceProxyRegistry registry;
@Autowired
ParamsClient params;
@Test
void groupsAndCrud() {
Transcript t = new Transcript("01-registry-and-crud.txt");
t.line("# groups the HttpServiceProxyRegistry knows about");
for (String g : new TreeSet<>(registry.getGroupNames())) {
t.line("group %-7s -> %s", g, registry.getClientTypesInGroup(g).stream().map(Class::getSimpleName).sorted().toList());
}
assertThat(registry.getGroupNames()).containsExactlyInAnyOrder("posts", "github", "flaky", "resilient", "bearer", "apikey", "oauth");
t.line("PostClient bean is a JDK proxy: %s", java.lang.reflect.Proxy.isProxyClass(posts.getClass()));
assertThat(registry.getClient("posts", PostClient.class)).isSameAs(posts);
t.line("registry.getClient(\"posts\", PostClient.class) is the injected bean: true");
t.blank().line("# CRUD against the stub (every call is a real HTTP request)");
List<Post> all = posts.findAll();
t.line("findAll() -> %d posts", all.size());
Post one = posts.findById(1);
t.line("findById(1) -> %s", one);
t.line("findByUser(2) -> %s", posts.findByUser(2).stream().map(Post::id).toList());
ResponseEntity<Post> entity = posts.findEntity(2);
t.line("findEntity(2) -> status %s, Content-Type %s", entity.getStatusCode(), entity.getHeaders().getContentType());
Post created = posts.create(new Post(null, 1, "HTTP Service Clients", "Spring Boot 4 is amazing!"));
t.line("create(...) -> id %d", created.id());
Post updated = posts.update(1, new Post(1, 1, "Updated Title", "Updated body"));
t.line("update(1, ...) -> title '%s'", updated.title());
posts.delete(1);
t.line("delete(1) -> void, no exception");
t.blank().line("# argument kinds (ParamsClient)");
t.line("@RequestHeader -> upstream saw X-Correlation-Id=%s", params.withHeader("abc-123").get("X-Correlation-Id"));
t.line("@RequestParam(required=false), null -> %d posts", params.optionalParam(null).size());
t.line("@RequestParam(defaultValue=\"2\") -> posts %s", params.defaultedParam(null).stream().map(Post::id).toList());
t.line("URI argument -> %s", params.byUri(java.net.URI.create(STUB.url() + "/posts/2")).title());
t.blank().line("# what the stub actually received");
STUB.requests().forEach(r -> t.line("%s", r));
t.save();
assertThat(all).hasSize(3);
assertThat(created.id()).isEqualTo(101);
assertThat(updated.title()).isEqualTo("Updated Title");
assertThat(STUB.requests()).contains("GET /posts?userId=2", "DELETE /posts/1");
}
}
@@ -0,0 +1,114 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.ImportHttpServices;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Checks claims from the first version of the article: which property keys really configure a group,
* what happens without a base URL, and a few "common mistakes" -- each one executed, not assumed.
*/
class Step02PropertiesAndPitfallsTest {
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@ImportHttpServices(group = "legacy", types = PostClient.class)
static class MiniApp {
}
interface WrongShape {
@GetExchange("/posts")
com.ankurm.hsc.model.Post list();
}
interface UsesRequestMapping {
@GetMapping("/posts")
String list();
}
private static ConfigurableApplicationContext start(String... props) {
return new SpringApplicationBuilder(MiniApp.class)
.web(WebApplicationType.NONE)
.properties(props)
.run();
}
private static Throwable rootCause(Throwable e) {
Throwable c = e;
while (c.getCause() != null) {
c = c.getCause();
}
return c;
}
private static String outcome(Supplier<Object> call) {
try {
Object r = call.get();
return "OK -> " + r;
} catch (Throwable e) {
String msg = String.valueOf(e.getMessage());
return e.getClass().getName() + ": " + (msg.length() > 140 ? msg.substring(0, 140) + "..." : msg);
}
}
@Test
void whichKeysWork() {
Transcript t = new Transcript("02-properties-and-pitfalls.txt");
String url = StubBackedTest.STUB.url();
String sep = "-".repeat(4);
t.line("# A. the keys the article's first version used (spring.http.client.service.group.<g>.base-url)");
try (var ctx = start("spring.http.client.service.group.legacy.base-url=" + url)) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).doesNotStartWith("OK");
}
t.blank().line("# B. the keys Spring Boot 4.1.2 binds (spring.http.serviceclient.<g>.base-url)");
try (var ctx = start("spring.http.serviceclient.legacy.base-url=" + url)) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).startsWith("OK");
}
t.blank().line("# C. no base URL at all");
try (var ctx = start()) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).doesNotStartWith("OK");
}
t.blank().line("# D. a return type that does not match the JSON (array decoded as a single Post)");
RestClient rc = RestClient.builder().baseUrl(url).build();
HttpServiceProxyFactory f = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rc)).build();
WrongShape wrong = f.createClient(WrongShape.class);
t.line("list() -> %s", outcome(wrong::list));
try {
wrong.list();
} catch (RuntimeException e) {
t.line("root cause class: %s", rootCause(e).getClass().getName());
}
t.blank().line("# E. @GetMapping on a client interface instead of @GetExchange");
t.line("createClient(UsesRequestMapping.class).list() -> %s", outcome(() -> f.createClient(UsesRequestMapping.class).list()));
t.save();
}
}
@@ -0,0 +1,108 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.error.UpstreamNotFoundException;
import com.ankurm.hsc.error.UpstreamProblemStatusHandler;
import com.ankurm.hsc.error.UpstreamUnavailableException;
import com.ankurm.hsc.error.UpstreamValidationException;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ProblemDetail;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.NotFoundRestClientAdapterDecorator;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Step03ErrorHandlingTest extends StubBackedTest {
@Autowired
PostClient groupPosts; // "posts" group: has our defaultStatusHandler
@Autowired
FlakyClient groupFlaky; // "flaky" group: no handler, framework defaults
private static <T> T thrown(Supplier<Object> call, Class<T> type) {
try {
Object r = call.get();
throw new AssertionError("expected " + type.getSimpleName() + " but got " + r);
} catch (Throwable e) {
assertThat(e).isInstanceOf(type);
return type.cast(e);
}
}
private HttpServiceProxyFactory.Builder plainFactory(String baseUrl) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(RestClient.builder().baseUrl(baseUrl).build()));
}
@Test
void defaultsAndCustomHandlers() {
Transcript t = new Transcript("03-error-handling.txt");
t.line("# 1. framework defaults (a plain RestClient, no status handler)");
PostClient plain = plainFactory(STUB.url()).build().createClient(PostClient.class);
HttpClientErrorException nf = thrown(() -> plain.findById(99), HttpClientErrorException.NotFound.class);
t.line("findById(99) -> %s", nf.getClass().getName().replace("org.springframework.web.client.", ""));
t.line(" status : %s", nf.getStatusCode());
t.line(" message : %s", nf.getMessage());
t.line(" content-type: %s", nf.getResponseHeaders().getContentType());
ProblemDetail pd = nf.getResponseBodyAs(ProblemDetail.class);
t.line(" getResponseBodyAs(ProblemDetail): title='%s' detail='%s' type=%s", pd.getTitle(), pd.getDetail(), pd.getType());
assertThat(pd.getStatus()).isEqualTo(404);
HttpServerErrorException se = thrown(groupFlaky::alwaysDown, HttpServerErrorException.ServiceUnavailable.class);
t.line("alwaysDown() -> HttpServerErrorException.ServiceUnavailable, status %s", se.getStatusCode());
t.blank().line("# 2. a response that never comes: read timeout is 1500ms on the flaky group, /slow takes 3000ms");
ResourceAccessException timeout = thrown(groupFlaky::slow, ResourceAccessException.class);
t.line("slow() -> ResourceAccessException, cause %s", timeout.getCause().getClass().getName());
t.line(" message: %s", timeout.getMessage().replaceAll(":\\d+/", ":<port>/"));
t.blank().line("# 3. nobody listening");
PostClient dead = plainFactory("http://127.0.0.1:1").build().createClient(PostClient.class);
ResourceAccessException refused = thrown(dead::findAll, ResourceAccessException.class);
t.line("findAll() -> ResourceAccessException, cause %s", refused.getCause().getClass().getName());
t.blank().line("# 4. Optional<Post> and ResponseEntity<Post> on a 404 with the default handler");
t.line("findOptional(99) -> %s", thrown(() -> plain.findOptional(99), HttpClientErrorException.class).getClass().getSimpleName());
t.line("findEntity(99) -> %s", thrown(() -> plain.findEntity(99), HttpClientErrorException.class).getClass().getSimpleName());
t.blank().line("# 5. NotFoundRestClientAdapterDecorator (spring-web 7.0.10) turns a 404 into an empty result");
PostClient lenient = plainFactory(STUB.url()).exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new)
.build().createClient(PostClient.class);
t.line("findById(99) -> %s", lenient.findById(99));
t.line("findOptional(99) -> %s", lenient.findOptional(99));
t.line("findEntity(99) -> status %s, body %s", lenient.findEntity(99).getStatusCode(), lenient.findEntity(99).getBody());
assertThat(lenient.findOptional(99)).isEmpty();
t.blank().line("# 6. our own defaultStatusHandler on the \"posts\" group (UpstreamProblemStatusHandler)");
UpstreamNotFoundException unf = thrown(() -> groupPosts.findById(99), UpstreamNotFoundException.class);
t.line("findById(99) -> UpstreamNotFoundException: %s", unf.getMessage());
t.line(" problem.type=%s instance=%s", unf.problem().getType(), unf.problem().getInstance());
UpstreamValidationException uve = thrown(() -> groupPosts.create(new Post(null, 1, "", "x")), UpstreamValidationException.class);
t.line("create(blank title) -> UpstreamValidationException: %s", uve.getMessage());
t.line(" extension 'errors' = %s", uve.properties().get("errors"));
PostClient handled = plainFactory(STUB.url()).build().createClient(PostClient.class);
FlakyClient flakyHandled = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url())
.defaultStatusHandler(s -> s.isError(), new UpstreamProblemStatusHandler()).build()))
.build().createClient(FlakyClient.class);
UpstreamUnavailableException uue = thrown(flakyHandled::alwaysDown, UpstreamUnavailableException.class);
t.line("alwaysDown() -> UpstreamUnavailableException: %s", uue.getMessage());
assertThat(handled).isNotNull();
t.save();
assertThatThrownBy(() -> groupPosts.findById(99)).isInstanceOf(UpstreamNotFoundException.class);
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hsc;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@AutoConfigureMockMvc
class Step04ControllerAdviceTest extends StubBackedTest {
@Autowired
MockMvc mvc;
private void call(Transcript t, String label, org.springframework.test.web.servlet.RequestBuilder rb, int expected) throws Exception {
MvcResult r = mvc.perform(rb).andReturn();
t.line("%-22s -> %d %s", label, r.getResponse().getStatus(), r.getResponse().getContentAsString());
assertThat(r.getResponse().getStatus()).isEqualTo(expected);
}
@Test
void upstreamFailuresBecomeOurOwnProblemDetails() throws Exception {
Transcript t = new Transcript("04-controller-advice.txt");
t.line("# our service (MockMvc, real HTTP to the stub behind it)");
call(t, "GET /api/posts/1", get("/api/posts/1"), 200);
call(t, "GET /api/posts/99", get("/api/posts/99"), 404);
call(t, "POST blank title", post("/api/posts").contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":1,\"title\":\"\",\"body\":\"x\"}"), 400);
call(t, "GET /api/down", get("/api/down"), 502);
call(t, "GET /api/slow", get("/api/slow"), 504);
t.save();
}
}
@@ -0,0 +1,137 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.ResilientClient;
import com.ankurm.hsc.config.RetryingInterceptor;
import com.ankurm.hsc.model.Post;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Step05RetryTest extends StubBackedTest {
@Autowired
FlakyClient annotated; // bean from the "flaky" group: @Retryable on its methods
@Autowired
ResilientClient viaInterceptor; // bean from the "resilient" group: retry lives in the interceptor
@Autowired
RetryingInterceptor interceptor;
private static String exceptionLine(Throwable e) {
return e.getClass().getName().replace("org.springframework.web.client.", "") + " / " + e.getMessage().replaceAll(":\\d+/", ":<port>/");
}
@Test
void retryStyles() {
Transcript t = new Transcript("05-retries.txt");
// a FlakyClient that is NOT a bean, so no @Retryable advice applies: used for the by-hand styles
FlakyClient plain = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url()).build())).build().createClient(FlakyClient.class);
t.line("# 1. RetryTemplate around the call (org.springframework.core.retry, spring-core 7.0.10)");
List<String> events = new ArrayList<>();
RetryListener listener = new RetryListener() {
@Override
public void beforeRetry(RetryPolicy policy, Retryable<?> retryable, RetryState state) {
events.add("beforeRetry");
}
@Override
public void onRetrySuccess(RetryPolicy policy, Retryable<?> retryable, Object result) {
events.add("onRetrySuccess");
}
@Override
public void onRetryFailure(RetryPolicy policy, Retryable<?> retryable, Throwable throwable) {
events.add("onRetryFailure(" + throwable.getClass().getSimpleName() + ")");
}
};
RetryTemplate template = new RetryTemplate(RetryPolicy.builder()
.maxRetries(3).delay(Duration.ofMillis(20)).includes(HttpServerErrorException.class).build());
template.setRetryListener(listener);
Post ok = template.invoke(() -> plain.get("tpl", 2));
t.line("get(\"tpl\", failures=2) -> '%s'", ok.title());
t.line("stub saw %d calls; listener events: %s", STUB.count("flaky-tpl"), events);
assertThat(STUB.count("flaky-tpl")).isEqualTo(3);
RetryTemplate twice = new RetryTemplate(RetryPolicy.builder()
.maxRetries(2).delay(Duration.ofMillis(20)).includes(HttpServerErrorException.class).build());
try {
twice.invoke(() -> plain.get("exhaust", 10));
throw new AssertionError("expected failure");
} catch (RuntimeException e) {
t.line("maxRetries(2) against a dead upstream -> %s", exceptionLine(e));
t.line("stub saw %d calls", STUB.count("flaky-exhaust"));
}
try {
twice.execute(() -> plain.get("exhaust2", 10));
} catch (RetryException e) {
t.line("execute() instead of invoke() -> RetryException, retryCount=%d, exceptions=%d, last=%s",
e.getRetryCount(), e.getExceptions().size(), e.getLastException().getClass().getSimpleName());
}
t.blank().line("# 2. @Retryable on the @HttpExchange interface method (bean from the \"flaky\" group)");
Post p2 = annotated.get("ann", 2);
t.line("get(\"ann\", failures=2) -> '%s'; stub saw %d calls", p2.title(), STUB.count("flaky-ann"));
assertThat(STUB.count("flaky-ann")).isEqualTo(3);
try {
annotated.get("ann-dead", 10);
} catch (RuntimeException e) {
t.line("get(\"ann-dead\", failures=10) -> %s", exceptionLine(e));
t.line("stub saw %d calls (maxRetries = 3)", STUB.count("flaky-ann-dead"));
}
t.blank().line("# 3. the same retry as a ClientHttpRequestInterceptor on the \"resilient\" group (idempotent methods only)");
Post p3 = viaInterceptor.get("icpt", 2);
t.line("get(\"icpt\", failures=2) -> '%s'; stub saw %d calls; interceptor attempts so far: %d",
p3.title(), STUB.count("flaky-icpt"), interceptor.attempts());
assertThat(STUB.count("flaky-icpt")).isEqualTo(3);
try {
viaInterceptor.createUnstable(new Post(null, 1, "x", "y"));
} catch (RuntimeException e) {
t.line("POST createUnstable -> %s", exceptionLine(e));
t.line("stub created the resource %d time(s): the interceptor did not retry a POST", STUB.count("unstable-created"));
assertThat(STUB.count("unstable-created")).isEqualTo(1);
}
t.blank().line("# 4. the trap: @Retryable on a POST whose response was lost after the server did the work");
STUB.reset();
Post p4 = annotated.createUnstable(new Post(null, 1, "x", "y"));
t.line("createUnstable() -> id %d, body '%s'", p4.id(), p4.body());
t.line("stub created the resource %d times for ONE logical call", STUB.count("unstable-created"));
assertThat(STUB.count("unstable-created")).isEqualTo(2);
t.blank().line("# 5. Resilience4j 2.4.0 around the same plain client");
Retry retry = Retry.of("r4j", RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(20))
.retryExceptions(HttpServerErrorException.class).build());
Post p5 = Retry.decorateSupplier(retry, () -> plain.get("r4j", 2)).get();
t.line("maxAttempts(3), failures=2 -> '%s'; stub saw %d calls", p5.title(), STUB.count("flaky-r4j"));
assertThat(STUB.count("flaky-r4j")).isEqualTo(3);
try {
Retry.decorateSupplier(Retry.of("r4j2", RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(20))
.retryExceptions(HttpServerErrorException.class).build()), () -> plain.get("r4j-dead", 10)).get();
} catch (RuntimeException e) {
t.line("maxAttempts(3) against a dead upstream -> %s; stub saw %d calls", e.getClass().getSimpleName(), STUB.count("flaky-r4j-dead"));
}
t.save();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.ApiKeyClient;
import com.ankurm.hsc.client.SecureClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class Step06AuthTest extends StubBackedTest {
@Autowired
SecureClient bearer;
@Autowired
ApiKeyClient apiKey;
@Test
void bearerAndApiKey() {
Transcript t = new Transcript("06-auth-bearer-and-api-key.txt");
t.line("# 1. no credentials (a plain RestClient)");
SecureClient anonymous = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url()).build())).build().createClient(SecureClient.class);
try {
anonymous.whoAmI();
} catch (HttpClientErrorException.Unauthorized e) {
t.line("whoAmI() -> 401 Unauthorized, WWW-Authenticate: %s", e.getResponseHeaders().getFirst("WWW-Authenticate"));
}
t.blank().line("# 2. bearer token added by a ClientHttpRequestInterceptor on the \"bearer\" group");
t.line("whoAmI() -> %s", bearer.whoAmI());
Map<String, String> seen = bearer.echoHeaders();
t.line("headers the upstream saw: Authorization=%s, X-Correlation-Id present=%s (%d chars)",
seen.get("Authorization"), seen.containsKey("X-Correlation-Id"), seen.get("X-Correlation-Id").length());
assertThat(seen.get("Authorization")).isEqualTo("Bearer <redacted>");
t.blank().line("# 3. API key from spring.http.serviceclient.apikey.default-header.X-Api-Key (no Java at all)");
t.line("whoAmI() -> %s", apiKey.whoAmI());
Map<String, String> seen2 = apiKey.echoHeaders();
t.line("headers the upstream saw: X-Api-Key=%s, X-Correlation-Id present=%s", seen2.get("X-Api-Key"), seen2.containsKey("X-Correlation-Id"));
assertThat(seen2.get("X-Api-Key")).isEqualTo("demo-key-123");
t.blank().line("# 4. the interceptor is per group: the \"apikey\" group never sends Authorization");
t.line("Authorization header on the apikey group: %s", seen2.get("Authorization"));
assertThat(seen2).doesNotContainKey("Authorization");
t.save();
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.OAuthClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class Step07OAuth2Test extends StubBackedTest {
@Autowired
OAuthClient oauth;
@Test
void clientCredentialsTokenIsFetchedOnceAndAttached() {
Transcript t = new Transcript("07-oauth2-client-credentials.txt");
t.line("# @ClientRegistrationId(\"stub\") on the interface + OAuth2RestClientHttpServiceGroupConfigurer");
t.line("call 1: whoAmI() -> %s", oauth.whoAmI());
t.line("call 2: whoAmI() -> %s", oauth.whoAmI());
t.line("call 3: whoAmI() -> %s", oauth.whoAmI());
t.blank().line("# what the stub saw, in order");
STUB.requests().forEach(r -> t.line("%s", r));
t.line("token requests: %d", STUB.count("token-requests"));
t.blank().line("# the same interface WITHOUT the group configurer: the annotation is silently ignored");
OAuthClient bare = org.springframework.web.service.invoker.HttpServiceProxyFactory.builderFor(
org.springframework.web.client.support.RestClientAdapter.create(
org.springframework.web.client.RestClient.builder().baseUrl(STUB.url()).build()))
.build().createClient(OAuthClient.class);
try {
bare.whoAmI();
} catch (org.springframework.web.client.HttpClientErrorException e) {
t.line("whoAmI() -> %s", e.getStatusCode());
}
t.save();
assertThat(STUB.count("token-requests")).isEqualTo(1);
}
}
@@ -0,0 +1,84 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.error.UpstreamNotFoundException;
import com.ankurm.hsc.error.UpstreamProblemStatusHandler;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/** No Spring context, no network: MockRestServiceServer bound to the RestClient.Builder that backs the proxy. */
class Step08MockServerTest {
private static final String BASE = "http://upstream.test";
@Test
void mockedUpstream() {
Transcript t = new Transcript("08-mock-rest-service-server.txt");
RestClient.Builder builder = RestClient.builder().baseUrl(BASE)
.defaultStatusHandler(s -> s.isError(), new UpstreamProblemStatusHandler());
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
PostClient client = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build()))
.build().createClient(PostClient.class);
t.line("# 1. GET: URL, method and the decoded record");
server.expect(requestTo(BASE + "/posts/1")).andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"id\":1,\"userId\":1,\"title\":\"Test Title\",\"body\":\"Test body\"}", MediaType.APPLICATION_JSON));
Post post = client.findById(1);
server.verify();
t.line("findById(1) -> %s", post);
assertThat(post.title()).isEqualTo("Test Title");
t.blank().line("# 2. POST: the request body the interface sent");
server.reset();
server.expect(requestTo(BASE + "/posts")).andExpect(method(HttpMethod.POST))
.andExpect(header("Content-Type", "application/json"))
.andExpect(jsonPath("$.title").value("New Post"))
.andExpect(content().json("{\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"))
.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON)
.body("{\"id\":101,\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"));
Post created = client.create(new Post(null, 1, "New Post", "Content"));
server.verify();
t.line("create(...) -> id %d (request matched: path, method, Content-Type, JSON body)", created.id());
t.blank().line("# 3. a ProblemDetail 404 goes through the real status handler");
server.reset();
server.expect(requestTo(BASE + "/posts/9")).andRespond(withStatus(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body("{\"title\":\"Post not found\",\"status\":404,\"detail\":\"No post with id 9\"}"));
assertThatThrownBy(() -> client.findById(9)).isInstanceOf(UpstreamNotFoundException.class);
t.line("findById(9) -> UpstreamNotFoundException (handler decoded the body: '%s')", "No post with id 9");
t.blank().line("# 4. a 500 with no body: the handler falls back instead of crashing");
server.reset();
server.expect(requestTo(BASE + "/posts")).andRespond(withServerError());
try {
client.findAll();
} catch (RuntimeException e) {
t.line("findAll() -> %s: %s", e.getClass().getSimpleName(), e.getMessage());
}
t.blank().line("# 5. what MockRestServiceServer cannot tell you");
t.line("timeouts, connection resets, TLS and real serialisation quirks never happen here; see 03 and 05 for the real server.");
t.save();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* @RestClientTest next to an @ImportHttpServices group: what binds to the auto-configured
* MockRestServiceServer and what does not.
*/
@RestClientTest(properties = "spring.http.serviceclient.slice.base-url=http://upstream.test")
@Import(Step09RestClientSliceTest.SliceGroups.class)
class Step09RestClientSliceTest {
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "slice", types = PostClient.class)
static class SliceGroups {
}
@Autowired
RestClient.Builder builder;
@Autowired
MockRestServiceServer server;
private static final String JSON = "{\"id\":1,\"userId\":1,\"title\":\"From the mock\",\"body\":\"b\"}";
@Test
void sliceWithHttpServiceGroup() {
Transcript t = new Transcript("09-rest-client-test-slice.txt");
t.line("# a proxy built from the slice's own RestClient.Builder, which the mock server IS bound to");
PostClient viaBuilder = HttpServiceProxyFactory.builderFor(
RestClientAdapter.create(builder.baseUrl("http://upstream.test").build())).build().createClient(PostClient.class);
server.expect(requestTo("http://upstream.test/posts/1")).andRespond(withSuccess(JSON, MediaType.APPLICATION_JSON));
Post p = viaBuilder.findById(1);
server.verify();
t.line("findById(1) -> %s", p);
t.save();
assertThat(p.title()).isEqualTo("From the mock");
}
}
@@ -0,0 +1,26 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.test.web.client.MockRestServiceServer;
/** The first version of the article wrote @RestClientTest(PostService.class) and autowired the interface. */
@RestClientTest(PostClient.class)
class Step09bOldClaimTest {
@Autowired(required = false)
PostClient client;
@Autowired(required = false)
MockRestServiceServer server;
@Test
void interfaceIsNotABeanJustBecauseItIsNamedInTheSlice() {
Transcript t = new Transcript("09b-rest-client-test-old-claim.txt");
t.line("@RestClientTest(PostClient.class), then @Autowired PostClient -> %s", client == null ? "no such bean (null)" : "bean present");
t.line("MockRestServiceServer bean present -> %s", server != null);
t.save();
}
}
@@ -0,0 +1,44 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/** The obvious attempt: inject the group's client and the auto-configured MockRestServiceServer side by side. */
@RestClientTest(properties = "spring.http.serviceclient.slice.base-url=http://upstream.test")
@Import(Step09cGroupClientInSliceTest.SliceGroups.class)
class Step09cGroupClientInSliceTest {
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "slice", types = PostClient.class)
static class SliceGroups {
}
@Autowired
PostClient groupClient;
@Autowired
MockRestServiceServer server;
@Test
void groupClientIsNotBoundToTheMockServer() {
Transcript t = new Transcript("09c-rest-client-test-group-client.txt");
t.line("PostClient from the @ImportHttpServices group is injected: %s", groupClient != null);
try {
server.expect(requestTo("http://upstream.test/posts/1")).andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
t.line("server.expect(...) accepted");
} catch (IllegalStateException e) {
t.line("server.expect(...) -> IllegalStateException: %s", e.getMessage());
}
t.save();
}
}
@@ -0,0 +1,24 @@
package com.ankurm.hsc;
import com.ankurm.hsc.stub.StubServer;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
/** Base class: an in-process stub upstream on a random port, wired into the app through stub.url. */
@SpringBootTest
abstract class StubBackedTest {
protected static final StubServer STUB = StubServer.shared();
@DynamicPropertySource
static void stub(DynamicPropertyRegistry registry) {
registry.add("stub.url", STUB::url);
}
@BeforeEach
void resetStub() {
STUB.reset();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hsc;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects lines while a test runs and writes them to output/NN-name.txt, so the post quotes real runs. */
public final class Transcript {
private final Path file;
private final List<String> lines = new ArrayList<>();
public Transcript(String name) {
this.file = Path.of("output", name);
}
public Transcript line(String format, Object... args) {
lines.add(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
lines.add("");
return this;
}
public void save() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}