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 Point | Interface | Scope | Key File | Source |
|---|---|---|---|---|
| Base SPI | HttpClientBuilderCustomizer<Builder> | All clients | HttpClientBuilderCustomizer.kt | HttpClientBuilderCustomizer.kt |
| Reactive customizer | WebClientBuilderCustomizer | WebClient clients | WebClientBuilderCustomizer.kt | WebClientBuilderCustomizer.kt |
| Sync customizer | RestClientBuilderCustomizer | RestClient clients | RestClientBuilderCustomizer.kt | RestClientBuilderCustomizer.kt |
| Per-client endpoint | ClientProperties | Individual clients | ClientProperties.kt | ClientProperties.kt |
| Per-client filters | ReactiveClientProperties → ComponentDefinition<ExchangeFilterFunction> | WebClient clients | ReactiveClientProperties.kt | ReactiveClientProperties.kt |
| Per-client interceptors | SyncClientProperties → ComponentDefinition<ClientHttpRequestInterceptor> | RestClient clients | SyncClientProperties.kt | SyncClientProperties.kt |
Customizer Class Hierarchy
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 --> ComponentDefinitionSince 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:
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):
| Order | Step | What | Configurable? |
|---|---|---|---|
| 1 | Resolve effective definition | ClientProperties.resolve(definition) — coapi.clients.<name>.* overrides the annotation | Via YAML |
| 2 | Get builder | WebClient.Builder from ApplicationContext | No |
| 3 | Set base URL | effective.baseUrl | Via coapi.clients.<name>.base-url |
| 4 | Apply filters | ComponentDefinition from ReactiveClientProperties | Via YAML |
| 5 | Load balancing | Only when effective.loadBalanced | Automatic |
| 6 | Customizer beans | All WebClientBuilderCustomizer beans, ordered | Register 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
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:
coapi:
clients:
MyApiClient:
reactive:
filter:
names:
- myAuthFilter
types:
- com.example.LoggingExchangeFilterFunctionSync (RestClient) interceptors:
coapi:
clients:
MyApiClient:
sync:
interceptor:
names:
- myAuthInterceptor
types:
- com.example.LoggingInterceptorResolution in AbstractHttpClientFactoryBean (all names first, then all types):
- names → resolved as beans from
ApplicationContextby name - types → resolved as beans from
ApplicationContextby class type
Example: Connection Pool Customizer
A real-world example from the consumer server demonstrates per-client connection pooling:
@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
@Serviceso Spring discovers it as a global customizer - Uses
coApiDefinition.nameto create a named connection pool per client - Applied to all
@CoApiclients viagetBeanProvider().orderedStream()
Example: Per-Client Auth Filter
Configure a filter for a specific client without affecting others:
coapi:
clients:
SecureApiClient:
base-url: https://api.example.com
reactive:
filter:
types:
- com.example.BearerTokenFilterOr register the filter by bean name:
coapi:
clients:
SecureApiClient:
reactive:
filter:
names:
- bearerTokenFilterCustom 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:
@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):
| Scenario | Factory used |
|---|---|
Exactly one HttpExchangeAdapterFactory bean | That bean |
Multiple candidates, one marked @Primary | The @Primary bean |
| Multiple candidates, none primary | The 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
| Property | Type | Default | Description |
|---|---|---|---|
coapi.clients.<name>.base-url | String | "" | Override annotation's baseUrl |
coapi.clients.<name>.load-balanced | Boolean | null | Override load balancing (true enables, false disables; unset falls back to the annotation) |
coapi.clients.<name>.reactive.filter.names | List | [] | Filter bean names |
coapi.clients.<name>.reactive.filter.types | List | [] | Filter class types |
coapi.clients.<name>.sync.interceptor.names | List | [] | Interceptor bean names |
coapi.clients.<name>.sync.interceptor.types | List | [] | Interceptor class types |
Related Pages
- Client Modes (Reactive & Sync) — WebClient vs RestClient internals
- Load Balancing — LB filter/interceptor integration
- Authentication — BearerTokenFilter and JWT caching
- Configuration Reference — all YAML properties
References
- HttpClientBuilderCustomizer.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/HttpClientBuilderCustomizer.kt - WebClientBuilderCustomizer.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/reactive/WebClientBuilderCustomizer.kt - RestClientBuilderCustomizer.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/sync/RestClientBuilderCustomizer.kt - ClientProperties.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/ClientProperties.kt - WebClientFactoryBean.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/reactive/WebClientFactoryBean.kt - RestClientFactoryBean.kt —
spring/src/main/kotlin/me/ahoo/coapi/spring/client/sync/RestClientFactoryBean.kt - ConsumerWebClientBuilderCustomizer.kt —
example/example-consumer-server/src/main/kotlin/.../ConsumerWebClientBuilderCustomizer.kt