New posts

This commit is contained in:
2026-11-06 21:44:06 +05:30
parent dc13293046
commit 8da3374155
51 changed files with 2099 additions and 0 deletions
@@ -0,0 +1,70 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.ParamsClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import java.util.List;
import java.util.TreeSet;
import static org.assertj.core.api.Assertions.assertThat;
class Step01RegistryAndCrudTest extends StubBackedTest {
@Autowired
PostClient posts;
@Autowired
HttpServiceProxyRegistry registry;
@Autowired
ParamsClient params;
@Test
void groupsAndCrud() {
Transcript t = new Transcript("01-registry-and-crud.txt");
t.line("# groups the HttpServiceProxyRegistry knows about");
for (String g : new TreeSet<>(registry.getGroupNames())) {
t.line("group %-7s -> %s", g, registry.getClientTypesInGroup(g).stream().map(Class::getSimpleName).sorted().toList());
}
assertThat(registry.getGroupNames()).containsExactlyInAnyOrder("posts", "github", "flaky", "resilient", "bearer", "apikey", "oauth");
t.line("PostClient bean is a JDK proxy: %s", java.lang.reflect.Proxy.isProxyClass(posts.getClass()));
assertThat(registry.getClient("posts", PostClient.class)).isSameAs(posts);
t.line("registry.getClient(\"posts\", PostClient.class) is the injected bean: true");
t.blank().line("# CRUD against the stub (every call is a real HTTP request)");
List<Post> all = posts.findAll();
t.line("findAll() -> %d posts", all.size());
Post one = posts.findById(1);
t.line("findById(1) -> %s", one);
t.line("findByUser(2) -> %s", posts.findByUser(2).stream().map(Post::id).toList());
ResponseEntity<Post> entity = posts.findEntity(2);
t.line("findEntity(2) -> status %s, Content-Type %s", entity.getStatusCode(), entity.getHeaders().getContentType());
Post created = posts.create(new Post(null, 1, "HTTP Service Clients", "Spring Boot 4 is amazing!"));
t.line("create(...) -> id %d", created.id());
Post updated = posts.update(1, new Post(1, 1, "Updated Title", "Updated body"));
t.line("update(1, ...) -> title '%s'", updated.title());
posts.delete(1);
t.line("delete(1) -> void, no exception");
t.blank().line("# argument kinds (ParamsClient)");
t.line("@RequestHeader -> upstream saw X-Correlation-Id=%s", params.withHeader("abc-123").get("X-Correlation-Id"));
t.line("@RequestParam(required=false), null -> %d posts", params.optionalParam(null).size());
t.line("@RequestParam(defaultValue=\"2\") -> posts %s", params.defaultedParam(null).stream().map(Post::id).toList());
t.line("URI argument -> %s", params.byUri(java.net.URI.create(STUB.url() + "/posts/2")).title());
t.blank().line("# what the stub actually received");
STUB.requests().forEach(r -> t.line("%s", r));
t.save();
assertThat(all).hasSize(3);
assertThat(created.id()).isEqualTo(101);
assertThat(updated.title()).isEqualTo("Updated Title");
assertThat(STUB.requests()).contains("GET /posts?userId=2", "DELETE /posts/1");
}
}
@@ -0,0 +1,114 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.ImportHttpServices;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Checks claims from the first version of the article: which property keys really configure a group,
* what happens without a base URL, and a few "common mistakes" -- each one executed, not assumed.
*/
class Step02PropertiesAndPitfallsTest {
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@ImportHttpServices(group = "legacy", types = PostClient.class)
static class MiniApp {
}
interface WrongShape {
@GetExchange("/posts")
com.ankurm.hsc.model.Post list();
}
interface UsesRequestMapping {
@GetMapping("/posts")
String list();
}
private static ConfigurableApplicationContext start(String... props) {
return new SpringApplicationBuilder(MiniApp.class)
.web(WebApplicationType.NONE)
.properties(props)
.run();
}
private static Throwable rootCause(Throwable e) {
Throwable c = e;
while (c.getCause() != null) {
c = c.getCause();
}
return c;
}
private static String outcome(Supplier<Object> call) {
try {
Object r = call.get();
return "OK -> " + r;
} catch (Throwable e) {
String msg = String.valueOf(e.getMessage());
return e.getClass().getName() + ": " + (msg.length() > 140 ? msg.substring(0, 140) + "..." : msg);
}
}
@Test
void whichKeysWork() {
Transcript t = new Transcript("02-properties-and-pitfalls.txt");
String url = StubBackedTest.STUB.url();
String sep = "-".repeat(4);
t.line("# A. the keys the article's first version used (spring.http.client.service.group.<g>.base-url)");
try (var ctx = start("spring.http.client.service.group.legacy.base-url=" + url)) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).doesNotStartWith("OK");
}
t.blank().line("# B. the keys Spring Boot 4.1.2 binds (spring.http.serviceclient.<g>.base-url)");
try (var ctx = start("spring.http.serviceclient.legacy.base-url=" + url)) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).startsWith("OK");
}
t.blank().line("# C. no base URL at all");
try (var ctx = start()) {
PostClient c = ctx.getBean(PostClient.class);
String r = outcome(() -> c.findAll().size());
t.line("findAll() -> %s", r);
assertThat(r).doesNotStartWith("OK");
}
t.blank().line("# D. a return type that does not match the JSON (array decoded as a single Post)");
RestClient rc = RestClient.builder().baseUrl(url).build();
HttpServiceProxyFactory f = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rc)).build();
WrongShape wrong = f.createClient(WrongShape.class);
t.line("list() -> %s", outcome(wrong::list));
try {
wrong.list();
} catch (RuntimeException e) {
t.line("root cause class: %s", rootCause(e).getClass().getName());
}
t.blank().line("# E. @GetMapping on a client interface instead of @GetExchange");
t.line("createClient(UsesRequestMapping.class).list() -> %s", outcome(() -> f.createClient(UsesRequestMapping.class).list()));
t.save();
}
}
@@ -0,0 +1,108 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.error.UpstreamNotFoundException;
import com.ankurm.hsc.error.UpstreamProblemStatusHandler;
import com.ankurm.hsc.error.UpstreamUnavailableException;
import com.ankurm.hsc.error.UpstreamValidationException;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ProblemDetail;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.NotFoundRestClientAdapterDecorator;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Step03ErrorHandlingTest extends StubBackedTest {
@Autowired
PostClient groupPosts; // "posts" group: has our defaultStatusHandler
@Autowired
FlakyClient groupFlaky; // "flaky" group: no handler, framework defaults
private static <T> T thrown(Supplier<Object> call, Class<T> type) {
try {
Object r = call.get();
throw new AssertionError("expected " + type.getSimpleName() + " but got " + r);
} catch (Throwable e) {
assertThat(e).isInstanceOf(type);
return type.cast(e);
}
}
private HttpServiceProxyFactory.Builder plainFactory(String baseUrl) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(RestClient.builder().baseUrl(baseUrl).build()));
}
@Test
void defaultsAndCustomHandlers() {
Transcript t = new Transcript("03-error-handling.txt");
t.line("# 1. framework defaults (a plain RestClient, no status handler)");
PostClient plain = plainFactory(STUB.url()).build().createClient(PostClient.class);
HttpClientErrorException nf = thrown(() -> plain.findById(99), HttpClientErrorException.NotFound.class);
t.line("findById(99) -> %s", nf.getClass().getName().replace("org.springframework.web.client.", ""));
t.line(" status : %s", nf.getStatusCode());
t.line(" message : %s", nf.getMessage());
t.line(" content-type: %s", nf.getResponseHeaders().getContentType());
ProblemDetail pd = nf.getResponseBodyAs(ProblemDetail.class);
t.line(" getResponseBodyAs(ProblemDetail): title='%s' detail='%s' type=%s", pd.getTitle(), pd.getDetail(), pd.getType());
assertThat(pd.getStatus()).isEqualTo(404);
HttpServerErrorException se = thrown(groupFlaky::alwaysDown, HttpServerErrorException.ServiceUnavailable.class);
t.line("alwaysDown() -> HttpServerErrorException.ServiceUnavailable, status %s", se.getStatusCode());
t.blank().line("# 2. a response that never comes: read timeout is 1500ms on the flaky group, /slow takes 3000ms");
ResourceAccessException timeout = thrown(groupFlaky::slow, ResourceAccessException.class);
t.line("slow() -> ResourceAccessException, cause %s", timeout.getCause().getClass().getName());
t.line(" message: %s", timeout.getMessage().replaceAll(":\\d+/", ":<port>/"));
t.blank().line("# 3. nobody listening");
PostClient dead = plainFactory("http://127.0.0.1:1").build().createClient(PostClient.class);
ResourceAccessException refused = thrown(dead::findAll, ResourceAccessException.class);
t.line("findAll() -> ResourceAccessException, cause %s", refused.getCause().getClass().getName());
t.blank().line("# 4. Optional<Post> and ResponseEntity<Post> on a 404 with the default handler");
t.line("findOptional(99) -> %s", thrown(() -> plain.findOptional(99), HttpClientErrorException.class).getClass().getSimpleName());
t.line("findEntity(99) -> %s", thrown(() -> plain.findEntity(99), HttpClientErrorException.class).getClass().getSimpleName());
t.blank().line("# 5. NotFoundRestClientAdapterDecorator (spring-web 7.0.10) turns a 404 into an empty result");
PostClient lenient = plainFactory(STUB.url()).exchangeAdapterDecorator(NotFoundRestClientAdapterDecorator::new)
.build().createClient(PostClient.class);
t.line("findById(99) -> %s", lenient.findById(99));
t.line("findOptional(99) -> %s", lenient.findOptional(99));
t.line("findEntity(99) -> status %s, body %s", lenient.findEntity(99).getStatusCode(), lenient.findEntity(99).getBody());
assertThat(lenient.findOptional(99)).isEmpty();
t.blank().line("# 6. our own defaultStatusHandler on the \"posts\" group (UpstreamProblemStatusHandler)");
UpstreamNotFoundException unf = thrown(() -> groupPosts.findById(99), UpstreamNotFoundException.class);
t.line("findById(99) -> UpstreamNotFoundException: %s", unf.getMessage());
t.line(" problem.type=%s instance=%s", unf.problem().getType(), unf.problem().getInstance());
UpstreamValidationException uve = thrown(() -> groupPosts.create(new Post(null, 1, "", "x")), UpstreamValidationException.class);
t.line("create(blank title) -> UpstreamValidationException: %s", uve.getMessage());
t.line(" extension 'errors' = %s", uve.properties().get("errors"));
PostClient handled = plainFactory(STUB.url()).build().createClient(PostClient.class);
FlakyClient flakyHandled = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url())
.defaultStatusHandler(s -> s.isError(), new UpstreamProblemStatusHandler()).build()))
.build().createClient(FlakyClient.class);
UpstreamUnavailableException uue = thrown(flakyHandled::alwaysDown, UpstreamUnavailableException.class);
t.line("alwaysDown() -> UpstreamUnavailableException: %s", uue.getMessage());
assertThat(handled).isNotNull();
t.save();
assertThatThrownBy(() -> groupPosts.findById(99)).isInstanceOf(UpstreamNotFoundException.class);
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hsc;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@AutoConfigureMockMvc
class Step04ControllerAdviceTest extends StubBackedTest {
@Autowired
MockMvc mvc;
private void call(Transcript t, String label, org.springframework.test.web.servlet.RequestBuilder rb, int expected) throws Exception {
MvcResult r = mvc.perform(rb).andReturn();
t.line("%-22s -> %d %s", label, r.getResponse().getStatus(), r.getResponse().getContentAsString());
assertThat(r.getResponse().getStatus()).isEqualTo(expected);
}
@Test
void upstreamFailuresBecomeOurOwnProblemDetails() throws Exception {
Transcript t = new Transcript("04-controller-advice.txt");
t.line("# our service (MockMvc, real HTTP to the stub behind it)");
call(t, "GET /api/posts/1", get("/api/posts/1"), 200);
call(t, "GET /api/posts/99", get("/api/posts/99"), 404);
call(t, "POST blank title", post("/api/posts").contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":1,\"title\":\"\",\"body\":\"x\"}"), 400);
call(t, "GET /api/down", get("/api/down"), 502);
call(t, "GET /api/slow", get("/api/slow"), 504);
t.save();
}
}
@@ -0,0 +1,137 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.FlakyClient;
import com.ankurm.hsc.client.ResilientClient;
import com.ankurm.hsc.config.RetryingInterceptor;
import com.ankurm.hsc.model.Post;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class Step05RetryTest extends StubBackedTest {
@Autowired
FlakyClient annotated; // bean from the "flaky" group: @Retryable on its methods
@Autowired
ResilientClient viaInterceptor; // bean from the "resilient" group: retry lives in the interceptor
@Autowired
RetryingInterceptor interceptor;
private static String exceptionLine(Throwable e) {
return e.getClass().getName().replace("org.springframework.web.client.", "") + " / " + e.getMessage().replaceAll(":\\d+/", ":<port>/");
}
@Test
void retryStyles() {
Transcript t = new Transcript("05-retries.txt");
// a FlakyClient that is NOT a bean, so no @Retryable advice applies: used for the by-hand styles
FlakyClient plain = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url()).build())).build().createClient(FlakyClient.class);
t.line("# 1. RetryTemplate around the call (org.springframework.core.retry, spring-core 7.0.10)");
List<String> events = new ArrayList<>();
RetryListener listener = new RetryListener() {
@Override
public void beforeRetry(RetryPolicy policy, Retryable<?> retryable, RetryState state) {
events.add("beforeRetry");
}
@Override
public void onRetrySuccess(RetryPolicy policy, Retryable<?> retryable, Object result) {
events.add("onRetrySuccess");
}
@Override
public void onRetryFailure(RetryPolicy policy, Retryable<?> retryable, Throwable throwable) {
events.add("onRetryFailure(" + throwable.getClass().getSimpleName() + ")");
}
};
RetryTemplate template = new RetryTemplate(RetryPolicy.builder()
.maxRetries(3).delay(Duration.ofMillis(20)).includes(HttpServerErrorException.class).build());
template.setRetryListener(listener);
Post ok = template.invoke(() -> plain.get("tpl", 2));
t.line("get(\"tpl\", failures=2) -> '%s'", ok.title());
t.line("stub saw %d calls; listener events: %s", STUB.count("flaky-tpl"), events);
assertThat(STUB.count("flaky-tpl")).isEqualTo(3);
RetryTemplate twice = new RetryTemplate(RetryPolicy.builder()
.maxRetries(2).delay(Duration.ofMillis(20)).includes(HttpServerErrorException.class).build());
try {
twice.invoke(() -> plain.get("exhaust", 10));
throw new AssertionError("expected failure");
} catch (RuntimeException e) {
t.line("maxRetries(2) against a dead upstream -> %s", exceptionLine(e));
t.line("stub saw %d calls", STUB.count("flaky-exhaust"));
}
try {
twice.execute(() -> plain.get("exhaust2", 10));
} catch (RetryException e) {
t.line("execute() instead of invoke() -> RetryException, retryCount=%d, exceptions=%d, last=%s",
e.getRetryCount(), e.getExceptions().size(), e.getLastException().getClass().getSimpleName());
}
t.blank().line("# 2. @Retryable on the @HttpExchange interface method (bean from the \"flaky\" group)");
Post p2 = annotated.get("ann", 2);
t.line("get(\"ann\", failures=2) -> '%s'; stub saw %d calls", p2.title(), STUB.count("flaky-ann"));
assertThat(STUB.count("flaky-ann")).isEqualTo(3);
try {
annotated.get("ann-dead", 10);
} catch (RuntimeException e) {
t.line("get(\"ann-dead\", failures=10) -> %s", exceptionLine(e));
t.line("stub saw %d calls (maxRetries = 3)", STUB.count("flaky-ann-dead"));
}
t.blank().line("# 3. the same retry as a ClientHttpRequestInterceptor on the \"resilient\" group (idempotent methods only)");
Post p3 = viaInterceptor.get("icpt", 2);
t.line("get(\"icpt\", failures=2) -> '%s'; stub saw %d calls; interceptor attempts so far: %d",
p3.title(), STUB.count("flaky-icpt"), interceptor.attempts());
assertThat(STUB.count("flaky-icpt")).isEqualTo(3);
try {
viaInterceptor.createUnstable(new Post(null, 1, "x", "y"));
} catch (RuntimeException e) {
t.line("POST createUnstable -> %s", exceptionLine(e));
t.line("stub created the resource %d time(s): the interceptor did not retry a POST", STUB.count("unstable-created"));
assertThat(STUB.count("unstable-created")).isEqualTo(1);
}
t.blank().line("# 4. the trap: @Retryable on a POST whose response was lost after the server did the work");
STUB.reset();
Post p4 = annotated.createUnstable(new Post(null, 1, "x", "y"));
t.line("createUnstable() -> id %d, body '%s'", p4.id(), p4.body());
t.line("stub created the resource %d times for ONE logical call", STUB.count("unstable-created"));
assertThat(STUB.count("unstable-created")).isEqualTo(2);
t.blank().line("# 5. Resilience4j 2.4.0 around the same plain client");
Retry retry = Retry.of("r4j", RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(20))
.retryExceptions(HttpServerErrorException.class).build());
Post p5 = Retry.decorateSupplier(retry, () -> plain.get("r4j", 2)).get();
t.line("maxAttempts(3), failures=2 -> '%s'; stub saw %d calls", p5.title(), STUB.count("flaky-r4j"));
assertThat(STUB.count("flaky-r4j")).isEqualTo(3);
try {
Retry.decorateSupplier(Retry.of("r4j2", RetryConfig.custom().maxAttempts(3).waitDuration(Duration.ofMillis(20))
.retryExceptions(HttpServerErrorException.class).build()), () -> plain.get("r4j-dead", 10)).get();
} catch (RuntimeException e) {
t.line("maxAttempts(3) against a dead upstream -> %s; stub saw %d calls", e.getClass().getSimpleName(), STUB.count("flaky-r4j-dead"));
}
t.save();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.ApiKeyClient;
import com.ankurm.hsc.client.SecureClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class Step06AuthTest extends StubBackedTest {
@Autowired
SecureClient bearer;
@Autowired
ApiKeyClient apiKey;
@Test
void bearerAndApiKey() {
Transcript t = new Transcript("06-auth-bearer-and-api-key.txt");
t.line("# 1. no credentials (a plain RestClient)");
SecureClient anonymous = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(
RestClient.builder().baseUrl(STUB.url()).build())).build().createClient(SecureClient.class);
try {
anonymous.whoAmI();
} catch (HttpClientErrorException.Unauthorized e) {
t.line("whoAmI() -> 401 Unauthorized, WWW-Authenticate: %s", e.getResponseHeaders().getFirst("WWW-Authenticate"));
}
t.blank().line("# 2. bearer token added by a ClientHttpRequestInterceptor on the \"bearer\" group");
t.line("whoAmI() -> %s", bearer.whoAmI());
Map<String, String> seen = bearer.echoHeaders();
t.line("headers the upstream saw: Authorization=%s, X-Correlation-Id present=%s (%d chars)",
seen.get("Authorization"), seen.containsKey("X-Correlation-Id"), seen.get("X-Correlation-Id").length());
assertThat(seen.get("Authorization")).isEqualTo("Bearer <redacted>");
t.blank().line("# 3. API key from spring.http.serviceclient.apikey.default-header.X-Api-Key (no Java at all)");
t.line("whoAmI() -> %s", apiKey.whoAmI());
Map<String, String> seen2 = apiKey.echoHeaders();
t.line("headers the upstream saw: X-Api-Key=%s, X-Correlation-Id present=%s", seen2.get("X-Api-Key"), seen2.containsKey("X-Correlation-Id"));
assertThat(seen2.get("X-Api-Key")).isEqualTo("demo-key-123");
t.blank().line("# 4. the interceptor is per group: the \"apikey\" group never sends Authorization");
t.line("Authorization header on the apikey group: %s", seen2.get("Authorization"));
assertThat(seen2).doesNotContainKey("Authorization");
t.save();
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.OAuthClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class Step07OAuth2Test extends StubBackedTest {
@Autowired
OAuthClient oauth;
@Test
void clientCredentialsTokenIsFetchedOnceAndAttached() {
Transcript t = new Transcript("07-oauth2-client-credentials.txt");
t.line("# @ClientRegistrationId(\"stub\") on the interface + OAuth2RestClientHttpServiceGroupConfigurer");
t.line("call 1: whoAmI() -> %s", oauth.whoAmI());
t.line("call 2: whoAmI() -> %s", oauth.whoAmI());
t.line("call 3: whoAmI() -> %s", oauth.whoAmI());
t.blank().line("# what the stub saw, in order");
STUB.requests().forEach(r -> t.line("%s", r));
t.line("token requests: %d", STUB.count("token-requests"));
t.blank().line("# the same interface WITHOUT the group configurer: the annotation is silently ignored");
OAuthClient bare = org.springframework.web.service.invoker.HttpServiceProxyFactory.builderFor(
org.springframework.web.client.support.RestClientAdapter.create(
org.springframework.web.client.RestClient.builder().baseUrl(STUB.url()).build()))
.build().createClient(OAuthClient.class);
try {
bare.whoAmI();
} catch (org.springframework.web.client.HttpClientErrorException e) {
t.line("whoAmI() -> %s", e.getStatusCode());
}
t.save();
assertThat(STUB.count("token-requests")).isEqualTo(1);
}
}
@@ -0,0 +1,84 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.error.UpstreamNotFoundException;
import com.ankurm.hsc.error.UpstreamProblemStatusHandler;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/** No Spring context, no network: MockRestServiceServer bound to the RestClient.Builder that backs the proxy. */
class Step08MockServerTest {
private static final String BASE = "http://upstream.test";
@Test
void mockedUpstream() {
Transcript t = new Transcript("08-mock-rest-service-server.txt");
RestClient.Builder builder = RestClient.builder().baseUrl(BASE)
.defaultStatusHandler(s -> s.isError(), new UpstreamProblemStatusHandler());
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
PostClient client = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build()))
.build().createClient(PostClient.class);
t.line("# 1. GET: URL, method and the decoded record");
server.expect(requestTo(BASE + "/posts/1")).andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"id\":1,\"userId\":1,\"title\":\"Test Title\",\"body\":\"Test body\"}", MediaType.APPLICATION_JSON));
Post post = client.findById(1);
server.verify();
t.line("findById(1) -> %s", post);
assertThat(post.title()).isEqualTo("Test Title");
t.blank().line("# 2. POST: the request body the interface sent");
server.reset();
server.expect(requestTo(BASE + "/posts")).andExpect(method(HttpMethod.POST))
.andExpect(header("Content-Type", "application/json"))
.andExpect(jsonPath("$.title").value("New Post"))
.andExpect(content().json("{\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"))
.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON)
.body("{\"id\":101,\"userId\":1,\"title\":\"New Post\",\"body\":\"Content\"}"));
Post created = client.create(new Post(null, 1, "New Post", "Content"));
server.verify();
t.line("create(...) -> id %d (request matched: path, method, Content-Type, JSON body)", created.id());
t.blank().line("# 3. a ProblemDetail 404 goes through the real status handler");
server.reset();
server.expect(requestTo(BASE + "/posts/9")).andRespond(withStatus(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body("{\"title\":\"Post not found\",\"status\":404,\"detail\":\"No post with id 9\"}"));
assertThatThrownBy(() -> client.findById(9)).isInstanceOf(UpstreamNotFoundException.class);
t.line("findById(9) -> UpstreamNotFoundException (handler decoded the body: '%s')", "No post with id 9");
t.blank().line("# 4. a 500 with no body: the handler falls back instead of crashing");
server.reset();
server.expect(requestTo(BASE + "/posts")).andRespond(withServerError());
try {
client.findAll();
} catch (RuntimeException e) {
t.line("findAll() -> %s: %s", e.getClass().getSimpleName(), e.getMessage());
}
t.blank().line("# 5. what MockRestServiceServer cannot tell you");
t.line("timeouts, connection resets, TLS and real serialisation quirks never happen here; see 03 and 05 for the real server.");
t.save();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import com.ankurm.hsc.model.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* @RestClientTest next to an @ImportHttpServices group: what binds to the auto-configured
* MockRestServiceServer and what does not.
*/
@RestClientTest(properties = "spring.http.serviceclient.slice.base-url=http://upstream.test")
@Import(Step09RestClientSliceTest.SliceGroups.class)
class Step09RestClientSliceTest {
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "slice", types = PostClient.class)
static class SliceGroups {
}
@Autowired
RestClient.Builder builder;
@Autowired
MockRestServiceServer server;
private static final String JSON = "{\"id\":1,\"userId\":1,\"title\":\"From the mock\",\"body\":\"b\"}";
@Test
void sliceWithHttpServiceGroup() {
Transcript t = new Transcript("09-rest-client-test-slice.txt");
t.line("# a proxy built from the slice's own RestClient.Builder, which the mock server IS bound to");
PostClient viaBuilder = HttpServiceProxyFactory.builderFor(
RestClientAdapter.create(builder.baseUrl("http://upstream.test").build())).build().createClient(PostClient.class);
server.expect(requestTo("http://upstream.test/posts/1")).andRespond(withSuccess(JSON, MediaType.APPLICATION_JSON));
Post p = viaBuilder.findById(1);
server.verify();
t.line("findById(1) -> %s", p);
t.save();
assertThat(p.title()).isEqualTo("From the mock");
}
}
@@ -0,0 +1,26 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.test.web.client.MockRestServiceServer;
/** The first version of the article wrote @RestClientTest(PostService.class) and autowired the interface. */
@RestClientTest(PostClient.class)
class Step09bOldClaimTest {
@Autowired(required = false)
PostClient client;
@Autowired(required = false)
MockRestServiceServer server;
@Test
void interfaceIsNotABeanJustBecauseItIsNamedInTheSlice() {
Transcript t = new Transcript("09b-rest-client-test-old-claim.txt");
t.line("@RestClientTest(PostClient.class), then @Autowired PostClient -> %s", client == null ? "no such bean (null)" : "bean present");
t.line("MockRestServiceServer bean present -> %s", server != null);
t.save();
}
}
@@ -0,0 +1,44 @@
package com.ankurm.hsc;
import com.ankurm.hsc.client.PostClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/** The obvious attempt: inject the group's client and the auto-configured MockRestServiceServer side by side. */
@RestClientTest(properties = "spring.http.serviceclient.slice.base-url=http://upstream.test")
@Import(Step09cGroupClientInSliceTest.SliceGroups.class)
class Step09cGroupClientInSliceTest {
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "slice", types = PostClient.class)
static class SliceGroups {
}
@Autowired
PostClient groupClient;
@Autowired
MockRestServiceServer server;
@Test
void groupClientIsNotBoundToTheMockServer() {
Transcript t = new Transcript("09c-rest-client-test-group-client.txt");
t.line("PostClient from the @ImportHttpServices group is injected: %s", groupClient != null);
try {
server.expect(requestTo("http://upstream.test/posts/1")).andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
t.line("server.expect(...) accepted");
} catch (IllegalStateException e) {
t.line("server.expect(...) -> IllegalStateException: %s", e.getMessage());
}
t.save();
}
}
@@ -0,0 +1,24 @@
package com.ankurm.hsc;
import com.ankurm.hsc.stub.StubServer;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
/** Base class: an in-process stub upstream on a random port, wired into the app through stub.url. */
@SpringBootTest
abstract class StubBackedTest {
protected static final StubServer STUB = StubServer.shared();
@DynamicPropertySource
static void stub(DynamicPropertyRegistry registry) {
registry.add("stub.url", STUB::url);
}
@BeforeEach
void resetStub() {
STUB.reset();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hsc;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects lines while a test runs and writes them to output/NN-name.txt, so the post quotes real runs. */
public final class Transcript {
private final Path file;
private final List<String> lines = new ArrayList<>();
public Transcript(String name) {
this.file = Path.of("output", name);
}
public Transcript line(String format, Object... args) {
lines.add(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
lines.add("");
return this;
}
public void save() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}