DEV Community

skaca8
skaca8

Posted on

A YAML-Driven Multi-Channel RestClient — The Story of Building mido-client

I built a Spring Boot 3.2+ open-source library that lets you declare and manage external API channels entirely from YAML.

It started in the travel industry

The thing is, every OTA has different API specs. One pattern that shows up often is when the lookup host and the booking/payment host are separated.

  • https://api-search.someota.com → product lookup, price check
  • https://api-booking.someota.com → actual booking and payment

Same OTA, but two endpoints — different auth tokens, different timeout policies.
Once you have 10 or 20 such OTAs, the problem starts in earnest.
Similar-looking @Bean declarations grow with the channel count — and double again for any channel with dual endpoints — honestly piling up.

The limits of RestClient + @bean

My first attempt was the obvious one: register RestClient (introduced in Spring Boot 3.2) as @Beans. It was boilerplate hell.

@Configuration
public class OtaClientConfig {

    @Bean("abcOtaSearchClient")
    public RestClient abcOtaSearchClient() {
        return buildClient(
            "https://api-search.someota.com",
            System.getenv("ABC_SEARCH_TOKEN"),
            Duration.ofSeconds(30),
            Duration.ofSeconds(5)
        );
    }

    @Bean("abcOtaBookingClient")
    public RestClient abcOtaBookingClient() {
        return buildClient(
            "https://api-booking.someota.com",
            System.getenv("ABC_BOOKING_TOKEN"),
            Duration.ofSeconds(60),
            Duration.ofSeconds(5)
        );
    }

    // Two more beans for XYZ OTA
    // Two more beans for DEF OTA
    // ... 10 channels means 20 beans

    private RestClient buildClient(String baseUrl, String token,
                                    Duration readTimeout, Duration connectTimeout) {
        // factory, interceptor, logging setup...
    }
}
Enter fullscreen mode Exit fullscreen mode

Sure, you can extract a buildClient helper. But the more channels you add, the more @Bean methods pile up.

Why not OpenFeign?

OpenFeign was on the table, of course. Clean abstraction — just interfaces and annotations. But when I actually tried it, things kept getting in the way.

1. It doesn't sit on top of Spring Boot 3.2's RestClient.
OpenFeign runs on Feign internally. If your org wants a unified HTTP client policy built on RestClient, that consistency breaks the moment you adopt Feign.

2. Mapping two endpoints to one service is awkward.
The dual-host OTA pattern — lookup and booking on different URLs — forces you to split into two Feign interfaces. "It's the same OTA, why am I splitting it into two classes?" That nagging feeling never goes away.

3. Customization is shallow.
Shaping request/response logging into a specific format (URL + body + response time + status) means bringing in a plugin or reconfiguring Logger.Level. Details like gzip compression or decompression-bomb defense? You write those yourself. I spent days just matching the log format ops required.

4. The code grows back anyway.
Interfaces, fallbacks, configuration classes, ErrorDecoder, RequestInterceptor — what the annotations seemed to save up front, you end up paying back elsewhere.

What I wanted was simple: "if I look at one YAML, I can see exactly how this system talks to the outside world." OpenFeign doesn't get you there.

So I built it myself — mido-client

I searched existing open-source options for a while. A few came close in concept, but none of them ticked every box. So I built it myself. Its name is mido-client.

The core idea is simple.

Declare external API channels in YAML. Let code focus on business logic.

Comparison

RestClient (vanilla) OpenFeign mido-client
Configuration style Java @Bean Interface + annotations YAML only
Multi-channel setup Manual per bean Manual per interface Built-in
Dual endpoint per service Manual Not supported Built-in (primary / secondary)
Request/response logging DIY Plugin required Built-in (4 levels)
Client caching Manual Managed by framework Built-in (ConcurrentHashMap)
Based on Spring Boot 3.2+ Feign Spring Boot 3.2+ RestClient

How the code shrinks

Before — 4 beans + a config class.
After — one block in application.yml.

mido-client:
  enabled: true
  channels:
    abcOta:
      title: "ABC OTA"
      charset: UTF-8
      primary:                            # lookup endpoint
        url: https://api-search.someota.com
        read-timeout-seconds: 30
        connect-timeout-seconds: 5
        authorization:
          type: bearer
          token: ${ABC_SEARCH_TOKEN}
        log: console
      secondary:                          # booking endpoint
        url: https://api-booking.someota.com
        read-timeout-seconds: 60
        authorization:
          type: bearer
          token: ${ABC_BOOKING_TOKEN}
        log: all                          # console + file
    xyzOta:
      primary:
        url: https://api.xyzota.com
        authorization:
          type: bearer
          token: ${XYZ_TOKEN}
Enter fullscreen mode Exit fullscreen mode

The service code looks like this:

@Service
public class AbcOtaService extends BaseExternalApi {

