Skip to content

Customization & Extensibility ​

Overview ​

CoApi's HTTP clients are not black boxes. The library exposes a layered customization SPI that lets you intercept and modify the client builder at three points: (1) per-client YAML configuration for filters and interceptors, (2) per-type builder customizers for load balancing and protocol-specific tweaks, and (3) global customizer beans applied to all clients in order. This design means common concerns (connection pooling, metrics, tracing) can be applied globally while client-specific overrides (auth headers, timeouts) target individual interfaces.

At a Glance ​

Customization PointInterfaceScopeKey FileSource
Base SPIHttpClientBuilderCustomizer<Builder>All clientsHttpClientBuilderCustomizer.ktHttpClientBuilderCustomizer.kt
Reactive customizerWebClientBuilderCustomizerWebClient clientsWebClientBuilderCustomizer.ktWebClientBuilderCustomizer.kt
Sync customizerRestClientBuilderCustomizerRestClient clientsRestClientBuilderCustomizer.ktRestClientBuilderCustomizer.kt
Per-client endpointClientPropertiesIndividual clientsClientProperties.ktClientProperties.kt
Per-client filtersReactiveClientProperties → ComponentDefinition<ExchangeFilterFunction>WebClient clientsReactiveClientProperties.ktReactiveClientProperties.kt
Per-client interceptorsSyncClientProperties → ComponentDefinition<ClientHttpRequestInterceptor>RestClient clientsSyncClientProperties.ktSyncClientProperties.kt

Customizer Class Hierarchy ​

mermaid
classDiagram
    class HttpClientBuilderCustomizer~Builder~ {
        <<fun interface>>
        +customize(CoApiDefinition, Builder)
    }
    class WebClientBuilderCustomizer {
        <<fun interface>>
        +customize(CoApiDefinition, WebClient.Builder)
        +NoOp
    }
    class RestClientBuilderCustomizer {
        <<fun interface>>
        +customize(CoApiDefinition, RestClient.Builder)
        +NoOp
    }
    class ClientProperties {
        <<interface>>
        +getBaseUri(String) String
        +getLoadBalanced(String) Boolean?
        +resolve(CoApiDefinition) CoApiDefinition
    }
    class ReactiveClientProperties {
        <<fun interface>>
        +getFilter(String) ComponentDefinition
    }
    class SyncClientProperties {
        <<fun interface>>
        +getInterceptor(String) ComponentDefinition
    }
    class ComponentDefinition~T~ {
        +names: List~String~
        +types: List~Class~
    }

    HttpClientBuilderCustomizer <|-- WebClientBuilderCustomizer
    HttpClientBuilderCustomizer <|-- RestClientBuilderCustomizer
    ReactiveClientProperties --> ComponentDefinition
    SyncClientProperties --> ComponentDefinition

Since v3.0.0 the mode-specific settings live in their own role interfaces: a sync-only application never sees a reactive type in ClientProperties. All three are optional beans; CoApiProperties (Spring Boot) implements all of them, and without Spring Boot each one falls back to its Empty implementation.

Customizer Invocation Order ​

When a WebClient or RestClient bean is created, customizers are applied in a strict order:

mermaid
sequenceDiagram
    autonumber
    participant FB as WebClientFactoryBean
    participant CTX as ApplicationContext
    participant Builder as WebClient.Builder
    participant LB as LoadBalancedWebClientBuilderCustomizer
    participant Global as WebClientBuilderCustomizer beans

    FB->>CTX: ClientProperties.resolve(definition)
    CTX-->>FB: effective definition
    FB->>CTX: getBean(WebClient.Builder)
    CTX-->>Builder: builder instance
    FB->>Builder: baseUrl(effective.baseUrl)
    FB->>CTX: ReactiveClientProperties.getFilter(name)
    FB->>Builder: apply filters (names, then types)
    opt effective.loadBalanced
        FB->>LB: customize(effective, builder)
        LB->>Builder: add LoadBalancedExchangeFilterFunction unless present
    end
    loop For each customizer bean (ordered)
        FB->>Global: customize(effective, builder)
    end
    FB->>Builder: build()

The invocation order in WebClientFactoryBean.getObject() (RestClientFactoryBean is symmetric with interceptors):

OrderStepWhatConfigurable?
1Resolve effective definitionClientProperties.resolve(definition) — coapi.clients.<name>.* overrides the annotationVia YAML
2Get builderWebClient.Builder from ApplicationContextNo
3Set base URLeffective.baseUrlVia coapi.clients.<name>.base-url
4Apply filtersComponentDefinition from ReactiveClientPropertiesVia YAML
5Load balancingOnly when effective.loadBalancedAutomatic
6Customizer beansAll WebClientBuilderCustomizer beans, orderedRegister as Spring bean

Customizers receive the effective definition (since v3.0.0): coApiDefinition.baseUrl and coApiDefinition.loadBalanced already reflect the coapi.clients.<name>.* overrides.

Customizer Decision Flow ​

