One client interface, every way to wire it up.
Every feature below works identically no matter how the client was constructed — a JDK dynamic proxy, a compile-time-generated class, or a Spring bean all resolve through the same RIP.getClient(...) choke point. What differs is how you configure and inject the client. Each entry shows the plain-Java form first, then every documented alternative — a different overload, a default, a collection form, or the Spring Boot starter's own way of expressing the same thing.
Amber snippets run in plain Java (or any JVM language). Green snippets are specific to the rest-in-peace-spring-boot-starter module. See the README for Maven/Gradle coordinates and the Javadoc for the full API surface.
How it compares
Placing REST-in-peace against the declarative Java HTTP clients you're probably already evaluating it next to. This is a scope comparison, not a maturity claim — Retrofit and Feign are both more established and more widely adopted today.
| REST-in-peace | Retrofit | Feign / OpenFeign | RestTemplate/WebClient | |
|---|---|---|---|---|
Declarative @RestClient-style interfaces | ✅ | ✅ | ✅ | ✅ via Spring HTTP Interfaces (@HttpExchange) |
Pagination helper (@Paginated → Page<T>/Stream<T>; Flux<T> via the separate rest-in-peace-reactor module) | ✅ built in | ❌ roll your own | ❌ roll your own | ❌ roll your own |
| Circuit breaker / bulkhead | ✅ built in | ❌ wire in Resilience4j yourself | ❌ standalone Feign: wire in feign-resilience4j yourself; Spring Cloud OpenFeign: add the CircuitBreaker starter + a property flag | ❌ wire in Resilience4j yourself |
HTTP-aware response caching (Cache-Control/ETag/stale-while-revalidate) | ✅ built in | ❌ configure OkHttp's cache + handle headers yourself | ❌ roll your own | ❌ roll your own |
| Bundled local test server | ✅ MockRestServer | OkHttp's MockWebServer (separate artifact) | needs WireMock (separate) | Spring's bundled MockRestServiceServer (a mock, not a real local server) |
| OpenAPI spec → client interface generator | ✅ bundled OpenApiClientGenerator | separate OpenAPI Generator template | separate OpenAPI Generator template | separate OpenAPI Generator template |
| Compile-time, reflection-free dispatch | ✅ optional annotation processor | runtime dynamic proxy only | runtime dynamic proxy only | runtime proxy (HttpServiceProxyFactory) |
| Pluggable reactive return types | CallAdapter SPI; Mono<T>/Flux<T> via rest-in-peace-reactor | ✅ via separate adapter artifacts | mainly synchronous | ✅ native (WebClient only) |
See the README's full "How it compares" section ↗ for the reasoning behind each row.
Declaring a client
Every client starts as an annotated interface. Where its base URL comes from is the first thing that varies by environment — and by whether Spring is involved.
@RestClient + @BaseUrl
A fixed base URL, known at compile time. Methods write relative paths against it.
@RestClient
@BaseUrl("https://api.example.com")
interface UserApi {
@GET("/users/{id}")
User getUser(@PathParam("id") String id);
}
UserApi api = RIP.getClient(UserApi.class);
default Optional tryGetUser(String id) {
try {
return Optional.of(getUser(id));
} catch (RestInPeaceHttpException e) {
return Optional.empty();
}
}
Base URL resolved at runtime
@BaseUrl must be a compile-time constant, so an env-dependent host bypasses it entirely.
@RestClient
interface UserApi {
@GET("/users/{id}")
User getUser(@PathParam("id") String id);
}
UserApi api = RIP.getClient(UserApi.class, System.getenv("USER_API_BASE_URL"));
UserApi prodApi = RIP.getClient(UserApi.class, RipClientConfig.builder()
.baseUrl(prodBaseUrl)
.connectTimeoutMillis(2_000)
.readTimeoutMillis(10_000)
.build());
@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi {
@GET("/users/{id}")
User getUser(@PathParam("id") String id);
}
user-api:
base-url: https://api.example.com
@SpringBootApplication
@EnableRestInPeaceClients
class Application { }
@Service
class Orders {
Orders(UserApi userApi) { ... } // injected — zero @Bean method
}
HTTP methods & URLs
One verb annotation per method, plus two ways to build the URL a request hits.
Method annotations
Exactly one of @GET @POST @PUT @PATCH @DELETE @HEAD @OPTIONS per method — none or more than one fails validation.
@GET("https://api.example.com/items/{id}")
String getItem(@PathParam("id") String id);
@DELETE("https://api.example.com/items/{id}")
void deleteItem(@PathParam("id") String id);
@PathParam
Fills a {placeholder} in the URL. Values are percent-encoded, so a stray / or ? never breaks the route.
@GET("https://api.example.com/items/{id}")
String getItem(@PathParam("id") String id);
@GET("https://api.example.com/items/{id}")
String getItem(String id); // missing @PathParam("id")
Fails validation: "has path param 'id' in its URL that is not annotated on any parameter with @PathParam." There's no implicit binding by position — every {placeholder} needs an explicit @PathParam.
@Url
For a URL that isn't a fixed template — a pagination next link, a HATEOAS action link.
@GET
Page- nextPage(@Url String url);
Page
- page = itemApi.firstPage();
while (page.next != null) {
page = itemApi.nextPage(page.next);
process(page.items);
}
@GET("https://api.example.com/items")
String get(@Url String url);
Fails validation: "has both a @Url parameter and a static URL ... - remove one or the other." Leave the method annotation's value empty (just @GET) when using @Url.
Query params & headers
Fixed-by-name params, open-ended maps for names known only at runtime, and headers that never change.
@QueryParam / @HeaderParam
required throws at call time with no value; defaultValue fills in for null.
@GET("https://api.example.com/items")
String search(@QueryParam(value = "q", required = true) String query,
@QueryParam(value = "page", defaultValue = "1") Integer page);
@GET("https://api.example.com/items")
String search(@QueryParam("tag") List tags);
@GET("https://api.example.com/items")
String search(@HeaderParam(value = "Authorization", required = true) String token);
api.search(null, 1);
Throws RestInPeaceException: "Missing required value for param 'q'." — required = true is enforced on every call, not just checked once at validation time.
@QueryMap / @HeaderMap
For names not known until runtime — an open-ended filter set, caller-supplied headers. One entry becomes one param; null entries are skipped, not thrown; a Collection entry value repeats the param once per element.
@GET("https://api.example.com/search")
String search(@QueryParam("q") String query, @QueryMap Map filters);
@GET("https://api.example.com/users/{id}")
User getUser(@PathParam("id") String id, @HeaderMap Map extraHeaders);
String search(@QueryMap Map<String, Object> a, @QueryMap Map<String, Object> b);
Fails validation: "has more than one parameter annotated with @QueryMap." Merge into one Map before calling instead.
@Headers
Fixed headers that are always the same value — split on the first :, whitespace trimmed.
@GET("https://api.example.com/users")
@Headers({ "Cache-Control: no-cache", "X-Api-Version: 2" })
List listUsers();
@Headers("X-Api-Version 2")
Fails validation: "has a @Headers entry 'X-Api-Version 2' with no ':' - expected 'Name: Value'." The single most common typo here.
Request bodies
Three mutually-exclusive body encodings — JSON/raw, multipart, and form-urlencoded — each valid only on POST/PUT/PATCH/DELETE.
@Body
A String goes as-is; anything else is JSON-serialized.
@POST("https://api.example.com/items")
String createItem(@Body Item item);
@POST("https://api.example.com/items/raw")
String createRaw(@Body String rawJson);
@POST("https://api.example.com/orders")
@Headers({ "Content-Type: application/xml" })
String createOrder(@Body Order order);
@GET("/items")
Item search(@Body SearchCriteria criteria);
Fails validation: "is annotated with @Body but HTTP method GET does not support a request body." Use @QueryParam/@QueryMap for GET filter criteria instead.
@Multipart / @Part / @PartMap
File uploads and classic form POSTs. File, byte[], and InputStream parts all become file parts; a plain String becomes a form field.
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatar(@PathParam("id") String id,
@Part("caption") String caption, @Part("file") File avatar);
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatarBytes(@PathParam("id") String id,
@Part(value = "file", fileName = "avatar.png") byte[] avatarBytes);
@POST("https://api.example.com/uploads")
@Multipart
String upload(@PartMap Map parts);
Map parts = new LinkedHashMap<>();
parts.put("caption", "vacation photo"); // plain field
parts.put("thumb", photoBytes); // filename defaults to "thumb"
parts.put("file", PartValue.of(photoBytes, "photo.jpg")); // explicit filename
api.upload(parts);
@POST("https://api.example.com/users/{id}/avatar")
@Multipart
String uploadAvatar(@PathParam("id") String id, @Part("file") File avatar,
UploadProgressListener onProgress);
api.uploadAvatar("42", avatar, (field, sent, total) ->
System.out.printf("%s: %d / %d%n", field, sent, total));
@POST("https://api.example.com/items")
String create(@Part("file") File f); // missing @Multipart
Fails validation: "has a @Part parameter but is not annotated with @Multipart." The single most common mistake with this feature — the two always travel together.
@FormUrlEncoded / @Field / @FieldMap
OAuth token endpoints and classic HTML forms — application/x-www-form-urlencoded, not JSON.
@POST("https://api.example.com/oauth/token")
@FormUrlEncoded
String getToken(@Field("grant_type") String grantType, @Field("client_id") String clientId);
@POST("https://api.example.com/search")
@FormUrlEncoded
String search(@FieldMap Map filters);
@POST("https://api.example.com/items")
@FormUrlEncoded
String create(@Body Item item, @Field("name") String name);
Fails validation: "is annotated with @FormUrlEncoded and also has a @Body parameter - use one or the other." A method has exactly one body-encoding strategy.
Return types
The declared return type is the whole decoding contract — no separate converter registration.
String / POJO / void
@GET("https://api.example.com/users/{id}")
String getUserRaw(@PathParam("id") String id); // raw response body
@GET("https://api.example.com/users/{id}")
User getUser(@PathParam("id") String id); // JSON deserialized into User
@POST("https://api.example.com/events")
void fireEvent(@Body Event event); // response discarded
Generic collections — List<T>
Decoded element-by-element into the declared type parameter, not left as raw maps. Also works inside RipResponse<T>/CompletableFuture<T>.
@GET("https://api.example.com/users")
List<User> listUsers();
@GET("https://api.example.com/users")
RipResponse<List<User>> listUsersWithResponse();
@GET("https://api.example.com/users")
CompletableFuture<List<User>> listUsersAsync();
@SuppressWarnings("rawtypes")
List listUsers();
Compiles, but decodes as a plain List of LinkedTreeMaps, not Users — ClassCastException the moment you treat an element as a User. No validation error for this one; always declare the type parameter.
Binary downloads: byte[] and File
byte[] buffers the whole response; File streams straight to disk for large payloads.
@GET("https://api.example.com/reports/{id}/pdf")
byte[] downloadReport(@PathParam("id") String id);
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id, @Destination File target);
File pdf = reportApi.downloadReport("42", new File("/tmp/report.pdf"));
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id, @Destination File target,
DownloadProgressListener onProgress);
reportApi.downloadReport("42", target, (sent, total) ->
System.out.printf("%d / %d%n", sent, total)); // total is -1 with no Content-Length
@GET("https://api.example.com/reports/{id}/pdf")
File downloadReport(@PathParam("id") String id); // missing @Destination
Fails validation: "returns File but has no @Destination parameter to write the response to." Unlike byte[], File needs somewhere to write the bytes.
RipResponse<T>
When a call needs the status code or a header too, not just the body.
@GET("https://api.example.com/users/{id}")
RipResponse getUser(@PathParam("id") String id);
RipResponse response = userApi.getUser("42");
response.getStatus(); // 200
response.getHeader("ETag"); // first value, case-insensitive
response.getHeaders(); // Map>
User user = response.getBody();
RipResponse<File> downloadReport(@PathParam("id") String id);
Fails validation: "returns RipResponse<File>, which is not supported - use a plain File return type with @Destination instead." RipResponse<byte[]> works fine if you need both the bytes and the headers.
Async — CompletableFuture<T>
Same decoding rules as a synchronous T, just non-blocking. Combines with byte[], File, and RipResponse<T> too.
@GET("https://api.example.com/users/{id}")
CompletableFuture getUserAsync(@PathParam("id") String id);
userApi.getUserAsync("42").thenAccept(user -> System.out.println(user.name));
RIP.useDaemonThreadsForAsync(); // call once at startup, before any async call
// A long-running Spring app has no reason to want non-daemon I/O threads
// outliving its own shutdown — the starter's auto-configuration calls
// RIP.useDaemonThreadsForAsync() automatically. No property to opt out;
// exclude RestInPeaceAutoConfiguration if you truly need the plain-Java default.
Pluggable CallAdapter SPI
For a return type RIP has no built-in support for — rest-in-peace-reactor's Mono<T>/Flux<T>, or your own — register a CallAdapterFactory that transforms the exact CompletableFuture RIP's async dispatch already produced. Zero hard dependency on whatever it adapts to.
public interface CallAdapter<T> {
Type responseBodyType(); // what to decode the response body into
T adapt(CompletableFuture<Object> delegate); // wraps the call RIP already dispatched
}
public interface CallAdapterFactory {
Optional<CallAdapter<?>> get(Method method); // recognize a method's return type, or decline
}
public final class OptionalCallAdapterFactory implements CallAdapterFactory {
@Override
public Optional<CallAdapter<?>> get(Method method) {
if (method.getReturnType() != Optional.class) return Optional.empty();
Type genericReturnType = method.getGenericReturnType();
if (!(genericReturnType instanceof ParameterizedType)) return Optional.empty();
Type innerType = ((ParameterizedType) genericReturnType).getActualTypeArguments()[0];
return Optional.of(new CallAdapter<Optional<Object>>() {
public Type responseBodyType() { return innerType; }
public Optional<Object> adapt(CompletableFuture<Object> delegate) {
try {
return Optional.ofNullable(delegate.join());
} catch (CompletionException e) {
if (e.getCause() instanceof RestInPeaceHttpException
&& ((RestInPeaceHttpException) e.getCause()).getStatus() == 404) {
return Optional.empty();
}
throw e;
}
}
});
}
}
RIP.addCallAdapterFactory(new OptionalCallAdapterFactory()); // once, at startup
@GET("/users/{id}")
Optional<User> findUser(@PathParam("id") String id);
public Optional<CallAdapter<?>> get(Method method) {
if (method.getReturnType() == byte[].class) {
return Optional.of(myByteArrayAdapter); // never actually consulted
}
return Optional.empty();
}
byte[]/File/CompletableFuture<T>/RipResponse<T> are checked before any CallAdapterFactory at dispatch time — a factory "claiming" one passes validation but is silently never consulted. Only claim a return type RIP has no built-in support for.
public Optional<Object> adapt(CompletableFuture<Object> delegate) {
return Optional.ofNullable(Unirest.get(someUrl).asString().getBody()); // never do this
}
Bypasses @Retry/cache/circuit-breaker/bulkhead/interceptors entirely for the second call. adapt must only ever transform delegate — never issue a new request.
Reactive — Mono<T> / Flux<T>
The rest-in-peace-reactor module adds a CallAdapterFactory for Project Reactor — add the dependency, call RestInPeaceReactor.register() once at startup, and any method can return Mono<T> or Flux<T> directly. Published on Maven Central alongside core. See the main README's Reactive (Project Reactor) section for the full walkthrough.
RestInPeaceReactor.register(); // once, at startup, before building any client
@GET("https://api.example.com/users/{id}")
Mono<User> getUser(@PathParam("id") String id); // also: Mono<Void>, Mono<byte[]>, Mono<RipResponse<T>>
@GET("https://api.example.com/orders")
Flux<Order> listOrders();
@GET("https://api.example.com/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Flux<Order> streamOrders(@QueryParam("cursor") @PaginationCursor String cursor);
Pagination
@Paginated follows a next-page pointer automatically instead of leaving the fetch-extract-repeat loop to hand-written code — page.next() re-invokes the exact same annotated method through the full existing call pipeline (@Retry, cache, circuit breaker, bulkhead, interceptors), exactly as if it were called again by hand.
@Paginated + Page<T>
The pointer is extracted from the response body and resent through whichever parameter carries @PaginationCursor.
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);
Page page = orderApi.listOrders(null);
while (true) {
for (Order order : page.items()) {
process(order);
}
if (!page.hasNext()) {
break;
}
page = page.next();
}
RipResponse meta = page.rawResponse();
meta.getStatus(); // 200
meta.getHeader("ETag");
@GET("/orders")
Page<Order> listOrders(); // missing @Paginated
Fails validation: "returns Page<T> but is not annotated with @Paginated and has no PaginationStrategy<T> parameter." Page<T> alone doesn't tell RIP how to find the next page.
Stream<T> / Iterator<T> auto-flattening
Same method, same annotation — just declare a different return type to flatten every page into one lazy sequence instead of managing Page<T> by hand. No network call happens until the first terminal operation / hasNext(), unlike Page<T>'s own eager first fetch.
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Stream streamOrders(@QueryParam("cursor") @PaginationCursor String cursor);
orderApi.streamOrders(null).forEach(this::process); // fetches pages on demand
long shipped = orderApi.streamOrders(null).filter(o -> "shipped".equals(o.status)).count();
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Iterator iterateOrders(@QueryParam("cursor") @PaginationCursor String cursor);
Iterator it = orderApi.iterateOrders(null);
while (it.hasNext()) {
process(it.next());
}
@GET // no @BaseUrl needed - a @Url parameter bypasses that check entirely
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page<Order> listOrders(@Url String url, @QueryParam("cursor") @PaginationCursor String cursor);
Fails validation: "is annotated with both @Paginated and @Url - remove one or the other."
Termination signals
An API that keeps the pointer populated even on the genuinely last page (Stripe, for one) needs an explicit stop condition instead of "the pointer is gone". Precedence when more than one is set: hasMore → total count → total pages → pointer presence, always under an empty-page safety net.
@GET("/charges")
@Paginated(itemsField = "data", pointerField = "next_cursor",
hasMoreSource = PaginationSignalSource.RESPONSE_BODY, hasMoreField = "has_more")
Page listCharges(@QueryParam("cursor") @PaginationCursor String cursor);
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor",
totalSource = PaginationSignalSource.RESPONSE_HEADER, totalField = "X-Total-Count")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);
@GET("/orders")
@Paginated(itemsField = "orders", pointerField = "next_cursor",
totalPagesSource = PaginationSignalSource.RESPONSE_BODY, totalPagesField = "total_pages")
Page listOrders(@QueryParam("cursor") @PaginationCursor String cursor);
@Paginated(itemsField = "orders", pointerField = "next_cursor",
hasMoreSource = PaginationSignalSource.ITEM_FIELD)
Fails validation: "sets hasMoreSource = ITEM_FIELD, which is only meaningful for pointerSource." ITEM_FIELD only makes sense for where the cursor itself comes from, not a has-more/total signal.
POST body cursor
For a cursor resent as a JSON request body field (Elasticsearch's search_after, DynamoDB's ExclusiveStartKey) rather than a query/path/header value — stack @PaginationCursor on a @Body Map<String,Object> parameter, with bodyField naming the dotted-path field to overwrite.
@POST("/orders/search")
@Paginated(itemsField = "orders", pointerField = "search_after")
Page searchOrders(@Body @PaginationCursor(bodyField = "search_after") Map body);
Map body = new HashMap<>();
body.put("filter", "region:eu");
body.put("size", 100);
Page page = orderApi.searchOrders(body);
// every field the caller put in `body` carries forward unchanged on every
// subsequent page; only `search_after` gets overwritten, `body` itself is never mutated
Page<Order> searchOrders(@Body @PaginationCursor Map<String, Object> body); // bodyField not set
Fails validation: "has a @PaginationCursor stacked on @Body but bodyField is empty - set it to the JSON path inside the body to write the next-page value to."
Keyset pagination & composite cursors
For a since_id/keyset-style API whose cursor isn't in the response envelope at all but derived from the last item on the page (Stripe, classic Twitter) — set pointerSource = ITEM_FIELD. A comma-separated pointerField covers an N-way composite key for a stable sort under concurrent writes.
@GET("/charges")
@Paginated(itemsField = "data", pointerSource = PaginationSignalSource.ITEM_FIELD, pointerField = "id")
Page listCharges(@QueryParam("starting_after") @PaginationCursor String startingAfter);
@GET("/events")
@Paginated(itemsField = "events", pointerSource = PaginationSignalSource.ITEM_FIELD, pointerField = "id,createdAt")
Page listEvents(@QueryParam("lastId") @PaginationCursor String lastId,
@QueryParam("lastTs") @PaginationCursor String lastTimestamp);
@Paginated(itemsField = "events", pointerSource = PaginationSignalSource.ITEM_FIELD,
pointerField = "id,createdAt") // 2 entries
Page<Event> listEvents(@QueryParam("lastId") @PaginationCursor String lastId); // only 1 @PaginationCursor
Fails validation: "needs 2 @PaginationCursor parameter(s) (matching pointerField's 2 comma-separated entries) - found 1."
RFC 8288 Link response header
GitHub/Shopify's Link: <url>; rel="next", <url>; rel="last" convention needs no special parsing beyond a FULL_URL pointer sourced from that header — RIP follows the rel="next" target until a page's Link header no longer has one.
@GET("/repos")
@Paginated(itemsField = "", pointerKind = PointerKind.FULL_URL,
pointerSource = PaginationSignalSource.RESPONSE_HEADER, pointerField = "Link")
Page listRepos();
@Paginated(itemsField = "", pointerKind = PointerKind.FULL_URL,
pointerSource = PaginationSignalSource.RESPONSE_HEADER, pointerField = "Link")
Page<Repo> listRepos(@QueryParam("cursor") @PaginationCursor String cursor);
Fails validation: "has pointerKind = FULL_URL but also has a @PaginationCursor parameter - a full URL is followed as-is, with nothing to inject."
Client-driven advance (no server pointer at all)
A homegrown endpoint that just expects the client to compute the next offset or page number itself, with no pointer to extract. INCREMENT_BY_PAGE_SIZE advances the offset by pageSize each fetch (stopping on a short page absent another signal); INCREMENT_BY_ONE advances the page number by one (stopping on an empty page).
@GET("/orders")
@Paginated(itemsField = "orders", pointerSource = PaginationSignalSource.NONE,
advance = PaginationAdvance.INCREMENT_BY_PAGE_SIZE, pageSize = 50,
totalSource = PaginationSignalSource.RESPONSE_HEADER, totalField = "X-Total-Count")
Page listOrders(@QueryParam("offset") @PaginationCursor int offset);
@GET("/orders")
@Paginated(itemsField = "orders", pointerSource = PaginationSignalSource.NONE,
advance = PaginationAdvance.INCREMENT_BY_ONE,
totalPagesSource = PaginationSignalSource.RESPONSE_BODY, totalPagesField = "total_pages")
Page listOrdersByPage(@QueryParam("page") @PaginationCursor int page);
@Paginated(itemsField = "orders", pointerField = "next_cursor",
advance = PaginationAdvance.INCREMENT_BY_ONE) // pointerSource defaults to RESPONSE_BODY, not NONE
Fails validation: "sets advance() but pointerSource is not NONE - advance() is only meaningful for client-driven pagination with no server-given pointer."
PaginationStrategy<T> — the programmatic escape hatch
For whatever the closed @Paginated attribute vocabulary can't reach — a cursor needing decoding before reuse, termination logic combining several signals with OR instead of the fixed precedence, a page count needing a separate API call. A plain parameter, no annotation, recognized by its declared type — the same idiom RIP already uses for CompletableFuture<T>/RipResponse<T> return types. Mutually exclusive with @Paginated on one method, and unlike it, the response body must itself be the JSON items array — there's no itemsField equivalent.
@GET("/orders")
Page listOrders(@QueryParam("status") String status, PaginationStrategy strategy);
PaginationStrategy strategy = ctx -> ctx.items().isEmpty() ? Optional.empty()
: Optional.of(PaginationRequest.withQueryParam("since",
ctx.items().get(ctx.items().size() - 1).getId()));
Page page = api.listOrders("active", strategy);
PaginationStrategy strategy = ctx -> {
String next = ctx.header("X-Next-Page-Url");
return next == null ? Optional.empty() : Optional.of(PaginationRequest.toUrl(next));
};
PaginationStrategy strategy = ctx -> {
if (ctx.items().isEmpty()) {
return Optional.empty();
}
Event last = ctx.items().get(ctx.items().size() - 1);
return Optional.of(PaginationRequest.withBodyField("lastId", last.getId())
.and(PaginationRequest.withBodyField("lastTimestamp", last.getCreatedAt())));
};
@Paginated(itemsField = "orders", pointerField = "next_cursor")
Page<Order> listOrders(@QueryParam("cursor") @PaginationCursor String cursor,
PaginationStrategy<Order> strategy);
Fails validation: "is annotated with @Paginated and also has a PaginationStrategy<T> parameter - use one or the other." Declarative and programmatic pagination are mutually exclusive.
Error handling & resilience
A non-2xx response always throws; retries and timeouts are declared per method.
RestInPeaceHttpException / @ErrorType
try {
User user = userApi.getUser("42");
} catch (RestInPeaceHttpException e) {
e.getStatus(); // 404
e.getRawBody(); // always available
}
@GET("/users/{id}")
@ErrorType(ApiError.class)
User getUser(@PathParam("id") String id);
catch (RestInPeaceHttpException e) {
ApiError error = e.getErrorBody(); // deserialized, not raw
System.out.println(error.code);
}
catch (RestInPeaceHttpException e) {
if (e.isServerError()) { scheduleRetryLater(); } // 500-599
else if (e.isClientError()) { throw new IllegalArgumentException(); } // 400-499
}
// Wrong - e.is(429) never runs
if (e.isClientError()) { ... }
else if (e.is(429)) { ... }
// Right - check the specific case first
if (e.is(429)) { ... }
else if (e.isClientError()) { ... }
catch (RestInPeaceHttpException e) {
if (e.is(429) && e.getRetryAfterMillis() != null) {
Thread.sleep(e.getRetryAfterMillis());
return retryManually();
}
}
@Retry & idempotency keys
Retries on transport errors or a configurable status set; honors a response's own Retry-After header when present; idempotent=true makes retrying a POST safe too.
@GET("https://api.example.com/users/{id}")
@Retry(times = 3, delayMillis = 200, backoffMultiplier = 2.0)
User getUser(@PathParam("id") String id);
@Retry(times = 3, delayMillis = 500, backoffMultiplier = 1.0) // no exponential backoff
@Retry(times = 3, delayMillis = 200, backoffMultiplier = 2.0, jitterFactor = 0.2)
@POST("https://api.example.com/charges")
@Retry(times = 3, delayMillis = 200, idempotent = true)
String createCharge(@Body Charge charge);
@RestClient
@Retry(times = 3, delayMillis = 200, retryOnStatus = { 503 })
public interface UserApi {
@GET("/users/{id}")
User getUser(@PathParam("id") String id); // uses the interface's @Retry
}
RIP.getClient(UserApi.class, RipClientConfig.builder()
.retry(RetryConfig.builder().times(3).delayMillis(200).retryOnStatus(503).build())
.build());
RIP.getClient(UserApi.class, RipClientConfig.builder()
.retryBudget(50, TimeUnit.MINUTES.toMillis(1)) // at most 50 retries/minute, client-wide
.build());
// A token bucket, not a once-a-minute burst - refills continuously.
// Once exhausted, a call that would retry instead gives up immediately,
// exactly like reaching its own @Retry times().
@Timeout
Overrides connect/read timeout for one method only. Priority: method @Timeout > interface-level @Timeout > RipClientConfig > shared client default.
@GET("https://api.example.com/reports/export")
@Timeout(readMillis = 120_000)
String exportReport();
@RestClient
@Timeout(readMillis = 50)
public interface SlowApi {
@GET("/slow/{id}")
String getSlow(@PathParam("id") String id); // uses the interface's @Timeout
}
@Timeout(readMillis = -5000)
Fails validation: "readMillis must be -1 (unset) or a non-negative number of milliseconds." Use -1 (or just omit the attribute) to mean "unset" — never another negative number.
Circuit breaker
Once a client's failure rate crosses a threshold, every call fails fast with CircuitOpenException for a cooldown period instead of paying for a timeout/@Retry loop that would fail anyway.
RIP.getClient(UserApi.class, RipClientConfig.builder()
.circuitBreaker(CircuitBreakerConfig.builder()
.slidingWindowSize(20) // the last 20 calls
.minimumNumberOfCalls(10) // don't evaluate a rate below this
.failureRateThreshold(50) // trip at 50% failures
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedCallsInHalfOpenState(3)
.build())
.build());
CircuitBreakerConfig.builder().slidingWindowSize(Duration.ofSeconds(30))...
CircuitBreaker r4jBreaker = CircuitBreaker.ofDefaults("payment-api");
RipClientConfig config = RipClientConfig.builder().circuitBreaker(new CircuitBreakerProvider() {
public boolean tryAcquirePermission() { return r4jBreaker.tryAcquirePermission(); }
public void onSuccess(long durationNanos, int statusCode) {
if (statusCode >= 500) r4jBreaker.onError(durationNanos, TimeUnit.NANOSECONDS, new RuntimeException("HTTP " + statusCode));
else r4jBreaker.onSuccess(durationNanos, TimeUnit.NANOSECONDS);
}
public void onError(long durationNanos, Throwable t) { r4jBreaker.onError(durationNanos, TimeUnit.NANOSECONDS, t); }
}).build();
UserApi api = RIP.getClient(UserApi.class, config);
CircuitBreakerConfig.builder()
.slidingWindowSize(5)
.minimumNumberOfCalls(10) // can never be reached by a window of 5
.build();
Not rejected by validation — both values are independently valid — but the breaker can never evaluate a failure rate at all, since the window never holds enough calls to reach minimumNumberOfCalls. Keep it ≤ slidingWindowSize.
CircuitBreakerConfig.builder().failureRateThreshold(0).build();
CircuitBreakerConfig.builder().failureRateThreshold(101).build();
Both throw IllegalArgumentException: "failureRateThreshold must be between 1 and 100 inclusive." — same pattern for slidingWindowSize(0), minimumNumberOfCalls(0), waitDurationInOpenState(Duration.ZERO), and permittedCallsInHalfOpenState(0), each with its own matching message.
Bulkhead
Caps how many calls to a client can be in flight at once, regardless of success/failure — so one slow downstream can't starve every other call sharing the same capacity.
RIP.getClient(UserApi.class, RipClientConfig.builder()
.bulkhead(BulkheadConfig.builder()
.maxConcurrentCalls(25)
.maxWaitDuration(Duration.ofMillis(500)) // queue briefly instead of failing outright
.build())
.build());
try {
api.getUser("42");
} catch (CircuitOpenException e) {
// never reached for a full bulkhead — it throws BulkheadFullException,
// a distinct type for a distinct reason (too much concurrent volume,
// not a failure-rate trip). Catch both separately.
}
BulkheadConfig.builder().maxConcurrentCalls(0).build();
// IllegalArgumentException: "maxConcurrentCalls must be at least 1."
BulkheadConfig.builder().maxWaitDuration(Duration.ofMillis(-1)).build();
// IllegalArgumentException: "maxWaitDuration must not be negative."
Client configuration
Everything one client's environment can differ in — base URL, timeout, proxy, JSON mapper, cache, interceptors — lives on RipClientConfig.
RipClientConfig.builder()
Setting a timeout, proxy, or objectMapper gives the client its own connection pool instead of sharing the app-wide one.
UserApi prodApi = RIP.getClient(UserApi.class, RipClientConfig.builder()
.baseUrl(prodBaseUrl)
.connectTimeoutMillis(2_000)
.readTimeoutMillis(10_000)
.proxy("proxy.example.com", 8080) // or proxy(host, port, user, pass)
.objectMapper(new JacksonObjectMapper(myMapper))
.build());
rest-in-peace:
clients:
user-api: # @RestClient(name = "user-api"), or the
# decapitalized interface name by default
connect-timeout-millis: 2000
read-timeout-millis: 10000
proxy: { host: proxy.example.com, port: 8080 }
JSON ObjectMapper
Gson-backed by default. Swap it app-wide, or per client.
RIP.setObjectMapper(new JacksonObjectMapper(myJacksonMapper)); // once, at startup
RipClientConfig.builder().objectMapper(new JacksonObjectMapper(myMapper)).build();
@Bean
@Qualifier("user-api") // wired only into the user-api client
ObjectMapper userApiMapper() { return new JacksonObjectMapper(myMapper); }
@Bean // unqualified — used as the shared default
ObjectMapper sharedMapper() { return new JacksonObjectMapper(myMapper); }
// ^ only applies when it's the single unqualified bean of that type
Response caching
Honors Cache-Control/ETag/Last-Modified — never invents caching the server didn't ask for. GET/POJO only, for now.
Cache — shared, per-client, or a bean
RIP.setCache(new InMemoryCache()); // ConcurrentHashMap-backed, ships free
UserApi api = RIP.getClient(UserApi.class,
RipClientConfig.builder().cache(new InMemoryCache()).build());
// Or back it with Redis / Caffeine / anything else:
class RedisCache implements Cache {
public CachedResponse get(String key) { ... }
public void put(String key, CachedResponse r) { ... }
public void evict(String key) { ... }
public void clear() { ... }
}
@GET("https://api.example.com/prices/{symbol}")
@NoCache
Price getLivePrice(@PathParam("symbol") String symbol);
@Bean
@Qualifier("user-api")
Cache userApiCache() { return new InMemoryCache(); }
Stale-while-revalidate
A stale entry normally blocks on a synchronous revalidation round trip. stale-while-revalidate=N serves it as-is instead, refreshing in the background for the next call.
// Cache-Control: max-age=60, stale-while-revalidate=30
Item item = api.getItem("42");
// fresh for 60s, then still instantly servable (stale) for 30s more
// while a background call refreshes the entry - this call never sees
// the new response, only ever the one already in hand
// A background refresh throwing, or answering with a non-2xx/non-304
// response carrying no caching headers, is swallowed - nothing is
// logged, nothing is thrown at the caller who triggered it. Once the
// stale-while-revalidate window itself elapses, the next call falls
// back to the usual synchronous revalidation (or re-fetch).
Negative caching
Every other cached status is stored because the server said so. A confirmed 404 is different - negativeCacheTtlMillis opts in regardless of the response's own headers.
UserApi api = RIP.getClient(UserApi.class, RipClientConfig.builder()
.cache(new InMemoryCache())
.negativeCacheTtlMillis(TimeUnit.MINUTES.toMillis(1))
.build());
api.getUser("does-not-exist"); // throws RestInPeaceHttpException(404, ...) - one real network call
api.getUser("does-not-exist"); // throws the same exception again - served from cache
RIP.setNegativeCacheTtlMillis(TimeUnit.MINUTES.toMillis(1));
api.getUser("rate-limited"); // a 429, NOT a 404 - never negatively cached
Only ever applies to a confirmed 404 — a 429/500/anything else is never cached under this mechanism, since those are transient failures, not a confirmed "this doesn't exist."
Time-based & manual eviction
Separate from server-driven freshness: how long an entry is held at all, and forcing one out on demand.
RIP.setCache(new InMemoryCache(TimeUnit.MINUTES.toMillis(10)));
cache.evict(Cache.key(HTTPMethod.GET, "https://api.example.com/users/42"));
cache.evict(Cache.key(HTTPMethod.GET, "https://api.example.com/users/42/")); // trailing slash
Cache.key(...) does no normalization — a URL that doesn't match the exact one actually requested (trailing slash, casing, query string) silently misses, and the stale entry stays cached with no error.
Query string in the cache key
On by default — /items?page=1 and ?page=2 cache separately. Turn it off for a query param that doesn't affect the response.
RIP.getClient(UserApi.class, RipClientConfig.builder()
.cache(new InMemoryCache())
.cacheKeyIncludesQueryString(false)
.build());
RIP.setCacheKeyIncludesQueryString(false);
// cacheKeyIncludesQueryString(false) on a client whose query params
// DO change the response:
api.search("laptops"); // cached under just the path
api.search("phones"); // served the CACHED "laptops" response instead
// of a fresh search - no error, looks like a hit
Only disable this when you've confirmed every cached endpoint's query params are provably irrelevant to the response (pure tracking params the server ignores) — otherwise it silently serves wrong data.
Interceptors
"Onion" ordering: global beforeRequest runs first, global afterResponse runs last — global interceptors bracket every per-client one.
Global vs. per-client
RIP.addInterceptor(new RequestInterceptor() {
public void beforeRequest(RequestContext ctx) {
ctx.addHeader("Authorization", "Bearer " + currentToken());
}
public void afterResponse(RequestContext ctx, int status, Object body) {
System.out.println(ctx.getHttpMethod() + " " + ctx.getUrl() + " -> " + status);
}
});
RIP.clearInterceptors(); // removes everything registered
public void beforeRequest(RequestContext ctx) {
String body = ctx.getBody(); // read-only — setting it here has no effect
}
public ShortCircuitResponse shortCircuit(RequestContext ctx) {
if (featureFlags.isEnabled("bypass-pricing-api")) {
return ShortCircuitResponse.ok("{\"price\":0}");
}
return null; // let the call proceed normally
}
public void beforeRequest(RequestContext ctx) {
ctx.setBody(redact(ctx.getBody())); // has NO effect on the wire
}
The actual request is already built by the time interceptors run — setBody(...) only updates what RequestContext itself reports to a later interceptor/your own code, never the bytes Unirest sends.
public ShortCircuitResponse shortCircuit(RequestContext ctx) {
return null; // means "make the real call" - NOT "short-circuit with an empty response"
}
Use ShortCircuitResponse.status(204, "") (or whatever you actually want) to short-circuit with an intentionally empty response — null always means "let the real call proceed."
public void afterResponse(RequestContext ctx, int status, Object body) {
if (status >= 400) {
System.err.println("Reproduce with:\n" + ctx.toCurlCommand());
}
}
ctx.toCurlCommand(RequestContext.CurlVerbosity.VVVV); // curl's own internal DNS/TCP/engine tracing - noisy, undocumented
ctx.toCurlCommand(RequestContext.CurlVerbosity.TRACE); // same ballpark of detail, but documented + stable
StripeApi stripe = RIP.getClient(StripeApi.class, RipClientConfig.builder()
.baseUrl("https://api.stripe.com")
.interceptors(List.of(new HeaderInterceptor("Authorization", () -> "Bearer " + stripeKey)))
.build());
@Bean // no @Qualifier — registered globally at startup
RequestInterceptor authInterceptor() {
return new HeaderInterceptor("Authorization", () -> "Bearer " + currentToken());
}
@Bean
@Qualifier("stripe-api") // wired only into the stripe-api client's own config
RequestInterceptor stripeAuth() { ... }
Pre-built interceptors
Registered exactly like a hand-written one — plain Java shown; each also works as a @Bean in Spring, qualified or not.
RIP.addInterceptor(new HeaderInterceptor("Authorization", "Bearer " + token)); // fixed value
RIP.addInterceptor(new HeaderInterceptor("Authorization", () -> currentToken())); // re-evaluated per call
RIP.addInterceptor(HeaderInterceptor.of(Map.of("X-Api-Key", "abc123"))); // several at once
RIP.addInterceptor(new HeaderInterceptor(Map.of("Authorization", () -> currentToken())));
RIP.addInterceptor(new LoggingInterceptor()); // to System.out
RIP.addInterceptor(new LoggingInterceptor(logger::info)); // or your own sink
RIP.addInterceptor(new RedactingLoggingInterceptor()); // masks password/token/secret/apiKey/ssn/authorization
RIP.addInterceptor(new RedactingLoggingInterceptor(Set.of("password", "creditCardNumber"), logger::info));
// "password": "hunter2" -> "password": "***" (masked cleanly)
// "token": { "value": "x" } -> masked only up to the value's first , } or ]
RIP.addInterceptor(new CorrelationIdInterceptor()); // random UUID, X-Request-Id
RIP.addInterceptor(new CorrelationIdInterceptor("X-Trace-Id", () -> traceIdGenerator.next()));
RIP.addInterceptor(new MetricsInterceptor((method, url, status, ms) ->
System.out.printf("%s %s -> %d (%dms)%n", method, url, status, ms)));
RIP.addInterceptor(new HeaderInterceptor("Authorization", "Bearer " + currentToken()));
// currentToken() is called ONCE, at registration time - every later call
// sends that same original token, even long after it's expired.
Use the Supplier<String> constructor whenever the value can change between calls — it's re-evaluated on every request.
Compile-time proxies & testing
Two things that happen underneath the interface, without changing how you call it.
Compile-time proxy generation
No opt-in — a supported method shape gets a real generated <Interface>_RipImpl class automatically; an unsupported method on the same interface falls back internally, method by method, not the whole interface.
UserApi api = RIP.getClient(UserApi.class);
// api.getClass() is UserApi_RipImpl — supported shape, reflection-free
// api.getClass() is a java.lang.reflect.Proxy — NO method on this interface is codegen-eligible
interface UserApi {
User getUser(String id); // codegen-eligible — real generated method
List<User> listUsers(); // not eligible — delegates internally to a lazily-built reflective proxy
}
// api.getClass() is still UserApi_RipImpl — one unsupported method no longer disqualifies the rest
UserApi_RipImpl impl = new UserApi_RipImpl(); // don't do this
The generated class's name/shape isn't committed public API and can change between releases. Always go through RIP.getClient(UserApi.class).
OpenAPI → @RestClient generator
The opposite direction - reads an OpenAPI 3.x JSON document and generates the interface itself, instead of hand-writing one.
OpenApiClientGenerator.generate(
new File("petstore-openapi.json"), // spec - JSON, not YAML
new File("src/main/java"),
"com.example.client",
"PetStoreApi");
// writes src/main/java/com/example/client/PetStoreApi.java
// servers[0].url -> @BaseUrl; OpenAPI's own {name} path placeholder already
// matches @PathParam's, so paths need no translation at all. header/cookie
// parameters are skipped (not part of the interface shape); any requestBody
// becomes one @Body String parameter regardless of its declared schema.
java -cp ... com.shri.restinpeace.codegen.OpenApiClientGenerator \
petstore-openapi.json src/main/java com.example.client PetStoreApi
OpenApiClientGenerator.generate(new File("openapi.yaml"), outDir, pkg, name);
Only JSON specs are supported — convert a YAML OpenAPI document to JSON first (most OpenAPI tooling can export either format).
MockRestServer
A real local HTTP server — @Retry, @Timeout, and every interceptor run completely unmodified against it.
MockRestServer server = MockRestServer.start();
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));
OrderApi api = RIP.getClient(OrderApi.class, server.baseUrl());
assertEquals("shipped", api.getOrder("42").status);
assertEquals(1, server.countOf(HTTPMethod.GET, "/orders/{id}"));
server.close();
@ExtendWith(MockRestServerExtension.class)
class OrderApiTest {
@Test
void getOrder_returnsDecodedBody(MockRestServer server) {
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));
OrderApi api = RIP.getClient(OrderApi.class, server.baseUrl());
assertEquals("shipped", api.getOrder("42").status);
}
}
server.onFlaky(HTTPMethod.GET, "/orders/{id}", 2,
MockResponse.status(503, ""), MockResponse.json(new Order("42", "shipped")));
Order order = orderApiWithRetry.getOrder("42"); // succeeds on the 3rd attempt
server.onPages(HTTPMethod.GET, "/orders",
MockResponse.ok("{\"orders\":[{\"id\":\"1\"}],\"has_more\":true,\"next_cursor\":\"tok\"}"),
MockResponse.ok("{\"orders\":[{\"id\":\"2\"}],\"has_more\":false}"));
Page page1 = api.listOrders(null); // page1.items() -> [{"id": "1"}]
Page page2 = page1.next(); // page2.items() -> [{"id": "2"}]
// a 3rd call would replay the last response (has_more: false) rather than error
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.connectionFailure());
// @Retry sees this exactly like a real network failure, no status code involved
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.ok("{}").delay(300));
// proves a 200ms @Timeout(readMillis = 200) actually fires
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.notModified());
// 304 shorthand for an ETag/If-None-Match revalidation round trip
class OrderApiTest {
@RegisterExtension
static MockRestServerExtension extension = new MockRestServerExtension().reportUnhitRoutes();
@Test
void getOrder_returnsDecodedBody(MockRestServer server) {
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42", "shipped")));
// a route left registered but never called prints a warning at afterAll
}
}
@BeforeEach
void resetServer() {
server.reset(); // one shared server across a whole test class, no cross-test leakage
}
server.on(HTTPMethod.GET, "/orders", MockResponse.json(allOrders));
server.enqueue(MockResponse.status(503, "")); // DEAD - on(...) already claims every GET /orders
// RIGHT tool: enqueueFor targets the already-registered route directly.
server.enqueueFor(HTTPMethod.GET, "/orders", MockResponse.status(503, ""));
enqueue(...) only ever answers a path with no route registered via on(...) at all — route matching always happens first and unconditionally shadows the plain queue for a path that has one.
@Test
void getOrder_works() {
MockRestServer server = MockRestServer.start();
// ... no server.close() anywhere - leaks a real bound socket
}
Use MockRestServerExtension or a @BeforeEach/@AfterEach pair so close() always runs, even if the test body throws.
server.on(HTTPMethod.POST, "/order", MockResponse.ok("{}"));
api.createOrder("{...}"); // actually POSTs to /orders
// RestInPeaceHttpException.getRawBody():
// MockRestServer: no response was queued or registered for POST /orders.
// Did you mean: POST /order?
server.on(HTTPMethod.GET, "/orders/{id}",
request -> "v2".equals(request.getHeader("Accept-Version")),
MockResponse.json(new OrderV2("42")));
server.on(HTTPMethod.GET, "/orders/{id}", MockResponse.json(new Order("42")));
// ^ plain fallback route for every request that doesn't match the predicate above
RecordedRequest sent = server.takeRequest();
RecordedRequest.Part file = sent.getParts().get(0);
file.getName(); // "file"
file.getFileName(); // "avatar.png"
file.getContentType(); // "image/png"
file.getContent(); // raw bytes
file.getContentAsString(); // decoded as text - for a text part, not a binary one
sent.getFormFields().get("grant_type"); // List.of("client_credentials") - @FormUrlEncoded instead
@SpringBootTest
@AutoConfigureMockRestServer
class UserServiceTest {
@Autowired MockRestServer server;
@Autowired UserApi userApi; // already pointed at server.baseUrl()
@Test
void getUser_returnsDecodedBody() {
server.on(HTTPMethod.GET, "/users/{id}", MockResponse.json(new User("42", "Shrinivas")));
assertEquals("Shrinivas", userApi.getUser("42").name);
}
}
Java, Spring, Kotlin
The construction site is the one place plain Java and Spring genuinely diverge — everything above is identical once you have a client instance.
Plain Java, CLI tools, scripts
public final class Clients {
public static final UserApi USER_API = RIP.getClient(UserApi.class);
private Clients() {}
}
Spring — two ways to wire a bean
@SpringBootApplication
@EnableRestInPeaceClients
class Application { }
@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi {
@GET("/users/{id}")
User getUser(@PathParam("id") String id);
}
@Configuration
class RipClientsConfig {
@Bean
UserApi userApi(@Value("${user-api.base-url}") String baseUrl) {
return RIP.getClient(UserApi.class, baseUrl);
}
@Bean
StripeApi stripeApi(@Value("${stripe.api-key}") String apiKey) {
return RIP.getClient(StripeApi.class, RipClientConfig.builder()
.baseUrl("https://api.stripe.com")
.connectTimeoutMillis(2_000)
.interceptors(List.of(new HeaderInterceptor("Authorization", () -> "Bearer " + apiKey)))
.build());
}
}
@SpringBootApplication
class Application { } // no @EnableRestInPeaceClients anywhere
No beans are registered at all — injecting UserApi anywhere fails with Spring's own NoSuchBeanDefinitionException. The starter being on the classpath doesn't trigger scanning by itself.
@RestClient(baseUrlProperty = "user-api.base-url")
interface UserApi { ... }
// application.yml never sets user-api.base-url
Fails Spring Boot startup entirely: IllegalStateException: Required key 'user-api.base-url' not found — the whole application context fails to refresh, not just this one bean.
Kotlin, Scala, other JVM languages
No Java-specific runtime magic beyond an annotated interface — works from any JVM language that can implement one.
@RestClient
@BaseUrl("https://api.example.com")
interface UserApi {
@GET("/users/{id}")
fun getUser(@PathParam("id") id: String): User
}