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