    private final RestClient searchClient;
    private final RestClient bookingClient;

    public AbcOtaService(MidoClientFactory midoClientFactory) {
        this.searchClient  = midoClientFactory.getOrCreateClient("abcOta");
        this.bookingClient = midoClientFactory.getOrCreateClient("abcOta", EndpointType.SECOND);
    }

    @Override
    protected String getChannelName() {
        return "abcOta";
    }

    public HotelList searchHotels(SearchCondition cond) {
        return withDefaultChannelAction("searchHotels", () ->
            searchClient.post()
                .uri("/hotels/search")
                .body(cond)
                .retrieve()
                .body(HotelList.class)
        );
    }

    public BookingResult book(BookingRequest req) {
        return withDefaultChannelAction("book", () ->
            bookingClient.post()
                .uri("/bookings")
                .body(req)
                .retrieve()
                .body(BookingResult.class)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

No @Bean methods. No factory classes. No proliferating interfaces. Want to add another channel? Add one more block in YAML.

Details worth showing off

1. Four-level logging — ops will thank you

log: off       # off
log: console   # console only
log: file      # file only (MidoClientFileLog logger)
log: all       # console + file simultaneously
Enter fullscreen mode Exit fullscreen mode

Each log line includes the request URL, method, request/response body, response time, and status. Whenever ops asks "trace this OTA request and tell me where it came from," you no longer need to touch the logging code.

2. ChannelContext + MDC — distributed log tracing

A single withDefaultChannelAction("methodName", ...) call automatically writes channelAction into SLF4J MDC.

<pattern>%d [%X{channelAction}] %-5level %msg%n</pattern>
Enter fullscreen mode Exit fullscreen mode

With a single log line, you can immediately see which channel and which action it broke at. Even when business logic throws an exception, BaseExternalApi handles it so the context is automatically cleared in finally.

Why not ScopedValue?
ScopedValue (JEP 446 / JDK 21+) is the modern way to do context propagation, but mido-client targets Java 17 as its minimum — so we went with ThreadLocal for now. I personally run Java 25, but that's too bleeding-edge to force on library users. We plan to migrate to ScopedValue when we bump the library's minimum Java version.

3. Gzip — compression + decompression-bomb defense

gzip:
  request: true
  response: true
  min-size: 1024                   # skip compression for bodies < 1KB
  max-decompressed-size: 10485760  # IOException if response > 10MB after decompression
Enter fullscreen mode Exit fullscreen mode

Request compression, automatic response decompression, and a decompression-bomb defense cap baked in. Some OTAs occasionally return abnormally inflated responses, so having a memory cap is genuinely reassuring.

4. Custom interceptors registered in YAML

Want to plug in Resilience4j? Just put the class name in interceptors:.

interceptors:
  - "com.yourapp.PaymentResilienceInterceptor"
Enter fullscreen mode Exit fullscreen mode

mido-client deliberately does NOT bundle a resilience layer. Each team's preferred tool (Resilience4j, Sentinel, Spring Retry, Failsafe...) plugs in via this hook. The README ships with a full Resilience4j integration recipe.

5. Fail-fast config validation

@Validated validates @ConfigurationProperties. If a URL is blank, a timeout is negative, or a required primary endpoint is missing, it throws a runtime error.

6. JSON / XML channel type

If you have a legacy SOAP OTA still hanging around — yes, those happen — just declare type: xml on the channel and Content-Type: application/xml is attached automatically.

channels:
  legacySoap:
    type: xml
    primary:
      url: https://soap.example.com
Enter fullscreen mode Exit fullscreen mode

7. Multi-header support

You can register multiple static headers per endpoint. Values that need to ride along on every request — auth tokens, tracing IDs, API versions — can all be collected in one place in YAML.

headers:
  - name: X-AUTH-TOKEN
    value: ${AUTH_TOKEN}
  - name: X-API-Version
    value: v1
Enter fullscreen mode Exit fullscreen mode

Getting started

dependencies {
    implementation 'io.github.skaca8:mido-client:1.0.8'
}
Enter fullscreen mode Exit fullscreen mode
mido-client:
  enabled: true
  channels:
    yourChannel:
      first:
        url: https://api.example.com
Enter fullscreen mode Exit fullscreen mode

These two blocks are all you need. In addition to Maven Central, JitPack-based GitHub dependency is also supported.

Closing

  • Code shouldn't grow just because channels grow.
  • Open application.yml once, and you can see — at a glance — where and how this service talks to the outside world.

Honestly, I built this library to scratch my own itch, so I hope it helps others working in similar environments. If you've ever watched your bean count balloon as you added one more channel, or thought "OpenFeign feels a bit heavy for what I need," give it a try. Feedback, issues, and PRs are all welcome.


P.S. The library's name mido is the name of my Maltese, who has been living with me for 13 years.

Top comments (0)