한국어 | English
YAML-driven multi-channel RestClient management for Spring Boot 3.2+
mido-client eliminates boilerplate RestClient configuration by letting you define multiple external API channels —
each with its own URL, auth, timeout, logging, and interceptors — entirely in application.yml. No @Bean methods, no
factory classes, no repeated setup code.
| RestClient (vanilla) | OpenFeign | mido-client | |
|---|---|---|---|
| Configuration style | Java @Bean |
Java interface + annotations | YAML only |
| Multi-channel setup | Manual per bean | Manual per interface | Built-in |
| Dual endpoint per service | Manual | Not supported | Built-in |
| Request/response logging | Manual interceptor | Plugin required | Built-in (4 levels) |
| Client instance caching | Manual | Managed by framework | Built-in |
| Based on Spring Boot 3.2 RestClient | Yes | No (uses Feign) | Yes |
- Multi-channel support — define unlimited external API channels, each with
primary/secondarydual endpoint - Automatic client caching — one
RestClientinstance per channel/endpoint, thread-safe viaConcurrentHashMap - 4-level built-in logging —
off/console/file/all(console + file simultaneously), includes body, URL, response time - Per-endpoint authentication — Bearer, Basic, API Key
- Smart charset detection — Content-Type header → UTF-8 validation → channel default fallback
- Custom interceptors — register any
ClientHttpRequestInterceptorby class name in YAML - Pluggable HTTP transport — pick
simple(default,HttpURLConnection) orjdk(java.net.http.HttpClient, per-channel connection pool + HTTP/2) globally or per endpoint - Per-channel gzip — opt-in request compression with
min-sizeskip threshold; response auto-decompression with decompression-bomb defense cap (max-decompressed-size) - Per-channel content type — pick
json(default) orxmlper channel; the requestContent-Typeheader is set automatically - Fail-fast configuration validation —
@ValidatedBean Validation rejects malformed YAML at startup with aBindValidationExceptionindicating the offending field - ChannelContext with MDC — scoped (
ScopedValue) channel action tracking, integrated with SLF4J MDC for distributed log tracing - Zero-code Auto-Configuration — activated with a single
mido-client.enabled: trueproperty
| Requirement | Version |
|---|---|
| Java | 25 |
| Spring Boot | 3.5.x (built & tested with 3.5.16) |
| Gradle | 8.14.4 |
Java 25 is required because
ChannelContextis built on the finaljava.lang.ScopedValueAPI (JEP 506, Java 25). Consumers need a Spring Framework 6.2 release whose bundled ASM can read Java 25 bytecode (recent 6.2.x patches; Spring Boot 3.2.x cannot). This library is built and tested against Spring Boot 3.5.16.
Gradle
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.skaca8:mido-client:2.0.1'
}Maven
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.github.skaca8</groupId>
<artifactId>mido-client</artifactId>
<version>2.0.1</version>
</dependency>To use a specific release, replace
2.0.1with a tag or a commit hash.
Gradle
implementation 'io.github.skaca8:mido-client:2.0.1'Maven
<dependency>
<groupId>io.github.skaca8</groupId>
<artifactId>mido-client</artifactId>
<version>2.0.1</version>
</dependency>mido-client:
enabled: true
channels:
payment:
title: "Payment Service"
charset: UTF-8
type: json # json (default) | xml
primary:
url: https://api.payment.com
read-timeout-seconds: 30
connect-timeout-seconds: 5
authorization:
type: bearer
token: ${PAYMENT_QUERY_TOKEN}
log: console
secondary: # optional: secondary endpoint for the same service
url: https://process.payment.com
read-timeout-seconds: 60
authorization:
type: bearer
token: ${PAYMENT_PROCESS_TOKEN}
log: all
auth:
primary:
url: https://auth.example.com
authorization:
type: bearer
token: ${AUTH_TOKEN}
headers:
- name: X-API-Version
value: v1@Service
public class PaymentService extends BaseExternalApi {
private final RestClient queryClient;
private final RestClient processClient;
public PaymentService(MidoClientFactory midoClientFactory) {
this.queryClient = midoClientFactory.getOrCreateClient("payment");
this.processClient = midoClientFactory.getOrCreateClient("payment", EndpointType.SECONDARY);
}
@Override
protected String getChannelName() {
return "payment";
}
public PaymentStatus getPaymentStatus(String paymentId) {
return withDefaultChannelAction("getPaymentStatus", () ->
queryClient.get()
.uri("/payments/{id}/status", paymentId)
.retrieve()
.body(PaymentStatus.class)
);
}
public PaymentResult processPayment(PaymentRequest request) {
return withDefaultChannelAction("processPayment", () ->
processClient.post()
.uri("/payments/process")
.body(request)
.retrieve()
.body(PaymentResult.class)
);
}
}
BaseExternalApi.withDefaultChannelAction()automatically sets and clearsChannelContextaround each call, including on exception.
| Property | Type | Default | Description |
|---|---|---|---|
title |
String | - | Channel description (optional) |
charset |
String | UTF-8 |
Default character encoding for response body |
type |
ContentType | json |
Request Content-Type for the channel — json / xml |
| Property | Type | Default | Description |
|---|---|---|---|
url |
String | - | Required. Base URL of the endpoint |
title |
String | - | Endpoint description (optional) |
read-timeout-seconds |
Long | 60 |
Read timeout |
connect-timeout-seconds |
Long | 3 |
Connection timeout |
log |
LogLevel | console |
off / console / file / all |
client-type |
ClientType | (inherits global) | simple / jdk — HTTP transport for this endpoint; overrides mido-client.client-type |
authorization.type |
TokenType | - | bearer / basic / api_key |
authorization.token |
String | - | Authentication token value |
headers |
List | - | Static headers to attach to every request |
interceptors |
List<String> | - | Fully-qualified class names of ClientHttpRequestInterceptor |
gzip.request |
Boolean | false |
Compress outgoing request body (Content-Encoding: gzip) |
gzip.response |
Boolean | false |
Force Accept-Encoding: gzip and auto-decompress response |
gzip.min-size |
Integer | 1024 |
Skip request compression when body is smaller than this (bytes) |
gzip.max-decompressed-size |
Integer | 10485760 |
Throw IOException if decompressed response exceeds this (bytes — decompression-bomb defense) |
| Property | Type | Default | Description |
|---|---|---|---|
mido-client.enabled |
Boolean | false |
Enable/disable the entire library |
mido-client.client-type |
ClientType | simple |
Default HTTP transport for every channel; a per-endpoint client-type overrides it |
mido-client validates @ConfigurationProperties at application startup. Misconfiguration causes the context to fail to start with a BindValidationException that indicates the offending field and the rejected value. Examples that fail validation:
urlis blank or doesn't match^https?://.+read-timeout-secondsorconnect-timeout-secondsis zero or negativegzip.min-sizeis negativegzip.max-decompressed-sizeis zero or negativeheaders[].nameorheaders[].valueis blank- A channel is missing its required
primaryendpoint typeis explicitly set tonull(must bejsonorxml; unknown values are rejected separately by Spring's enum binder at startup)
Implement ClientHttpRequestInterceptor and register by class name in YAML:
@Component
public class RequestIdInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add("X-Request-Id", UUID.randomUUID().toString());
return execution.execute(request, body);
}
}interceptors:
- "com.example.RequestIdInterceptor"Custom interceptors are instantiated via the no-arg public constructor (
Class.forName(...).getDeclaredConstructor().newInstance()). The resulting instance is not a Spring-managed bean, so neither constructor injection nor@Autowiredfield injection works — even if the class is also annotated@Component, the bean Spring manages is a separate instance thatmido-clientnever sees.Two patterns are practical today:
staticfields — best for stateless interceptors (preferred).ApplicationContextHolderescape hatch — store theApplicationContextin astaticfield at startup and look up beans insideintercept(...). Treat as an escape hatch, not recommended design.A first-class "register interceptor by Spring bean name" option is on the roadmap for the next minor release.
Fail-fast behavior: if the class cannot be loaded, has no public no-arg constructor, or does not implement
ClientHttpRequestInterceptor, the first call toMidoClientFactory.getOrCreateClient(...)throwsIllegalStateExceptionnaming the channel and the offending class.
mido-client intentionally does not bundle a resilience layer — bring your own (Resilience4j, Sentinel, Failsafe, Spring Retry, …) via the interceptors: config. Below is a copy-paste-ready recipe with Resilience4j.
1. Add Resilience4j to your application's dependencies (NOT to mido-client itself):
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0'
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'2. Write a single interceptor that wraps the call with Resilience4j decorators:
package com.yourapp.interceptor;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.decorators.Decorators;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
import java.time.Duration;
public class PaymentResilienceInterceptor implements ClientHttpRequestInterceptor {
private static final RateLimiter RATE_LIMITER = RateLimiter.of("payment",
RateLimiterConfig.custom()
.limitForPeriod(100)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(500))
.build());
private static final CircuitBreaker CIRCUIT_BREAKER = CircuitBreaker.of("payment",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(20)
.build());
private static final Retry RETRY = Retry.of("payment",
RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(200))
.build());
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
try {
return Decorators.ofCallable(() -> execution.execute(request, body))
.withCircuitBreaker(CIRCUIT_BREAKER)
.withRateLimiter(RATE_LIMITER)
.withRetry(RETRY)
.decorate()
.call();
} catch (IOException | RuntimeException e) {
throw e;
} catch (Exception e) {
throw new IOException(e);
}
}
}3. Register on the channel via YAML:
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
interceptors:
- "com.yourapp.interceptor.PaymentResilienceInterceptor"Tips:
- Custom interceptors are registered before
mido-client's logging interceptor, so retry attempts and rate-limit waits show up as separate log entries — useful for debugging cascading failures. - Prefer one interceptor class per channel — the decorators' state (open/closed window, retry counters) is keyed by the registry name, so sharing across channels with different SLAs causes cross-talk.
- For YAML-driven tuning without recompiling, add the
resilience4j-spring-boot3starter to your app and configure registries inapplication.yml; then look up decorators by channel name inside the interceptor instead of building them asstatic finalfields. - If you only need one of the three (e.g. rate limiting), drop the unused decorators — chaining only what you need keeps stack traces shallow and behavior predictable.
Each channel sends requests with a single default Content-Type. Pick it once per channel via type; if omitted, json is used.
mido-client:
channels:
legacySoap:
type: xml # outgoing Content-Type: application/xml
primary:
url: https://soap.example.com
modernRest:
# type omitted → defaults to json
primary:
url: https://api.example.comBehavior:
type: json(default) —Content-Type: application/jsonis attached to every request; POJO bodies are serialized via Jackson.type: xml—Content-Type: application/xmlis attached to every request. Provide the body as a pre-serialized XMLString(Jackson XML marshalling is not bundled — bring your own converter viainterceptorsif you need POJO ↔ XML).
Per-channel opt-in HTTP body compression. Each direction is independently toggleable.
mido-client:
channels:
payment:
primary:
url: https://api.payment.com
gzip:
request: true # compress outgoing body
response: true # request gzipped response and auto-decompress
min-size: 1024 # skip compression for small bodies
max-decompressed-size: 10485760 # 10 MB safety capBehavior:
request: true— bodies ≥min-sizebytes are gzipped before sending;Content-Encoding: gzipis added automatically.response: true—Accept-Encoding: gzipis sent; if the server replies withContent-Encoding: gzip, the body is transparently decompressed before reaching your message converters.max-decompressed-sizedefends against decompression bombs — if the decompressed response exceeds the cap, anIOExceptionis thrown immediately and memory stays bounded to roughly buffer + cap.
Interceptors are ordered so that logging always sees plain-text bodies while the network carries compressed bytes.
Choose the underlying request factory globally or per endpoint. The default is simple, so existing configurations keep their current behavior.
mido-client:
enabled: true
client-type: jdk # global default for every channel
channels:
payment:
primary:
url: https://api.payment.com
# inherits global -> jdk
secondary:
url: https://process.payment.com
client-type: simple # override just this endpoint| Value | Backing factory | Connection reuse | HTTP/2 |
|---|---|---|---|
simple |
SimpleClientHttpRequestFactory |
JVM-global HttpURLConnection keep-alive |
No |
jdk |
JdkClientHttpRequestFactory |
Per-channel HttpClient connection pool |
Yes |
When to pick jdk: since a simple client reuses connections through the JVM-global keep-alive cache, all channels effectively share one pool. jdk gives each channel/endpoint its own HttpClient — and therefore its own connection pool — which is what actually delivers per-channel connection isolation. Prefer it for high-throughput channels or when HTTP/2 matters.
Behavior notes: the jdk transport follows redirects (Redirect.NORMAL, which refuses HTTPS→HTTP downgrades) and applies connect-timeout-seconds / read-timeout-seconds the same way. Unlike simple, the JDK HttpClient does not use the JVM's default proxy selector unless explicitly configured. Both transports keep the logging and gzip interceptor behavior unchanged.
BaseExternalApi.withDefaultChannelAction() binds ChannelContext automatically. ChannelContext is
backed by a ScopedValue (Java 25): the action is bound only for the dynamic extent of the call and
is auto-unbound on both return and exception — there is no manual set/clear. For direct usage:
// void form
ChannelContext.runWithChannelAction("payment.processPayment", () -> {
// your REST call — channelAction appears in all logs via MDC
});
// value-returning form
String status = ChannelContext.callWithChannelAction("payment.processPayment", () ->
restClient.get().uri("/status").retrieve().body(String.class));The action key channelAction is available in log patterns:
<!-- logback.xml -->
<pattern>%d [%X{channelAction}] %-5level %msg%n</pattern>| Level | Console | File (MidoClientFileLog) |
|---|---|---|
off |
- | - |
console |
Yes | - |
file |
- | Yes |
all |
Yes | Yes |
Each log entry includes: channel action, HTTP method, URL, request/response body, response time, HTTP status.
To enable file logging, add a logger named MidoClientFileLog in your logback.xml:
<appender name="MIDO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/mido-client.log</file>
<!-- rolling policy -->
</appender>
<logger name="MidoClientFileLog" level="INFO" additivity="false">
<appender-ref ref="MIDO_FILE"/>
</logger>This project is licensed under the Apache License 2.0 — see the LICENSE file for details.
- Fork the repository
- Create your feature branch (
git checkout -b feature/your-feature) - Commit your changes
- Push to the branch
- Open a Pull Request