ClientLoggingAutoConfiguration.kt

package eu.inqudium.legatium.restclient.logging

import eu.inqudium.legatium.common.ClientLoggingProperties
import eu.inqudium.legatium.common.CorrelationIdGenerator
import eu.inqudium.legatium.common.HeaderValueMasker
import eu.inqudium.legatium.common.NanoTimeSource
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.composite.CompositeMeterRegistry
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.restclient.RestClientCustomizer
import org.springframework.boot.restclient.RestTemplateCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order

/**
 * Registers the [ClientRequestLoggingInterceptor] in a Spring Boot application and attaches it to every
 * `RestClient` and `RestTemplate` Boot builds - drop the module on the classpath and every outbound call
 * is logged; `adapter-logging.enabled=false` removes it again. Unlike an inbound filter the interceptor
 * needs no web application: a batch job or a message consumer calling out is a client too.
 *
 * Every bean backs off to a host-provided one: a host may pin [NanoTimeSource] or
 * [CorrelationIdGenerator] (tests do), replace the [HeaderValueMasker] (a keyed fingerprint for a
 * compliance regime), or define its own [ClientRequestLoggingInterceptor] bean to take over the
 * interceptor while keeping the customizer wiring below.
 *
 * The customizers live in nested configurations conditional on Boot's `spring-boot-restclient` module
 * (an optional dependency of this one): a host that builds its clients by hand keeps the interceptor bean
 * and adds it itself.
 */
@AutoConfiguration
@ConditionalOnProperty(prefix = "adapter-logging", name = ["enabled"], havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(ClientLoggingProperties::class)
class ClientLoggingAutoConfiguration {
    /** The system's monotonic clock, unless the host pins a time source. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingNanoTimeSource(): NanoTimeSource = NanoTimeSource.SYSTEM

    /** The counting default of ADR-0004, unless the host pins a generator. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingCorrelationIdGenerator(): CorrelationIdGenerator = CorrelationIdGenerator.DEFAULT

    /** The fingerprint the `masking-key` property selects, unless the host pins a masker. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingHeaderValueMasker(properties: ClientLoggingProperties): HeaderValueMasker = HeaderValueMasker.forKey(properties.maskingKey)

    /**
     * The interceptor as its own bean, so a host can replace it while keeping the customizers below.
     *
     * The meter registry arrives as an [ObjectProvider] and is CONSUMED, never exported: a logging
     * library must not define the host's `MeterRegistry`. A host without one - no actuator - gets an
     * empty [CompositeMeterRegistry]: Micrometer's meters against it are no-ops, so nothing is
     * accumulated in a registry nobody reads, and the module works unchanged.
     */
    @Bean
    @ConditionalOnMissingBean
    fun clientRequestLoggingInterceptor(
        properties: ClientLoggingProperties,
        nanoTime: NanoTimeSource,
        correlationIds: CorrelationIdGenerator,
        masker: HeaderValueMasker,
        meterRegistry: ObjectProvider<MeterRegistry>,
    ): ClientRequestLoggingInterceptor = ClientRequestLoggingInterceptor(properties, nanoTime, correlationIds, meterRegistry.getIfAvailable { CompositeMeterRegistry() }, masker)

    /**
     * Attaches the interceptor to every `RestClient.Builder` Boot hands out (and to every HTTP service
     * client group built from one). Ordered LATE among the customizers, so the interceptor is appended
     * behind the interceptors of earlier customizers and runs INSIDE them - closest to the wire, once
     * per attempt of an outer retry ([ClientRequestLoggingInterceptor]).
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(RestClientCustomizer::class)
    class RestClientCustomization {
        @Bean
        @Order(CUSTOMIZER_ORDER)
        fun clientLoggingRestClientCustomizer(interceptor: ClientRequestLoggingInterceptor): RestClientCustomizer = RestClientCustomizer { builder -> builder.requestInterceptor(interceptor) }
    }

    /** As [RestClientCustomization], for every `RestTemplate` built through Boot's `RestTemplateBuilder`. */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(RestTemplateCustomizer::class)
    class RestTemplateCustomization {
        @Bean
        @Order(CUSTOMIZER_ORDER)
        fun clientLoggingRestTemplateCustomizer(interceptor: ClientRequestLoggingInterceptor): RestTemplateCustomizer = RestTemplateCustomizer { restTemplate -> restTemplate.interceptors = restTemplate.interceptors + interceptor }
    }

    companion object {
        /**
         * Late, not last: room is left below for a host customizer that must see the fully configured
         * interceptor list (a metrics or diagnostics wrapper), exactly as the inbound filter's
         * `HIGHEST_PRECEDENCE + 10` leaves room above it.
         */
        const val CUSTOMIZER_ORDER = Ordered.LOWEST_PRECEDENCE - 10
    }
}