From 8da337415584f077247e2c962a7b3af64fd2fbb1 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 6 Nov 2026 21:44:06 +0530 Subject: [PATCH] New posts --- README.md | 1 + http-service-clients/README.md | 38 +++ .../output/01-registry-and-crud.txt | 38 +++ .../output/02-properties-and-pitfalls.txt | 15 + .../output/03-error-handling.txt | 30 ++ .../output/04-controller-advice.txt | 6 + http-service-clients/output/05-retries.txt | 24 ++ .../output/06-auth-bearer-and-api-key.txt | 13 + .../output/07-oauth2-client-credentials.txt | 14 + .../output/08-mock-rest-service-server.txt | 14 + .../output/09-rest-client-test-slice.txt | 2 + .../output/09b-rest-client-test-old-claim.txt | 2 + .../09c-rest-client-test-group-client.txt | 2 + http-service-clients/pom.xml | 64 ++++ http-service-clients/scripts/run-all.sh | 8 + http-service-clients/scripts/run-stub.sh | 8 + .../main/java/com/ankurm/hsc/Application.java | 8 + .../com/ankurm/hsc/client/ApiKeyClient.java | 16 + .../com/ankurm/hsc/client/FlakyClient.java | 32 ++ .../com/ankurm/hsc/client/GitHubClient.java | 24 ++ .../com/ankurm/hsc/client/OAuthClient.java | 16 + .../com/ankurm/hsc/client/ParamsClient.java | 29 ++ .../com/ankurm/hsc/client/PostClient.java | 45 +++ .../ankurm/hsc/client/ResilientClient.java | 20 ++ .../com/ankurm/hsc/client/SecureClient.java | 17 + .../hsc/config/BearerTokenInterceptor.java | 26 ++ .../ankurm/hsc/config/DemoSecurityConfig.java | 22 ++ .../ankurm/hsc/config/HttpClientsConfig.java | 113 +++++++ .../hsc/config/RetryingInterceptor.java | 69 ++++ .../ankurm/hsc/error/UpstreamController.java | 43 +++ .../hsc/error/UpstreamExceptionAdvice.java | 38 +++ .../hsc/error/UpstreamNotFoundException.java | 17 + .../error/UpstreamProblemStatusHandler.java | 46 +++ .../error/UpstreamUnavailableException.java | 16 + .../error/UpstreamValidationException.java | 24 ++ .../main/java/com/ankurm/hsc/model/Post.java | 4 + .../java/com/ankurm/hsc/stub/StubServer.java | 320 ++++++++++++++++++ .../src/main/resources/application.yml | 45 +++ .../ankurm/hsc/Step01RegistryAndCrudTest.java | 70 ++++ .../hsc/Step02PropertiesAndPitfallsTest.java | 114 +++++++ .../ankurm/hsc/Step03ErrorHandlingTest.java | 108 ++++++ .../hsc/Step04ControllerAdviceTest.java | 38 +++ .../java/com/ankurm/hsc/Step05RetryTest.java | 137 ++++++++ .../java/com/ankurm/hsc/Step06AuthTest.java | 55 +++ .../java/com/ankurm/hsc/Step07OAuth2Test.java | 37 ++ .../com/ankurm/hsc/Step08MockServerTest.java | 84 +++++ .../ankurm/hsc/Step09RestClientSliceTest.java | 55 +++ .../com/ankurm/hsc/Step09bOldClaimTest.java | 26 ++ .../hsc/Step09cGroupClientInSliceTest.java | 44 +++ .../java/com/ankurm/hsc/StubBackedTest.java | 24 ++ .../test/java/com/ankurm/hsc/Transcript.java | 38 +++ 51 files changed, 2099 insertions(+) create mode 100644 http-service-clients/README.md create mode 100644 http-service-clients/output/01-registry-and-crud.txt create mode 100644 http-service-clients/output/02-properties-and-pitfalls.txt create mode 100644 http-service-clients/output/03-error-handling.txt create mode 100644 http-service-clients/output/04-controller-advice.txt create mode 100644 http-service-clients/output/05-retries.txt create mode 100644 http-service-clients/output/06-auth-bearer-and-api-key.txt create mode 100644 http-service-clients/output/07-oauth2-client-credentials.txt create mode 100644 http-service-clients/output/08-mock-rest-service-server.txt create mode 100644 http-service-clients/output/09-rest-client-test-slice.txt create mode 100644 http-service-clients/output/09b-rest-client-test-old-claim.txt create mode 100644 http-service-clients/output/09c-rest-client-test-group-client.txt create mode 100644 http-service-clients/pom.xml create mode 100644 http-service-clients/scripts/run-all.sh create mode 100644 http-service-clients/scripts/run-stub.sh create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/Application.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/ApiKeyClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/FlakyClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/GitHubClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/OAuthClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/ParamsClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/PostClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/ResilientClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/client/SecureClient.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/config/BearerTokenInterceptor.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/config/DemoSecurityConfig.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/config/HttpClientsConfig.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/config/RetryingInterceptor.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamController.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamExceptionAdvice.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamNotFoundException.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamProblemStatusHandler.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamUnavailableException.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamValidationException.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/model/Post.java create mode 100644 http-service-clients/src/main/java/com/ankurm/hsc/stub/StubServer.java create mode 100644 http-service-clients/src/main/resources/application.yml create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step01RegistryAndCrudTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step02PropertiesAndPitfallsTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step03ErrorHandlingTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step04ControllerAdviceTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step05RetryTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step06AuthTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step07OAuth2Test.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step08MockServerTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step09RestClientSliceTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step09bOldClaimTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Step09cGroupClientInSliceTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/StubBackedTest.java create mode 100644 http-service-clients/src/test/java/com/ankurm/hsc/Transcript.java diff --git a/README.md b/README.md index f77d5ff..a06f8ef 100644 --- a/README.md +++ b/README.md @@ -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..*` (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 | diff --git a/http-service-clients/README.md b/http-service-clients/README.md new file mode 100644 index 0000000..b6b0f98 --- /dev/null +++ b/http-service-clients/README.md @@ -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..*`, 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 | diff --git a/http-service-clients/output/01-registry-and-crud.txt b/http-service-clients/output/01-registry-and-crud.txt new file mode 100644 index 0000000..3cb4ec9 --- /dev/null +++ b/http-service-clients/output/01-registry-and-crud.txt @@ -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 diff --git a/http-service-clients/output/02-properties-and-pitfalls.txt b/http-service-clients/output/02-properties-and-pitfalls.txt new file mode 100644 index 0000000..b9887c9 --- /dev/null +++ b/http-service-clients/output/02-properties-and-pitfalls.txt @@ -0,0 +1,15 @@ +# A. the keys the article's first version used (spring.http.client.service.group..base-url) +findAll() -> java.lang.IllegalArgumentException: URI with undefined scheme + +# B. the keys Spring Boot 4.1.2 binds (spring.http.serviceclient..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() diff --git a/http-service-clients/output/03-error-handling.txt b/http-service-clients/output/03-error-handling.txt new file mode 100644 index 0000000..2e92a56 --- /dev/null +++ b/http-service-clients/output/03-error-handling.txt @@ -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:/slow": Request cancelled + +# 3. nobody listening +findAll() -> ResourceAccessException, cause java.net.ConnectException + +# 4. Optional and ResponseEntity 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 diff --git a/http-service-clients/output/04-controller-advice.txt b/http-service-clients/output/04-controller-advice.txt new file mode 100644 index 0000000..6889430 --- /dev/null +++ b/http-service-clients/output/04-controller-advice.txt @@ -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"} diff --git a/http-service-clients/output/05-retries.txt b/http-service-clients/output/05-retries.txt new file mode 100644 index 0000000..ffb42d8 --- /dev/null +++ b/http-service-clients/output/05-retries.txt @@ -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 diff --git a/http-service-clients/output/06-auth-bearer-and-api-key.txt b/http-service-clients/output/06-auth-bearer-and-api-key.txt new file mode 100644 index 0000000..c25347b --- /dev/null +++ b/http-service-clients/output/06-auth-bearer-and-api-key.txt @@ -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 , 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 diff --git a/http-service-clients/output/07-oauth2-client-credentials.txt b/http-service-clients/output/07-oauth2-client-credentials.txt new file mode 100644 index 0000000..6db2b60 --- /dev/null +++ b/http-service-clients/output/07-oauth2-client-credentials.txt @@ -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 diff --git a/http-service-clients/output/08-mock-rest-service-server.txt b/http-service-clients/output/08-mock-rest-service-server.txt new file mode 100644 index 0000000..473c8a1 --- /dev/null +++ b/http-service-clients/output/08-mock-rest-service-server.txt @@ -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. diff --git a/http-service-clients/output/09-rest-client-test-slice.txt b/http-service-clients/output/09-rest-client-test-slice.txt new file mode 100644 index 0000000..eeaf9c0 --- /dev/null +++ b/http-service-clients/output/09-rest-client-test-slice.txt @@ -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] diff --git a/http-service-clients/output/09b-rest-client-test-old-claim.txt b/http-service-clients/output/09b-rest-client-test-old-claim.txt new file mode 100644 index 0000000..4316933 --- /dev/null +++ b/http-service-clients/output/09b-rest-client-test-old-claim.txt @@ -0,0 +1,2 @@ +@RestClientTest(PostClient.class), then @Autowired PostClient -> no such bean (null) +MockRestServiceServer bean present -> true diff --git a/http-service-clients/output/09c-rest-client-test-group-client.txt b/http-service-clients/output/09c-rest-client-test-group-client.txt new file mode 100644 index 0000000..db0524d --- /dev/null +++ b/http-service-clients/output/09c-rest-client-test-group-client.txt @@ -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 diff --git a/http-service-clients/pom.xml b/http-service-clients/pom.xml new file mode 100644 index 0000000..4d42895 --- /dev/null +++ b/http-service-clients/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + http-service-clients + 1.0.0 + http-service-clients + HTTP Service Clients (@HttpExchange + RestClient) on Spring Boot 4.1: groups, errors, retries, auth, testing + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-restclient + + + io.github.resilience4j + resilience4j-retry + 2.4.0 + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework.boot + spring-boot-starter-restclient-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/http-service-clients/scripts/run-all.sh b/http-service-clients/scripts/run-all.sh new file mode 100644 index 0000000..4441773 --- /dev/null +++ b/http-service-clients/scripts/run-all.sh @@ -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/" diff --git a/http-service-clients/scripts/run-stub.sh b/http-service-clients/scripts/run-stub.sh new file mode 100644 index 0000000..c566390 --- /dev/null +++ b/http-service-clients/scripts/run-stub.sh @@ -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}" diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/Application.java b/http-service-clients/src/main/java/com/ankurm/hsc/Application.java new file mode 100644 index 0000000..6dd11f6 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/Application.java @@ -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 { +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/ApiKeyClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/ApiKeyClient.java new file mode 100644 index 0000000..9fe7b1e --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/ApiKeyClient.java @@ -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 whoAmI(); + + @GetExchange("/echo/headers") + Map echoHeaders(); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/FlakyClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/FlakyClient.java new file mode 100644 index 0000000..56a3c1c --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/FlakyClient.java @@ -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); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/GitHubClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/GitHubClient.java new file mode 100644 index 0000000..b7a8bd1 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/GitHubClient.java @@ -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 getRepositories(@PathVariable String username); + + @GetExchange("/repos/{owner}/{repo}/milestones") + List getMilestones(@PathVariable String owner, @PathVariable String repo); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/OAuthClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/OAuthClient.java new file mode 100644 index 0000000..7b52ce9 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/OAuthClient.java @@ -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 whoAmI(); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/ParamsClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/ParamsClient.java new file mode 100644 index 0000000..dd3e83e --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/ParamsClient.java @@ -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 withHeader(@RequestHeader("X-Correlation-Id") String correlationId); + + @GetExchange("/posts") + List optionalParam(@RequestParam(required = false) Integer userId); + + @GetExchange("/posts") + List defaultedParam(@RequestParam(defaultValue = "2") Integer userId); + + /** A java.net.URI argument replaces the whole URL, base URL included. */ + @GetExchange + Post byUri(URI uri); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/PostClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/PostClient.java new file mode 100644 index 0000000..0c07f5a --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/PostClient.java @@ -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 findAll(); + + @GetExchange + List findByUser(@RequestParam Integer userId); + + @GetExchange("/{id}") + Post findById(@PathVariable Integer id); + + @GetExchange("/{id}") + ResponseEntity findEntity(@PathVariable Integer id); + + /** Used only to show that Optional does NOT turn a 404 into Optional.empty(). */ + @GetExchange("/{id}") + Optional 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); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/ResilientClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/ResilientClient.java new file mode 100644 index 0000000..0980ca9 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/ResilientClient.java @@ -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); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/client/SecureClient.java b/http-service-clients/src/main/java/com/ankurm/hsc/client/SecureClient.java new file mode 100644 index 0000000..a530f47 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/client/SecureClient.java @@ -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 whoAmI(); + + @GetExchange("/echo/headers") + Map echoHeaders(); +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/config/BearerTokenInterceptor.java b/http-service-clients/src/main/java/com/ankurm/hsc/config/BearerTokenInterceptor.java new file mode 100644 index 0000000..7da0da5 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/config/BearerTokenInterceptor.java @@ -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 " to every request, asking the supplier each time so tokens can rotate. */ +public class BearerTokenInterceptor implements ClientHttpRequestInterceptor { + + private final Supplier tokens; + + public BearerTokenInterceptor(Supplier 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); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/config/DemoSecurityConfig.java b/http-service-clients/src/main/java/com/ankurm/hsc/config/DemoSecurityConfig.java new file mode 100644 index 0000000..d0696bb --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/config/DemoSecurityConfig.java @@ -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(); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/config/HttpClientsConfig.java b/http-service-clients/src/main/java/com/ankurm/hsc/config/HttpClientsConfig.java new file mode 100644 index 0000000..76bedcc --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/config/HttpClientsConfig.java @@ -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.; 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); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/config/RetryingInterceptor.java b/http-service-clients/src/main/java/com/ankurm/hsc/config/RetryingInterceptor.java new file mode 100644 index 0000000..43375fb --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/config/RetryingInterceptor.java @@ -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); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamController.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamController.java new file mode 100644 index 0000000..c5bd5db --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamController.java @@ -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(); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamExceptionAdvice.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamExceptionAdvice.java new file mode 100644 index 0000000..527dc9d --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamExceptionAdvice.java @@ -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"); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamNotFoundException.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamNotFoundException.java new file mode 100644 index 0000000..2e82004 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamNotFoundException.java @@ -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; + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamProblemStatusHandler.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamProblemStatusHandler.java new file mode 100644 index 0000000..6a1f6e0 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamProblemStatusHandler.java @@ -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; + } + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamUnavailableException.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamUnavailableException.java new file mode 100644 index 0000000..f84e8a3 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamUnavailableException.java @@ -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; + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamValidationException.java b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamValidationException.java new file mode 100644 index 0000000..d5695ce --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/error/UpstreamValidationException.java @@ -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 properties() { + return problem.getProperties() == null ? Map.of() : problem.getProperties(); + } +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/model/Post.java b/http-service-clients/src/main/java/com/ankurm/hsc/model/Post.java new file mode 100644 index 0000000..aa62d90 --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/model/Post.java @@ -0,0 +1,4 @@ +package com.ankurm.hsc.model; + +public record Post(Integer id, Integer userId, String title, String body) { +} diff --git a/http-service-clients/src/main/java/com/ankurm/hsc/stub/StubServer.java b/http-service-clients/src/main/java/com/ankurm/hsc/stub/StubServer.java new file mode 100644 index 0000000..3896d6e --- /dev/null +++ b/http-service-clients/src/main/java/com/ankurm/hsc/stub/StubServer.java @@ -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 log = Collections.synchronizedList(new ArrayList<>()); + private final Map 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 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 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") : 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()); + } +} diff --git a/http-service-clients/src/main/resources/application.yml b/http-service-clients/src/main/resources/application.yml new file mode 100644 index 0000000..b76003f --- /dev/null +++ b/http-service-clients/src/main/resources/application.yml @@ -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 diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step01RegistryAndCrudTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step01RegistryAndCrudTest.java new file mode 100644 index 0000000..8d9479d --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step01RegistryAndCrudTest.java @@ -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 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 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"); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step02PropertiesAndPitfallsTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step02PropertiesAndPitfallsTest.java new file mode 100644 index 0000000..a6eeaba --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step02PropertiesAndPitfallsTest.java @@ -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 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..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..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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step03ErrorHandlingTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step03ErrorHandlingTest.java new file mode 100644 index 0000000..b311d58 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step03ErrorHandlingTest.java @@ -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 thrown(Supplier call, Class 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+/", ":/")); + + 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 and ResponseEntity 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); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step04ControllerAdviceTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step04ControllerAdviceTest.java new file mode 100644 index 0000000..fb5e552 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step04ControllerAdviceTest.java @@ -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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step05RetryTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step05RetryTest.java new file mode 100644 index 0000000..972ac10 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step05RetryTest.java @@ -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+/", ":/"); + } + + @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 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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step06AuthTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step06AuthTest.java new file mode 100644 index 0000000..f3be1d3 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step06AuthTest.java @@ -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 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 "); + + 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 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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step07OAuth2Test.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step07OAuth2Test.java new file mode 100644 index 0000000..864e401 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step07OAuth2Test.java @@ -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); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step08MockServerTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step08MockServerTest.java new file mode 100644 index 0000000..15cf482 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step08MockServerTest.java @@ -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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step09RestClientSliceTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step09RestClientSliceTest.java new file mode 100644 index 0000000..194bf84 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step09RestClientSliceTest.java @@ -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"); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step09bOldClaimTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step09bOldClaimTest.java new file mode 100644 index 0000000..a85f7ad --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step09bOldClaimTest.java @@ -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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Step09cGroupClientInSliceTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/Step09cGroupClientInSliceTest.java new file mode 100644 index 0000000..e4eae18 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Step09cGroupClientInSliceTest.java @@ -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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/StubBackedTest.java b/http-service-clients/src/test/java/com/ankurm/hsc/StubBackedTest.java new file mode 100644 index 0000000..4d30046 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/StubBackedTest.java @@ -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(); + } +} diff --git a/http-service-clients/src/test/java/com/ankurm/hsc/Transcript.java b/http-service-clients/src/test/java/com/ankurm/hsc/Transcript.java new file mode 100644 index 0000000..3f230c4 --- /dev/null +++ b/http-service-clients/src/test/java/com/ankurm/hsc/Transcript.java @@ -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 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); + } + } +}