mermaid
flowchart TD
    A["FactoryBean.getObject()"] --> A2["Resolve effective definition"]
    A2 --> B["Get Builder from Context"]
    B --> C[Set baseUrl]
    C --> D[Apply per-client filters/interceptors]
    D --> E{Load balanced?}
    E -->|Yes| F[Add LB filter/interceptor]
    E -->|No| H[Apply customizer beans]
    F --> H
    H --> I[Build client]

Per-Client Filter Configuration ​

Filters and interceptors are configured per client via YAML properties; ReactiveClientProperties and SyncClientProperties provide typed access:

Reactive (WebClient) filters:

yaml
coapi:
  clients:
    MyApiClient:
      reactive:
        filter:
          names:
            - myAuthFilter
          types:
            - com.example.LoggingExchangeFilterFunction

Sync (RestClient) interceptors:

yaml
coapi:
  clients:
    MyApiClient:
      sync:
        interceptor:
          names:
            - myAuthInterceptor
          types:
            - com.example.LoggingInterceptor

Resolution in AbstractHttpClientFactoryBean (all names first, then all types):

  • names → resolved as beans from ApplicationContext by name
  • types → resolved as beans from ApplicationContext by class type

Example: Connection Pool Customizer ​

A real-world example from the consumer server demonstrates per-client connection pooling:

kotlin
@Service
class ConsumerWebClientBuilderCustomizer : WebClientBuilderCustomizer {
    override fun customize(
        coApiDefinition: CoApiDefinition,
        builder: WebClient.Builder
    ) {
        val connectionProvider = ConnectionProvider.builder(coApiDefinition.name)
            .maxConnections(500)
            .maxIdleTime(Duration.ofSeconds(20))
            .maxLifeTime(Duration.ofSeconds(60))
            .pendingAcquireTimeout(Duration.ofSeconds(60))
            .evictInBackground(Duration.ofSeconds(120))
            .build()
        val httpClient = HttpClient.create(connectionProvider)
        builder.clientConnector(ReactorClientHttpConnector(httpClient))
    }
}

Key points:

  • Registered as @Service so Spring discovers it as a global customizer
  • Uses coApiDefinition.name to create a named connection pool per client
  • Applied to all @CoApi clients via getBeanProvider().orderedStream()

Example: Per-Client Auth Filter ​

Configure a filter for a specific client without affecting others:

yaml
coapi:
  clients:
    SecureApiClient:
      base-url: https://api.example.com
      reactive:
        filter:
          types:
            - com.example.BearerTokenFilter

Or register the filter by bean name:

yaml
coapi:
  clients:
    SecureApiClient:
      reactive:
        filter:
          names:
            - bearerTokenFilter

Custom HttpExchangeAdapterFactory ​

HttpExchangeAdapterFactory decides how an HTTP client bean (WebClient or RestClient) is turned into the HttpExchangeAdapter that powers the interface proxy. You can replace the default factory with your own bean — for example to wrap adapters with metrics or tracing:

kotlin
@Configuration(proxyBeanMethods = false)
class MyCoApiConfiguration {
    @Bean
    fun customHttpExchangeAdapterFactory(): HttpExchangeAdapterFactory =
        HttpExchangeAdapterFactory { beanFactory, httpClientName ->
            val webClient = beanFactory.getBean(httpClientName, WebClient::class.java)
            MetricsWebClientAdapter.wrap(WebClientAdapter.create(webClient))
        }
}

Resolution order in CoApiFactoryBean (since v2.1.1):

ScenarioFactory used
Exactly one HttpExchangeAdapterFactory beanThat bean
Multiple candidates, one marked @PrimaryThe @Primary bean
Multiple candidates, none primaryThe bean registered under the standard name CoApi.HttpExchangeAdapterFactory (the registrar default)

Registering a custom factory under the standard bean name replaces the default outright; a custom factory under any other name takes effect only when it is the single candidate or marked @Primary. Before v2.1.1, multiple non-primary candidates failed startup with NoUniqueBeanDefinitionException.

YAML Configuration Reference ​

PropertyTypeDefaultDescription
coapi.clients.<name>.base-urlString""Override annotation's baseUrl
coapi.clients.<name>.load-balancedBooleannullOverride load balancing (true enables, false disables; unset falls back to the annotation)
coapi.clients.<name>.reactive.filter.namesList[]Filter bean names
coapi.clients.<name>.reactive.filter.typesList[]Filter class types
coapi.clients.<name>.sync.interceptor.namesList[]Interceptor bean names
coapi.clients.<name>.sync.interceptor.typesList[]Interceptor class types

References ​

  1. HttpClientBuilderCustomizer.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/HttpClientBuilderCustomizer.kt
  2. WebClientBuilderCustomizer.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/reactive/WebClientBuilderCustomizer.kt
  3. RestClientBuilderCustomizer.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/sync/RestClientBuilderCustomizer.kt
  4. ClientProperties.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/ClientProperties.kt
  5. WebClientFactoryBean.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/reactive/WebClientFactoryBean.kt
  6. RestClientFactoryBean.kt — spring/src/main/kotlin/me/ahoo/coapi/spring/client/sync/RestClientFactoryBean.kt
  7. ConsumerWebClientBuilderCustomizer.kt — example/example-consumer-server/src/main/kotlin/.../ConsumerWebClientBuilderCustomizer.kt