New posts
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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/"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user