init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# adapter:outbound:cache-redis — cache and Redis adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-cache-redis`
|
||||
- Gradle path: `:adapter:outbound:cache-redis`
|
||||
- Focused test: `./gradlew :adapter:outbound:cache-redis:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.cache`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports.
|
||||
- Own cache binding settings and Redis client adaptation.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
|
||||
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
|
||||
|
||||
## Tests
|
||||
|
||||
Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in
|
||||
focused tests; configuration changes include binding/validation coverage.
|
||||
@@ -0,0 +1,37 @@
|
||||
# adapter:outbound:cache-redis — 설계 결정 참조
|
||||
|
||||
캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브
|
||||
패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support` 에
|
||||
의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty`
|
||||
(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만
|
||||
갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig`
|
||||
/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다.
|
||||
|
||||
## 중앙 fail-open 합성
|
||||
|
||||
`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend` 에 **중앙에서**
|
||||
적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로
|
||||
다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며
|
||||
라우터에서 fail-fast 한다(Layer 3).
|
||||
|
||||
## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore`
|
||||
|
||||
기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의
|
||||
`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을
|
||||
실패시킨다. `CacheStore.get()` 의 `Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로
|
||||
새지 않게 — B7).
|
||||
|
||||
## 라우팅 바인딩
|
||||
|
||||
논리 캐시 이름 → 백엔드는 `app.cache.bindings.<name>=<backendId>` 로 선택하며, `backendId` 는
|
||||
`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예:
|
||||
`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다.
|
||||
@@ -0,0 +1,14 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -0,0 +1,156 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.cache;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Logical-cache-name → backendId bindings bound from {@code app.cache.bindings.*} (relaxed binding
|
||||
* also accepts env keys, e.g. {@code APP_CACHE_BINDINGS_WORKLOG=redis}).
|
||||
*
|
||||
* <p>Example: {@code app.cache.bindings.worklog=redis} routes {@code
|
||||
* CacheStoreRouter.get("worklog", key)} to the backend whose {@link CacheBackend#backendId()} is
|
||||
* {@code redis}. Absent keys default to an empty map so the cache template stays a non-required
|
||||
* optional module. Binding consistency (every referenced backendId has an enabled backend) is
|
||||
* validated fail-fast by {@code CacheStoreRouter} at startup.
|
||||
*
|
||||
* @param bindings logical cache name → backendId (default empty)
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.cache")
|
||||
public record CacheBindingSettings(Map<String, String> bindings) {
|
||||
|
||||
public CacheBindingSettings {
|
||||
bindings = (bindings == null) ? Map.of() : Map.copyOf(bindings);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.outbound.cache;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.FailOpenCacheStore;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Assembles the {@link CacheStoreRouter} from every contributed {@link CacheBackend} bean. Adding a
|
||||
* backend is new files only — this config and the router never change. The fail-open policy is
|
||||
* applied here, centrally, by wrapping every backend in {@link FailOpenCacheStore}, so a backend
|
||||
* config cannot forget it.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(CacheBindingSettings.class)
|
||||
public class CacheRouterConfig {
|
||||
|
||||
@Bean
|
||||
public CacheStoreRouter cacheStoreRouter(
|
||||
ObjectProvider<List<CacheBackend>> backends,
|
||||
CacheBindingSettings settings,
|
||||
FailOpenDependencyLogger failOpenDependencyLogger) {
|
||||
List<FailOpenCacheStore> failOpenBackends =
|
||||
backends.getIfAvailable(List::of).stream()
|
||||
.map(backend -> new FailOpenCacheStore(backend, failOpenDependencyLogger))
|
||||
.toList();
|
||||
return new CacheStoreRouter(failOpenBackends, settings.bindings());
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
/**
|
||||
* Cache backend contribution contract. A backend opts into routing by registering a bean of this
|
||||
* interface; {@link #backendId()} is the identifier referenced by {@code
|
||||
* app.cache.bindings.<logicalName>} values.
|
||||
*/
|
||||
public interface CacheBackend extends CacheStore {
|
||||
|
||||
/** Stable backend identifier referenced by {@code app.cache.bindings.*} values. */
|
||||
String backendId();
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
/**
|
||||
* Unchecked wrapper a cache backend binding throws when its integration client fails (the seam
|
||||
* interfaces declare {@code throws Exception}, but {@link CacheStore} does not). The {@link
|
||||
* FailOpenCacheStore} decorator catches it and applies the fail-open cache-miss contract — backend
|
||||
* bindings must propagate failures, never swallow them, so a backend outage is observable and
|
||||
* cannot be mistaken for a miss.
|
||||
*/
|
||||
public class CacheBackendException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CacheBackendException(String backendId, Throwable cause) {
|
||||
super("cache backend '" + backendId + "' access failed", cause);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Per-backend cache SPI for the optional adapter template. Consumers do not inject this type
|
||||
* directly — they call {@link CacheStoreRouter} with a logical cache name. {@link #get(String)}
|
||||
* returns {@link Optional#empty()} on a miss (so no cache SDK type escapes the adapter — B7).
|
||||
* Routing and fail-open composition rationale is in the module README.
|
||||
*/
|
||||
public interface CacheStore {
|
||||
|
||||
/**
|
||||
* Reads a cached value. {@link Optional#empty()} == miss (or a degraded backend's fail-open
|
||||
* downgrade).
|
||||
*/
|
||||
Optional<String> get(String key);
|
||||
|
||||
/**
|
||||
* Writes a value. Backend failures are handled fail-open by the central {@link
|
||||
* FailOpenCacheStore}.
|
||||
*/
|
||||
void put(String key, String value);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Routes logical cache names to contributed {@link CacheBackend}s ({@code
|
||||
* app.cache.bindings.<logicalName>=<backendId>}). The Layer 3 fail-fast contract lives here: a
|
||||
* duplicate backendId or a binding to a backendId with no enabled backend fails construction, and
|
||||
* {@code get}/{@code put} on an unbound logical name throws {@link AdapterDisabledException} (never
|
||||
* a silent no-op). With no backends and no bindings it constructs cleanly, so the cache template
|
||||
* never becomes a required dependency. It does not expose the resolved {@link CacheStore}, so no
|
||||
* adapter type escapes via a public return (B7).
|
||||
*/
|
||||
public final class CacheStoreRouter {
|
||||
|
||||
private static final String ADAPTER_NAME = "cache";
|
||||
|
||||
private final Map<String, CacheStore> backends;
|
||||
private final Map<String, String> bindings;
|
||||
|
||||
public CacheStoreRouter(
|
||||
Collection<? extends CacheBackend> backends, Map<String, String> bindings) {
|
||||
Map<String, CacheStore> byId = new HashMap<>();
|
||||
for (CacheBackend backend : backends) {
|
||||
CacheStore previous = byId.putIfAbsent(backend.backendId(), backend);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException(
|
||||
"duplicate cache backendId '"
|
||||
+ backend.backendId()
|
||||
+ "' — every contributed CacheBackend bean must have a unique backendId");
|
||||
}
|
||||
}
|
||||
this.backends = Map.copyOf(byId);
|
||||
this.bindings = Map.copyOf(bindings);
|
||||
for (Map.Entry<String, String> binding : this.bindings.entrySet()) {
|
||||
if (!this.backends.containsKey(binding.getValue())) {
|
||||
throw new IllegalStateException(
|
||||
"app.cache.bindings."
|
||||
+ binding.getKey()
|
||||
+ "="
|
||||
+ binding.getValue()
|
||||
+ " references cache backend '"
|
||||
+ binding.getValue()
|
||||
+ "' but no enabled backend contributes that id — enable the backend"
|
||||
+ " (e.g. app.cache."
|
||||
+ binding.getValue()
|
||||
+ ".enabled=true)"
|
||||
+ " or fix the binding");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads from the backend bound to {@code logicalName} (empty == miss). */
|
||||
public Optional<String> get(String logicalName, String key) {
|
||||
return resolve(logicalName).get(key);
|
||||
}
|
||||
|
||||
/** Writes to the backend bound to {@code logicalName}. */
|
||||
public void put(String logicalName, String key, String value) {
|
||||
resolve(logicalName).put(key, value);
|
||||
}
|
||||
|
||||
private CacheStore resolve(String logicalName) {
|
||||
String backendId = bindings.get(logicalName);
|
||||
if (backendId == null) {
|
||||
throw new AdapterDisabledException(
|
||||
ADAPTER_NAME,
|
||||
"no cache backend bound for logical cache '"
|
||||
+ logicalName
|
||||
+ "' — set app.cache.bindings."
|
||||
+ logicalName
|
||||
+ "=<backendId> and enable that backend"
|
||||
+ " (integration-adapter-templates Layer 3)");
|
||||
}
|
||||
return backends.get(backendId);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Fail-open decorator: a cache backend outage degrades to a miss ({@code get} → empty, {@code put}
|
||||
* swallowed), never a 5xx. Applied centrally by {@code CacheRouterConfig}.
|
||||
*/
|
||||
public final class FailOpenCacheStore implements CacheBackend {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "cache";
|
||||
|
||||
private final CacheBackend delegate;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public FailOpenCacheStore(CacheBackend delegate, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.delegate = delegate;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendId() {
|
||||
return delegate.backendId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
try {
|
||||
Optional<String> value = delegate.get(key);
|
||||
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "get");
|
||||
return value;
|
||||
} catch (Exception ex) {
|
||||
// fail-open: an unavailable backend degrades to a cache-miss, not a 5xx.
|
||||
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "get", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {
|
||||
try {
|
||||
delegate.put(key, value);
|
||||
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "put");
|
||||
} catch (Exception ex) {
|
||||
// fail-open: a failed cache write is observed, not propagated.
|
||||
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "put", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 bean-gating for the Redis cache backend. The {@code app.cache.redis.enabled} flag (env
|
||||
* {@code APP_CACHE_REDIS_ENABLED}, default false) decides whether this backend <em>contributes</em>
|
||||
* a {@link CacheBackend} bean whose {@link CacheBackend#backendId()} is {@link
|
||||
* RedisCacheStore#BACKEND_ID} — the id that {@code app.cache.bindings.<logicalName>=redis} routes
|
||||
* to. Fail-open wrapping and dependency logging are applied centrally by {@code CacheRouterConfig};
|
||||
* this config stays a thin contribution.
|
||||
*
|
||||
* <p>No disabled-sentinel bean: when disabled this config contributes nothing, and the Layer 3
|
||||
* fail-fast contract is enforced by {@code CacheStoreRouter} (unbound logical name → {@code
|
||||
* AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs
|
||||
* therefore never need to know about each other — a new backend is new files only.
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisCacheAdapterConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.cache.redis.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public CacheBackend redisCacheBackend(RedisClient redisClient) {
|
||||
return new RedisCacheStore(redisClient);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Thin Redis binding of {@link CacheBackend} (active only when {@code
|
||||
* app.cache.redis.enabled=true}). Delegates to the project-supplied {@link RedisClient} seam and
|
||||
* wraps its checked failures into {@link CacheBackendException}. The fail-open contract (outage ==
|
||||
* cache-miss, never a 5xx) lives in {@code FailOpenCacheStore}, which {@code CacheRouterConfig}
|
||||
* composes around every contributed backend centrally — keeping the policy identical across all
|
||||
* backends.
|
||||
*/
|
||||
public class RedisCacheStore implements CacheBackend {
|
||||
|
||||
/** Routing id referenced by {@code app.cache.bindings.*} values. */
|
||||
public static final String BACKEND_ID = "redis";
|
||||
|
||||
private final RedisClient client;
|
||||
|
||||
public RedisCacheStore(RedisClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendId() {
|
||||
return BACKEND_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
try {
|
||||
return client.read(key);
|
||||
} catch (Exception ex) {
|
||||
throw new CacheBackendException(BACKEND_ID, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {
|
||||
try {
|
||||
client.write(key, value);
|
||||
} catch (Exception ex) {
|
||||
throw new CacheBackendException(BACKEND_ID, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Redis cache template to a real
|
||||
* client. The skeleton carries no Redis SDK dependency — the implementation is supplied by the
|
||||
* project that enables Redis. Implementations may throw on a backend outage; the fail-open handling
|
||||
* is done by {@code FailOpenCacheStore} (module README).
|
||||
*/
|
||||
public interface RedisClient {
|
||||
|
||||
/**
|
||||
* Reads a value from Redis.
|
||||
*
|
||||
* @return the value, or empty if absent
|
||||
* @throws Exception on a backend/connection failure (handled fail-open by the store)
|
||||
*/
|
||||
Optional<String> read(String key) throws Exception;
|
||||
|
||||
/**
|
||||
* Writes a value to Redis.
|
||||
*
|
||||
* @throws Exception on a backend/connection failure (handled fail-open by the store)
|
||||
*/
|
||||
void write(String key, String value) throws Exception;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CacheBindingSettingsTest {
|
||||
|
||||
@Test
|
||||
void nullBindingsDefaultToAnEmptyMap() {
|
||||
// absent app.cache.bindings.* keys must not be a startup dependency (L262).
|
||||
CacheBindingSettings settings = new CacheBindingSettings(null);
|
||||
assertThat(settings.bindings()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingsAreDefensivelyCopied() {
|
||||
CacheBindingSettings settings = new CacheBindingSettings(Map.of("worklog", "redis"));
|
||||
assertThat(settings.bindings()).containsEntry("worklog", "redis");
|
||||
assertThatThrownBy(() -> settings.bindings().put("x", "y"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CacheStoreRouterTest {
|
||||
|
||||
@Test
|
||||
void routesEachLogicalCacheToItsBoundBackend() {
|
||||
CacheStoreRouter router =
|
||||
new CacheStoreRouter(
|
||||
List.of(fixedStore("redis", "from-redis"), fixedStore("local", "from-local")),
|
||||
Map.of("worklog", "redis", "codes", "local"));
|
||||
|
||||
assertThat(router.get("worklog", "k")).contains("from-redis");
|
||||
assertThat(router.get("codes", "k")).contains("from-local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void putRoutesToTheBoundBackend() {
|
||||
AtomicReference<String> written = new AtomicReference<>();
|
||||
CacheBackend recording =
|
||||
new CacheBackend() {
|
||||
@Override
|
||||
public String backendId() {
|
||||
return "redis";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {
|
||||
written.set(key + "=" + value);
|
||||
}
|
||||
};
|
||||
CacheStoreRouter router = new CacheStoreRouter(List.of(recording), Map.of("worklog", "redis"));
|
||||
|
||||
router.put("worklog", "k", "v");
|
||||
|
||||
assertThat(written.get()).isEqualTo("k=v");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundLogicalCacheFailsFastWithRemediationMessage() {
|
||||
// D4: a cache path that is not wired is a configuration error — fail fast,
|
||||
// never a silent no-op (DisabledAdapterSentinelTest contract, router edition).
|
||||
CacheStoreRouter router = new CacheStoreRouter(List.of(), Map.of());
|
||||
|
||||
assertThatThrownBy(() -> router.get("worklog", "k"))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.hasMessageContaining("app.cache.bindings.worklog")
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("cache");
|
||||
assertThatThrownBy(() -> router.put("worklog", "k", "v"))
|
||||
.isInstanceOf(AdapterDisabledException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingToAnUnknownBackendFailsConstruction() {
|
||||
// startup validation: a binding that names a backend with no enabled bean is a
|
||||
// configuration contradiction — surface it at boot, not on first cache access.
|
||||
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of("worklog", "redis")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("app.cache.bindings.worklog")
|
||||
.hasMessageContaining("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateBackendIdsFailConstruction() {
|
||||
// two contributions claiming the same backendId is a wiring bug — routing would
|
||||
// silently pick one of them; surface it at boot with the offending id.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CacheStoreRouter(
|
||||
List.of(fixedStore("redis", "a"), fixedStore("redis", "b")), Map.of()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("duplicate")
|
||||
.hasMessageContaining("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyRouterConstructsCleanly() {
|
||||
// L262: zero backends + zero bindings must not block startup.
|
||||
assertThat(new CacheStoreRouter(List.of(), Map.of())).isNotNull();
|
||||
}
|
||||
|
||||
private static CacheBackend fixedStore(String backendId, String cachedValue) {
|
||||
return new CacheBackend() {
|
||||
@Override
|
||||
public String backendId() {
|
||||
return backendId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
return Optional.of(cachedValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {}
|
||||
};
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
class FailOpenCacheStoreTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.failopen");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReturnsTheDelegateValueOnAHit() {
|
||||
FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger);
|
||||
|
||||
assertThat(store.get("k")).contains("cached");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backendIdDelegatesToTheWrappedBackend() {
|
||||
FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger);
|
||||
|
||||
// the decorator must not change routing identity — the router maps by this id.
|
||||
assertThat(store.backendId()).isEqualTo("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDegradesToACacheMissWhenTheDelegateThrows() {
|
||||
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
|
||||
|
||||
// fail-open: unavailable backend == cache-miss (Optional.empty), never an exception.
|
||||
assertThat(store.get("k")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOnFailingDelegateNeverThrowsAndLogsCorrelationId() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-cache-1");
|
||||
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
|
||||
|
||||
assertThatCode(() -> store.get("k")).doesNotThrowAnyException();
|
||||
|
||||
ILoggingEvent warn =
|
||||
appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow();
|
||||
assertThat(warn.getFormattedMessage())
|
||||
.contains("dependency_name=\"redis\"")
|
||||
.contains("dependency_type=\"cache\"")
|
||||
.contains("correlation_id=\"corr-cache-1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void putIsFailOpenWhenTheDelegateThrows() {
|
||||
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
|
||||
|
||||
assertThatCode(() -> store.put("k", "v")).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static CacheBackend fixedDelegate(String cachedValue) {
|
||||
return new CacheBackend() {
|
||||
@Override
|
||||
public String backendId() {
|
||||
return "redis";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
return Optional.of(cachedValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {}
|
||||
};
|
||||
}
|
||||
|
||||
private static CacheBackend failingDelegate() {
|
||||
return new CacheBackend() {
|
||||
@Override
|
||||
public String backendId() {
|
||||
return "redis";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
throw new RuntimeException("connection refused");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {
|
||||
throw new RuntimeException("connection refused");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RedisCacheStoreTest {
|
||||
|
||||
@Test
|
||||
void backendIdIsRedis() {
|
||||
// routing identity the router maps app.cache.bindings.* values against.
|
||||
assertThat(new RedisCacheStore(failingClient()).backendId()).isEqualTo("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReturnsTheClientValueOnAHit() {
|
||||
RedisCacheStore store =
|
||||
new RedisCacheStore(
|
||||
new RedisClient() {
|
||||
@Override
|
||||
public Optional<String> read(String key) {
|
||||
return Optional.of("cached");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {}
|
||||
});
|
||||
|
||||
assertThat(store.get("k")).contains("cached");
|
||||
}
|
||||
|
||||
@Test
|
||||
void putDelegatesToTheClient() {
|
||||
AtomicReference<String> written = new AtomicReference<>();
|
||||
RedisCacheStore store =
|
||||
new RedisCacheStore(
|
||||
new RedisClient() {
|
||||
@Override
|
||||
public Optional<String> read(String key) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {
|
||||
written.set(key + "=" + value);
|
||||
}
|
||||
});
|
||||
|
||||
store.put("k", "v");
|
||||
|
||||
assertThat(written.get()).isEqualTo("k=v");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWrapsACheckedClientFailureIntoCacheBackendException() {
|
||||
RedisCacheStore store = new RedisCacheStore(failingClient());
|
||||
|
||||
// fail-open is the decorator's job (FailOpenCacheStore) — the binding itself
|
||||
// must propagate, otherwise a failure could be silently mistaken for a miss.
|
||||
assertThatThrownBy(() -> store.get("k"))
|
||||
.isInstanceOf(CacheBackendException.class)
|
||||
.hasMessageContaining("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void putWrapsACheckedClientFailureIntoCacheBackendException() {
|
||||
RedisCacheStore store = new RedisCacheStore(failingClient());
|
||||
|
||||
assertThatThrownBy(() -> store.put("k", "v"))
|
||||
.isInstanceOf(CacheBackendException.class)
|
||||
.hasMessageContaining("redis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPropagatesEmptyOnAMiss() {
|
||||
RedisCacheStore store =
|
||||
new RedisCacheStore(
|
||||
new RedisClient() {
|
||||
@Override
|
||||
public Optional<String> read(String key) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {}
|
||||
});
|
||||
|
||||
assertThat(store.get("k")).isEmpty();
|
||||
}
|
||||
|
||||
private static RedisClient failingClient() {
|
||||
return new RedisClient() {
|
||||
@Override
|
||||
public Optional<String> read(String key) throws Exception {
|
||||
throw new Exception("connection refused");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) throws Exception {
|
||||
throw new Exception("connection refused");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# adapter:outbound:fileserver — module rules
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-fileserver`
|
||||
- Gradle path: `:adapter:outbound:fileserver`
|
||||
- Focused test: `./gradlew :adapter:outbound:fileserver:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing
|
||||
`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in
|
||||
[README.md](README.md).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Export tabular data as CSV files behind `FileExportPort`, written under
|
||||
`ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation
|
||||
(`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service.
|
||||
- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with
|
||||
`@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain
|
||||
class; the config assembles it as a bean.
|
||||
|
||||
## Allowed
|
||||
|
||||
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
|
||||
`adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle`
|
||||
enforces it. No
|
||||
`:domain-core`, no sibling adapters.
|
||||
- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor`
|
||||
(annotation processor) only.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
|
||||
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
|
||||
- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` /
|
||||
`List<String>` / `List<List<String>>` / `ExportedFile`.
|
||||
- Fully-qualified inline type references; more than one public top-level type per file.
|
||||
|
||||
## Tests
|
||||
|
||||
`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping,
|
||||
null-field, overwrite, path-traversal + blank-name rejection).
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:fileserver:check
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# adapter:outbound:fileserver — design-decision reference
|
||||
|
||||
File-server export outbound (driven) adapter. Package root:
|
||||
`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port
|
||||
`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty`
|
||||
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient /
|
||||
objectstorage).
|
||||
|
||||
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
||||
`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in
|
||||
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
||||
comments.
|
||||
|
||||
## Module overview
|
||||
|
||||
An **opt-in** file-export adapter placed behind an application-core port. A single
|
||||
`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a
|
||||
stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no
|
||||
external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only
|
||||
pins the shared Spring Boot / tooling graph.
|
||||
|
||||
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a
|
||||
single implementation, so no backend switch is needed; the `enabled` flag keeps the module from
|
||||
activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single
|
||||
`FileExportPort` bean on that flag.
|
||||
|
||||
## The port contract (framework/domain-neutral)
|
||||
|
||||
`FileExportPort` is a minimal, domain-neutral surface:
|
||||
|
||||
- `ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows)`.
|
||||
|
||||
The caller supplies a bare file name, an optional header row, and the data rows as lists of
|
||||
already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte
|
||||
encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`,
|
||||
`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled
|
||||
from the CSV format and the destination filesystem. A fork that needs a real domain export maps its
|
||||
rows to `List<List<String>>` at the call site (or adds a typed convenience method in its own layer).
|
||||
|
||||
## CSV escaping
|
||||
|
||||
Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage return, or
|
||||
line feed is wrapped in double-quotes with embedded quotes doubled. A `null` field is written as an
|
||||
empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file
|
||||
at the same name replaces it.
|
||||
|
||||
## IO-failure handling
|
||||
|
||||
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
|
||||
(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the
|
||||
other outbound dependencies. A blank file name or one that escapes the base directory (path
|
||||
traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter
|
||||
normalises the resolved path and checks it still starts with the base directory.
|
||||
|
||||
## Tests
|
||||
|
||||
- `FilesystemCsvExportAdapterTest` — `@TempDir` write/verify: header + rows, CSV escaping of a field
|
||||
containing a comma / quote / newline, null-field handling, header-only and headerless exports,
|
||||
overwrite, and path-traversal + blank-name rejection.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:fileserver:check
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
// Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes
|
||||
// delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file
|
||||
// server, or SFTP drop. Pure JDK filesystem IO, so there are NO external dependencies: the lockfile
|
||||
// only pins the shared Spring Boot / tooling graph. Opt-in via @ConditionalOnProperty
|
||||
// (ca-skeleton.fileserver.enabled), off by default so the module never activates unexpectedly.
|
||||
description = 'Outbound adapter: file server exports (filesystem/CSV)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.fileexport.FileExportPort;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Opt-in wiring for the file-server export adapter. The single {@link FileExportPort} bean is
|
||||
* contributed only when {@code ca-skeleton.fileserver.enabled=true}, so the module never activates
|
||||
* unexpectedly when merely present on the classpath (there is a single implementation, so no
|
||||
* backend selector is needed). The adapter is a plain class; this config assembles it as a bean,
|
||||
* mirroring the object-storage module.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(FileExportProperties.class)
|
||||
public class FileExportConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true")
|
||||
public FileExportPort filesystemCsvExportPort(FileExportProperties properties) {
|
||||
return new FilesystemCsvExportAdapter(properties.getBaseDirectory());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Typed settings for the file-server export adapter, bound from {@code ca-skeleton.fileserver.*}.
|
||||
* Bound as a mutable JavaBean (not a record) so a fork can leave any subset of fields unset and
|
||||
* inherit the defaults below.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.fileserver")
|
||||
public class FileExportProperties {
|
||||
|
||||
/**
|
||||
* Whether to contribute the export adapter. Defaults to {@code false} so the module never
|
||||
* activates unexpectedly when merely present on the classpath; a fork opts in explicitly.
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Base directory that export files are written under (stand-in for an NFS/SFTP drop). */
|
||||
private String baseDirectory = "./.data/fileserver";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBaseDirectory() {
|
||||
return baseDirectory;
|
||||
}
|
||||
|
||||
public void setBaseDirectory(String baseDirectory) {
|
||||
this.baseDirectory = baseDirectory;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import dev.caskeleton.application.fileexport.ExportedFile;
|
||||
import dev.caskeleton.application.fileexport.FileExportPort;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Filesystem-backed {@link FileExportPort} — writes a CSV export under a configured base directory
|
||||
* (the "file server" boundary, a stand-in for NFS/SFTP). Fields are escaped per RFC-4180 and the
|
||||
* file is UTF-8 encoded; the returned {@link ExportedFile} carries the absolute path, byte size,
|
||||
* and row count.
|
||||
*/
|
||||
public class FilesystemCsvExportAdapter implements FileExportPort {
|
||||
|
||||
private static final String DEPENDENCY_NAME = "fileserver";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FilesystemCsvExportAdapter.class);
|
||||
|
||||
private final Path baseDir;
|
||||
|
||||
public FilesystemCsvExportAdapter(String baseDirectory) {
|
||||
this.baseDir = Path.of(baseDirectory).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(baseDir);
|
||||
log.info("filesystem file-export base dir: {}", baseDir);
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR,
|
||||
DEPENDENCY_NAME,
|
||||
"cannot create file-export base dir",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows) {
|
||||
Objects.requireNonNull(header, "header must be non-null");
|
||||
Objects.requireNonNull(rows, "rows must be non-null");
|
||||
Path target = resolve(fileName);
|
||||
|
||||
StringBuilder csv = new StringBuilder();
|
||||
if (!header.isEmpty()) {
|
||||
appendRow(csv, header);
|
||||
}
|
||||
for (List<String> row : rows) {
|
||||
Objects.requireNonNull(row, "row must be non-null");
|
||||
appendRow(csv, row);
|
||||
}
|
||||
|
||||
byte[] bytes = csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.write(target, bytes);
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to write CSV export", e);
|
||||
}
|
||||
log.info("exported {} rows -> {} ({} bytes)", rows.size(), target, bytes.length);
|
||||
return new ExportedFile(fileName, target.toString(), bytes.length, rows.size());
|
||||
}
|
||||
|
||||
/** Appends one CSV record (comma-separated, escaped fields, trailing line feed). */
|
||||
private static void appendRow(StringBuilder csv, List<String> fields) {
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
if (i > 0) {
|
||||
csv.append(',');
|
||||
}
|
||||
csv.append(escape(fields.get(i)));
|
||||
}
|
||||
csv.append('\n');
|
||||
}
|
||||
|
||||
/** Minimal RFC-4180 escaping: quote fields containing a comma, quote, CR, or LF. */
|
||||
private static String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (value.contains(",")
|
||||
|| value.contains("\"")
|
||||
|| value.contains("\n")
|
||||
|| value.contains("\r")) {
|
||||
return '"' + value.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Resolves a bare file name under {@code baseDir}, rejecting blank names and path traversal. */
|
||||
private Path resolve(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
throw new IllegalArgumentException("fileName must be non-null and non-blank");
|
||||
}
|
||||
Path resolved = baseDir.resolve(fileName).normalize();
|
||||
if (!resolved.startsWith(baseDir) || resolved.equals(baseDir)) {
|
||||
throw new IllegalArgumentException("illegal export file name (path traversal): " + fileName);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.caskeleton.adapter.outbound.fileserver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.fileexport.ExportedFile;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Temp-dir CSV write/verify contract for {@link FilesystemCsvExportAdapter}. */
|
||||
class FilesystemCsvExportAdapterTest {
|
||||
|
||||
private Path baseDir;
|
||||
private FilesystemCsvExportAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) {
|
||||
baseDir = tempDir;
|
||||
adapter = new FilesystemCsvExportAdapter(tempDir.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsHeaderAndRowsWithCsvEscaping() throws IOException {
|
||||
List<String> header = List.of("id", "name", "note");
|
||||
List<List<String>> rows =
|
||||
List.of(
|
||||
List.of("1", "plain", "ok"),
|
||||
List.of("2", "has,comma", "quote\"inside"),
|
||||
List.of("3", "line\nbreak", "trailing"));
|
||||
|
||||
ExportedFile result = adapter.exportCsv("export.csv", header, rows);
|
||||
|
||||
Path written = baseDir.resolve("export.csv");
|
||||
assertThat(written).exists();
|
||||
assertThat(result.fileName()).isEqualTo("export.csv");
|
||||
assertThat(result.path()).isEqualTo(written.toAbsolutePath().normalize().toString());
|
||||
assertThat(result.rowCount()).isEqualTo(3);
|
||||
assertThat(result.byteSize()).isEqualTo(Files.size(written));
|
||||
|
||||
String content = Files.readString(written, StandardCharsets.UTF_8);
|
||||
assertThat(content)
|
||||
.isEqualTo(
|
||||
"id,name,note\n"
|
||||
+ "1,plain,ok\n"
|
||||
+ "2,\"has,comma\",\"quote\"\"inside\"\n"
|
||||
+ "3,\"line\nbreak\",trailing\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesHeaderOnlyWhenRowsEmpty() throws IOException {
|
||||
ExportedFile result = adapter.exportCsv("empty.csv", List.of("a", "b"), List.of());
|
||||
|
||||
assertThat(result.rowCount()).isZero();
|
||||
assertThat(Files.readString(baseDir.resolve("empty.csv"), StandardCharsets.UTF_8))
|
||||
.isEqualTo("a,b\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesNoHeaderLineWhenHeaderEmpty() throws IOException {
|
||||
adapter.exportCsv("headerless.csv", List.of(), List.of(List.of("only", "data")));
|
||||
|
||||
assertThat(Files.readString(baseDir.resolve("headerless.csv"), StandardCharsets.UTF_8))
|
||||
.isEqualTo("only,data\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullFieldIsWrittenAsEmpty() throws IOException {
|
||||
List<String> rowWithNull = Arrays.asList("x", null, "z");
|
||||
adapter.exportCsv("nulls.csv", List.of("a", "b", "c"), List.of(rowWithNull));
|
||||
|
||||
assertThat(Files.readString(baseDir.resolve("nulls.csv"), StandardCharsets.UTF_8))
|
||||
.isEqualTo("a,b,c\nx,,z\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overwritesExistingFile() throws IOException {
|
||||
adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("first")));
|
||||
adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("second")));
|
||||
|
||||
assertThat(Files.readString(baseDir.resolve("dup.csv"), StandardCharsets.UTF_8))
|
||||
.isEqualTo("a\nsecond\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTraversalFileNameIsRejected() {
|
||||
assertThatThrownBy(() -> adapter.exportCsv("../escape.csv", List.of("a"), List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankFileNameIsRejected() {
|
||||
assertThatThrownBy(() -> adapter.exportCsv(" ", List.of("a"), List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# adapter:outbound:httpclient — resilient HTTP client adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-httpclient`
|
||||
- Gradle path: `:adapter:outbound:httpclient`
|
||||
- Focused test: `./gradlew :adapter:outbound:httpclient:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.httpclient`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Own outbound REST client construction, timeouts, retries, circuit breakers, response-size bounds,
|
||||
trace propagation, diagnostics, and shutdown safety.
|
||||
- Adapt external HTTP calls behind application/domain ports.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
|
||||
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
|
||||
sequencing stay in application/domain layers.
|
||||
|
||||
## Tests
|
||||
|
||||
Use fake clients/servers or direct collaborator fakes with no real network. Settings receive
|
||||
binding/validation tests; retry/error mapping and resource bounds receive focused unit tests.
|
||||
@@ -0,0 +1,148 @@
|
||||
# adapter:outbound:httpclient — 설계 결정 참조
|
||||
|
||||
아웃바운드 HTTP client 베이스라인 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.httpclient`(`resilience`, `diagnostics` 서브패키지 포함).
|
||||
`:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:httpclient']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
|
||||
## OutboundHttpClient
|
||||
|
||||
단일 명명 의존성(named upstream dependency)용 베이스라인 HTTP 클라이언트.
|
||||
|
||||
### static `baseline(...)` 팩토리인 이유
|
||||
`public final class` + `private` 생성자 + `public static baseline(...)` 형태다. ArchUnit B7 은
|
||||
non-`@Configuration` 아웃바운드 클래스의 public **non-static** 메서드가 `..adapter.outbound..`
|
||||
타입을 반환하는 것을 금지한다. static 메서드는 B7 대상에서 제외되므로, 인스턴스 팩토리 대신
|
||||
static 팩토리를 쓴다.
|
||||
|
||||
### 템플릿 seam — 기본 client 빈 없음
|
||||
일반(generic) `OutboundHttpClient` 빈은 두지 않는다. 의존성마다 고유한 이름·base URL 이
|
||||
필요하므로, 포킹 프로젝트가 자신의 `@Configuration` 에서 `baseline(...)` 을 직접 호출해
|
||||
의존성별 인스턴스를 만든다 — `OutboundHttpClientConfig` 가 만들어 주지 않는다.
|
||||
|
||||
### 내부 RestClient 두 개
|
||||
`buffered` 와 `streaming` 두 인스턴스를 둔다. 둘은 하나의 공유 `JdkClientHttpRequestFactory`
|
||||
위에 만들어져 TCP 연결 풀/타임아웃 설정이 동일하다. `buffered` 만
|
||||
`ResponseSizeBoundingInterceptor`(D7)를 포함하고, `streaming` 은 크기 인터셉터 없이 raw
|
||||
`InputStream` 을 그대로 전달한다.
|
||||
|
||||
### retry 를 CB **바깥**에 두는 이유
|
||||
`exchange()` 의 decoration 순서는 CB(outer) → retry(inner)다. retry 를 CB 바깥에 둬야 각 retry
|
||||
시도가 CB 슬라이딩 윈도에 **독립적으로** 카운트된다. retry 를 CB 안에 두면 모든 재시도가 CB 호출
|
||||
1건으로 합산돼 실제 실패 빈도가 CB 에 가려진다.
|
||||
|
||||
### size 위반은 분류하지 않고 전파
|
||||
`OutboundResponseSizeExceededException` 은 의도적으로 `DependencyFailureException` 이
|
||||
**아니다**. 이는 업스트림 실패가 아니라 호출자가 잘못된 API 경로를 골랐다는 사용 계약
|
||||
(usage-contract) 위반이며, 분류 없이 그대로 전파한다. 큰 응답이 예상되면 호출자는 `stream()` 을
|
||||
써야 한다.
|
||||
|
||||
### streaming 경로에 retry 없음
|
||||
이미 소비된 스트림은 안전하게 재발행할 수 없다 — reader 에 이미 전달된 바이트는 잃고, 서버가
|
||||
처음부터 재전송을 보장하지 않는다. 그래서 `stream()` 은 retry 없이 shutdown 게이팅·분류·로깅만
|
||||
적용한다.
|
||||
|
||||
### OutboundHttpShutdownGuard — SmartLifecycle 인 이유
|
||||
`SmartLifecycle` + `getPhase() = Integer.MAX_VALUE`(가장 먼저 stop)로 종료 시 아웃바운드
|
||||
호출자보다 먼저 멈춘다. `ContextClosedEvent` 를 쓰지 않는 이유: SmartLifecycle phase 순서는
|
||||
결정적이고 close 시퀀스가 빈을 파괴하기 전에 동작하지만, `ContextClosedEvent` 는 컨텍스트 종료가
|
||||
시작된 뒤 발생하고 다른 lifecycle 빈과의 순서가 정의되지 않는다. 종료 중에는
|
||||
`DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast 한다(전용 shutdown 코드를 새로 만들지 않고
|
||||
가장 가까운 버킷을 재사용).
|
||||
|
||||
### OutboundHttpTimeoutEnforcer — `static @Bean` BeanPostProcessor
|
||||
raw `RestClient`/`RestClient.Builder` 빈이 등록되면 startup 을 실패시키는 BeanPostProcessor 다.
|
||||
`static @Bean` 으로 선언해야 다른 빈보다 먼저 생성된다 — non-static BeanPostProcessor 는 일찍
|
||||
생성되는 빈을 놓칠 수 있다. 인라인으로 직접 만든 `RestClient`(빈이 아닌)는 잡지 못하는 잔여
|
||||
리스크가 있다.
|
||||
|
||||
## resilience — `OutboundHttpResilienceConfig` / `OutboundHttpResilience` / `OutboundRetryPolicy`
|
||||
|
||||
### 메트릭 없는 resilience 금지 (D3)
|
||||
retry/CB 중 하나라도 켜지면 `MeterRegistry` 빈이 **반드시** 있어야 한다. low-cardinality 메트릭
|
||||
없이 retry/CB 를 돌리는 것은 D3 가 금지한다. enable 상태인데 registry 가 없으면 빈 생성 시점에
|
||||
`IllegalStateException` 으로 startup 을 실패시킨다.
|
||||
|
||||
### D4 메트릭 정규화 — `@Bean` 이 아니라 직접 주입
|
||||
`MeterFilter` 를 `MeterRegistry.Config#meterFilter` 로 **직접** 등록한다. Spring Boot Actuator 의
|
||||
`MeterRegistryCustomizer`(filter 빈을 주워가는 자동설정)는 이 모듈 classpath 에 없으므로,
|
||||
`@Bean MeterFilter` 로 등록하면 아무 효과가 없다.
|
||||
|
||||
### Micrometer 1.15.x 비호환 — 커스텀 `map(Meter.Id)` 필요
|
||||
`MeterFilter.replaceTagValues` / `MeterFilter.renameTag` 는 `TaggedRetryMetrics` /
|
||||
`TaggedCircuitBreakerMetrics` 가 등록한 `FunctionCounter`·`DefaultGauge` 인스턴스의 태그
|
||||
키/값을 Micrometer 1.15.x 에서 안정적으로 변환하지 못한다(이들 ID 가 편의 팩토리의 `map()`
|
||||
체인을 타지 않음). 명시적 `map(Meter.Id)` 구현을 가진 커스텀 `MeterFilter` 가 필요하다
|
||||
(Micrometer 1.15.11 / Resilience4j 2.2.0 에서 확인).
|
||||
|
||||
### MeterFilter 설치 순서 불변식
|
||||
필터는 설치 **이후** 등록되는 meter 에만 영향을 준다. `TaggedCircuitBreakerMetrics` 는 state
|
||||
gauge 를 `bindTo()` 시점에 즉시(eager) 등록하므로, state 태그 대문자화 필터는 `bindTo()`
|
||||
**전에** 설치해야 한다 — 아니면 그 gauge 들에는 변환이 조용히 누락된다. 정규화 필터(1–3)는
|
||||
DENY 필터(4)보다 먼저 설치해 rename 된 태그 키가 필터 4 의 NEUTRAL 판정에 보인다.
|
||||
|
||||
### 승인된 3개 meter 외 전부 DENY
|
||||
`resilience4j.retry.calls`, `resilience4j.circuitbreaker.calls`,
|
||||
`resilience4j.circuitbreaker.state` 만 통과시키고, vendor 가 추가로 내보내는 `failure.rate`,
|
||||
`buffered.calls`, `not.permitted.calls`, `slow.call.rate` 등은 D4 low-cardinality 를 위해
|
||||
필터 4 가 DENY 한다.
|
||||
|
||||
### OutboundHttpResilience — `Optional.empty()` 계약
|
||||
retry/CB 머신을 담는 홀더. `retryFor()` / `circuitBreakerFor()` 는 해당 기능이 비활성이면
|
||||
`Optional.empty()`(= decoration 없음)를 반환한다. 생성자의 registry 인자는 nullable — 기능이
|
||||
비활성일 때 `null` 을 넘긴다.
|
||||
|
||||
### OutboundRetryPolicy — POST/PATCH 는 항상 non-retryable
|
||||
재시도 조건은 ThreadLocal 호출 컨텍스트 기반의 4가지로, 멱등(idempotent) 메서드만 재시도한다.
|
||||
POST/PATCH 는 Idempotency-Key 계약이 정의되지 않았으므로 보수적으로 항상 재시도하지 않는다.
|
||||
exponential random backoff(jitter)는 settings 로 구동된다.
|
||||
|
||||
## diagnostics — `OutboundHttpErrorMapper` / `OutboundHttpDependencyLogger`
|
||||
|
||||
### 분류 순서가 타입 계층 때문에 중요
|
||||
`HttpConnectTimeoutException extends HttpTimeoutException` 이므로 connect-timeout 을
|
||||
read-timeout **보다 먼저** 검사해야 한다 — 순서가 바뀌면 connect 타임아웃이 read 타임아웃으로
|
||||
오분류된다. `ConnectException` 이 `UnresolvedAddressException`(JDK HttpClient 래핑)을 감쌀 수
|
||||
있어, DNS 검사는 `CONNECT_FAILED` 반환 전에 sub-cause 체인을 훑는다.
|
||||
|
||||
업스트림 4xx 는 408/429 포함 전부 `DEPENDENCY_4XX_CLIENT`(non-retryable, PERMANENT)로
|
||||
분류한다. 의미상 408/429 는 재시도 가능하지만 Idempotency-Key 계약이 없는 상태에서의 보수적·
|
||||
안전한 결정이며, 열린(open) 리스크로 남겨 둔다.
|
||||
|
||||
### 진단 메시지 본문 누출 금지 (D12)
|
||||
진단 메시지에 `getResponseBodyAsString()`, 응답 헤더, 업스트림 페이로드를 **절대** 포함하지
|
||||
않는다 — HTTP status code 와 예외 클래스명만 쓴다.
|
||||
|
||||
### 로그 레벨 규칙
|
||||
로그 필드는 MDC SSOT 를 따른다. SUCCESS=DEBUG, CIRCUIT_OPEN/REJECTED=WARN(예상되는 일시적
|
||||
상태 — use case 는 성공), 그 외 hard failure=ERROR. 이는 `:adapter:outbound:support` 의
|
||||
`FailOpenDependencyLogger`(선택형 어댑터 fail-open, 전부 WARN)와 명확히 구분된다 — 이 client
|
||||
는 호출자에게 직접 노출되는 hard failure 를 다루므로 ERROR 까지 올린다. 본문/수신자/페이로드를
|
||||
받지 않아 PII 가 로그에 닿지 않는다.
|
||||
|
||||
## TraceContextPropagationInterceptor
|
||||
|
||||
> 코드에는 압축된 경고만 남기고, 전체 메커니즘은 여기 둔다.
|
||||
|
||||
sampled 플래그가 `00`(not-sampled)로 하드코딩돼 있다 — 스켈레톤은 exporter/sampler 를
|
||||
와이어링하지 않고 foundation `mdc-keys.yaml` 에 `trace_flags`/`sampled` 키가 없어 inbound
|
||||
sampled 비트를 전파할 수 없기 때문이다. `traceparent` 는 `trace_id`+`span_id` 로만 재구성되고
|
||||
sampled 비트는 `00` 으로 강제된다.
|
||||
|
||||
포크가 실제 트레이서(Micrometer Tracing + OTel)를 붙이면 두 가지가 터진다:
|
||||
|
||||
1. **downstream suppression** — downstream `ParentBased` sampler 가 `00` 을 "parent not
|
||||
sampled" 로 읽고 child span 을 버린다. upstream 이 샘플링한 트레이스라도 이 경계에서 분산
|
||||
트레이스가 끊긴다.
|
||||
2. **이 인터셉터가 경쟁에서 이긴다** — RestClient 에 **가장 먼저** 등록돼(`OutboundHttpClient`
|
||||
참조) 나중 OTel instrumentation 인터셉터보다 앞서 `traceparent` 를 찍고, 멱등 가드가 실제
|
||||
instrumentation 을 스킵시킨다. 즉 `00` 은 fallback 이 아니라 실제 결정을 덮어쓴다.
|
||||
|
||||
**포크 체크리스트**: (a) 이 인터셉터를 비활성화/제거하고 `traceparent` 소유를 OTel 에 넘기거나,
|
||||
(b) `TraceParent.of(.., false)` 의 `false` 를 실제 `Span.getSpanContext().isSampled()` 로
|
||||
교체하고 foundation MDC `trace_flags` carrier 를 마련한다. 스켈레톤 테스트는 no-tracer
|
||||
메커니즘만 검증하며 live SDK 와의 합성은 검증하지 않는다.
|
||||
@@ -0,0 +1,19 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework:spring-web'
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -0,0 +1,166 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
|
||||
/**
|
||||
* Package-private observer that centralises duration calculation, success/failure logging, error
|
||||
* classification, and shutdown rejection for outbound HTTP calls.
|
||||
*/
|
||||
final class OutboundHttpCallObserver {
|
||||
|
||||
private final String dependencyName;
|
||||
private final OutboundHttpErrorMapper errorMapper;
|
||||
private final OutboundHttpDependencyLogger logger;
|
||||
|
||||
OutboundHttpCallObserver(
|
||||
String dependencyName,
|
||||
OutboundHttpErrorMapper errorMapper,
|
||||
OutboundHttpDependencyLogger logger) {
|
||||
this.dependencyName = dependencyName;
|
||||
this.errorMapper = errorMapper;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
void recordSuccess(long startNs, int retryAttempt) {
|
||||
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
|
||||
logger.logSuccess(dependencyName, durationMs, retryAttempt);
|
||||
}
|
||||
|
||||
/**
|
||||
* <em>Returns</em> the classified exception so the caller throws via {@code throw
|
||||
* observer.recordFailure(...)}.
|
||||
*/
|
||||
DependencyFailureException recordFailure(Throwable t, long startNs, int retryAttempt) {
|
||||
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
|
||||
DependencyFailureException dfe = errorMapper.classify(dependencyName, t);
|
||||
String outcome = outcomeFor(dfe);
|
||||
logger.logFailure(dependencyName, outcome, durationMs, retryAttempt, dfe);
|
||||
return dfe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses {@code DEPENDENCY_CIRCUIT_OPEN}/REJECTED semantics since no dedicated shutdown code
|
||||
* exists.
|
||||
*/
|
||||
DependencyFailureException rejectShutdown(String message) {
|
||||
DependencyFailureException rejected =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CIRCUIT_OPEN, dependencyName, message, null);
|
||||
logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
private static String outcomeFor(DependencyFailureException dfe) {
|
||||
return switch ((OperationalError) dfe.errorCode()) {
|
||||
case DEPENDENCY_TIMEOUT -> "TIMEOUT";
|
||||
case DEPENDENCY_CIRCUIT_OPEN -> "CIRCUIT_OPEN";
|
||||
default -> "FAILURE";
|
||||
};
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import java.io.InputStream;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Baseline outbound HTTP client for a single named upstream dependency. Created via the {@code
|
||||
* static baseline(...)} factory; holds two internal RestClients (buffered + streaming). Rationale
|
||||
* for the static factory, the two-client split, and retry/CB decoration order is in the module
|
||||
* README.
|
||||
*/
|
||||
public final class OutboundHttpClient {
|
||||
|
||||
private final String dependencyName;
|
||||
private final OutboundHttpSettings settings;
|
||||
private final OutboundHttpShutdownGuard shutdownGuard;
|
||||
private final OutboundHttpResilience resilience;
|
||||
private final OutboundRetryPolicy retryPolicy;
|
||||
private final OutboundHttpCallObserver observer;
|
||||
|
||||
private final RestClient bufferedClient;
|
||||
private final RestClient streamingClient;
|
||||
|
||||
private OutboundHttpClient(
|
||||
String dependencyName,
|
||||
String baseUrl,
|
||||
OutboundHttpSettings settings,
|
||||
OutboundHttpShutdownGuard shutdownGuard,
|
||||
OutboundHttpResilience resilience,
|
||||
OutboundRetryPolicy retryPolicy,
|
||||
OutboundHttpErrorMapper errorMapper,
|
||||
OutboundHttpDependencyLogger logger) {
|
||||
this.dependencyName = dependencyName;
|
||||
this.settings = settings;
|
||||
this.shutdownGuard = shutdownGuard;
|
||||
this.resilience = resilience;
|
||||
this.retryPolicy = retryPolicy;
|
||||
this.observer = new OutboundHttpCallObserver(dependencyName, errorMapper, logger);
|
||||
|
||||
var clients = OutboundHttpRestClientFactory.create(dependencyName, baseUrl, settings);
|
||||
this.bufferedClient = clients.buffered();
|
||||
this.streamingClient = clients.streaming();
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline client factory. Why it is static (B7) and the template-seam usage are in the module
|
||||
* README — forking projects call this per dependency from their own {@code @Configuration}.
|
||||
*/
|
||||
public static OutboundHttpClient baseline(
|
||||
String dependencyName,
|
||||
String baseUrl,
|
||||
OutboundHttpSettings settings,
|
||||
OutboundHttpShutdownGuard shutdownGuard,
|
||||
OutboundHttpResilience resilience,
|
||||
OutboundRetryPolicy retryPolicy,
|
||||
OutboundHttpErrorMapper errorMapper,
|
||||
OutboundHttpDependencyLogger logger) {
|
||||
return new OutboundHttpClient(
|
||||
dependencyName,
|
||||
baseUrl,
|
||||
settings,
|
||||
shutdownGuard,
|
||||
resilience,
|
||||
retryPolicy,
|
||||
errorMapper,
|
||||
logger);
|
||||
}
|
||||
|
||||
/** Shortcut: GET with buffered deserialization. */
|
||||
public <T> T get(String uri, Class<T> responseType) {
|
||||
return exchange(HttpMethod.GET, uri, null, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the response exceeds the size limit, {@link OutboundResponseSizeExceededException}
|
||||
* propagates unclassified (usage-contract violation — large responses must use {@link #stream}).
|
||||
*/
|
||||
public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
|
||||
if (shutdownGuard.isShuttingDown()) {
|
||||
throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast (D8)");
|
||||
}
|
||||
|
||||
Instant deadline = Instant.now().plus(settings.globalCallTimeout());
|
||||
retryPolicy.beginCall(method, deadline);
|
||||
|
||||
// Track attempt count for logging — declared outside try so catch can read it.
|
||||
int[] attemptCount = {0};
|
||||
long startNs = System.nanoTime();
|
||||
try {
|
||||
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
|
||||
|
||||
// Retry OUTSIDE the CB so each attempt is independently CB-counted
|
||||
// (inside the CB, all retries would count as a single CB call).
|
||||
Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName);
|
||||
Optional<Retry> retry = resilience.retryFor(dependencyName);
|
||||
|
||||
Supplier<T> countingSupplier =
|
||||
() -> {
|
||||
attemptCount[0]++;
|
||||
return supplier.get();
|
||||
};
|
||||
|
||||
Supplier<T> decorated = countingSupplier;
|
||||
if (retry.isPresent()) {
|
||||
decorated = Retry.decorateSupplier(retry.get(), decorated);
|
||||
}
|
||||
if (cb.isPresent()) {
|
||||
decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
|
||||
}
|
||||
|
||||
T result = decorated.get();
|
||||
|
||||
// retryAttempt = attemptCount - 1 (0 means the first attempt succeeded).
|
||||
observer.recordSuccess(startNs, Math.max(0, attemptCount[0] - 1));
|
||||
return result;
|
||||
|
||||
} catch (OutboundResponseSizeExceededException sizeEx) {
|
||||
// Size violation — propagate unclassified (usage-contract violation, not an upstream
|
||||
// failure).
|
||||
throw sizeEx;
|
||||
|
||||
} catch (Throwable t) {
|
||||
throw observer.recordFailure(t, startNs, Math.max(0, attemptCount[0] - 1));
|
||||
|
||||
} finally {
|
||||
retryPolicy.endCall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming API for large responses. No retry — a consumed stream cannot be safely re-issued
|
||||
* (delivered bytes are lost and the server may not support resending).
|
||||
*/
|
||||
public <T> T stream(HttpMethod method, String uri, Function<InputStream, T> reader) {
|
||||
if (shutdownGuard.isShuttingDown()) {
|
||||
throw observer.rejectShutdown(
|
||||
"shutdown in progress — outbound stream call rejected fail-fast (D8)");
|
||||
}
|
||||
|
||||
long startNs = System.nanoTime();
|
||||
try {
|
||||
T result =
|
||||
streamingClient
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.exchange((req, res) -> reader.apply(res.getBody()));
|
||||
|
||||
observer.recordSuccess(startNs, 0);
|
||||
return result;
|
||||
|
||||
} catch (Throwable t) {
|
||||
throw observer.recordFailure(t, startNs, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Supplier<T> buildSupplier(
|
||||
HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
|
||||
return () -> {
|
||||
var spec = bufferedClient.method(method).uri(uri);
|
||||
if (requestBody != null) {
|
||||
spec = spec.body(requestBody);
|
||||
}
|
||||
// Default RestClient status handling throws on 4xx/5xx; the error mapper classifies it.
|
||||
return spec.retrieve().body(responseType);
|
||||
};
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Registers the common outbound HTTP infrastructure beans. No default {@link OutboundHttpClient}
|
||||
* bean — forking projects call {@link OutboundHttpClient#baseline} per dependency (module README).
|
||||
* {@code @ConditionalOnMissingBean} on each bean lets a fork substitute its own implementation.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OutboundHttpSettings.class)
|
||||
public class OutboundHttpClientConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public OutboundHttpShutdownGuard outboundHttpShutdownGuard() {
|
||||
return new OutboundHttpShutdownGuard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be {@code static}: a BeanPostProcessor must be created before other beans to intercept
|
||||
* their post-init callbacks. A {@code static @Bean} is built directly by the BeanFactory
|
||||
* infrastructure, bypassing the {@code @Configuration} instance lifecycle, so it is ready early
|
||||
* enough.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public static OutboundHttpTimeoutEnforcer outboundHttpTimeoutEnforcer() {
|
||||
return new OutboundHttpTimeoutEnforcer();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public OutboundHttpErrorMapper outboundHttpErrorMapper() {
|
||||
return new OutboundHttpErrorMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public OutboundHttpDependencyLogger outboundHttpDependencyLogger() {
|
||||
return new OutboundHttpDependencyLogger();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public OutboundRetryPolicy outboundRetryPolicy(
|
||||
OutboundHttpSettings settings,
|
||||
OutboundHttpShutdownGuard guard,
|
||||
OutboundHttpErrorMapper mapper) {
|
||||
return new OutboundRetryPolicy(settings, guard, mapper);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Package-private factory that builds the shared {@link JdkClientHttpRequestFactory} and the
|
||||
* buffered/streaming {@link RestClient} pair for a single named dependency. Both share one request
|
||||
* factory so TCP pooling/settings are identical — only buffered carries the {@link
|
||||
* ResponseSizeBoundingInterceptor}.
|
||||
*/
|
||||
final class OutboundHttpRestClientFactory {
|
||||
|
||||
private OutboundHttpRestClientFactory() {}
|
||||
|
||||
record Clients(RestClient buffered, RestClient streaming) {}
|
||||
|
||||
static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) {
|
||||
HttpClient httpClient =
|
||||
HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build();
|
||||
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
|
||||
requestFactory.setReadTimeout(settings.readTimeout());
|
||||
|
||||
// Register TraceContextPropagationInterceptor first so trace headers exist before
|
||||
// the size interceptor inspects the response.
|
||||
RestClient buffered =
|
||||
RestClient.builder()
|
||||
.requestFactory(requestFactory)
|
||||
.baseUrl(baseUrl)
|
||||
.requestInterceptor(new TraceContextPropagationInterceptor())
|
||||
.requestInterceptor(
|
||||
new ResponseSizeBoundingInterceptor(
|
||||
dependencyName, settings.responseSizeLimit().toBytes()))
|
||||
.build();
|
||||
|
||||
RestClient streaming =
|
||||
RestClient.builder()
|
||||
.requestFactory(requestFactory)
|
||||
.baseUrl(baseUrl)
|
||||
.requestInterceptor(new TraceContextPropagationInterceptor())
|
||||
.build();
|
||||
|
||||
return new Clients(buffered, streaming);
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
/**
|
||||
* Typed settings for the outbound HTTP client baseline, bound from {@code app.outbound.http.*}. The
|
||||
* compact constructor rejects missing/zero/negative timeouts at binding time (startup failure); the
|
||||
* rationale is in the module README.
|
||||
*
|
||||
* @param connectTimeout TCP connect timeout; must be positive
|
||||
* @param readTimeout socket read timeout; must be positive
|
||||
* @param globalCallTimeout end-to-end deadline budget per call including retries; must be positive
|
||||
* @param retryEnabled whether the Resilience4j retry decorator is active
|
||||
* @param circuitBreakerEnabled whether the Resilience4j circuit-breaker decorator is active
|
||||
* @param responseSizeLimit max in-memory response body size; null defaults to 10 MB; zero/negative
|
||||
* forbidden
|
||||
* @param retry retry tuning; null applies defaults
|
||||
* @param circuitBreaker circuit-breaker tuning; null applies defaults
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.outbound.http")
|
||||
public record OutboundHttpSettings(
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
Duration globalCallTimeout,
|
||||
boolean retryEnabled,
|
||||
boolean circuitBreakerEnabled,
|
||||
DataSize responseSizeLimit,
|
||||
Retry retry,
|
||||
CircuitBreaker circuitBreaker) {
|
||||
|
||||
/** Registry default for {@code APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT}. */
|
||||
private static final DataSize DEFAULT_RESPONSE_SIZE_LIMIT = DataSize.ofMegabytes(10);
|
||||
|
||||
private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3;
|
||||
private static final Duration DEFAULT_RETRY_INITIAL_BACKOFF = Duration.ofMillis(100);
|
||||
private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0;
|
||||
|
||||
private static final float DEFAULT_CB_FAILURE_RATE_THRESHOLD = 50f;
|
||||
private static final int DEFAULT_CB_SLIDING_WINDOW_SIZE = 100;
|
||||
private static final int DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS = 100;
|
||||
private static final Duration DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE = Duration.ofSeconds(60);
|
||||
private static final int DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN = 10;
|
||||
|
||||
@ConstructorBinding
|
||||
public OutboundHttpSettings {
|
||||
if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CONNECT_TIMEOUT (app.outbound.http.connect-timeout) must be a "
|
||||
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
|
||||
}
|
||||
if (readTimeout == null || readTimeout.isZero() || readTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_READ_TIMEOUT (app.outbound.http.read-timeout) must be a "
|
||||
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
|
||||
}
|
||||
if (globalCallTimeout == null || globalCallTimeout.isZero() || globalCallTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT (app.outbound.http.global-call-timeout) must be a "
|
||||
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
|
||||
}
|
||||
if (responseSizeLimit == null) {
|
||||
responseSizeLimit = DEFAULT_RESPONSE_SIZE_LIMIT;
|
||||
} else if (responseSizeLimit.toBytes() <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT (app.outbound.http.response-size-limit) "
|
||||
+ "must be a positive DataSize (registry default 10MB)");
|
||||
}
|
||||
// Unset nested sections (absent yml / secondary ctor) → substitute defaults-filled records.
|
||||
if (retry == null) {
|
||||
retry = new Retry(null, null, null);
|
||||
}
|
||||
if (circuitBreaker == null) {
|
||||
circuitBreaker = new CircuitBreaker(null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary constructor: defaults for resilience tuning; preserves the original 6-arg call sites.
|
||||
*/
|
||||
public OutboundHttpSettings(
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout,
|
||||
Duration globalCallTimeout,
|
||||
boolean retryEnabled,
|
||||
boolean circuitBreakerEnabled,
|
||||
DataSize responseSizeLimit) {
|
||||
this(
|
||||
connectTimeout,
|
||||
readTimeout,
|
||||
globalCallTimeout,
|
||||
retryEnabled,
|
||||
circuitBreakerEnabled,
|
||||
responseSizeLimit,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resilience4j retry tuning. Null fields fall back to defaults (maxAttempts=3 /
|
||||
* initialBackoff=100ms / backoffMultiplier=2.0), preserving the prior hardcoded behavior.
|
||||
*/
|
||||
public record Retry(Integer maxAttempts, Duration initialBackoff, Double backoffMultiplier) {
|
||||
public Retry {
|
||||
if (maxAttempts == null) {
|
||||
maxAttempts = DEFAULT_RETRY_MAX_ATTEMPTS;
|
||||
} else if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS (app.outbound.http.retry.max-attempts) "
|
||||
+ "must be >= 1 (positive_int)");
|
||||
}
|
||||
if (initialBackoff == null) {
|
||||
initialBackoff = DEFAULT_RETRY_INITIAL_BACKOFF;
|
||||
} else if (initialBackoff.isZero() || initialBackoff.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF (app.outbound.http.retry.initial-backoff) "
|
||||
+ "must be a positive duration (spring_duration_shorthand_non_zero)");
|
||||
}
|
||||
if (backoffMultiplier == null) {
|
||||
backoffMultiplier = DEFAULT_RETRY_BACKOFF_MULTIPLIER;
|
||||
} else if (backoffMultiplier < 1.0) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER (app.outbound.http.retry.backoff-multiplier) "
|
||||
+ "must be >= 1.0 (double_ge_1)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resilience4j circuit-breaker tuning. Null fields fall back to Resilience4j {@code ofDefaults()}
|
||||
* (failureRate=50 / slidingWindow=100 / minCalls=100 / waitOpen=60s / permittedHalfOpen=10).
|
||||
* slidingWindowType is not exposed and stays the library default (COUNT_BASED).
|
||||
*/
|
||||
public record CircuitBreaker(
|
||||
Float failureRateThreshold,
|
||||
Integer slidingWindowSize,
|
||||
Integer minimumNumberOfCalls,
|
||||
Duration waitDurationInOpenState,
|
||||
Integer permittedCallsInHalfOpen) {
|
||||
public CircuitBreaker {
|
||||
if (failureRateThreshold == null) {
|
||||
failureRateThreshold = DEFAULT_CB_FAILURE_RATE_THRESHOLD;
|
||||
} else if (failureRateThreshold <= 0f || failureRateThreshold > 100f) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD "
|
||||
+ "(app.outbound.http.circuit-breaker.failure-rate-threshold) "
|
||||
+ "must be in (0, 100] (float_in_0_exclusive_to_100)");
|
||||
}
|
||||
if (slidingWindowSize == null) {
|
||||
slidingWindowSize = DEFAULT_CB_SLIDING_WINDOW_SIZE;
|
||||
} else if (slidingWindowSize < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE "
|
||||
+ "(app.outbound.http.circuit-breaker.sliding-window-size) must be >= 1 (positive_int)");
|
||||
}
|
||||
if (minimumNumberOfCalls == null) {
|
||||
minimumNumberOfCalls = DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS;
|
||||
} else if (minimumNumberOfCalls < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS "
|
||||
+ "(app.outbound.http.circuit-breaker.minimum-number-of-calls) must be >= 1 (positive_int)");
|
||||
}
|
||||
if (waitDurationInOpenState == null) {
|
||||
waitDurationInOpenState = DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE;
|
||||
} else if (waitDurationInOpenState.isZero() || waitDurationInOpenState.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE "
|
||||
+ "(app.outbound.http.circuit-breaker.wait-duration-in-open-state) "
|
||||
+ "must be a positive duration (spring_duration_shorthand_non_zero)");
|
||||
}
|
||||
if (permittedCallsInHalfOpen == null) {
|
||||
permittedCallsInHalfOpen = DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN;
|
||||
} else if (permittedCallsInHalfOpen < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN "
|
||||
+ "(app.outbound.http.circuit-breaker.permitted-calls-in-half-open) must be >= 1 (positive_int)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* Outbound HTTP client shutdown guard. With {@code getPhase() = }{@link Integer#MAX_VALUE} it is
|
||||
* stopped first during shutdown, setting the {@link #isShuttingDown()} flag ahead of any outbound
|
||||
* caller. Why SmartLifecycle instead of {@code ContextClosedEvent}: see the module README.
|
||||
*/
|
||||
public final class OutboundHttpShutdownGuard implements SmartLifecycle {
|
||||
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running.set(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
shuttingDown.set(true);
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoStartup() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* True after {@link #stop()} — callers short-circuit on this flag instead of waiting for
|
||||
* timeouts.
|
||||
*/
|
||||
public boolean isShuttingDown() {
|
||||
return shuttingDown.get();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.BeanCreationException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* {@link BeanPostProcessor} that blocks raw, timeout-less {@link RestClient} / {@link
|
||||
* RestClient.Builder} beans at startup. On detection it fails context startup, pointing to {@code
|
||||
* OutboundHttpClient.baseline(...)} and the required env keys. It cannot catch a non-bean inline
|
||||
* {@code RestClient.create()} — code review and the import-gate (G4) defend that case.
|
||||
*/
|
||||
public final class OutboundHttpTimeoutEnforcer implements BeanPostProcessor {
|
||||
|
||||
private static final String ERROR_MESSAGE =
|
||||
"A raw RestClient or RestClient.Builder bean was detected. "
|
||||
+ "All outbound HTTP clients must be built via OutboundHttpClient.baseline(...) "
|
||||
+ "so that connect, read, and global-call timeouts are applied. "
|
||||
+ "Set the required env keys: "
|
||||
+ "APP_OUTBOUND_HTTP_CONNECT_TIMEOUT, "
|
||||
+ "APP_OUTBOUND_HTTP_READ_TIMEOUT, "
|
||||
+ "APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT "
|
||||
+ "(feature-outbound-http-client-baseline I2 — timeout | Forbidden row).";
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (bean instanceof RestClient || bean instanceof RestClient.Builder) {
|
||||
throw new BeanCreationException(beanName, ERROR_MESSAGE);
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
/**
|
||||
* Thrown on the BUFFERED client path when a response body exceeds {@link
|
||||
* OutboundHttpSettings#responseSizeLimit()}. Callers expecting large responses must use {@link
|
||||
* OutboundHttpClient#stream}.
|
||||
*
|
||||
* <p>Intentionally NOT a {@link dev.caskeleton.shared.error.DependencyFailureException} — it is a
|
||||
* usage-contract violation (wrong API path), not an upstream failure, so it propagates
|
||||
* unclassified.
|
||||
*/
|
||||
public final class OutboundResponseSizeExceededException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String dependencyName;
|
||||
private final long limitBytes;
|
||||
|
||||
public OutboundResponseSizeExceededException(String dependencyName, long limitBytes) {
|
||||
super(
|
||||
"Response from dependency '"
|
||||
+ dependencyName
|
||||
+ "' exceeds the configured limit of "
|
||||
+ limitBytes
|
||||
+ " bytes. Use the streaming API (OutboundHttpClient#stream) "
|
||||
+ "for responses larger than the limit (D7: responses above the limit must be "
|
||||
+ "streamed; buffered in-memory load above the limit is forbidden).");
|
||||
this.dependencyName = dependencyName;
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
|
||||
/** The upstream dependency name that triggered the size violation. */
|
||||
public String dependencyName() {
|
||||
return dependencyName;
|
||||
}
|
||||
|
||||
/** The configured limit in bytes that was exceeded. */
|
||||
public long limitBytes() {
|
||||
return limitBytes;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpMethod;
|
||||
|
||||
/**
|
||||
* Per-call retry gate for the outbound HTTP client. {@link #shouldRetry(Throwable)} retries only
|
||||
* when all four conditions hold (not shutting down, idempotent method, retryable classification,
|
||||
* deadline budget remaining). The call context lives in a ThreadLocal; pair {@link
|
||||
* #beginCall}/{@link #endCall} in try/finally.
|
||||
*/
|
||||
public final class OutboundRetryPolicy {
|
||||
|
||||
/**
|
||||
* Only RFC 9110 idempotent methods are retried. POST/PATCH are always excluded (no
|
||||
* Idempotency-Key contract).
|
||||
*/
|
||||
private static final Set<HttpMethod> IDEMPOTENT_METHODS =
|
||||
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE);
|
||||
|
||||
private final OutboundHttpShutdownGuard guard;
|
||||
private final OutboundHttpErrorMapper mapper;
|
||||
|
||||
private final ThreadLocal<CallContext> callContextHolder = new ThreadLocal<>();
|
||||
|
||||
public OutboundRetryPolicy(
|
||||
OutboundHttpSettings settings,
|
||||
OutboundHttpShutdownGuard guard,
|
||||
OutboundHttpErrorMapper mapper) {
|
||||
// settings kept in the signature for future policy extension — the gate uses guard/mapper only.
|
||||
this.guard = guard;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Must be paired with {@link #endCall()} in try/finally. */
|
||||
public void beginCall(HttpMethod method, Instant deadline) {
|
||||
callContextHolder.set(new CallContext(method, deadline));
|
||||
}
|
||||
|
||||
public void endCall() {
|
||||
callContextHolder.remove();
|
||||
}
|
||||
|
||||
public boolean shouldRetry(Throwable failure) {
|
||||
if (guard.isShuttingDown()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CallContext ctx = callContextHolder.get();
|
||||
if (ctx == null) {
|
||||
return false;
|
||||
}
|
||||
if (!IDEMPOTENT_METHODS.contains(ctx.method())) {
|
||||
// POST/PATCH are always false — Idempotency-Key contract undefined.
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean retryable;
|
||||
if (failure instanceof DependencyFailureException dfe) {
|
||||
// Already classified — use the embedded code to avoid double-classification.
|
||||
retryable = dfe.errorCode().retryable();
|
||||
} else {
|
||||
retryable = mapper.classify("_retry-check_", failure).errorCode().retryable();
|
||||
}
|
||||
if (!retryable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Instant.now().isBefore(ctx.deadline());
|
||||
}
|
||||
|
||||
private record CallContext(HttpMethod method, Instant deadline) {}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
/**
|
||||
* Interceptor for the BUFFERED RestClient path that enforces {@link
|
||||
* OutboundHttpSettings#responseSizeLimit()}. Rejects immediately when Content-Length exceeds the
|
||||
* limit; since that header may be absent or wrong, it also wraps the body in a counting {@link
|
||||
* BoundedInputStream} that throws once the limit is crossed.
|
||||
*/
|
||||
final class ResponseSizeBoundingInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
private final String dependencyName;
|
||||
private final long limitBytes;
|
||||
|
||||
ResponseSizeBoundingInterceptor(String dependencyName, long limitBytes) {
|
||||
this.dependencyName = dependencyName;
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(
|
||||
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
|
||||
ClientHttpResponse response = execution.execute(request, body);
|
||||
|
||||
// Level 1: Content-Length fast-path — refuse immediately, no body bytes consumed.
|
||||
long contentLength = response.getHeaders().getContentLength();
|
||||
if (contentLength > limitBytes) {
|
||||
response.close();
|
||||
throw new OutboundResponseSizeExceededException(dependencyName, limitBytes);
|
||||
}
|
||||
|
||||
// Level 2: wrap the body stream with a counting InputStream that throws once
|
||||
// the limit is crossed (handles missing or lying Content-Length).
|
||||
return new SizeCapClientHttpResponse(response, dependencyName, limitBytes);
|
||||
}
|
||||
|
||||
private static final class SizeCapClientHttpResponse implements ClientHttpResponse {
|
||||
|
||||
private final ClientHttpResponse delegate;
|
||||
private final String dependencyName;
|
||||
private final long limitBytes;
|
||||
private InputStream boundedBody;
|
||||
|
||||
SizeCapClientHttpResponse(ClientHttpResponse delegate, String dependencyName, long limitBytes) {
|
||||
this.delegate = delegate;
|
||||
this.dependencyName = dependencyName;
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getBody() throws IOException {
|
||||
if (boundedBody == null) {
|
||||
boundedBody = new BoundedInputStream(delegate.getBody(), dependencyName, limitBytes);
|
||||
}
|
||||
return boundedBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpStatusCode getStatusCode() throws IOException {
|
||||
return delegate.getStatusCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStatusText() throws IOException {
|
||||
return delegate.getStatusText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return delegate.getHeaders();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
delegate.close();
|
||||
}
|
||||
}
|
||||
|
||||
static final class BoundedInputStream extends InputStream {
|
||||
|
||||
private final InputStream delegate;
|
||||
private final String dependencyName;
|
||||
private final long limitBytes;
|
||||
private long bytesRead = 0;
|
||||
|
||||
BoundedInputStream(InputStream delegate, String dependencyName, long limitBytes) {
|
||||
this.delegate = delegate;
|
||||
this.dependencyName = dependencyName;
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
int b = delegate.read();
|
||||
if (b != -1) {
|
||||
checkLimit(1);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buf, int off, int len) throws IOException {
|
||||
int n = delegate.read(buf, off, len);
|
||||
if (n > 0) {
|
||||
checkLimit(n);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
delegate.close();
|
||||
}
|
||||
|
||||
private void checkLimit(int n) {
|
||||
bytesRead += n;
|
||||
if (bytesRead > limitBytes) {
|
||||
throw new OutboundResponseSizeExceededException(dependencyName, limitBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import dev.caskeleton.shared.tracing.BaggageAllowlist;
|
||||
import dev.caskeleton.shared.tracing.TraceParent;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
/**
|
||||
* {@link ClientHttpRequestInterceptor} that propagates the distributed trace context from the
|
||||
* current SLF4J {@link MDC} into outbound HTTP headers ({@code traceparent}, {@code X-Request-Id},
|
||||
* {@code X-Correlation-Id}, {@code baggage}). Baggage is filtered by {@link BaggageAllowlist} so
|
||||
* only {@code request_id}/{@code tenant_id} leave and credentials/PII are stripped (D8).
|
||||
* Already-set headers are not overwritten, and the downstream call always runs.
|
||||
*
|
||||
* <p><strong>⚠ FORK LANDMINE:</strong> the sampled flag is hardcoded {@code 00} (not-sampled) and
|
||||
* this interceptor is registered FIRST on the RestClient — a fork that wires a real tracer
|
||||
* (Micrometer Tracing + OTel) will have its {@code traceparent} overwritten and its sampling
|
||||
* decision dropped. A fork MUST disable this interceptor OR replace the hardcoded flag. See the
|
||||
* module README (FORK LANDMINE section) for the full mechanism and checklist.
|
||||
*/
|
||||
public final class TraceContextPropagationInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
// snake_case MDC keys — SSOT: docs/registries/mdc-keys.yaml (foundation D11)
|
||||
private static final String MDC_TRACE_ID = "trace_id";
|
||||
private static final String MDC_SPAN_ID = "span_id";
|
||||
private static final String MDC_REQUEST_ID = "request_id";
|
||||
private static final String MDC_CORRELATION_ID = "correlation_id";
|
||||
private static final String MDC_TENANT_ID = "tenant_id";
|
||||
|
||||
// Outbound header names (W3C / docs/registries/headers.yaml)
|
||||
private static final String HEADER_TRACEPARENT = "traceparent";
|
||||
private static final String HEADER_REQUEST_ID = "X-Request-Id";
|
||||
private static final String HEADER_CORRELATION_ID = "X-Correlation-Id";
|
||||
private static final String HEADER_BAGGAGE = "baggage";
|
||||
|
||||
// Baggage MDC keys to collect (must all be allowlisted — D8 defense in depth)
|
||||
private static final List<String> BAGGAGE_MDC_KEYS = List.of(MDC_REQUEST_ID, MDC_TENANT_ID);
|
||||
|
||||
@Override
|
||||
public ClientHttpResponse intercept(
|
||||
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
|
||||
injectHeaders(request);
|
||||
return execution.execute(request, body);
|
||||
}
|
||||
|
||||
private void injectHeaders(HttpRequest request) {
|
||||
injectTraceparent(request);
|
||||
injectSingleHeader(request, HEADER_REQUEST_ID, MDC.get(MDC_REQUEST_ID));
|
||||
injectSingleHeader(request, HEADER_CORRELATION_ID, MDC.get(MDC_CORRELATION_ID));
|
||||
injectBaggage(request);
|
||||
}
|
||||
|
||||
private void injectTraceparent(HttpRequest request) {
|
||||
if (request.getHeaders().containsHeader(HEADER_TRACEPARENT)) {
|
||||
return; // already set — do not overwrite
|
||||
}
|
||||
String traceId = MDC.get(MDC_TRACE_ID);
|
||||
String spanId = MDC.get(MDC_SPAN_ID);
|
||||
if (!TraceParent.isValidTraceId(traceId) || !TraceParent.isValidSpanId(spanId)) {
|
||||
return; // malformed or absent — skip silently
|
||||
}
|
||||
try {
|
||||
// sampled=false: the skeleton has no exporter/sampler and mdc-keys.yaml has no
|
||||
// trace-flags key, so the inbound sampled bit cannot be propagated (see FORK LANDMINE).
|
||||
TraceParent tp = TraceParent.of(traceId, spanId, false);
|
||||
request.getHeaders().set(HEADER_TRACEPARENT, tp.toHeader());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// The guards above should prevent this; if reached (e.g. concurrent MDC mutation), skip
|
||||
// instead of throwing.
|
||||
}
|
||||
}
|
||||
|
||||
private void injectBaggage(HttpRequest request) {
|
||||
if (request.getHeaders().containsHeader(HEADER_BAGGAGE)) {
|
||||
return; // already set — do not overwrite
|
||||
}
|
||||
Map<String, String> raw = new LinkedHashMap<>();
|
||||
for (String key : BAGGAGE_MDC_KEYS) {
|
||||
String value = MDC.get(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
raw.put(key, value);
|
||||
}
|
||||
}
|
||||
// Defense in depth: even if BAGGAGE_MDC_KEYS drifts, the filter removes forbidden keys.
|
||||
Map<String, String> safe = BaggageAllowlist.filter(raw);
|
||||
String rendered = BaggageAllowlist.renderHeader(safe);
|
||||
if (!rendered.isEmpty()) {
|
||||
request.getHeaders().set(HEADER_BAGGAGE, rendered);
|
||||
}
|
||||
}
|
||||
|
||||
private static void injectSingleHeader(HttpRequest request, String headerName, String mdcValue) {
|
||||
if (mdcValue == null || mdcValue.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (request.getHeaders().containsHeader(headerName)) {
|
||||
return; // do not overwrite existing header
|
||||
}
|
||||
request.getHeaders().set(headerName, mdcValue);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Structured log emitter for the outbound HTTP path. Field names follow the registry
|
||||
* log_field_mapping SSOT. It accepts no body/URI/payload, so PII cannot reach the log (D13). Log
|
||||
* levels: success=DEBUG, outcome CIRCUIT_OPEN/REJECTED=WARN (expected transient state), other
|
||||
* failures=ERROR.
|
||||
*/
|
||||
public final class OutboundHttpDependencyLogger {
|
||||
|
||||
private final Logger log;
|
||||
|
||||
public OutboundHttpDependencyLogger() {
|
||||
this(LoggerFactory.getLogger(OutboundHttpDependencyLogger.class));
|
||||
}
|
||||
|
||||
/** Test seam — inject a logger bound to a captured appender. */
|
||||
public OutboundHttpDependencyLogger(Logger log) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
public void logSuccess(String dependencyName, long durationMs, int retryAttempt) {
|
||||
log.debug(
|
||||
"dependency_name=\"{}\" dependency_type=\"http\" outcome=\"SUCCESS\" "
|
||||
+ "duration_ms={} retry_attempt={} correlation_id=\"{}\"",
|
||||
dependencyName,
|
||||
durationMs,
|
||||
retryAttempt,
|
||||
OutboundCorrelation.current());
|
||||
}
|
||||
|
||||
public void logFailure(
|
||||
String dependencyName, String outcome, long durationMs, int retryAttempt, Throwable cause) {
|
||||
String errorField = cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
String format =
|
||||
"dependency_name=\"{}\" dependency_type=\"http\" "
|
||||
+ "outcome=\"{}\" "
|
||||
+ "duration_ms={} "
|
||||
+ "retry_attempt={} "
|
||||
+ "correlation_id=\"{}\" "
|
||||
+ "error=\"{}\"";
|
||||
|
||||
if ("CIRCUIT_OPEN".equals(outcome) || "REJECTED".equals(outcome)) {
|
||||
log.warn(
|
||||
format,
|
||||
dependencyName,
|
||||
outcome,
|
||||
durationMs,
|
||||
retryAttempt,
|
||||
OutboundCorrelation.current(),
|
||||
errorField);
|
||||
} else {
|
||||
log.error(
|
||||
format,
|
||||
dependencyName,
|
||||
outcome,
|
||||
durationMs,
|
||||
retryAttempt,
|
||||
OutboundCorrelation.current(),
|
||||
errorField);
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
|
||||
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpConnectTimeoutException;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.channels.UnresolvedAddressException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
/**
|
||||
* Classifies raw network/HTTP exceptions into stable {@link OperationalError}{@code .DEPENDENCY_*}
|
||||
* codes. Walks the full cause chain once and takes the first match in priority order. Why the
|
||||
* ordering matters (type hierarchy) and the 4xx/408/429 policy are in the module README.
|
||||
*
|
||||
* <p>Body-leakage safety (D12): the returned diagnostic message never includes the response body,
|
||||
* headers, or payload — only the HTTP status and exception class names. The original throwable is
|
||||
* attached as the cause only.
|
||||
*/
|
||||
public final class OutboundHttpErrorMapper {
|
||||
|
||||
/** Returns the classified exception — never {@code null}; always rethrow or propagate. */
|
||||
public DependencyFailureException classify(String dependencyName, Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current != null) {
|
||||
if (current instanceof CallNotPermittedException) {
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CIRCUIT_OPEN,
|
||||
dependencyName,
|
||||
"Circuit breaker open for dependency: "
|
||||
+ dependencyName
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
if (current instanceof UnknownHostException
|
||||
|| current instanceof UnresolvedAddressException) {
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_DNS_FAILED,
|
||||
dependencyName,
|
||||
"DNS resolution failed for dependency: "
|
||||
+ dependencyName
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
// HttpConnectTimeoutException extends HttpTimeoutException — check before the read-timeout
|
||||
// rule.
|
||||
if (current instanceof HttpConnectTimeoutException) {
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CONNECT_FAILED,
|
||||
dependencyName,
|
||||
"Connect failed for dependency: "
|
||||
+ dependencyName
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
if (current instanceof ConnectException) {
|
||||
// A ConnectException may wrap an UnresolvedAddressException, so DNS takes priority.
|
||||
if (hasDnsCauseInChain(current.getCause())) {
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_DNS_FAILED,
|
||||
dependencyName,
|
||||
"DNS resolution failed for dependency: "
|
||||
+ dependencyName
|
||||
+ " (wrapped in ConnectException; root cause "
|
||||
+ rootCauseClassName(current)
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CONNECT_FAILED,
|
||||
dependencyName,
|
||||
"Connect failed for dependency: "
|
||||
+ dependencyName
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
if (current instanceof HttpTimeoutException
|
||||
|| current instanceof SocketTimeoutException
|
||||
|| current instanceof TimeoutException) {
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_TIMEOUT,
|
||||
dependencyName,
|
||||
"Timeout for dependency: "
|
||||
+ dependencyName
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
if (current instanceof RestClientResponseException responseEx) {
|
||||
int status = responseEx.getStatusCode().value();
|
||||
if (status >= 400 && status < 500) {
|
||||
String diagnostic = build4xxDiagnostic(dependencyName, status);
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_4XX_CLIENT, dependencyName, diagnostic, failure);
|
||||
}
|
||||
if (status >= 500) {
|
||||
// Diagnostic message must NOT include getResponseBodyAsString() (D12).
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_5XX_SERVER,
|
||||
dependencyName,
|
||||
"Upstream 5xx from dependency: "
|
||||
+ dependencyName
|
||||
+ " status="
|
||||
+ status
|
||||
+ " ("
|
||||
+ current.getClass().getSimpleName()
|
||||
+ ")",
|
||||
failure);
|
||||
}
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
|
||||
// No recognised cause in the chain — conservative fallback.
|
||||
String rootCauseClass = rootCauseClassName(failure);
|
||||
return new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CONNECT_FAILED,
|
||||
dependencyName,
|
||||
"Unclassified failure for dependency: " + dependencyName + " root-cause=" + rootCauseClass,
|
||||
failure);
|
||||
}
|
||||
|
||||
private static String build4xxDiagnostic(String dependencyName, int status) {
|
||||
String base = "Upstream 4xx from dependency: " + dependencyName + " status=" + status;
|
||||
return switch (status) {
|
||||
case 401 -> base + " — check credential / auth-token configuration";
|
||||
case 403 -> base + " — check scope/config for dependency access";
|
||||
default -> base;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean hasDnsCauseInChain(Throwable t) {
|
||||
Throwable current = t;
|
||||
while (current != null) {
|
||||
if (current instanceof UnknownHostException
|
||||
|| current instanceof UnresolvedAddressException) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String rootCauseClassName(Throwable t) {
|
||||
Throwable root = t;
|
||||
while (root.getCause() != null) {
|
||||
root = root.getCause();
|
||||
}
|
||||
return root.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.core.IntervalFunction;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Holder for the optional Resilience4j retry / circuit-breaker machinery. Returns {@link
|
||||
* Optional#empty()} (no decoration) when the corresponding feature is disabled.
|
||||
*/
|
||||
public final class OutboundHttpResilience {
|
||||
|
||||
private final OutboundHttpSettings settings;
|
||||
private final OutboundRetryPolicy retryPolicy;
|
||||
private final RetryRegistry retryRegistry;
|
||||
private final CircuitBreakerRegistry circuitBreakerRegistry;
|
||||
|
||||
/** Registry args are nullable — pass {@code null} when the feature is disabled. */
|
||||
public OutboundHttpResilience(
|
||||
OutboundHttpSettings settings,
|
||||
OutboundRetryPolicy retryPolicy,
|
||||
RetryRegistry retryRegistry,
|
||||
CircuitBreakerRegistry circuitBreakerRegistry) {
|
||||
this.settings = settings;
|
||||
this.retryPolicy = retryPolicy;
|
||||
this.retryRegistry = retryRegistry;
|
||||
this.circuitBreakerRegistry = circuitBreakerRegistry;
|
||||
}
|
||||
|
||||
/** Returns {@link Optional#empty()} when retry is disabled. */
|
||||
public Optional<Retry> retryFor(String dependencyName) {
|
||||
if (!settings.retryEnabled() || retryRegistry == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
OutboundHttpSettings.Retry r = settings.retry();
|
||||
RetryConfig config =
|
||||
RetryConfig.custom()
|
||||
.maxAttempts(r.maxAttempts())
|
||||
.intervalFunction(
|
||||
IntervalFunction.ofExponentialRandomBackoff(
|
||||
r.initialBackoff(), r.backoffMultiplier()))
|
||||
.retryOnException(retryPolicy::shouldRetry)
|
||||
.build();
|
||||
return Optional.of(retryRegistry.retry(dependencyName, config));
|
||||
}
|
||||
|
||||
/** Returns {@link Optional#empty()} when the circuit breaker is disabled. */
|
||||
public Optional<CircuitBreaker> circuitBreakerFor(String dependencyName) {
|
||||
if (!settings.circuitBreakerEnabled() || circuitBreakerRegistry == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
OutboundHttpSettings.CircuitBreaker c = settings.circuitBreaker();
|
||||
CircuitBreakerConfig config =
|
||||
CircuitBreakerConfig.custom()
|
||||
.failureRateThreshold(c.failureRateThreshold())
|
||||
.slidingWindowSize(c.slidingWindowSize())
|
||||
.minimumNumberOfCalls(c.minimumNumberOfCalls())
|
||||
.waitDurationInOpenState(c.waitDurationInOpenState())
|
||||
.permittedNumberOfCallsInHalfOpenState(c.permittedCallsInHalfOpen())
|
||||
.build();
|
||||
return Optional.of(circuitBreakerRegistry.circuitBreaker(dependencyName, config));
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.micrometer.tagged.TaggedCircuitBreakerMetrics;
|
||||
import io.github.resilience4j.micrometer.tagged.TaggedRetryMetrics;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.config.MeterFilterReply;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Builds the {@link OutboundHttpResilience} bean and normalises the resilience4j metrics.
|
||||
*
|
||||
* <p>Micrometer 1.15.x incompatibility: {@code MeterFilter.replaceTagValues}/{@code renameTag} do
|
||||
* not transform the tags of the {@code FunctionCounter}/{@code DefaultGauge} instances registered
|
||||
* by {@code TaggedRetryMetrics}/{@code TaggedCircuitBreakerMetrics} — an explicit {@code
|
||||
* map(Meter.Id)} implementation is required (verified on Micrometer 1.15.11 / Resilience4j 2.2.0).
|
||||
* Full normalisation / DENY-policy rationale is in the module README.
|
||||
*/
|
||||
@Configuration
|
||||
public class OutboundHttpResilienceConfig {
|
||||
|
||||
private static final String RETRY_CALLS_METER = "resilience4j.retry.calls";
|
||||
private static final String CB_CALLS_METER = "resilience4j.circuitbreaker.calls";
|
||||
private static final String CB_STATE_METER = "resilience4j.circuitbreaker.state";
|
||||
private static final String RESILIENCE4J_PREFIX = "resilience4j.";
|
||||
|
||||
@Bean
|
||||
public OutboundHttpResilience outboundHttpResilience(
|
||||
OutboundHttpSettings settings,
|
||||
OutboundRetryPolicy retryPolicy,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
|
||||
boolean resilienceEnabled = settings.retryEnabled() || settings.circuitBreakerEnabled();
|
||||
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
|
||||
|
||||
// D3: a MeterRegistry is required when retry/CB is enabled — resilience without metrics is
|
||||
// forbidden.
|
||||
if (resilienceEnabled && meterRegistry == null) {
|
||||
throw new IllegalStateException(
|
||||
"APP_OUTBOUND_HTTP_RETRY_ENABLED/CIRCUIT_BREAKER_ENABLED=true requires a "
|
||||
+ "MeterRegistry — retry/circuit breaker without low-cardinality metrics is "
|
||||
+ "forbidden (feature-outbound-http-client-baseline D3)");
|
||||
}
|
||||
|
||||
RetryRegistry retryRegistry = null;
|
||||
CircuitBreakerRegistry cbRegistry = null;
|
||||
|
||||
if (resilienceEnabled) {
|
||||
// Filters affect only meters registered AFTER install. CB state gauges are
|
||||
// registered eagerly at bindTo(), so filters must be installed before bindTo()
|
||||
// or the transform is silently skipped for them.
|
||||
applyMeterFilters(meterRegistry);
|
||||
|
||||
if (settings.retryEnabled()) {
|
||||
retryRegistry = RetryRegistry.ofDefaults();
|
||||
TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);
|
||||
}
|
||||
if (settings.circuitBreakerEnabled()) {
|
||||
cbRegistry = CircuitBreakerRegistry.ofDefaults();
|
||||
TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(cbRegistry).bindTo(meterRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
return new OutboundHttpResilience(settings, retryPolicy, retryRegistry, cbRegistry);
|
||||
}
|
||||
|
||||
private static void applyMeterFilters(MeterRegistry meterRegistry) {
|
||||
// Rename "kind" → "outcome" and remap vendor values (retry.calls).
|
||||
meterRegistry
|
||||
.config()
|
||||
.meterFilter(
|
||||
remapKindToOutcome(
|
||||
RETRY_CALLS_METER,
|
||||
kind ->
|
||||
switch (kind) {
|
||||
case "successful_without_retry", "successful_with_retry" -> "SUCCESS";
|
||||
case "failed_without_retry", "failed_with_retry" -> "FAILURE";
|
||||
default -> kind;
|
||||
}));
|
||||
|
||||
// Rename "kind" → "outcome" (circuitbreaker.calls).
|
||||
meterRegistry
|
||||
.config()
|
||||
.meterFilter(
|
||||
remapKindToOutcome(
|
||||
CB_CALLS_METER,
|
||||
kind ->
|
||||
switch (kind) {
|
||||
case "successful" -> "SUCCESS";
|
||||
case "failed", "ignored" -> "FAILURE";
|
||||
default -> kind;
|
||||
}));
|
||||
|
||||
// Uppercase the "state" tag values (circuitbreaker.state).
|
||||
meterRegistry.config().meterFilter(uppercaseStateTag(CB_STATE_METER));
|
||||
|
||||
// DENY vendor resilience4j.* extras outside the approved three meters (D4 low-cardinality).
|
||||
meterRegistry
|
||||
.config()
|
||||
.meterFilter(
|
||||
new MeterFilter() {
|
||||
@Override
|
||||
public MeterFilterReply accept(Meter.Id id) {
|
||||
String name = id.getName();
|
||||
if (!name.startsWith(RESILIENCE4J_PREFIX)) {
|
||||
return MeterFilterReply.NEUTRAL;
|
||||
}
|
||||
if (name.equals(RETRY_CALLS_METER)
|
||||
|| name.equals(CB_CALLS_METER)
|
||||
|| name.equals(CB_STATE_METER)) {
|
||||
return MeterFilterReply.NEUTRAL;
|
||||
}
|
||||
return MeterFilterReply.DENY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Meter.Id map(Meter.Id id) {
|
||||
return id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static MeterFilter remapKindToOutcome(
|
||||
String meterName, Function<String, String> kindMapper) {
|
||||
return new MeterFilter() {
|
||||
@Override
|
||||
public Meter.Id map(Meter.Id id) {
|
||||
if (!id.getName().equals(meterName)) {
|
||||
return id;
|
||||
}
|
||||
List<Tag> newTags = new ArrayList<>();
|
||||
for (Tag t : id.getTags()) {
|
||||
if ("kind".equals(t.getKey())) {
|
||||
newTags.add(Tag.of("outcome", kindMapper.apply(t.getValue())));
|
||||
} else {
|
||||
newTags.add(t);
|
||||
}
|
||||
}
|
||||
return id.replaceTags(newTags);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static MeterFilter uppercaseStateTag(String meterName) {
|
||||
return new MeterFilter() {
|
||||
@Override
|
||||
public Meter.Id map(Meter.Id id) {
|
||||
if (!id.getName().equals(meterName)) {
|
||||
return id;
|
||||
}
|
||||
List<Tag> newTags = new ArrayList<>();
|
||||
for (Tag t : id.getTags()) {
|
||||
if ("state".equals(t.getKey())) {
|
||||
newTags.add(Tag.of("state", t.getValue().toUpperCase()));
|
||||
} else {
|
||||
newTags.add(t);
|
||||
}
|
||||
}
|
||||
return id.replaceTags(newTags);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper
|
||||
import dev.caskeleton.shared.error.DependencyFailureException
|
||||
import dev.caskeleton.shared.error.OperationalError
|
||||
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.util.unit.DataSize
|
||||
import org.springframework.web.client.HttpClientErrorException
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
import java.net.http.HttpTimeoutException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* {@link OutboundRetryPolicy} 도메인 스펙 — C2 테스트 형태(순수 재시도 결정 로직 = Spock).
|
||||
*
|
||||
* <p>feature-outbound-http-client-baseline D6/D8, plan I3/I4: shouldRetry 는 네 조건이 모두 참일
|
||||
* 때만 true — (1) 셧다운 아님, (2) 컨텍스트 존재 + 멱등 메서드, (3) 분류가 재시도 가능, (4) 마감 이내.</p>
|
||||
*/
|
||||
class OutboundRetryPolicySpec extends Specification {
|
||||
|
||||
OutboundHttpShutdownGuard guard
|
||||
OutboundRetryPolicy policy
|
||||
|
||||
def setup() {
|
||||
def settings = new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
true,
|
||||
false,
|
||||
DataSize.ofMegabytes(10))
|
||||
guard = new OutboundHttpShutdownGuard()
|
||||
guard.start()
|
||||
policy = new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper())
|
||||
}
|
||||
|
||||
def "멱등 메서드 #method 는 재시도 가능 오류 + 마감 이내면 재시도 여부가 #expected 다"() {
|
||||
given:
|
||||
policy.beginCall(method, Instant.now().plusSeconds(30))
|
||||
|
||||
expect: "POST/PATCH 는 멱등키 계약 미정의(I4)라 항상 false"
|
||||
policy.shouldRetry(new HttpTimeoutException("read timed out")) == expected
|
||||
|
||||
cleanup:
|
||||
policy.endCall()
|
||||
|
||||
where:
|
||||
method || expected
|
||||
HttpMethod.GET || true
|
||||
HttpMethod.HEAD || true
|
||||
HttpMethod.PUT || true
|
||||
HttpMethod.DELETE || true
|
||||
HttpMethod.POST || false
|
||||
HttpMethod.PATCH || false
|
||||
}
|
||||
|
||||
def "셧다운 중이면 GET 도 재시도를 억제한다 (D8)"() {
|
||||
given:
|
||||
guard.stop()
|
||||
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
|
||||
|
||||
expect:
|
||||
!policy.shouldRetry(new HttpTimeoutException("timeout"))
|
||||
|
||||
cleanup:
|
||||
policy.endCall()
|
||||
}
|
||||
|
||||
def "비재시도 분류(4xx)는 GET 이라도 재시도하지 않는다"() {
|
||||
given:
|
||||
def fourXx = HttpClientErrorException.create(HttpStatus.NOT_FOUND, "Not Found",
|
||||
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
|
||||
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
|
||||
|
||||
expect:
|
||||
!policy.shouldRetry(fourXx)
|
||||
|
||||
cleanup:
|
||||
policy.endCall()
|
||||
}
|
||||
|
||||
def "마감을 지난 호출은 재시도 가능 오류라도 재시도하지 않는다 (I3)"() {
|
||||
given:
|
||||
policy.beginCall(HttpMethod.GET, Instant.now().minusSeconds(1))
|
||||
|
||||
expect:
|
||||
!policy.shouldRetry(new HttpTimeoutException("timeout"))
|
||||
|
||||
cleanup:
|
||||
policy.endCall()
|
||||
}
|
||||
|
||||
def "호출 컨텍스트가 없으면 재시도하지 않는다"() {
|
||||
expect:
|
||||
!policy.shouldRetry(new HttpTimeoutException("timeout"))
|
||||
}
|
||||
|
||||
def "endCall 이후에는 컨텍스트가 비어 재시도하지 않는다"() {
|
||||
given:
|
||||
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
|
||||
policy.endCall()
|
||||
|
||||
expect:
|
||||
!policy.shouldRetry(new HttpTimeoutException("timeout"))
|
||||
}
|
||||
|
||||
def "이미 분류된 DependencyFailureException 은 내장 코드(#code)로 재시도 여부를 #expected 로 판단한다"() {
|
||||
given:
|
||||
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
|
||||
|
||||
expect:
|
||||
policy.shouldRetry(new DependencyFailureException(code, "test-api", "x", null)) == expected
|
||||
|
||||
cleanup:
|
||||
policy.endCall()
|
||||
|
||||
where:
|
||||
code || expected
|
||||
OperationalError.DEPENDENCY_5XX_SERVER || true
|
||||
OperationalError.DEPENDENCY_4XX_CLIENT || false
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.diagnostics
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker
|
||||
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.client.HttpClientErrorException
|
||||
import org.springframework.web.client.HttpServerErrorException
|
||||
import org.springframework.web.client.ResourceAccessException
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.net.http.HttpConnectTimeoutException
|
||||
import java.net.http.HttpTimeoutException
|
||||
import java.nio.channels.UnresolvedAddressException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* {@link OutboundHttpErrorMapper} 도메인 스펙 — C2 테스트 형태(순수 예외→코드 분류 매트릭스 = Spock).
|
||||
*
|
||||
* <p>feature-outbound-http-client-baseline D12, plan I5/I10: 원인 체인을 따라 우선순위 표의 첫 매칭이
|
||||
* 이긴다. 상류 응답 바디는 분류 예외 메시지에 노출되지 않는다(D12 누출 방지).</p>
|
||||
*/
|
||||
class OutboundHttpErrorMapperSpec extends Specification {
|
||||
|
||||
def mapper = new OutboundHttpErrorMapper()
|
||||
|
||||
def "#desc 는 #expectedCode 로 분류된다"() {
|
||||
expect:
|
||||
mapper.classify("dep-api", failure).errorCode() == expectedCode
|
||||
|
||||
where:
|
||||
desc | failure || expectedCode
|
||||
"서킷 오픈" | CallNotPermittedException.createCallNotPermittedException(CircuitBreaker.ofDefaults("cb")) || OperationalError.DEPENDENCY_CIRCUIT_OPEN
|
||||
"알 수 없는 호스트" | new UnknownHostException("h") || OperationalError.DEPENDENCY_DNS_FAILED
|
||||
"미해결 주소" | new UnresolvedAddressException() || OperationalError.DEPENDENCY_DNS_FAILED
|
||||
"ResourceAccessException 으로 감싼 호스트 실패" | new ResourceAccessException("I/O error", new UnknownHostException("h")) || OperationalError.DEPENDENCY_DNS_FAILED
|
||||
"커넥트 타임아웃(read 타임아웃보다 우선)" | new HttpConnectTimeoutException("connect timed out") || OperationalError.DEPENDENCY_CONNECT_FAILED
|
||||
"커넥트 거부" | new ConnectException("Connection refused") || OperationalError.DEPENDENCY_CONNECT_FAILED
|
||||
"HTTP read 타임아웃" | new HttpTimeoutException("read timed out") || OperationalError.DEPENDENCY_TIMEOUT
|
||||
"소켓 타임아웃" | new SocketTimeoutException("Read timed out") || OperationalError.DEPENDENCY_TIMEOUT
|
||||
"concurrent 타임아웃" | new TimeoutException("deadline exceeded") || OperationalError.DEPENDENCY_TIMEOUT
|
||||
"HTTP 404" | clientError(HttpStatus.NOT_FOUND) || OperationalError.DEPENDENCY_4XX_CLIENT
|
||||
"HTTP 408 (registry SSOT: 4xx)" | clientError(HttpStatus.REQUEST_TIMEOUT) || OperationalError.DEPENDENCY_4XX_CLIENT
|
||||
"HTTP 429 (registry SSOT: 4xx)" | clientError(HttpStatus.TOO_MANY_REQUESTS) || OperationalError.DEPENDENCY_4XX_CLIENT
|
||||
"HTTP 500" | serverError(HttpStatus.INTERNAL_SERVER_ERROR) || OperationalError.DEPENDENCY_5XX_SERVER
|
||||
"HTTP 503" | serverError(HttpStatus.SERVICE_UNAVAILABLE) || OperationalError.DEPENDENCY_5XX_SERVER
|
||||
"미인식 예외(보수적 폴백)" | new RuntimeException("something weird") || OperationalError.DEPENDENCY_CONNECT_FAILED
|
||||
}
|
||||
|
||||
def "401 은 4xx 로 분류되고 credential 진단을 포함한다"() {
|
||||
when:
|
||||
def result = mapper.classify("secure-api", clientError(HttpStatus.UNAUTHORIZED))
|
||||
|
||||
then:
|
||||
result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT
|
||||
result.message.contains("credential")
|
||||
}
|
||||
|
||||
def "403 은 4xx 로 분류되고 scope 진단을 포함한다"() {
|
||||
when:
|
||||
def result = mapper.classify("secure-api", clientError(HttpStatus.FORBIDDEN))
|
||||
|
||||
then:
|
||||
result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT
|
||||
result.message.contains("scope")
|
||||
}
|
||||
|
||||
def "폴백 진단은 root cause 클래스명을 포함한다"() {
|
||||
when:
|
||||
def result = mapper.classify("unknown-api", new RuntimeException("something weird"))
|
||||
|
||||
then:
|
||||
result.message.contains("RuntimeException")
|
||||
}
|
||||
|
||||
def "상류 응답 바디는 분류된 예외 메시지에 노출되지 않는다 (D12)"() {
|
||||
given:
|
||||
byte[] secretBody = "UPSTREAM_SECRET".getBytes(StandardCharsets.UTF_8)
|
||||
def ex = HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error",
|
||||
HttpHeaders.EMPTY, secretBody, StandardCharsets.UTF_8)
|
||||
|
||||
when:
|
||||
def result = mapper.classify("leaky-api", ex)
|
||||
|
||||
then:
|
||||
!result.message.contains("UPSTREAM_SECRET")
|
||||
}
|
||||
|
||||
private static HttpClientErrorException clientError(HttpStatus status) {
|
||||
HttpClientErrorException.create(status, status.reasonPhrase,
|
||||
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private static HttpServerErrorException serverError(HttpStatus status) {
|
||||
HttpServerErrorException.create(status, status.reasonPhrase,
|
||||
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import java.net.SocketTimeoutException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link OutboundHttpCallObserver}.
|
||||
*
|
||||
* <p>Verifies:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code recordSuccess} emits a DEBUG log line with the correct fields.
|
||||
* <li>{@code recordFailure} classifies, logs at ERROR/WARN, and returns a {@link
|
||||
* DependencyFailureException} (so the caller can {@code throw observer.recordFailure(...)}).
|
||||
* <li>{@code rejectShutdown} builds DEPENDENCY_CIRCUIT_OPEN / REJECTED and returns the dfe.
|
||||
* <li>{@code outcomeFor} mapping: TIMEOUT → "TIMEOUT", CIRCUIT_OPEN → "CIRCUIT_OPEN", other →
|
||||
* "FAILURE".
|
||||
* </ul>
|
||||
*/
|
||||
class OutboundHttpCallObserverTest {
|
||||
|
||||
private static final String DEP = "test-dep";
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> logAppender;
|
||||
private OutboundHttpCallObserver observer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.observer");
|
||||
logAppender = new ListAppender<>();
|
||||
logAppender.start();
|
||||
logbackLogger.addAppender(logAppender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
|
||||
OutboundHttpDependencyLogger logger = new OutboundHttpDependencyLogger(logbackLogger);
|
||||
observer = new OutboundHttpCallObserver(DEP, new OutboundHttpErrorMapper(), logger);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// recordSuccess
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void recordSuccessEmitsDebugLogWithOutcomeSUCCESS() {
|
||||
long startNs = System.nanoTime();
|
||||
observer.recordSuccess(startNs, 0);
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
ILoggingEvent event = logAppender.list.get(0);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.DEBUG);
|
||||
assertThat(event.getFormattedMessage()).contains("outcome=\"SUCCESS\"");
|
||||
assertThat(event.getFormattedMessage()).contains("dependency_name=\"" + DEP + "\"");
|
||||
assertThat(event.getFormattedMessage()).contains("retry_attempt=0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordSuccessIncludesNonZeroRetryAttempt() {
|
||||
long startNs = System.nanoTime();
|
||||
observer.recordSuccess(startNs, 2);
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
assertThat(logAppender.list.get(0).getFormattedMessage()).contains("retry_attempt=2");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// recordFailure — outcome mapping
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void recordFailureTimeoutMapsToTIMEOUTOutcomeAndReturnsDfe() {
|
||||
long startNs = System.nanoTime();
|
||||
SocketTimeoutException cause = new SocketTimeoutException("read timed out");
|
||||
|
||||
DependencyFailureException dfe = observer.recordFailure(cause, startNs, 0);
|
||||
|
||||
assertThat(dfe).isNotNull();
|
||||
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
assertThat(logAppender.list.get(0).getFormattedMessage()).contains("outcome=\"TIMEOUT\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordFailureCircuitOpenMapsToCIRCUITOPENOutcomeAndLogsWARN() {
|
||||
long startNs = System.nanoTime();
|
||||
// Build a DFE that classify() will return for a CallNotPermittedException
|
||||
CircuitBreaker cb = CircuitBreaker.ofDefaults("test");
|
||||
cb.transitionToOpenState();
|
||||
|
||||
CallNotPermittedException cnp = CallNotPermittedException.createCallNotPermittedException(cb);
|
||||
|
||||
DependencyFailureException dfe = observer.recordFailure(cnp, startNs, 0);
|
||||
|
||||
assertThat(dfe).isNotNull();
|
||||
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
ILoggingEvent event = logAppender.list.get(0);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.WARN);
|
||||
assertThat(event.getFormattedMessage()).contains("outcome=\"CIRCUIT_OPEN\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordFailureUnclassifiedMapsToFAILUREOutcomeAndLogsERROR() {
|
||||
long startNs = System.nanoTime();
|
||||
RuntimeException cause = new RuntimeException("unexpected");
|
||||
|
||||
DependencyFailureException dfe = observer.recordFailure(cause, startNs, 1);
|
||||
|
||||
assertThat(dfe).isNotNull();
|
||||
// fallback → DEPENDENCY_CONNECT_FAILED in classifier, outcome → "FAILURE"
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
ILoggingEvent event = logAppender.list.get(0);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
|
||||
assertThat(event.getFormattedMessage()).contains("outcome=\"FAILURE\"");
|
||||
assertThat(event.getFormattedMessage()).contains("retry_attempt=1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordFailureReturnsDfeSoCallerCanThrow() {
|
||||
long startNs = System.nanoTime();
|
||||
DependencyFailureException dfe =
|
||||
observer.recordFailure(new RuntimeException("boom"), startNs, 0);
|
||||
// Must be throwable
|
||||
assertThat(dfe).isInstanceOf(DependencyFailureException.class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// rejectShutdown
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void rejectShutdownReturnsDEPENDENCYCIRCUITOPENWithProvidedMessage() {
|
||||
String msg = "shutdown in progress — outbound call rejected fail-fast (D8)";
|
||||
DependencyFailureException dfe = observer.rejectShutdown(msg);
|
||||
|
||||
assertThat(dfe).isNotNull();
|
||||
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
|
||||
assertThat(dfe.getMessage()).contains(msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectShutdownLogsREJECTEDOutcomeAtWARN() {
|
||||
observer.rejectShutdown("shutdown test");
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
ILoggingEvent event = logAppender.list.get(0);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.WARN);
|
||||
assertThat(event.getFormattedMessage()).contains("outcome=\"REJECTED\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectShutdownLogsDuration0AndRetryAttempt0() {
|
||||
observer.rejectShutdown("shutdown test");
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
String msg = logAppender.list.get(0).getFormattedMessage();
|
||||
assertThat(msg).contains("duration_ms=0");
|
||||
assertThat(msg).contains("retry_attempt=0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectShutdownDfeCauseIsNull() {
|
||||
DependencyFailureException dfe = observer.rejectShutdown("shutdown test");
|
||||
assertThat(dfe.getCause()).isNull();
|
||||
}
|
||||
}
|
||||
+777
@@ -0,0 +1,777 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilienceConfig;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link OutboundHttpClient} against a local JDK {@link HttpServer} (no
|
||||
* network, no Testcontainers, no Spring context).
|
||||
*
|
||||
* <p>Covers the spec 테스트 계약 from the plan §검증 매트릭스:
|
||||
*
|
||||
* <ol>
|
||||
* <li>200 OK → body decoded + structured log fields verified
|
||||
* <li>Read timeout → {@code DEPENDENCY_TIMEOUT} / retryable
|
||||
* <li>Connect refused → {@code DEPENDENCY_CONNECT_FAILED}
|
||||
* <li>Unknown host → {@code DEPENDENCY_DNS_FAILED}
|
||||
* <li>500 with secret body → {@code DEPENDENCY_5XX_SERVER} + secret NOT in msg/log
|
||||
* <li>401 / 404 → {@code DEPENDENCY_4XX_CLIENT} non-retryable
|
||||
* <li>Retry disabled default → exactly 1 hit on always-500
|
||||
* <li>Retry enabled → GET 3 hits; no {@code kind} tag; POST 1 hit (I4)
|
||||
* <li>Circuit breaker open → {@code DEPENDENCY_CIRCUIT_OPEN} + 0 hits + meters present
|
||||
* <li>Shutdown → {@code DEPENDENCY_CIRCUIT_OPEN} + outcome REJECTED in log
|
||||
* <li>Response size limit → buffered throws; streaming path succeeds
|
||||
* </ol>
|
||||
*/
|
||||
class OutboundHttpClientTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logger capture
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> logAppender;
|
||||
private OutboundHttpDependencyLogger testLogger;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Local JDK HttpServer lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private HttpServer server;
|
||||
private String baseUrl;
|
||||
|
||||
@BeforeEach
|
||||
void setUpLoggerAndServer() throws IOException {
|
||||
// Set up log capture
|
||||
logbackLogger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.client");
|
||||
logAppender = new ListAppender<>();
|
||||
logAppender.start();
|
||||
logbackLogger.addAppender(logAppender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
testLogger = new OutboundHttpDependencyLogger(logbackLogger);
|
||||
|
||||
// Bind to localhost:0 (ephemeral port)
|
||||
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
|
||||
server.start();
|
||||
int port = server.getAddress().getPort();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(logAppender);
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 1: 200 OK → body decoded + structured log fields
|
||||
// Spec: "outbound log에 dependency.name/type/duration_ms가 없으면 실패"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t1200okBodyDecodedAndSuccessLogContainsRequiredFields() {
|
||||
server.createContext(
|
||||
"/hello",
|
||||
exchange -> {
|
||||
byte[] body = "world".getBytes();
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
});
|
||||
|
||||
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
|
||||
String result = client.get("/hello", String.class);
|
||||
|
||||
assertThat(result).isEqualTo("world");
|
||||
|
||||
assertThat(logAppender.list).hasSize(1);
|
||||
String logMsg = logAppender.list.get(0).getFormattedMessage();
|
||||
assertThat(logMsg).contains("dependency_name=\"test-dep\"");
|
||||
assertThat(logMsg).contains("dependency_type=\"http\"");
|
||||
assertThat(logMsg).contains("duration_ms=");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 2: Read timeout → DEPENDENCY_TIMEOUT + retryable
|
||||
// Spec: "upstream timeout은 retryable dependency failure로 분류"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t2ReadTimeoutThrowsDEPENDENCYTIMEOUTAndIsRetryable() {
|
||||
server.createContext(
|
||||
"/slow",
|
||||
exchange -> {
|
||||
// Sleep longer than the read timeout (read=300ms)
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
OutboundHttpSettings settings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500), // connectTimeout
|
||||
Duration.ofMillis(300), // readTimeout
|
||||
Duration.ofSeconds(2), // globalCallTimeout
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
|
||||
OutboundHttpClient client = client(settings, defaultResilience(settings));
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/slow", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
|
||||
assertThat(ex.errorCode().retryable()).isTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 3: Connect refused → DEPENDENCY_CONNECT_FAILED
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t3ConnectRefusedThrowsDEPENDENCYCONNECTFAILED() throws IOException {
|
||||
// Bind a ServerSocket to get a port, then close it so the OS knows nothing is
|
||||
// listening — subsequent connect attempts get immediate "Connection refused" rather
|
||||
// than a timeout (unlike HttpServer.stop() which may leave the port in TIME_WAIT).
|
||||
int refusedPort;
|
||||
try (ServerSocket ss = new ServerSocket(0)) {
|
||||
refusedPort = ss.getLocalPort();
|
||||
}
|
||||
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
"http://localhost:" + refusedPort,
|
||||
defaultSettings(),
|
||||
activeGuard(),
|
||||
defaultResilience(defaultSettings()),
|
||||
retryPolicy(defaultSettings()),
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/any", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CONNECT_FAILED);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 4: Unknown host → DEPENDENCY_DNS_FAILED
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t4UnknownHostThrowsDEPENDENCYDNSFAILED() {
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
"http://nonexistent-host-zzz.invalid",
|
||||
defaultSettings(),
|
||||
activeGuard(),
|
||||
defaultResilience(defaultSettings()),
|
||||
retryPolicy(defaultSettings()),
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/path", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_DNS_FAILED);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 5: 500 with secret body → DEPENDENCY_5XX_SERVER
|
||||
// AND "UPSTREAM_SECRET" NOT in ex.getMessage() NOR in any log line
|
||||
// Spec: "upstream raw error body가 response/log에 노출되면 실패"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t5500BodySecretNotLeakedInMessageOrLog() {
|
||||
final String secret = "UPSTREAM_SECRET";
|
||||
server.createContext(
|
||||
"/fail500",
|
||||
exchange -> {
|
||||
byte[] body = secret.getBytes();
|
||||
exchange.sendResponseHeaders(500, body.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
});
|
||||
|
||||
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/fail500", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_5XX_SERVER);
|
||||
|
||||
// The secret must NOT appear in the exception message
|
||||
assertThat(ex.getMessage()).doesNotContain(secret);
|
||||
|
||||
// The secret must NOT appear in any captured log line
|
||||
for (ILoggingEvent event : logAppender.list) {
|
||||
assertThat(event.getFormattedMessage()).doesNotContain(secret);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 6: 401 → DEPENDENCY_4XX_CLIENT non-retryable; 404 → same code
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t6401ThrowsDEPENDENCY4XXCLIENTNonRetryable() {
|
||||
server.createContext(
|
||||
"/auth",
|
||||
exchange -> {
|
||||
exchange.sendResponseHeaders(401, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/auth", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT);
|
||||
assertThat(ex.errorCode().retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void t6404ThrowsDEPENDENCY4XXCLIENTNonRetryable() {
|
||||
server.createContext(
|
||||
"/notfound",
|
||||
exchange -> {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/notfound", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT);
|
||||
assertThat(ex.errorCode().retryable()).isFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 7: Retry disabled (default) → exactly 1 hit on always-500
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t7RetryDisabledSingleHitOnAlways500() {
|
||||
AtomicInteger hitCount = new AtomicInteger(0);
|
||||
server.createContext(
|
||||
"/fail",
|
||||
exchange -> {
|
||||
hitCount.incrementAndGet();
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
// Default settings: retryEnabled=false
|
||||
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
|
||||
|
||||
assertThatThrownBy(() -> client.get("/fail", String.class))
|
||||
.isInstanceOf(DependencyFailureException.class);
|
||||
|
||||
assertThat(hitCount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 8: Retry enabled → GET 3 hits; no `kind` tag; POST 1 hit (plan I4)
|
||||
// Spec: "POST retry fully forbidden"; meter outcome tag, no kind tag
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t8RetryEnabledGETAlways500Hits3Times() {
|
||||
AtomicInteger hitCount = new AtomicInteger(0);
|
||||
server.createContext(
|
||||
"/retry",
|
||||
exchange -> {
|
||||
hitCount.incrementAndGet();
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
|
||||
OutboundHttpSettings retrySettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10), // generous global timeout for 3 retries
|
||||
true,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
|
||||
// CRITICAL: the retryPolicy instance MUST be shared between the resilience config
|
||||
// and the client. OutboundHttpClient.exchange() calls retryPolicy.beginCall() to
|
||||
// set the thread-local call context that retryPolicy.shouldRetry() checks.
|
||||
// Using two separate instances means shouldRetry() sees no context (ctx == null)
|
||||
// and always returns false — producing exactly 1 hit instead of 3.
|
||||
OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings);
|
||||
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilienceConfig()
|
||||
.outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry));
|
||||
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
baseUrl,
|
||||
retrySettings,
|
||||
activeGuard(),
|
||||
resilience,
|
||||
sharedPolicy,
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
assertThatThrownBy(() -> client.get("/retry", String.class))
|
||||
.isInstanceOf(DependencyFailureException.class);
|
||||
|
||||
assertThat(hitCount.get()).isEqualTo(3);
|
||||
|
||||
// Failure log must record retry_attempt=2 (3 attempts → attemptCount=3 → 3-1=2).
|
||||
// This assertion catches the previously hardcoded retry_attempt=0 bug on the failure path.
|
||||
assertThat(logAppender.list).isNotEmpty();
|
||||
boolean foundRetryAttempt2 =
|
||||
logAppender.list.stream()
|
||||
.anyMatch(e -> e.getFormattedMessage().contains("retry_attempt=2"));
|
||||
assertThat(foundRetryAttempt2)
|
||||
.as("Failure log must contain retry_attempt=2 for always-500 GET with 3 hits")
|
||||
.isTrue();
|
||||
|
||||
// Spec: resilience4j.retry.calls meters must exist with `outcome` tag and NO `kind` tag.
|
||||
// TaggedRetryMetrics registers FunctionCounters (not Counter), so use find().meters()
|
||||
// rather than find().counters() — FunctionCounter does not implement Counter.
|
||||
var retryMeters = meterRegistry.find("resilience4j.retry.calls").meters();
|
||||
assertThat(retryMeters).isNotEmpty();
|
||||
retryMeters.forEach(
|
||||
meter -> {
|
||||
assertThat(meter.getId().getTag("outcome"))
|
||||
.as("outcome tag must be present on resilience4j.retry.calls")
|
||||
.isNotNull();
|
||||
assertThat(meter.getId().getTag("kind"))
|
||||
.as("kind tag must NOT be present (D4 vendor tag remapped to outcome)")
|
||||
.isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void t8RetryEnabledPOSTAlways500HitsExactly1TimePlanI4() {
|
||||
AtomicInteger hitCount = new AtomicInteger(0);
|
||||
server.createContext(
|
||||
"/post-retry",
|
||||
exchange -> {
|
||||
hitCount.incrementAndGet();
|
||||
exchange.sendResponseHeaders(500, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
|
||||
OutboundHttpSettings retrySettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
true,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
|
||||
// Same shared-policy pattern as GET test above (plan I4 verification).
|
||||
OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings);
|
||||
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilienceConfig()
|
||||
.outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry));
|
||||
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
baseUrl,
|
||||
retrySettings,
|
||||
activeGuard(),
|
||||
resilience,
|
||||
sharedPolicy,
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
assertThatThrownBy(() -> client.exchange(HttpMethod.POST, "/post-retry", null, String.class))
|
||||
.isInstanceOf(DependencyFailureException.class);
|
||||
|
||||
// Plan I4: POST retry is fully forbidden — exactly 1 hit regardless of retry enabled
|
||||
assertThat(hitCount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 9: Circuit breaker open → DEPENDENCY_CIRCUIT_OPEN + 0 hits
|
||||
// + resilience4j.circuitbreaker.state gauge with UPPERCASE state
|
||||
// + denied vendor meters ABSENT
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t9CircuitBreakerOpenShortCircuitsCallAndMetersCorrect() {
|
||||
AtomicInteger hitCount = new AtomicInteger(0);
|
||||
server.createContext(
|
||||
"/cb",
|
||||
exchange -> {
|
||||
hitCount.incrementAndGet();
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
|
||||
OutboundHttpSettings cbSettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
true,
|
||||
DataSize.ofMegabytes(10));
|
||||
|
||||
OutboundRetryPolicy sharedPolicy = retryPolicy(cbSettings);
|
||||
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilienceConfig()
|
||||
.outboundHttpResilience(cbSettings, sharedPolicy, singletonProvider(meterRegistry));
|
||||
|
||||
// Force the CB into OPEN state before any call
|
||||
resilience.circuitBreakerFor("test-dep").get().transitionToOpenState();
|
||||
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
baseUrl,
|
||||
cbSettings,
|
||||
activeGuard(),
|
||||
resilience,
|
||||
sharedPolicy,
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/cb", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
|
||||
|
||||
// Hit count must be 0 — CB blocked the call entirely
|
||||
assertThat(hitCount.get()).isEqualTo(0);
|
||||
|
||||
// resilience4j.circuitbreaker.state gauge must be present with UPPERCASE state tag value
|
||||
var stateMeter = meterRegistry.find("resilience4j.circuitbreaker.state").gauges();
|
||||
assertThat(stateMeter).isNotEmpty();
|
||||
stateMeter.forEach(
|
||||
gauge -> {
|
||||
String stateTag = gauge.getId().getTag("state");
|
||||
assertThat(stateTag).isNotNull();
|
||||
// UPPERCASE: CLOSED, OPEN, HALF_OPEN — not lowercase
|
||||
assertThat(stateTag).isEqualTo(stateTag.toUpperCase());
|
||||
});
|
||||
|
||||
// Denied vendor meter must be ABSENT (D4 low-cardinality filter)
|
||||
assertThat(meterRegistry.find("resilience4j.circuitbreaker.failure.rate").gauges()).isEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 10: Shutdown → DEPENDENCY_CIRCUIT_OPEN + outcome REJECTED in log
|
||||
// + server hit count unchanged (fail-fast, no network)
|
||||
// Spec: "shutdown phase에서 outbound HTTP 호출이 retry를 시도하면 실패"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t10ShutdownGuardStoppedThrowsDEPENDENCYCIRCUITOPENAndLogsREJECTED() {
|
||||
AtomicInteger hitCount = new AtomicInteger(0);
|
||||
server.createContext(
|
||||
"/shutdown-test",
|
||||
exchange -> {
|
||||
hitCount.incrementAndGet();
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
// Trigger shutdown
|
||||
guard.stop();
|
||||
|
||||
OutboundHttpClient client =
|
||||
OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
baseUrl,
|
||||
defaultSettings(),
|
||||
guard,
|
||||
defaultResilience(defaultSettings()),
|
||||
retryPolicy(defaultSettings()),
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
|
||||
DependencyFailureException ex =
|
||||
catchThrowableOfType(
|
||||
DependencyFailureException.class, () -> client.get("/shutdown-test", String.class));
|
||||
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
|
||||
|
||||
// Server hit count unchanged — no network call was made
|
||||
assertThat(hitCount.get()).isEqualTo(0);
|
||||
|
||||
// Failure log must contain outcome="REJECTED"
|
||||
assertThat(logAppender.list).isNotEmpty();
|
||||
boolean foundRejected =
|
||||
logAppender.list.stream()
|
||||
.anyMatch(e -> e.getFormattedMessage().contains("outcome=\"REJECTED\""));
|
||||
assertThat(foundRejected)
|
||||
.as("Log must contain outcome=\"REJECTED\" for shutdown path")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 11a: Response size limit — Content-Length path throws
|
||||
// OutboundResponseSizeExceededException
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t11aBufferedGetThrowsSizeExceededWhenContentLengthExceedsLimit() {
|
||||
byte[] bigBody = new byte[1024]; // 1KB
|
||||
Arrays.fill(bigBody, (byte) 'X');
|
||||
|
||||
server.createContext(
|
||||
"/big",
|
||||
exchange -> {
|
||||
exchange.sendResponseHeaders(200, bigBody.length); // known Content-Length
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bigBody);
|
||||
}
|
||||
});
|
||||
|
||||
// 64-byte limit
|
||||
OutboundHttpSettings tinyLimitSettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofBytes(64));
|
||||
|
||||
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
|
||||
|
||||
assertThatThrownBy(() -> client.get("/big", byte[].class))
|
||||
.isInstanceOf(OutboundResponseSizeExceededException.class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 11b: Response size limit — chunked/streaming path without Content-Length
|
||||
// Counting-stream also throws for buffered path
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t11bBufferedGetThrowsSizeExceededOnChunkedResponseNoContentLength() {
|
||||
byte[] bigBody = new byte[1024]; // 1KB
|
||||
Arrays.fill(bigBody, (byte) 'Y');
|
||||
|
||||
server.createContext(
|
||||
"/chunked",
|
||||
exchange -> {
|
||||
// sendResponseHeaders(200, 0) = chunked (unknown Content-Length)
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bigBody);
|
||||
}
|
||||
});
|
||||
|
||||
OutboundHttpSettings tinyLimitSettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofBytes(64));
|
||||
|
||||
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
|
||||
|
||||
// Counting-stream path in BoundedInputStream should throw too
|
||||
assertThatThrownBy(() -> client.get("/chunked", byte[].class))
|
||||
.isInstanceOf(OutboundResponseSizeExceededException.class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 11c: stream() API on the same oversized response succeeds (D7)
|
||||
// Spec: D7 "초과 시 streaming 처리 의무" — stream() path has no size limit
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t11cStreamApiSucceedsOnOversizedResponse() throws Exception {
|
||||
byte[] bigBody = new byte[1024]; // 1KB
|
||||
Arrays.fill(bigBody, (byte) 'Z');
|
||||
|
||||
server.createContext(
|
||||
"/stream-ok",
|
||||
exchange -> {
|
||||
exchange.sendResponseHeaders(200, bigBody.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bigBody);
|
||||
}
|
||||
});
|
||||
|
||||
OutboundHttpSettings tinyLimitSettings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofBytes(64));
|
||||
|
||||
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
|
||||
|
||||
// stream() bypasses the ResponseSizeBoundingInterceptor — should succeed and return all bytes
|
||||
byte[] result =
|
||||
client.stream(
|
||||
HttpMethod.GET,
|
||||
"/stream-ok",
|
||||
in -> {
|
||||
try {
|
||||
return in.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(result).hasSize(1024);
|
||||
assertThat(result[0]).isEqualTo((byte) 'Z');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private factory / helper methods
|
||||
// =========================================================================
|
||||
|
||||
/** Standard settings with generous timeouts for most tests. */
|
||||
private static OutboundHttpSettings defaultSettings() {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
/** Creates an already-started (not-shutting-down) shutdown guard. */
|
||||
private static OutboundHttpShutdownGuard activeGuard() {
|
||||
OutboundHttpShutdownGuard g = new OutboundHttpShutdownGuard();
|
||||
g.start();
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Creates a retry policy wired to a fresh guard and error mapper. */
|
||||
private static OutboundRetryPolicy retryPolicy(OutboundHttpSettings settings) {
|
||||
return new OutboundRetryPolicy(settings, activeGuard(), new OutboundHttpErrorMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link OutboundHttpResilience} with both retry and CB disabled — no MeterRegistry
|
||||
* needed.
|
||||
*/
|
||||
private static OutboundHttpResilience defaultResilience(OutboundHttpSettings settings) {
|
||||
return new OutboundHttpResilience(settings, retryPolicy(settings), null, null);
|
||||
}
|
||||
|
||||
/** Builds a client against the local {@link #server} with the given settings/resilience. */
|
||||
private OutboundHttpClient client(
|
||||
OutboundHttpSettings settings, OutboundHttpResilience resilience) {
|
||||
return OutboundHttpClient.baseline(
|
||||
"test-dep",
|
||||
baseUrl,
|
||||
settings,
|
||||
activeGuard(),
|
||||
resilience,
|
||||
retryPolicy(settings),
|
||||
new OutboundHttpErrorMapper(),
|
||||
testLogger);
|
||||
}
|
||||
|
||||
/**
|
||||
* ObjectProvider returning the given singleton (mirrors OutboundHttpResilienceConfigTest
|
||||
* pattern).
|
||||
*/
|
||||
private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) {
|
||||
return new ObjectProvider<>() {
|
||||
@Override
|
||||
public MeterRegistry getObject() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getObject(Object... args) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfAvailable() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfUnique() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<MeterRegistry> iterator() {
|
||||
return Stream.of(instance).iterator();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link OutboundHttpRestClientFactory}.
|
||||
*
|
||||
* <p>Verifies that {@code create()} returns a non-null {@code Clients} record with distinct
|
||||
* (different object identity) buffered and streaming {@link RestClient} instances, both backed by
|
||||
* the SAME shared request factory (behavior-preserving refactor: the original constructor built one
|
||||
* {@link org.springframework.http.client.JdkClientHttpRequestFactory} and shared it).
|
||||
*
|
||||
* <p>We cannot directly assert that the two RestClients share the same factory instance via public
|
||||
* API, but we can verify:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Both clients are non-null and distinct objects.
|
||||
* <li>The factory method completes without throwing given valid settings (proxy for "timeout
|
||||
* wiring did not blow up").
|
||||
* <li>The nested {@code Clients} record accessors work correctly.
|
||||
* </ol>
|
||||
*/
|
||||
class OutboundHttpRestClientFactoryTest {
|
||||
|
||||
private static OutboundHttpSettings validSettings() {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofMillis(500),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReturnsNonNullClientsRecord() {
|
||||
var clients =
|
||||
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
|
||||
assertThat(clients).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBufferedClientIsNonNull() {
|
||||
var clients =
|
||||
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
|
||||
assertThat(clients.buffered()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStreamingClientIsNonNull() {
|
||||
var clients =
|
||||
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
|
||||
assertThat(clients.streaming()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBufferedAndStreamingAreDistinctObjects() {
|
||||
var clients =
|
||||
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
|
||||
// They must be distinct RestClient instances (buffered has the size interceptor; streaming does
|
||||
// not)
|
||||
assertThat(clients.buffered()).isNotSameAs(clients.streaming());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createIsStableForMultipleCallsWithSameArgs() {
|
||||
// Each call creates a fresh set of clients — factory is stateless/repeatable
|
||||
var c1 =
|
||||
OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings());
|
||||
var c2 =
|
||||
OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings());
|
||||
assertThat(c1).isNotNull();
|
||||
assertThat(c2).isNotNull();
|
||||
// Different invocations produce independent client instances
|
||||
assertThat(c1.buffered()).isNotSameAs(c2.buffered());
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
/**
|
||||
* Binding and compact-constructor validation tests for {@link OutboundHttpSettings}
|
||||
* (feature-outbound-http-client-baseline D5 — "timeout 미설정 또는 무한 timeout forbidden"; registry
|
||||
* validation {@code spring_duration_shorthand_non_zero}).
|
||||
*/
|
||||
class OutboundHttpSettingsTest {
|
||||
|
||||
// --- direct construction ---
|
||||
|
||||
@Test
|
||||
void validSettingsConstructedDirectly() {
|
||||
OutboundHttpSettings settings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(2));
|
||||
assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(5));
|
||||
assertThat(settings.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10));
|
||||
assertThat(settings.retryEnabled()).isFalse();
|
||||
assertThat(settings.circuitBreakerEnabled()).isFalse();
|
||||
assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullConnectTimeoutThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
null,
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT")
|
||||
.hasMessageContaining("app.outbound.http.connect-timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroConnectTimeoutThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ZERO,
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeConnectTimeoutThrows() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(-1),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullReadTimeoutThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
null,
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT")
|
||||
.hasMessageContaining("app.outbound.http.read-timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroReadTimeoutThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ZERO,
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullGlobalCallTimeoutThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT")
|
||||
.hasMessageContaining("app.outbound.http.global-call-timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeGlobalCallTimeoutThrows() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofMillis(-1),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullResponseSizeLimitDefaultsTo10MB() {
|
||||
OutboundHttpSettings settings =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
null);
|
||||
assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeResponseSizeLimitThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofBytes(-1)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroResponseSizeLimitThrowsNamingEnvKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofBytes(0)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT");
|
||||
}
|
||||
|
||||
// --- ApplicationContextRunner binding ---
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OutboundHttpSettings.class)
|
||||
@EnableAutoConfiguration
|
||||
static class BindingConfig {}
|
||||
|
||||
@Test
|
||||
void settingsBindFromApplicationContextRunner() {
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(BindingConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.outbound.http.connect-timeout=2s",
|
||||
"app.outbound.http.read-timeout=5s",
|
||||
"app.outbound.http.global-call-timeout=10s",
|
||||
"app.outbound.http.retry-enabled=true",
|
||||
"app.outbound.http.circuit-breaker-enabled=false",
|
||||
"app.outbound.http.response-size-limit=10MB")
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class);
|
||||
assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2));
|
||||
assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5));
|
||||
assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10));
|
||||
assertThat(s.retryEnabled()).isTrue();
|
||||
assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextFailsWhenConnectTimeoutIsMissingFromBinding() {
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(BindingConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.outbound.http.read-timeout=5s", "app.outbound.http.global-call-timeout=10s")
|
||||
.run(ctx -> assertThat(ctx).hasFailed());
|
||||
}
|
||||
|
||||
// --- nested record 기본값 (직접 생성) ---
|
||||
|
||||
@Test
|
||||
void nestedRecordsDefaultWhenNullViaAuxConstructor() {
|
||||
// 보조 6-arg 생성자: retry/circuitBreaker 미지정 → 기본값 채워진 record
|
||||
OutboundHttpSettings s =
|
||||
new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
assertThat(s.retry().maxAttempts()).isEqualTo(3);
|
||||
assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(100));
|
||||
assertThat(s.retry().backoffMultiplier()).isEqualTo(2.0);
|
||||
assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(50f);
|
||||
assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(100);
|
||||
assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(100);
|
||||
assertThat(s.circuitBreaker().waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60));
|
||||
assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRecordNullFieldsDefault() {
|
||||
OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(null, null, null);
|
||||
assertThat(r.maxAttempts()).isEqualTo(3);
|
||||
assertThat(r.initialBackoff()).isEqualTo(Duration.ofMillis(100));
|
||||
assertThat(r.backoffMultiplier()).isEqualTo(2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void circuitBreakerRecordNullFieldsDefault() {
|
||||
OutboundHttpSettings.CircuitBreaker c =
|
||||
new OutboundHttpSettings.CircuitBreaker(null, null, null, null, null);
|
||||
assertThat(c.failureRateThreshold()).isEqualTo(50f);
|
||||
assertThat(c.slidingWindowSize()).isEqualTo(100);
|
||||
assertThat(c.minimumNumberOfCalls()).isEqualTo(100);
|
||||
assertThat(c.waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60));
|
||||
assertThat(c.permittedCallsInHalfOpen()).isEqualTo(10);
|
||||
}
|
||||
|
||||
// --- 경계 검증 ---
|
||||
|
||||
@Test
|
||||
void retryMaxAttemptsBelowOneThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(0, null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryInitialBackoffZeroThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, Duration.ZERO, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryBackoffMultiplierBelowOneThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, null, 0.5))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cbFailureRateOutOfRangeThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(0f, null, null, null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD");
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(150f, null, null, null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cbSlidingWindowBelowOneThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 0, null, null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cbMinimumCallsBelowOneThrows() {
|
||||
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 0, null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cbWaitDurationZeroThrows() {
|
||||
assertThatThrownBy(
|
||||
() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ZERO, null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cbPermittedHalfOpenBelowOneThrows() {
|
||||
assertThatThrownBy(
|
||||
() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ofSeconds(60), 0))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN");
|
||||
}
|
||||
|
||||
// --- ApplicationContextRunner 바인딩 ---
|
||||
|
||||
@Test
|
||||
void nestedSettingsBindFromApplicationContextRunner() {
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(BindingConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.outbound.http.connect-timeout=2s",
|
||||
"app.outbound.http.read-timeout=5s",
|
||||
"app.outbound.http.global-call-timeout=10s",
|
||||
"app.outbound.http.retry.max-attempts=5",
|
||||
"app.outbound.http.retry.initial-backoff=250ms",
|
||||
"app.outbound.http.retry.backoff-multiplier=3.0",
|
||||
"app.outbound.http.circuit-breaker.failure-rate-threshold=25",
|
||||
"app.outbound.http.circuit-breaker.sliding-window-size=20",
|
||||
"app.outbound.http.circuit-breaker.minimum-number-of-calls=7",
|
||||
"app.outbound.http.circuit-breaker.wait-duration-in-open-state=30s",
|
||||
"app.outbound.http.circuit-breaker.permitted-calls-in-half-open=4")
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class);
|
||||
assertThat(s.retry().maxAttempts()).isEqualTo(5);
|
||||
assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(250));
|
||||
assertThat(s.retry().backoffMultiplier()).isEqualTo(3.0);
|
||||
assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(25f);
|
||||
assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(20);
|
||||
assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(7);
|
||||
assertThat(s.circuitBreaker().waitDurationInOpenState())
|
||||
.isEqualTo(Duration.ofSeconds(30));
|
||||
assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Contract tests for {@link OutboundHttpShutdownGuard} (feature-outbound-http-client-baseline D8 /
|
||||
* plan decision I6 — SmartLifecycle phase ordering ensures shutdown guard is stopped first).
|
||||
*/
|
||||
class OutboundHttpShutdownGuardTest {
|
||||
|
||||
@Test
|
||||
void autoStartupIsTrue() {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
assertThat(guard.isAutoStartup()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsAsRunningAndNotShuttingDown() {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
assertThat(guard.isRunning()).isTrue();
|
||||
assertThat(guard.isShuttingDown()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopFlipsShuttingDownToTrueAndStopsRunning() {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
guard.stop();
|
||||
assertThat(guard.isShuttingDown()).isTrue();
|
||||
assertThat(guard.isRunning()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void phaseIsIntegerMaxValue() {
|
||||
// Spring stops phases in DESCENDING order — Integer.MAX_VALUE means this
|
||||
// lifecycle bean is stopped FIRST during shutdown (D8 / plan I6).
|
||||
assertThat(new OutboundHttpShutdownGuard().getPhase()).isEqualTo(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isNotShuttingDownBeforeStopIsCalled() {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
// guard has never been started or stopped
|
||||
assertThat(guard.isShuttingDown()).isFalse();
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Contract tests for {@link OutboundHttpTimeoutEnforcer} (plan decision I2 — BeanPostProcessor that
|
||||
* detects raw {@link RestClient} / {@link RestClient.Builder} beans and fails the
|
||||
* ApplicationContext startup with a descriptive error message citing the registry env-key names).
|
||||
*/
|
||||
class OutboundHttpTimeoutEnforcerTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner().withBean(OutboundHttpTimeoutEnforcer.class);
|
||||
|
||||
@Configuration
|
||||
static class RawRestClientConfig {
|
||||
@Bean
|
||||
RestClient rawClient() {
|
||||
return RestClient.create();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class RawRestClientBuilderConfig {
|
||||
@Bean
|
||||
RestClient.Builder rawBuilder() {
|
||||
return RestClient.builder();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class SafeConfig {
|
||||
@Bean
|
||||
String harmlessBean() {
|
||||
return "hello";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextFailsWhenARawRestClientBeanIsRegistered() {
|
||||
runner
|
||||
.withUserConfiguration(RawRestClientConfig.class)
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx.getStartupFailure().getMessage())
|
||||
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureMessageMentionsAllThreeTimeoutEnvKeys() {
|
||||
runner
|
||||
.withUserConfiguration(RawRestClientConfig.class)
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
String msg = ctx.getStartupFailure().getMessage();
|
||||
assertThat(msg)
|
||||
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT")
|
||||
.contains("APP_OUTBOUND_HTTP_READ_TIMEOUT")
|
||||
.contains("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextFailsWhenARawRestClientBuilderBeanIsRegistered() {
|
||||
runner
|
||||
.withUserConfiguration(RawRestClientBuilderConfig.class)
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx.getStartupFailure().getMessage())
|
||||
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextStartsFineWhenNoRawRestClientBeanIsPresent() {
|
||||
runner.withUserConfiguration(SafeConfig.class).run(ctx -> assertThat(ctx).hasNotFailed());
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.caskeleton.shared.tracing.BaggageAllowlist;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.mock.http.client.MockClientHttpRequest;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TraceContextPropagationInterceptor} (Slice 3a,
|
||||
* feature-distributed-tracing-contract §1 / D2 / D8).
|
||||
*
|
||||
* <p>Covers:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Full MDC → all four headers set; baggage contains ONLY allowlisted keys.
|
||||
* <li>Forbidden MDC key does NOT appear in baggage (D2/D8 trust-boundary).
|
||||
* <li>Empty/invalid MDC → no headers set; call still executed.
|
||||
* <li>Header already set → not overwritten.
|
||||
* <li>Only partial MDC (trace_id/span_id present, no request_id/correlation_id/tenant_id) → only
|
||||
* traceparent set.
|
||||
* </ol>
|
||||
*/
|
||||
class TraceContextPropagationInterceptorTest {
|
||||
|
||||
// Valid W3C values (32-hex trace-id, 16-hex span-id)
|
||||
private static final String VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
|
||||
private static final String VALID_SPAN_ID = "00f067aa0ba902b7";
|
||||
private static final String VALID_REQUEST_ID = "req-abc-123";
|
||||
private static final String VALID_CORR_ID = "corr-xyz-456";
|
||||
private static final String VALID_TENANT_ID = "tenant-42";
|
||||
|
||||
private TraceContextPropagationInterceptor interceptor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
interceptor = new TraceContextPropagationInterceptor();
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 1: Full MDC → all four outbound headers; baggage allowlisted only
|
||||
// Spec: "outbound HTTP → propagate traceparent, requestId, correlationId"
|
||||
// "baggage에 금지 정보가 기록되면 실패"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t1FullMdcAllHeadersSetBaggageContainsOnlyAllowlistedKeys() throws IOException {
|
||||
MDC.put("trace_id", VALID_TRACE_ID);
|
||||
MDC.put("span_id", VALID_SPAN_ID);
|
||||
MDC.put("request_id", VALID_REQUEST_ID);
|
||||
MDC.put("correlation_id", VALID_CORR_ID);
|
||||
MDC.put("tenant_id", VALID_TENANT_ID);
|
||||
// Forbidden key that must never appear in baggage
|
||||
MDC.put("user_principal", "evil-secret");
|
||||
MDC.put("jwt_token", "Bearer eyJhbGci...");
|
||||
|
||||
MockClientHttpRequest request =
|
||||
new MockClientHttpRequest(HttpMethod.GET, URI.create("/api/resource"));
|
||||
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
|
||||
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
|
||||
|
||||
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
|
||||
|
||||
assertThat(result).isSameAs(fakeResponse);
|
||||
verify(execution, times(1)).execute(any(), any());
|
||||
|
||||
HttpHeaders headers = request.getHeaders();
|
||||
|
||||
// traceparent — W3C format 00-<traceId>-<spanId>-00
|
||||
assertThat(headers.getFirst("traceparent"))
|
||||
.isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00");
|
||||
|
||||
// requestId and correlationId
|
||||
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID);
|
||||
assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID);
|
||||
|
||||
// baggage: ONLY tenant_id and request_id may appear
|
||||
String baggage = headers.getFirst("baggage");
|
||||
assertThat(baggage).isNotNull().isNotBlank();
|
||||
assertThat(baggage).contains("tenant_id=" + VALID_TENANT_ID);
|
||||
assertThat(baggage).contains("request_id=" + VALID_REQUEST_ID);
|
||||
|
||||
// Forbidden keys must NOT appear in baggage
|
||||
assertThat(baggage).doesNotContain("user_principal");
|
||||
assertThat(baggage).doesNotContain("jwt_token");
|
||||
assertThat(baggage).doesNotContain("evil-secret");
|
||||
assertThat(baggage).doesNotContain("Bearer");
|
||||
|
||||
// Cross-check: all baggage keys must be in the ALLOWED set
|
||||
BaggageAllowlist.parseHeader(baggage)
|
||||
.forEach(
|
||||
(key, value) ->
|
||||
assertThat(BaggageAllowlist.isAllowed(key))
|
||||
.as("Baggage key '%s' must be allowlisted", key)
|
||||
.isTrue());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 2: Empty/invalid MDC → no headers added; call still executes
|
||||
// Spec: "invalid MDC value never throws — skip the header instead"
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t2EmptyMdcNoHeadersAddedCallStillExecutes() throws IOException {
|
||||
// MDC is empty (no keys set)
|
||||
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/ping"));
|
||||
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
|
||||
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
|
||||
|
||||
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
|
||||
|
||||
assertThat(result).isSameAs(fakeResponse);
|
||||
verify(execution, times(1)).execute(any(), any());
|
||||
|
||||
HttpHeaders headers = request.getHeaders();
|
||||
assertThat(headers.containsHeader("traceparent")).isFalse();
|
||||
assertThat(headers.containsHeader("X-Request-Id")).isFalse();
|
||||
assertThat(headers.containsHeader("X-Correlation-Id")).isFalse();
|
||||
assertThat(headers.containsHeader("baggage")).isFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 3: Invalid trace_id/span_id (wrong format) → no traceparent header
|
||||
// but call still executes and other valid headers ARE set
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t3InvalidTraceIdAndSpanIdTraceparentSkippedOtherHeadersSet() throws IOException {
|
||||
MDC.put("trace_id", "not-a-valid-trace-id"); // not 32 hex
|
||||
MDC.put("span_id", "BAD"); // uppercase, not 16 hex
|
||||
MDC.put("request_id", VALID_REQUEST_ID);
|
||||
MDC.put("correlation_id", VALID_CORR_ID);
|
||||
|
||||
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/data"));
|
||||
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
|
||||
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
|
||||
|
||||
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
|
||||
|
||||
assertThat(result).isSameAs(fakeResponse);
|
||||
verify(execution, times(1)).execute(any(), any());
|
||||
|
||||
HttpHeaders headers = request.getHeaders();
|
||||
// No traceparent — both IDs are invalid
|
||||
assertThat(headers.containsHeader("traceparent")).isFalse();
|
||||
// Other headers still set
|
||||
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID);
|
||||
assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 4: Header already set on the request → not overwritten (idempotent)
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t4HeaderAlreadySetNotOverwritten() throws IOException {
|
||||
MDC.put("trace_id", VALID_TRACE_ID);
|
||||
MDC.put("span_id", VALID_SPAN_ID);
|
||||
MDC.put("request_id", VALID_REQUEST_ID);
|
||||
|
||||
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/item"));
|
||||
// Pre-set an existing traceparent
|
||||
String existingTraceparent = "00-aaaabbbbccccdddd1111222233334444-5555666677778888-01";
|
||||
request.getHeaders().set("traceparent", existingTraceparent);
|
||||
String existingRequestId = "already-set-req-id";
|
||||
request.getHeaders().set("X-Request-Id", existingRequestId);
|
||||
|
||||
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
|
||||
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
|
||||
|
||||
interceptor.intercept(request, new byte[0], execution);
|
||||
|
||||
HttpHeaders headers = request.getHeaders();
|
||||
// Must preserve the original values
|
||||
assertThat(headers.getFirst("traceparent")).isEqualTo(existingTraceparent);
|
||||
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(existingRequestId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 5: Only trace_id + span_id in MDC (no request_id/corr/tenant) →
|
||||
// only traceparent set; no baggage header
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void t5OnlyTraceContextTraceparentSetNoBaggage() throws IOException {
|
||||
MDC.put("trace_id", VALID_TRACE_ID);
|
||||
MDC.put("span_id", VALID_SPAN_ID);
|
||||
|
||||
MockClientHttpRequest request =
|
||||
new MockClientHttpRequest(HttpMethod.GET, URI.create("/status"));
|
||||
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
|
||||
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
|
||||
|
||||
interceptor.intercept(request, new byte[0], execution);
|
||||
|
||||
HttpHeaders headers = request.getHeaders();
|
||||
assertThat(headers.getFirst("traceparent"))
|
||||
.isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00");
|
||||
assertThat(headers.containsHeader("X-Request-Id")).isFalse();
|
||||
assertThat(headers.containsHeader("X-Correlation-Id")).isFalse();
|
||||
// request_id absent → baggage only has tenant_id if present; here neither → no baggage
|
||||
assertThat(headers.containsHeader("baggage")).isFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers
|
||||
// =========================================================================
|
||||
|
||||
private static ClientHttpRequestExecution stubExecution(ClientHttpResponse response)
|
||||
throws IOException {
|
||||
ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class);
|
||||
when(execution.execute(any(), any())).thenReturn(response);
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Contract tests for {@link OutboundHttpDependencyLogger} (feature-outbound-http-client-baseline
|
||||
* §Audit F1 / plan decision I12 — registry log_field_mapping field names: dependency_name,
|
||||
* dependency_type, outcome, duration_ms, retry_attempt, correlation_id).
|
||||
*
|
||||
* <p>Uses the ListAppender pattern from {@code support/FailOpenDependencyLoggerTest} (test seam via
|
||||
* logger injection).
|
||||
*/
|
||||
class OutboundHttpDependencyLoggerTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private OutboundHttpDependencyLogger logger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.dependency");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
logger = new OutboundHttpDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
// --- success ---
|
||||
|
||||
@Test
|
||||
void successLogIsAtDEBUGLevel() {
|
||||
logger.logSuccess("github", 123L, 0);
|
||||
|
||||
assertThat(appender.list).hasSize(1);
|
||||
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.DEBUG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successLogContainsAllRequiredFields() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-42");
|
||||
|
||||
logger.logSuccess("github", 250L, 1);
|
||||
|
||||
String msg = appender.list.get(0).getFormattedMessage();
|
||||
assertThat(msg)
|
||||
.contains("dependency_name=\"github\"")
|
||||
.contains("dependency_type=\"http\"")
|
||||
.contains("outcome=\"SUCCESS\"")
|
||||
.contains("duration_ms=250")
|
||||
.contains("retry_attempt=1")
|
||||
.contains("correlation_id=\"corr-42\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void successLogUsesUnknownCorrelationIdWhenMdcIsEmpty() {
|
||||
logger.logSuccess("payment-api", 10L, 0);
|
||||
|
||||
assertThat(appender.list.get(0).getFormattedMessage())
|
||||
.contains("correlation_id=\"" + OutboundCorrelation.UNKNOWN + "\"");
|
||||
}
|
||||
|
||||
// --- failure ---
|
||||
|
||||
@Test
|
||||
void failureLogIsAtERRORLevelForFAILUREOutcome() {
|
||||
logger.logFailure(
|
||||
"inventory-api", "FAILURE", 500L, 2, new RuntimeException("connection refused"));
|
||||
|
||||
assertThat(appender.list).hasSize(1);
|
||||
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureLogContainsAllRequiredFields() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-99");
|
||||
|
||||
logger.logFailure("payment-api", "TIMEOUT", 3000L, 3, new RuntimeException("read timed out"));
|
||||
|
||||
String msg = appender.list.get(0).getFormattedMessage();
|
||||
assertThat(msg)
|
||||
.contains("dependency_name=\"payment-api\"")
|
||||
.contains("dependency_type=\"http\"")
|
||||
.contains("outcome=\"TIMEOUT\"")
|
||||
.contains("duration_ms=3000")
|
||||
.contains("retry_attempt=3")
|
||||
.contains("correlation_id=\"corr-99\"")
|
||||
.contains("error=\"RuntimeException: read timed out\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void circuitOpenOutcomeLogsAtWARNLevel() {
|
||||
logger.logFailure("catalog-api", "CIRCUIT_OPEN", 0L, 0, new RuntimeException("circuit open"));
|
||||
|
||||
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedOutcomeLogsAtWARNLevel() {
|
||||
logger.logFailure("catalog-api", "REJECTED", 0L, 0, new RuntimeException("shutting down"));
|
||||
|
||||
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureLogContainsOnlyExceptionClassAndMessageNotBody() {
|
||||
// By construction: logFailure signature does not accept a request/response body.
|
||||
// A throwable whose message contains a "BODY_SECRET" string is acceptable to pass
|
||||
// as cause (the exception message appears in the error= field for server logs),
|
||||
// but we assert the field format stays within class+message and never exposes
|
||||
// a body string that was not part of the throwable message.
|
||||
RuntimeException cause = new RuntimeException("upstream error");
|
||||
logger.logFailure("external-api", "FAILURE", 100L, 0, cause);
|
||||
|
||||
String msg = appender.list.get(0).getFormattedMessage();
|
||||
// The log must contain the error= field
|
||||
assertThat(msg).contains("error=\"RuntimeException: upstream error\"");
|
||||
// The signature has no body parameter — a body string passed ONLY as a separate
|
||||
// argument cannot appear in the log (by-construction contract).
|
||||
assertThat(msg).doesNotContain("RESPONSE_BODY_CONTENT");
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.util.Iterator;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
/**
|
||||
* TDD tests for {@link OutboundHttpResilienceConfig} (feature-outbound-http-client-baseline plan
|
||||
* decisions I7 — D3 activation guard).
|
||||
*
|
||||
* <p>Test contract: "retry/CB enabled 인데 metric/retryable classification 부재 시 실패".
|
||||
*/
|
||||
class OutboundHttpResilienceConfigTest {
|
||||
|
||||
// --- direct construction tests (no Spring context needed) ---
|
||||
|
||||
@Test
|
||||
void retryEnabledWithoutMeterRegistryThrowsOnBeanCreation() {
|
||||
OutboundHttpSettings settings = retrySettings();
|
||||
OutboundRetryPolicy policy = policy(settings);
|
||||
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
|
||||
|
||||
// No MeterRegistry available → must throw with D3 message
|
||||
assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("MeterRegistry")
|
||||
.hasMessageContaining("D3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void circuitBreakerEnabledWithoutMeterRegistryThrowsOnBeanCreation() {
|
||||
OutboundHttpSettings settings = cbSettings();
|
||||
OutboundRetryPolicy policy = policy(settings);
|
||||
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
|
||||
|
||||
assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("MeterRegistry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryEnabledWithSimpleMeterRegistryProducesResilienceWithRetry() {
|
||||
OutboundHttpSettings settings = retrySettings();
|
||||
OutboundRetryPolicy policy = policy(settings);
|
||||
SimpleMeterRegistry meter = new SimpleMeterRegistry();
|
||||
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
|
||||
|
||||
OutboundHttpResilience resilience =
|
||||
config.outboundHttpResilience(settings, policy, singletonProvider(meter));
|
||||
|
||||
assertThat(resilience.retryFor("test-dep")).isPresent();
|
||||
assertThat(resilience.circuitBreakerFor("test-dep")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothDisabledProducesEmptyOptionalsAndNoExceptionWithoutMeterRegistry() {
|
||||
OutboundHttpSettings settings = disabledSettings();
|
||||
OutboundRetryPolicy policy = policy(settings);
|
||||
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
|
||||
|
||||
// Must NOT throw even without a MeterRegistry when both features are disabled
|
||||
OutboundHttpResilience resilience =
|
||||
config.outboundHttpResilience(settings, policy, emptyProvider());
|
||||
|
||||
assertThat(resilience.retryFor("dep")).isEmpty();
|
||||
assertThat(resilience.circuitBreakerFor("dep")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void circuitBreakerEnabledWithSimpleMeterRegistryProducesCircuitBreaker() {
|
||||
OutboundHttpSettings settings = cbSettings();
|
||||
OutboundRetryPolicy policy = policy(settings);
|
||||
SimpleMeterRegistry meter = new SimpleMeterRegistry();
|
||||
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
|
||||
|
||||
OutboundHttpResilience resilience =
|
||||
config.outboundHttpResilience(settings, policy, singletonProvider(meter));
|
||||
|
||||
assertThat(resilience.circuitBreakerFor("test-dep")).isPresent();
|
||||
assertThat(resilience.retryFor("test-dep")).isEmpty();
|
||||
}
|
||||
|
||||
// --- ApplicationContextRunner test for startup failure path ---
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OutboundHttpSettings.class)
|
||||
static class RetryEnabledNoMeterConfig {
|
||||
|
||||
@Bean
|
||||
OutboundHttpShutdownGuard guard() {
|
||||
return new OutboundHttpShutdownGuard();
|
||||
}
|
||||
|
||||
@Bean
|
||||
OutboundHttpErrorMapper mapper() {
|
||||
return new OutboundHttpErrorMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
OutboundRetryPolicy retryPolicy(
|
||||
OutboundHttpSettings s, OutboundHttpShutdownGuard g, OutboundHttpErrorMapper m) {
|
||||
return new OutboundRetryPolicy(s, g, m);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationContextFailsToStartWhenRetryEnabledAndNoMeterRegistryBean() {
|
||||
// OutboundHttpResilienceConfig must be registered as a configuration class (NOT as a
|
||||
// @Bean factory product — Spring does not process @Bean methods on factory-method
|
||||
// beans), so its outboundHttpResilience(...) bean definition participates in startup.
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(RetryEnabledNoMeterConfig.class, OutboundHttpResilienceConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.outbound.http.connect-timeout=2s",
|
||||
"app.outbound.http.read-timeout=5s",
|
||||
"app.outbound.http.global-call-timeout=10s",
|
||||
"app.outbound.http.retry-enabled=true" // enabled but no MeterRegistry bean
|
||||
)
|
||||
.run(
|
||||
ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
assertThat(ctx.getStartupFailure().getMessage()).contains("MeterRegistry");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static OutboundHttpSettings retrySettings() {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
true,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
private static OutboundHttpSettings cbSettings() {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
true,
|
||||
DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
private static OutboundHttpSettings disabledSettings() {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
false,
|
||||
false,
|
||||
DataSize.ofMegabytes(10));
|
||||
}
|
||||
|
||||
private static OutboundRetryPolicy policy(OutboundHttpSettings settings) {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
return new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper());
|
||||
}
|
||||
|
||||
/** ObjectProvider that always returns null (simulates absent bean). */
|
||||
private static ObjectProvider<MeterRegistry> emptyProvider() {
|
||||
return new ObjectProvider<>() {
|
||||
@Override
|
||||
public MeterRegistry getObject() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getObject(Object... args) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfAvailable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfUnique() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<MeterRegistry> iterator() {
|
||||
return Stream.<MeterRegistry>empty().iterator();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** ObjectProvider that returns the supplied singleton instance. */
|
||||
private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) {
|
||||
return new ObjectProvider<>() {
|
||||
@Override
|
||||
public MeterRegistry getObject() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getObject(Object... args) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfAvailable() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterRegistry getIfUnique() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<MeterRegistry> iterator() {
|
||||
return Stream.of(instance).iterator();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.core.IntervalBiFunction;
|
||||
import io.github.resilience4j.core.functions.Either;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
/**
|
||||
* {@link OutboundHttpResilience}가 {@link OutboundHttpSettings}의 retry/circuit-breaker 튜닝 값을 실제
|
||||
* Resilience4j config로 흘려보내는지 검증한다(설정 외부화 계약).
|
||||
*/
|
||||
class OutboundHttpResilienceTest {
|
||||
|
||||
private static OutboundHttpSettings settings(
|
||||
boolean retry,
|
||||
boolean cb,
|
||||
OutboundHttpSettings.Retry r,
|
||||
OutboundHttpSettings.CircuitBreaker c) {
|
||||
return new OutboundHttpSettings(
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10),
|
||||
retry,
|
||||
cb,
|
||||
DataSize.ofMegabytes(10),
|
||||
r,
|
||||
c);
|
||||
}
|
||||
|
||||
private static OutboundRetryPolicy policy(OutboundHttpSettings s) {
|
||||
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
|
||||
guard.start();
|
||||
return new OutboundRetryPolicy(s, guard, new OutboundHttpErrorMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryConfigCarriesConfiguredMaxAttemptsAndBackoff() {
|
||||
OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(5, Duration.ofMillis(200), 3.0);
|
||||
OutboundHttpSettings s = settings(true, false, r, null);
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilience(s, policy(s), RetryRegistry.ofDefaults(), null);
|
||||
|
||||
Retry retry = resilience.retryFor("dep").orElseThrow();
|
||||
assertThat(retry.getRetryConfig().getMaxAttempts()).isEqualTo(5);
|
||||
// exponential random backoff: attempt 1 interval ∈ [initial*0.5, initial*1.5] (jitter 0.5).
|
||||
// RetryConfig.getIntervalFunction() is deprecated in Resilience4j 2.x → read the backoff via
|
||||
// the non-deprecated getIntervalBiFunction(); the Either result is unused by a plain
|
||||
// interval-based backoff (IntervalBiFunction wraps the IntervalFunction, ignoring the result).
|
||||
IntervalBiFunction<Object> backoff = retry.getRetryConfig().getIntervalBiFunction();
|
||||
long firstInterval = backoff.apply(1, Either.right(null));
|
||||
assertThat(firstInterval).isBetween(100L, 300L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void circuitBreakerConfigCarriesConfiguredValues() {
|
||||
OutboundHttpSettings.CircuitBreaker c =
|
||||
new OutboundHttpSettings.CircuitBreaker(25f, 20, 7, Duration.ofSeconds(30), 4);
|
||||
OutboundHttpSettings s = settings(false, true, null, c);
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilience(s, policy(s), null, CircuitBreakerRegistry.ofDefaults());
|
||||
|
||||
CircuitBreaker cb = resilience.circuitBreakerFor("dep").orElseThrow();
|
||||
var cfg = cb.getCircuitBreakerConfig();
|
||||
assertThat(cfg.getFailureRateThreshold()).isEqualTo(25f);
|
||||
assertThat(cfg.getSlidingWindowSize()).isEqualTo(20);
|
||||
assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(7);
|
||||
assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(4);
|
||||
// CB는 plain Duration getter가 없음 → interval function으로 검증(javap 확인)
|
||||
assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(30_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSettingsReturnEmptyOptionals() {
|
||||
OutboundHttpSettings s = settings(false, false, null, null);
|
||||
OutboundHttpResilience resilience = new OutboundHttpResilience(s, policy(s), null, null);
|
||||
assertThat(resilience.retryFor("dep")).isEmpty();
|
||||
assertThat(resilience.circuitBreakerFor("dep")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultTuningPreservesResilience4jDefaults() {
|
||||
OutboundHttpSettings s = settings(true, true, null, null); // null nested → 기본값
|
||||
OutboundHttpResilience resilience =
|
||||
new OutboundHttpResilience(
|
||||
s, policy(s), RetryRegistry.ofDefaults(), CircuitBreakerRegistry.ofDefaults());
|
||||
assertThat(resilience.retryFor("dep").orElseThrow().getRetryConfig().getMaxAttempts())
|
||||
.isEqualTo(3);
|
||||
var cfg = resilience.circuitBreakerFor("dep").orElseThrow().getCircuitBreakerConfig();
|
||||
assertThat(cfg.getFailureRateThreshold()).isEqualTo(50f);
|
||||
assertThat(cfg.getSlidingWindowSize()).isEqualTo(100);
|
||||
assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(100);
|
||||
assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(10);
|
||||
assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(60_000L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# adapter:outbound:identifier — non-IO infrastructure-capability adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-identifier`
|
||||
- Gradle path: `:adapter:outbound:identifier`
|
||||
- Focused test: `./gradlew :adapter:outbound:identifier:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.identifier`.
|
||||
|
||||
Design decisions previously kept as code comments (algorithm SSOT, salt origin,
|
||||
build choices) live in [README.md](README.md).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Driven adapters for capabilities with **no external-system integration**:
|
||||
identifier generation/encoding today, and clock / crypto/random sources by the
|
||||
same rationale (feature-resource-identifier-contract §4 taxonomy).
|
||||
- `UuidCodec` — UUID handling on top of the JDK `java.util.UUID` (RFC 9562 UUIDv7):
|
||||
`normalize(String)` accepts a case-insensitive canonical UUID and returns the
|
||||
canonical 36-character lowercase form (D3); `toUuid` / `fromUuid` convert between
|
||||
the UUID string and the 128-bit `UUID` stored in the PostgreSQL `uuid` column (D10).
|
||||
- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is
|
||||
infrastructure, not an outbound integration point, so `adapter-outbound` keeps its
|
||||
documented meaning (external HTTP / messaging / cache / notifications).
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently
|
||||
only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in
|
||||
[build.gradle](build.gradle).
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
|
||||
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
|
||||
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
|
||||
- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`,
|
||||
`sample-portfolio`.
|
||||
- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`.
|
||||
|
||||
## Test
|
||||
|
||||
Pure unit tests, no Spring context (`UuidCodecSpec`).
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:identifier:test --console=plain
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# adapter-identifier — 설계 결정 참조
|
||||
|
||||
비-IO 인프라 능력(capability) 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.identifier`.
|
||||
|
||||
허용/금지 의존과 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다.
|
||||
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
|
||||
"왜 이렇게 했나"가 궁금할 때 본다.
|
||||
|
||||
## 왜 별도 모듈인가 (adapter-outbound 와의 구분)
|
||||
|
||||
`adapter-outbound` 처럼 도메인 포트를 구현하는 driven/secondary 어댑터지만, **외부 시스템 연동이
|
||||
없는**(no external-system integration) 능력만 담는다: 식별자 생성/인코딩(UUIDv7), 같은 근거로 clock·
|
||||
crypto/random 소스. UUID id/코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로, 이것을
|
||||
`adapter-outbound` 밖에 둬야 그 모듈의 문서화된 의미("외부 HTTP / messaging / cache / notifications")가
|
||||
유지된다.
|
||||
|
||||
## UuidCodec
|
||||
|
||||
도메인 무관 UUID 변환 유틸. JDK `java.util.UUID`(RFC 9562 UUIDv7) 위에서 동작한다.
|
||||
|
||||
- `normalize(String)` — **D3**: 대소문자 무관 canonical UUID 입력을 받아 canonical 36자 소문자
|
||||
형태로 반환. 형식 오류 UUID 에는 `IllegalArgumentException`. `null` 입력은 `null` 반환.
|
||||
- `toUuid(String)` — **D10**: UUID 문자열 → 128-bit `UUID` (PostgreSQL `uuid` 컬럼용).
|
||||
- `fromUuid(UUID)` — **D10**: 저장된 `UUID` → canonical 36자 소문자 UUID 문자열.
|
||||
|
||||
## HmacUserPrincipalPseudonymizer
|
||||
|
||||
`UserPrincipalPseudonymizerPort`(application-core) 의 HMAC-SHA-256 구현.
|
||||
|
||||
### 알고리즘 SSOT
|
||||
구체 알고리즘은 90일 회전 salt 로 키잉한 HMAC-SHA-256 이다. 이 클래스가 유일한 구현이며,
|
||||
비-IO crypto 능력 어댑터로 이 모듈에 있고 `app-bootstrap`
|
||||
이 싱글톤 빈으로 와이어링한다.
|
||||
|
||||
### 출력
|
||||
비어있지 않은 `rawPrincipal` 에 대해 단방향·안정적인 256-bit HMAC 토큰을 64자 소문자 hex 로 반환.
|
||||
`rawPrincipal` 이 `null` 이거나 blank 면 `null` 반환.
|
||||
|
||||
### Salt 출처
|
||||
salt 는 `app-bootstrap` 이 `APP_PRIVACY_PSEUDONYMIZATION_SALT` 환경변수에서 공급한다(분류: secret,
|
||||
회전 주기: 90일). 이 클래스는 salt 를 스스로 조달하지 않는다.
|
||||
|
||||
### Thread safety
|
||||
`Mac` 인스턴스는 thread-safe 하지 않다. 매 `pseudonymize(String)` 호출마다 새 `Mac` 을 생성하므로
|
||||
공유 싱글톤 빈으로 안전하다. HmacSHA256 은 JDK 필수 알고리즘(JCA spec)이라 `NoSuchAlgorithmException`·
|
||||
`InvalidKeyException` 은 사실상 도달 불가능하며, 호출부에 checked exception 잡음을 남기지 않으려고
|
||||
`IllegalStateException` 으로 감싼다.
|
||||
|
||||
### Spring-free
|
||||
이 모듈(`adapter-identifier`)은 설계상 Spring-free 다. 어노테이션이 없고, 빈 생성은
|
||||
`app-bootstrap` 의 책임이다.
|
||||
|
||||
## 빌드 결정 (build.gradle)
|
||||
|
||||
### Groovy / Spock (C2 테스트 형태)
|
||||
순수 값-코덱 동작(`UuidCodec`)은 Groovy/Spock 스펙(`src/test/groovy`)으로 명세한다. core `groovy`
|
||||
플러그인이 컴파일하고, 모든 서브프로젝트에 이미 켜진 JUnit Platform(`useJUnitPlatform()`)에서 실행된다.
|
||||
가드/계약 테스트(`HmacUserPrincipalPseudonymizerTest` — 생성자 가드, 정확한 예외/포맷 계약)는 설계상
|
||||
Java(`src/test/java`)로 둔다. Spock 2.4 / Groovy 4.0 variant 를 쓰며, spock-core 가 groovy.jar 를
|
||||
transitive 로 끌어오므로 data-driven `where:` 스펙에 다른 Groovy 모듈이 필요 없다.
|
||||
|
||||
### implementation vs api
|
||||
`:application-core` 를 `implementation` 으로 선언한다(`api` 아님). adapter-identifier 가 자신의 public
|
||||
ABI 에 application-core 타입을 노출하지 않기 때문이다. 유일한 와이어링 소비자인 `app-bootstrap` 은
|
||||
이미 자기 classpath 에 application-core 를 갖고 있다. 이 의존 edge 는 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter-identifier']` 로 허용된다.
|
||||
|
||||
### UTF-8 인코딩 고정
|
||||
한국어(비-ASCII) Spock 스펙 메서드명은 소스를 UTF-8 로 읽어야만 컴파일·리포팅이 정상이다. 이 모듈이
|
||||
비-ASCII 소스를 처음 갖는 모듈이라 컴파일 인코딩을 명시적으로 고정한다 — UTF-8 호스트에선 no-op 지만,
|
||||
플랫폼 기본이 다른 fork(예: 한국어 Windows / MS949)에서 mojibake 빌드를 막는다. C2 가 더 많은 모듈로
|
||||
퍼지면 root subprojects 블록(`-parameters` 옆)으로 승격한다.
|
||||
@@ -0,0 +1,22 @@
|
||||
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
|
||||
plugins {
|
||||
id 'groovy'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation 'com.github.f4b6a3:uuid-creator:6.1.1'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
|
||||
// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README.
|
||||
tasks.withType(GroovyCompile).configureEach {
|
||||
groovyOptions.encoding = 'UTF-8'
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* HMAC-SHA-256 implementation of {@link UserPrincipalPseudonymizerPort}: returns a stable
|
||||
* 64-character lowercase hex token, or {@code null} for null/blank input. Thread-safe as a shared
|
||||
* singleton. See README for the algorithm SSOT, salt origin, and design rationale.
|
||||
*/
|
||||
public final class HmacUserPrincipalPseudonymizer implements UserPrincipalPseudonymizerPort {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
|
||||
public HmacUserPrincipalPseudonymizer(byte[] salt) {
|
||||
if (salt == null || salt.length == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"HMAC salt must not be null or empty — supplied by APP_PRIVACY_PSEUDONYMIZATION_SALT");
|
||||
}
|
||||
byte[] saltCopy = salt.clone(); // defensive copy; caller's array is not retained
|
||||
this.key = new SecretKeySpec(saltCopy, ALGORITHM);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String pseudonymize(String rawPrincipal) {
|
||||
if (rawPrincipal == null || rawPrincipal.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM); // Mac is not thread-safe — fresh instance per call
|
||||
mac.init(key);
|
||||
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
// Unreachable: HmacSHA256 is a mandatory JDK algorithm and the key spec is valid.
|
||||
throw new IllegalStateException(
|
||||
"HmacSHA256 unavailable or key invalid — this should never happen on a compliant JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Domain-agnostic UUID conversion utility. See README for the design rationale. */
|
||||
public final class UuidCodec {
|
||||
|
||||
private UuidCodec() {}
|
||||
|
||||
/**
|
||||
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
|
||||
* lowercase form; {@code null} input returns {@code null}.
|
||||
*
|
||||
* @throws IllegalArgumentException on a malformed UUID
|
||||
*/
|
||||
public static String normalize(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return UUID.fromString(input).toString();
|
||||
}
|
||||
|
||||
public static UUID toUuid(String uuidString) {
|
||||
return UUID.fromString(uuidString);
|
||||
}
|
||||
|
||||
public static String fromUuid(UUID uuid) {
|
||||
return uuid.toString();
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Non-IO infrastructure-capability adapters (identifier generation/codec, clock, crypto/random).
|
||||
* See README for why these are separate from {@code adapter-outbound}.
|
||||
*/
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
class UuidCodecSpec extends Specification {
|
||||
|
||||
static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab"
|
||||
|
||||
def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() {
|
||||
expect:
|
||||
UuidCodec.normalize(input) == CANONICAL
|
||||
|
||||
where:
|
||||
label | input
|
||||
"이미 canonical 인 입력" | CANONICAL
|
||||
"대문자 입력" | CANONICAL.toUpperCase()
|
||||
}
|
||||
|
||||
def "normalize 는 null 입력에 대해 null 을 반환한다"() {
|
||||
expect:
|
||||
UuidCodec.normalize(null) == null
|
||||
}
|
||||
|
||||
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
|
||||
when:
|
||||
UuidCodec.normalize("not-a-uuid")
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() {
|
||||
given:
|
||||
def uuid = UuidCodec.toUuid(CANONICAL)
|
||||
|
||||
expect:
|
||||
UuidCodec.fromUuid(uuid) == CANONICAL
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HmacUserPrincipalPseudonymizerTest {
|
||||
|
||||
private static final byte[] SALT_A = "test-salt-A-32-bytes-padding-xxx".getBytes();
|
||||
private static final byte[] SALT_B = "test-salt-B-32-bytes-padding-yyy".getBytes();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructor guard tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nullSaltThrowsIllegalArgumentException() {
|
||||
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySaltThrowsIllegalArgumentException() {
|
||||
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(new byte[0]))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Null / blank input → null output
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nullInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize(" ")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyStringInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize("")).isNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void sameInputSameSaltProducesSameOutputOnSameInstance() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String first = pseudonymizer.pseudonymize("user-123");
|
||||
String second = pseudonymizer.pseudonymize("user-123");
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameInputSameSaltProducesSameOutputAcrossTwoInstances() {
|
||||
var p1 = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
var p2 = new HmacUserPrincipalPseudonymizer(SALT_A.clone());
|
||||
assertThat(p1.pseudonymize("user-abc")).isEqualTo(p2.pseudonymize("user-abc"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Salt-sensitivity
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void differentSaltProducesDifferentOutput() {
|
||||
var pA = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
var pB = new HmacUserPrincipalPseudonymizer(SALT_B);
|
||||
assertThat(pA.pseudonymize("user-xyz")).isNotEqualTo(pB.pseudonymize("user-xyz"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// One-way property (output != input, output does not contain input)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void outputDoesNotEqualRawInput() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String raw = "alice@example.com";
|
||||
assertThat(pseudonymizer.pseudonymize(raw)).isNotEqualTo(raw);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outputDoesNotContainRawInputAsSubstring() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String raw = "alice";
|
||||
assertThat(pseudonymizer.pseudonymize(raw)).doesNotContain(raw);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Output format: lowercase hex, exactly 64 characters (256-bit HMAC)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void outputMatchesLowercaseHex64CharPattern() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String token = pseudonymizer.pseudonymize("some-user");
|
||||
assertThat(token).matches("^[0-9a-f]{64}$");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# adapter:outbound:messaging — messaging adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-messaging`
|
||||
- Gradle path: `:adapter:outbound:messaging`
|
||||
- Focused test: `./gradlew :adapter:outbound:messaging:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.messaging`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement outbound message publication and broker integration behind application/domain ports.
|
||||
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
|
||||
publication adaptation.
|
||||
- Reuse `adapter:outbound:support` for shared technical concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
|
||||
- Do not hide use-case sequencing or business routing policy in broker adapters.
|
||||
|
||||
## Tests
|
||||
|
||||
Use unit/contract tests with fake broker senders. No real network or broker is used in focused tests;
|
||||
settings records receive binding/validation tests when configuration changes.
|
||||
@@ -0,0 +1,44 @@
|
||||
# adapter:outbound:messaging — 설계 결정 참조
|
||||
|
||||
메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유
|
||||
correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도
|
||||
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
|
||||
기록이다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 두는 **선택형**
|
||||
연동 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 기본 비활성이며, 비활성 바인딩은
|
||||
`Disabled*` 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에
|
||||
최소화하고, 실제 broker client(`KafkaSender`)는 포킹 프로젝트가 채우는 seam 이다.
|
||||
|
||||
## 두 포트를 하나의 활성 broker 에 조립
|
||||
|
||||
`MessagingConfig` 는 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다 — broker
|
||||
추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다.
|
||||
|
||||
## broker 선택 검증
|
||||
|
||||
`app.messaging.broker` 가 설정됐는데 `MessageBroker` 빈이 없으면 startup 을 명시적 메시지로
|
||||
실패시킨다(조용한 no-op 아님). settings 와 활성 빈의 `brokerId()` 불일치도 startup 실패다.
|
||||
|
||||
## 비활성 sentinel 두 개를 분리한 이유
|
||||
|
||||
`DisabledMessagePublisher` 와 `DisabledOutboxMessagePublisher` 는 별도 클래스다 — 한 클래스가
|
||||
두 포트를 모두 구현하면 `getBean(MessagePublisher.class)` 가 모호해진다.
|
||||
|
||||
## OutboxEnvelopeJson — 손수 짠 JSON
|
||||
|
||||
이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope
|
||||
직렬화는 의존성 없는 손수 짠 JSON 이다.
|
||||
|
||||
## MessagePublisher vs OutboxMessagePublishPort
|
||||
|
||||
`MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께
|
||||
로깅하고 삼켜(→ `:adapter:outbound:support` 의 `FailOpenDependencyLogger`) outbox/retry 로
|
||||
위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort` 를
|
||||
쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
|
||||
@@ -0,0 +1,14 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -0,0 +1,156 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Assembles both messaging ports onto the single active {@link MessageBroker} (the cache
|
||||
* central-assembly pattern). A broker is contributed as a
|
||||
* {@code @ConditionalOnProperty(app.messaging.broker=<id>)}-gated bean, at most one active — adding
|
||||
* a broker is new files only and this config never changes. With no active broker it binds the
|
||||
* disabled sentinels (fail-fast); if set but no contributing bean exists, startup fails.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(MessagingSettings.class)
|
||||
public class MessagingConfig {
|
||||
|
||||
@Bean
|
||||
public MessagePublisher messagePublisher(
|
||||
ObjectProvider<MessageBroker> brokerProvider,
|
||||
MessagingSettings settings,
|
||||
FailOpenDependencyLogger dependencyLogger) {
|
||||
MessageBroker active = resolveBroker(brokerProvider, settings);
|
||||
return (active == null)
|
||||
? new DisabledMessagePublisher()
|
||||
: new OutboundMessagePublisher(active, dependencyLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OutboxMessagePublishPort outboxMessagePublishPort(
|
||||
ObjectProvider<MessageBroker> brokerProvider,
|
||||
MessagingSettings settings,
|
||||
FailOpenDependencyLogger dependencyLogger) {
|
||||
MessageBroker active = resolveBroker(brokerProvider, settings);
|
||||
return (active == null)
|
||||
? new DisabledOutboxMessagePublisher()
|
||||
: new OutboxMessagePublishAdapter(active, dependencyLogger);
|
||||
}
|
||||
|
||||
private static MessageBroker resolveBroker(
|
||||
ObjectProvider<MessageBroker> brokerProvider, MessagingSettings settings) {
|
||||
if (settings.broker().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
MessageBroker active = brokerProvider.getIfAvailable();
|
||||
if (active == null) {
|
||||
throw new IllegalStateException(
|
||||
"app.messaging.broker="
|
||||
+ settings.broker()
|
||||
+ " but no MessageBroker bean contributes that id — enable the broker template"
|
||||
+ " (supply its client seam) or unset app.messaging.broker");
|
||||
}
|
||||
if (!settings.broker().equals(active.brokerId())) {
|
||||
throw new IllegalStateException(
|
||||
"app.messaging.broker="
|
||||
+ settings.broker()
|
||||
+ " but the active MessageBroker reports brokerId '"
|
||||
+ active.brokerId()
|
||||
+ "'");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Messaging template selection. {@code app.messaging.broker=<brokerId>} chooses the single active
|
||||
* {@link MessageBroker} (e.g. {@code kafka}); unset/blank = no broker = fail-fast on use (the
|
||||
* disabled sentinels {@code DisabledMessagePublisher} / {@code DisabledOutboxMessagePublisher}).
|
||||
*
|
||||
* @param broker the active broker id, matched against {@link MessageBroker#brokerId()}; blank means
|
||||
* the messaging template is disabled (the default)
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.messaging")
|
||||
public record MessagingSettings(String broker) {
|
||||
|
||||
public MessagingSettings {
|
||||
broker = (broker == null) ? "" : broker.trim();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
|
||||
/**
|
||||
* Fail-fast {@link MessagePublisher} binding when no broker is active ({@code app.messaging.broker}
|
||||
* unset — the default). Any publish throws {@link AdapterDisabledException} — never a silent no-op
|
||||
* (mirrors the cache router's unbound fail-fast). Broker-agnostic; the outbox counterpart is {@code
|
||||
* DisabledOutboxMessagePublisher}.
|
||||
*/
|
||||
public class DisabledMessagePublisher implements MessagePublisher {
|
||||
|
||||
@Override
|
||||
public void publish(OutboundMessage message) {
|
||||
throw new AdapterDisabledException("messaging");
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
/**
|
||||
* SPI a forking project contributes to bind the messaging template to a real broker. The active
|
||||
* broker is selected by {@code app.messaging.broker=<brokerId>}. The skeleton carries no broker SDK
|
||||
* — it is supplied by the project that selects the broker.
|
||||
*/
|
||||
public interface MessageBroker {
|
||||
|
||||
/** Stable broker identifier matched against {@code app.messaging.broker}. */
|
||||
String brokerId();
|
||||
|
||||
/**
|
||||
* Sends a message to the broker. May throw on any transport/broker failure; the binding decorator
|
||||
* applies the fail-open (general) or fail-closed (outbox) policy.
|
||||
*
|
||||
* @throws Exception on any send failure (handled by the binding decorator)
|
||||
*/
|
||||
void send(OutboundMessage message) throws Exception;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
/**
|
||||
* Adapter-local fire-and-forget (fail-open) publishing port for the optional adapter template. Not
|
||||
* use-case-facing ({@code adapter-outbound} type, so application-core cannot hold it) — a use case
|
||||
* that needs durable delivery uses the application-core {@code OutboxMessagePublishPort}. The
|
||||
* active binding is selected by {@code app.messaging.broker}. The {@code void} return keeps broker
|
||||
* SDK types from escaping the adapter (B7).
|
||||
*/
|
||||
public interface MessagePublisher {
|
||||
|
||||
void publish(OutboundMessage message);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Transport-neutral message a {@link MessagePublisher} emits. Carries only the routing key family
|
||||
* ({@code topic}, partition {@code key}) and an already-serialized {@code payload} string — it
|
||||
* deliberately does NOT depend on any broker SDK type, so the messaging port stays
|
||||
* template-portable across Kafka and any future broker.
|
||||
*
|
||||
* @param topic logical destination / topic
|
||||
* @param key partition / ordering key (may be empty, never null)
|
||||
* @param payload serialized message body (the producer is responsible for serialization)
|
||||
*/
|
||||
public record OutboundMessage(String topic, String key, String payload) {
|
||||
|
||||
public OutboundMessage {
|
||||
Objects.requireNonNull(topic, "topic");
|
||||
Objects.requireNonNull(key, "key");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
if (topic.isBlank()) {
|
||||
throw new IllegalArgumentException("topic must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
|
||||
/**
|
||||
* General {@link MessagePublisher} binding (fail-open). Delegates to the active {@link
|
||||
* MessageBroker}; on failure it logs with the correlation id and swallows — a broker outage must
|
||||
* never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path).
|
||||
* The fail-closed counterpart is {@code OutboxMessagePublishAdapter}.
|
||||
*
|
||||
* <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never
|
||||
* touches this class.
|
||||
*/
|
||||
public class OutboundMessagePublisher implements MessagePublisher {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "messaging";
|
||||
|
||||
private final MessageBroker broker;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public OutboundMessagePublisher(MessageBroker broker, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.broker = broker;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(OutboundMessage message) {
|
||||
try {
|
||||
broker.send(message);
|
||||
dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish");
|
||||
} catch (Exception ex) {
|
||||
// fail-open: observe with correlationId, delegate durability to outbox/retry,
|
||||
// do NOT propagate — the core use case must still succeed.
|
||||
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.kafka;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 gating for the Kafka broker template: registers the Kafka {@link MessageBroker} only when
|
||||
* {@code app.messaging.broker=kafka}. The central {@code MessagingConfig} binds both messaging
|
||||
* ports onto it.
|
||||
*
|
||||
* <p>Needs a project-supplied {@link KafkaSender} bean (the integration seam) and a non-empty
|
||||
* {@code app.messaging.kafka.brokers}. Adding another broker is a new config like this one — this
|
||||
* file never changes.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(KafkaAdapterSettings.class)
|
||||
public class KafkaAdapterConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")
|
||||
public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) {
|
||||
if (settings.brokers().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"app.messaging.broker=kafka requires a non-empty app.messaging.kafka.brokers "
|
||||
+ "(CSV of host:port)");
|
||||
}
|
||||
return new KafkaMessageBroker(sender);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.kafka;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Kafka broker tuning bound from {@code app.messaging.kafka.*}. Validation is format-only ({@code
|
||||
* host:port} per entry); the "Kafka selected ⇒ brokers required" cross-field rule is enforced in
|
||||
* {@code KafkaAdapterConfig}, so an empty list is valid at bind time.
|
||||
*
|
||||
* @param brokers CSV of {@code host:port} broker endpoints (each entry format-validated)
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.messaging.kafka")
|
||||
public record KafkaAdapterSettings(List<String> brokers) {
|
||||
|
||||
private static final Pattern HOST_PORT = Pattern.compile("^[^:\\s]+:\\d{1,5}$");
|
||||
|
||||
public KafkaAdapterSettings {
|
||||
brokers = (brokers == null) ? List.of() : List.copyOf(brokers);
|
||||
for (String broker : brokers) {
|
||||
if (!HOST_PORT.matcher(broker.trim()).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_MESSAGING_KAFKA_BROKERS entry '" + broker + "' is not host:port");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.kafka;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
|
||||
/**
|
||||
* Kafka contribution of the {@link MessageBroker} SPI (brokerId {@code "kafka"}). Delegates the raw
|
||||
* send to the project-supplied {@link KafkaSender} seam; the fail-open (general) and fail-closed
|
||||
* (outbox) policies are applied by the binding decorators in the messaging package, not here — so
|
||||
* this class carries no policy and no Kafka SDK.
|
||||
*/
|
||||
public class KafkaMessageBroker implements MessageBroker {
|
||||
|
||||
private static final String BROKER_ID = "kafka";
|
||||
|
||||
private final KafkaSender sender;
|
||||
|
||||
public KafkaMessageBroker(KafkaSender sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String brokerId() {
|
||||
return BROKER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(OutboundMessage message) throws Exception {
|
||||
sender.send(message);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.kafka;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Kafka template to a real producer.
|
||||
* The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka. A
|
||||
* send failure may throw; the binding decorator applies the failure policy.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface KafkaSender {
|
||||
|
||||
/**
|
||||
* Sends a message to the broker. May throw on any transport/broker failure.
|
||||
*
|
||||
* @throws Exception on any send failure (caught and handled fail-open by the publisher)
|
||||
*/
|
||||
void send(OutboundMessage message) throws Exception;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
|
||||
/**
|
||||
* Fail-fast {@link OutboxMessagePublishPort} binding when no broker is active ({@code
|
||||
* app.messaging.broker} unset — the default). Any publish throws {@link AdapterDisabledException} —
|
||||
* never a silent no-op. Kept separate from {@code DisabledMessagePublisher} so each disabled bean
|
||||
* implements exactly one port (a single class implementing both makes {@code
|
||||
* getBean(MessagePublisher.class)} ambiguous).
|
||||
*/
|
||||
public class DisabledOutboxMessagePublisher implements OutboxMessagePublishPort {
|
||||
|
||||
@Override
|
||||
public void publish(OutboxEvent event) {
|
||||
throw new AdapterDisabledException("messaging");
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
|
||||
/**
|
||||
* Hand-rolled, dependency-free JSON serialiser for the outbox envelope (no Jackson — the module
|
||||
* deliberately keeps {@code jackson-databind} off its classpath).
|
||||
*
|
||||
* <p>{@link OutboxEvent#payload()} MUST already be a valid serialised JSON value; it is inserted
|
||||
* verbatim (no escaping) — serialisation policy is owned by the {@code schema-serialization}
|
||||
* branch, this class only assembles the envelope. All other string fields go through {@link
|
||||
* #escape(String)}.
|
||||
*/
|
||||
public final class OutboxEnvelopeJson {
|
||||
|
||||
private OutboxEnvelopeJson() {}
|
||||
|
||||
public static String toJson(OutboxEvent event) {
|
||||
return "{"
|
||||
+ "\"eventId\":\""
|
||||
+ escape(event.eventId())
|
||||
+ "\","
|
||||
+ "\"eventType\":\""
|
||||
+ escape(event.eventType())
|
||||
+ "\","
|
||||
+ "\"aggregateId\":\""
|
||||
+ escape(event.aggregateId())
|
||||
+ "\","
|
||||
+ "\"occurredAt\":\""
|
||||
+ escape(event.occurredAt().toString())
|
||||
+ "\","
|
||||
+ "\"correlationId\":\""
|
||||
+ escape(event.correlationId())
|
||||
+ "\","
|
||||
+ "\"idempotencyKey\":\""
|
||||
+ escape(event.idempotencyKey())
|
||||
+ "\","
|
||||
+ "\"payload\":"
|
||||
+ event.payload()
|
||||
+ "}";
|
||||
}
|
||||
|
||||
/** Escapes a string for a JSON string literal (RFC 8259 §7). */
|
||||
static String escape(String value) {
|
||||
StringBuilder sb = new StringBuilder(value.length() + 4);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '\\') {
|
||||
sb.append("\\\\");
|
||||
} else if (c == '"') {
|
||||
sb.append("\\\"");
|
||||
} else if (c == '\b') {
|
||||
sb.append("\\b");
|
||||
} else if (c == '\t') {
|
||||
sb.append("\\t");
|
||||
} else if (c == '\n') {
|
||||
sb.append("\\n");
|
||||
} else if (c == '\f') {
|
||||
sb.append("\\f");
|
||||
} else if (c == '\r') {
|
||||
sb.append("\\r");
|
||||
} else if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
|
||||
/**
|
||||
* Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link
|
||||
* OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}; on
|
||||
* failure it logs and re-throws so the relay can drive the FAILED/DEAD transition (the documented
|
||||
* fail-closed contract — contrast the fail-open general {@code OutboundMessagePublisher}).
|
||||
*
|
||||
* <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never
|
||||
* touches this class.
|
||||
*/
|
||||
public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "messaging";
|
||||
|
||||
private final MessageBroker broker;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public OutboxMessagePublishAdapter(
|
||||
MessageBroker broker, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.broker = broker;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(OutboxEvent event) {
|
||||
String envelope = OutboxEnvelopeJson.toJson(event);
|
||||
OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
|
||||
try {
|
||||
broker.send(message);
|
||||
dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish");
|
||||
} catch (RuntimeException ex) {
|
||||
// fail-closed: log then propagate — the relay must observe this to drive FAILED/DEAD.
|
||||
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
// Wrap checked exceptions; preserve cause so the relay can inspect it.
|
||||
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
|
||||
throw new RuntimeException(
|
||||
"outbox publish failed for broker '" + broker.brokerId() + "'", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* General {@link OutboundMessagePublisher} = fail-open: delegates to the active {@link
|
||||
* MessageBroker}; a broker failure is logged with the correlation id and swallowed (never
|
||||
* propagated). Broker-agnostic — a fake broker stands in for any real broker.
|
||||
*/
|
||||
class OutboundMessagePublisherTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.messaging");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
/** Fake broker (brokerId "kafka") capturing sends, optionally failing. */
|
||||
private static final class FakeBroker implements MessageBroker {
|
||||
final List<OutboundMessage> sent = new ArrayList<>();
|
||||
private final RuntimeException failure;
|
||||
|
||||
FakeBroker() {
|
||||
this.failure = null;
|
||||
}
|
||||
|
||||
FakeBroker(RuntimeException failure) {
|
||||
this.failure = failure;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String brokerId() {
|
||||
return "kafka";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(OutboundMessage message) {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
sent.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishDelegatesToTheActiveBrokerOnSuccess() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
|
||||
OutboundMessage message = new OutboundMessage("worklog-events", "wl-1", "{}");
|
||||
|
||||
publisher.publish(message);
|
||||
|
||||
assertThat(broker.sent).containsExactly(message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureIsFailOpenAndNeverThrows() {
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
|
||||
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
|
||||
|
||||
// fail-open: a broker outage must not propagate to the core use case.
|
||||
assertThatCode(() -> publisher.publish(new OutboundMessage("t", "k", "p")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureLogCarriesCorrelationIdAndBrokerId() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-msg-1");
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
|
||||
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
|
||||
|
||||
publisher.publish(new OutboundMessage("t", "k", "p"));
|
||||
|
||||
ILoggingEvent event =
|
||||
appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow();
|
||||
assertThat(event.getFormattedMessage())
|
||||
.contains("correlation_id=\"corr-msg-1\"")
|
||||
.contains("dependency_name=\"kafka\"")
|
||||
.contains("dependency_type=\"messaging\"")
|
||||
.contains("operation=\"publish\"");
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.kafka;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class KafkaAdapterSettingsTest {
|
||||
|
||||
@Test
|
||||
void emptyOrNullBrokersIsTolerated() {
|
||||
// bound globally via @ConfigurationPropertiesScan even when Kafka is not the active
|
||||
// broker — an empty list must bind cleanly (the "required" guard lives in the config).
|
||||
assertThat(new KafkaAdapterSettings((List<String>) null).brokers()).isEmpty();
|
||||
assertThat(new KafkaAdapterSettings(List.of()).brokers()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsABrokerThatIsNotHostPort() {
|
||||
assertThatThrownBy(() -> new KafkaAdapterSettings(List.of("not-a-broker")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("host:port");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCsvOfHostPortBrokers() {
|
||||
KafkaAdapterSettings settings =
|
||||
new KafkaAdapterSettings(List.of("broker-1:9092", "broker-2:9092"));
|
||||
assertThat(settings.brokers()).containsExactly("broker-1:9092", "broker-2:9092");
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokersListIsDefensivelyCopied() {
|
||||
List<String> mutable = new ArrayList<>(List.of("broker-1:9092"));
|
||||
KafkaAdapterSettings settings = new KafkaAdapterSettings(mutable);
|
||||
mutable.add("broker-2:9092");
|
||||
assertThat(settings.brokers()).containsExactly("broker-1:9092");
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.outbox;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
|
||||
import dev.caskeleton.application.outbox.OutboxEvent;
|
||||
import dev.caskeleton.application.outbox.OutboxEventStatus;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Broker-agnostic outbox publish adapter (fail-closed). Covers:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Success — correct OutboundMessage (topic=eventType, key=aggregateId, payload=envelope JSON)
|
||||
* sent to the active broker.
|
||||
* <li>Fail-closed — broker failure is logged then propagated (never swallowed); checked
|
||||
* exceptions are wrapped.
|
||||
* <li>Envelope JSON fields and escaping.
|
||||
* </ul>
|
||||
*/
|
||||
class OutboxMessagePublishAdapterTest {
|
||||
|
||||
private ch.qos.logback.classic.Logger logbackLogger;
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox");
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logbackLogger.addAppender(appender);
|
||||
logbackLogger.setLevel(Level.DEBUG);
|
||||
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
logbackLogger.detachAppender(appender);
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
/** Fake broker (brokerId "kafka") capturing sends, optionally failing with a given throwable. */
|
||||
private static final class FakeBroker implements MessageBroker {
|
||||
final List<OutboundMessage> sent = new ArrayList<>();
|
||||
private final Exception failure;
|
||||
|
||||
FakeBroker() {
|
||||
this.failure = null;
|
||||
}
|
||||
|
||||
FakeBroker(Exception failure) {
|
||||
this.failure = failure;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String brokerId() {
|
||||
return "kafka";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(OutboundMessage message) throws Exception {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
sent.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private OutboxEvent sampleEvent() {
|
||||
return new OutboxEvent(
|
||||
"evt-01",
|
||||
"WorkLogReserved",
|
||||
"wl-agg-1",
|
||||
"{\"workLogId\":\"wl-01\"}",
|
||||
Instant.parse("2024-01-02T03:04:05Z"),
|
||||
"corr-abc",
|
||||
"idem-xyz",
|
||||
OutboxEventStatus.IN_FLIGHT,
|
||||
1);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SuccessPath {
|
||||
|
||||
@Test
|
||||
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
assertThat(broker.sent).hasSize(1);
|
||||
OutboundMessage sent = broker.sent.get(0);
|
||||
assertThat(sent.topic()).isEqualTo("WorkLogReserved");
|
||||
assertThat(sent.key()).isEqualTo("wl-agg-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishEnvelopeContainsAllFields() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
String payload = broker.sent.get(0).payload();
|
||||
assertThat(payload).contains("\"eventId\"");
|
||||
assertThat(payload).contains("\"eventType\"");
|
||||
assertThat(payload).contains("\"aggregateId\"");
|
||||
assertThat(payload).contains("\"occurredAt\"");
|
||||
assertThat(payload).contains("\"correlationId\"");
|
||||
assertThat(payload).contains("\"idempotencyKey\"");
|
||||
assertThat(payload).contains("\"payload\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishEnvelopeFieldValuesMatchEvent() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
adapter.publish(sampleEvent());
|
||||
|
||||
String payload = broker.sent.get(0).payload();
|
||||
assertThat(payload).contains("\"evt-01\"");
|
||||
assertThat(payload).contains("\"WorkLogReserved\"");
|
||||
assertThat(payload).contains("\"wl-agg-1\"");
|
||||
assertThat(payload).contains("2024-01-02T03:04:05Z");
|
||||
assertThat(payload).contains("\"corr-abc\"");
|
||||
assertThat(payload).contains("\"idem-xyz\"");
|
||||
assertThat(payload).contains("{\"workLogId\":\"wl-01\"}");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FailClosedPath {
|
||||
|
||||
@Test
|
||||
void publishFailurePropagatesAsRuntimeException() {
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureIsLoggedBeforePropagation() {
|
||||
MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1");
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
try {
|
||||
adapter.publish(sampleEvent());
|
||||
} catch (RuntimeException ignored) {
|
||||
// expected
|
||||
}
|
||||
|
||||
boolean warnLogged =
|
||||
appender.list.stream()
|
||||
.anyMatch(
|
||||
e ->
|
||||
e.getLevel() == Level.WARN
|
||||
&& e.getFormattedMessage().contains("corr-fail-1"));
|
||||
assertThat(warnLogged).as("Expected a WARN log with the correlationId on failure").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureLogCarriesDependencyAndOperation() {
|
||||
FakeBroker broker = new FakeBroker(new RuntimeException("connection refused"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
try {
|
||||
adapter.publish(sampleEvent());
|
||||
} catch (RuntimeException ignored) {
|
||||
// expected
|
||||
}
|
||||
|
||||
String msg =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.findFirst()
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.orElse("");
|
||||
assertThat(msg)
|
||||
.contains("dependency_name=\"kafka\"")
|
||||
.contains("dependency_type=\"messaging\"")
|
||||
.contains("operation=\"publish\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWrapsCheckedExceptionInRuntimeException() {
|
||||
FakeBroker broker = new FakeBroker(new IOException("network error"));
|
||||
OutboxMessagePublishAdapter adapter =
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger);
|
||||
|
||||
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class JsonEscape {
|
||||
|
||||
@Test
|
||||
void envelopeEscapesDoubleQuotesInStringFields() {
|
||||
OutboxEvent eventWithQuote =
|
||||
new OutboxEvent(
|
||||
"evt-02",
|
||||
"Has\"Quote",
|
||||
"agg-1",
|
||||
"{}",
|
||||
Instant.parse("2024-01-01T00:00:00Z"),
|
||||
"corr-1",
|
||||
"idem-1",
|
||||
OutboxEventStatus.IN_FLIGHT,
|
||||
1);
|
||||
FakeBroker broker = new FakeBroker();
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote);
|
||||
|
||||
assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envelopeInsertsPayloadRawWithoutDoubleEncoding() {
|
||||
OutboxEvent event =
|
||||
new OutboxEvent(
|
||||
"evt-05",
|
||||
"SomeEvent",
|
||||
"agg-2",
|
||||
"{\"nested\":{\"a\":1}}",
|
||||
Instant.parse("2024-01-01T00:00:00Z"),
|
||||
"corr-2",
|
||||
"idem-2",
|
||||
OutboxEventStatus.IN_FLIGHT,
|
||||
1);
|
||||
FakeBroker broker = new FakeBroker();
|
||||
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event);
|
||||
|
||||
String payload = broker.sent.get(0).payload();
|
||||
assertThat(payload).contains("{\"nested\":{\"a\":1}}");
|
||||
assertThat(payload).doesNotContain("\"{\\\"nested\\\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# adapter:outbound:notification — notification adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-notification`
|
||||
- Gradle path: `:adapter:outbound:notification`
|
||||
- Focused test: `./gradlew :adapter:outbound:notification:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.notification`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Implement notification provider routing and provider-specific Slack/email clients behind ports.
|
||||
- Own provider settings, technical fallback, and provider adaptation.
|
||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
|
||||
- No inbound DTO/controller, persistence, bootstrap, or sample dependency.
|
||||
- Provider selection may route configured channels but must not encode business eligibility rules.
|
||||
|
||||
## Tests
|
||||
|
||||
Use provider/client fakes and contract tests; no real webhook or email network calls. Settings changes
|
||||
include binding/validation tests.
|
||||
@@ -0,0 +1,34 @@
|
||||
# adapter:outbound:notification — 설계 결정 참조
|
||||
|
||||
알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유
|
||||
correlation / fail-open 의존성 로깅을 재사용한다.
|
||||
|
||||
허용/금지 의존 정책은 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직
|
||||
별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
|
||||
참조용 기록이다.
|
||||
|
||||
## 모듈 개요
|
||||
|
||||
application-core 포트 뒤에 두는 **선택형** 알림 어댑터다. `@ConditionalOnProperty` 로 게이팅되고
|
||||
기본 비활성이다. 이 모듈이 기본 제공하는 프로바이더는 `email/google`(`GoogleEmailProvider` /
|
||||
`GoogleEmailClient`)과 `slack/webhook`(`SlackWebhookProvider` / `SlackClient`)이며, 실제 연동
|
||||
client 는 포킹 프로젝트가 채우는 seam 이다.
|
||||
|
||||
## (channel, providerId) 복합 키 + fan-out
|
||||
|
||||
`RoutingNotifier` 는 `(channel, providerId)` 복합 키로 프로바이더를 등록한다 — 채널 내 중복
|
||||
`providerId` 는 생성 시점에 실패한다. 라우트당 providerId 목록을 주면 fan-out(모든 프로바이더
|
||||
호출)이 된다. 각 프로바이더는 이미 `FailOpenNotificationProvider` 로 감싸져 있어 한 곳의 실패가
|
||||
다른 곳을 막지 않고, 그래서 fan-out 루프에 try/catch 가 필요 없다. `FailOpenNotificationProvider
|
||||
.send` 가 `throws` 를 선언하지 않는 건 이 루프를 try/catch 없이 예외-free 로 증명하기 위함이다.
|
||||
|
||||
## 중앙 fail-open 합성 + 라우팅 바인딩
|
||||
|
||||
`NotificationConfig` 가 모든 프로바이더를 `FailOpenNotificationProvider` 로 중앙에서 감싼다(→
|
||||
`:adapter:outbound:support` 의 `FailOpenDependencyLogger` 로 WARN 로깅). 라우팅은
|
||||
`app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]`. 프로바이더는
|
||||
`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예:
|
||||
`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가
|
||||
구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
plugins { id 'groovy' }
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -0,0 +1,156 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.adapter.outbound.notification;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.notification.NotificationPort;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Assembles the {@link RoutingNotifier} from every contributed {@link NotificationProvider} bean,
|
||||
* mirroring {@code CacheRouterConfig}.
|
||||
*
|
||||
* <p>Provider discovery is type-explicit: a provider opts in by registering a {@link
|
||||
* NotificationProvider} bean (its {@link NotificationProvider#providerId()} + {@link
|
||||
* NotificationProvider#channel()} are the routing keys). Adding a provider is therefore new files
|
||||
* only — this config and {@link RoutingNotifier} never change. The fail-open policy is applied
|
||||
* here, centrally, by wrapping every provider in {@link FailOpenNotificationProvider} — a provider
|
||||
* config cannot forget it.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(NotificationRoutesSettings.class)
|
||||
public class NotificationConfig {
|
||||
|
||||
@Bean
|
||||
public NotificationPort notificationPort(
|
||||
ObjectProvider<List<NotificationProvider>> providers,
|
||||
NotificationRoutesSettings settings,
|
||||
FailOpenDependencyLogger failOpenDependencyLogger) {
|
||||
List<FailOpenNotificationProvider> failOpenProviders =
|
||||
providers.getIfAvailable(List::of).stream()
|
||||
.map(p -> new FailOpenNotificationProvider(p, failOpenDependencyLogger))
|
||||
.toList();
|
||||
return new RoutingNotifier(failOpenProviders, settings.routes());
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.notification;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Routes binding for the multi-provider notification template: {@code
|
||||
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}.
|
||||
*
|
||||
* <p>Mirrors {@code CacheBindingSettings}: relaxed binding maps the channel segment (e.g. {@code
|
||||
* email} → {@link Channel#EMAIL}) automatically via Spring's {@code ApplicationConversionService}.
|
||||
* Route values are coerced to {@code List<String>} by the binder (comma-separated or YAML list).
|
||||
* Default is an empty map so the notification template stays a non-required optional module:
|
||||
* startup never fails when no routes are configured. Consistency (every referenced providerId has
|
||||
* an enabled provider) is validated fail-fast by {@link RoutingNotifier} at construction time.
|
||||
*
|
||||
* @param routes channel → (route → providerId list), default empty
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.notification")
|
||||
public record NotificationRoutesSettings(Map<Channel, Map<String, List<String>>> routes) {
|
||||
|
||||
public NotificationRoutesSettings {
|
||||
routes = (routes == null) ? Map.of() : Map.copyOf(routes);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Fail-open decorator: a provider failure is logged (no payload/PII) and swallowed so a
|
||||
* notification — a side-effect — never fails the core use case. Applied centrally by
|
||||
* NotificationConfig.
|
||||
*/
|
||||
public final class FailOpenNotificationProvider implements NotificationProvider {
|
||||
|
||||
private static final String DEPENDENCY_TYPE = "notification";
|
||||
|
||||
private final NotificationProvider delegate;
|
||||
private final FailOpenDependencyLogger dependencyLogger;
|
||||
|
||||
public FailOpenNotificationProvider(
|
||||
NotificationProvider delegate, FailOpenDependencyLogger dependencyLogger) {
|
||||
this.delegate = delegate;
|
||||
this.dependencyLogger = dependencyLogger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return delegate.channel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return delegate.providerId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification) {
|
||||
try {
|
||||
delegate.send(notification);
|
||||
dependencyLogger.logSuccess(delegate.providerId(), DEPENDENCY_TYPE, "send");
|
||||
} catch (Exception ex) {
|
||||
// fail-open: observe (no payload/PII), do not fail the core use case.
|
||||
dependencyLogger.logFailure(delegate.providerId(), DEPENDENCY_TYPE, "send", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* SPI a forking project contributes to bind the notification template to a real provider for a
|
||||
* given {@link Channel}. Each provider registers a bean of this type. {@link #providerId()} is the
|
||||
* identifier referenced by {@code app.notification.routes.<channel>.<route>=<id>[,<id>]} values and
|
||||
* must be unique within a channel.
|
||||
*/
|
||||
public interface NotificationProvider {
|
||||
|
||||
/** The channel this provider delivers to (e.g. {@link Channel#EMAIL}). */
|
||||
Channel channel();
|
||||
|
||||
/**
|
||||
* Stable provider id referenced by {@code app.notification.routes.*}; unique within a channel.
|
||||
*/
|
||||
String providerId();
|
||||
|
||||
/**
|
||||
* Sends the notification via the provider. May throw on any transport/provider failure; the
|
||||
* {@link FailOpenNotificationProvider} decorator applies the fail-open policy centrally — this
|
||||
* method must not swallow its own exceptions.
|
||||
*
|
||||
* @param notification the notification to send (contains PII — never log this value)
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the decorator)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.core;
|
||||
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.application.notification.NotificationPort;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Routes notification calls to contributed {@link NotificationProvider}s ({@code
|
||||
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}). A duplicate {@code
|
||||
* providerId} within a channel, or a route to a providerId with no enabled provider, fails
|
||||
* construction; {@code notify} on an unbound channel/route throws {@link AdapterDisabledException}
|
||||
* (Layer 3, never a silent no-op). A route's providerId list is fanned out to all providers, each
|
||||
* already wrapped in {@link FailOpenNotificationProvider}, so one failure does not block the others
|
||||
* — hence no try/catch in the fan-out loop. It does not expose the resolved provider, so no adapter
|
||||
* type escapes via a public return (B7).
|
||||
*/
|
||||
public final class RoutingNotifier implements NotificationPort {
|
||||
|
||||
private static final String ADAPTER_NAME = "notification";
|
||||
|
||||
private final Map<Channel, Map<String, FailOpenNotificationProvider>> registry;
|
||||
|
||||
private final Map<Channel, Map<String, List<String>>> routes;
|
||||
|
||||
public RoutingNotifier(
|
||||
Collection<? extends FailOpenNotificationProvider> providers,
|
||||
Map<Channel, Map<String, List<String>>> routes) {
|
||||
this.registry = buildRegistry(providers);
|
||||
validateRoutes(routes, this.registry);
|
||||
this.routes = immutableRoutesCopy(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes contributed providers by {@code (channel, providerId)}. A duplicate providerId within a
|
||||
* channel fails at construction (boot).
|
||||
*/
|
||||
private static Map<Channel, Map<String, FailOpenNotificationProvider>> buildRegistry(
|
||||
Collection<? extends FailOpenNotificationProvider> providers) {
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> registry = new EnumMap<>(Channel.class);
|
||||
for (FailOpenNotificationProvider provider : providers) {
|
||||
Map<String, FailOpenNotificationProvider> byId =
|
||||
registry.computeIfAbsent(provider.channel(), ch -> new HashMap<>());
|
||||
FailOpenNotificationProvider previous = byId.putIfAbsent(provider.providerId(), provider);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException(
|
||||
"duplicate notification providerId '"
|
||||
+ provider.providerId()
|
||||
+ "' for channel "
|
||||
+ provider.channel()
|
||||
+ " — every contributed NotificationProvider bean must have a"
|
||||
+ " unique providerId within its channel");
|
||||
}
|
||||
}
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> immutable =
|
||||
new EnumMap<>(Channel.class);
|
||||
registry.forEach((channel, byId) -> immutable.put(channel, Map.copyOf(byId)));
|
||||
return Map.copyOf(immutable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails at construction (boot) if any route references a providerId with no enabled provider on
|
||||
* that channel. The per-channel registry lookup is hoisted out of the inner loops — it depends
|
||||
* only on the channel.
|
||||
*/
|
||||
private static void validateRoutes(
|
||||
Map<Channel, Map<String, List<String>>> routes,
|
||||
Map<Channel, Map<String, FailOpenNotificationProvider>> registry) {
|
||||
for (Map.Entry<Channel, Map<String, List<String>>> channelEntry : routes.entrySet()) {
|
||||
Channel channel = channelEntry.getKey();
|
||||
Map<String, FailOpenNotificationProvider> channelRegistry =
|
||||
registry.getOrDefault(channel, Map.of());
|
||||
for (Map.Entry<String, List<String>> routeEntry : channelEntry.getValue().entrySet()) {
|
||||
String route = routeEntry.getKey();
|
||||
for (String providerId : routeEntry.getValue()) {
|
||||
if (!channelRegistry.containsKey(providerId)) {
|
||||
throw new IllegalStateException(
|
||||
"app.notification.routes."
|
||||
+ channel.name().toLowerCase()
|
||||
+ "."
|
||||
+ route
|
||||
+ " references providerId '"
|
||||
+ providerId
|
||||
+ "' but no enabled provider contributes that id for channel "
|
||||
+ channel
|
||||
+ " — enable the provider or fix the route binding");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep, immutable copy of the routes map (channel → route → providerId list). */
|
||||
private static Map<Channel, Map<String, List<String>>> immutableRoutesCopy(
|
||||
Map<Channel, Map<String, List<String>>> routes) {
|
||||
Map<Channel, Map<String, List<String>>> immutable = new EnumMap<>(Channel.class);
|
||||
routes.forEach(
|
||||
(channel, routeMap) -> {
|
||||
Map<String, List<String>> copy = new HashMap<>();
|
||||
routeMap.forEach((route, ids) -> copy.put(route, List.copyOf(ids)));
|
||||
immutable.put(channel, Map.copyOf(copy));
|
||||
});
|
||||
return Map.copyOf(immutable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notify(Channel channel, String route, Notification notification) {
|
||||
List<String> providerIds = resolveRoute(channel, route);
|
||||
Map<String, FailOpenNotificationProvider> channelRegistry =
|
||||
registry.getOrDefault(channel, Map.of());
|
||||
// FailOpenNotificationProvider.send declares no throws — no try/catch needed.
|
||||
// Individual provider failures are observed (logged) inside the decorator
|
||||
// and never propagated, so one failure does not block remaining fan-out sends.
|
||||
for (String providerId : providerIds) {
|
||||
channelRegistry.get(providerId).send(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveRoute(Channel channel, String route) {
|
||||
Map<String, List<String>> channelRoutes = routes.get(channel);
|
||||
if (channelRoutes == null || !channelRoutes.containsKey(route)) {
|
||||
throw new AdapterDisabledException(
|
||||
ADAPTER_NAME,
|
||||
"no notification route bound for channel="
|
||||
+ channel
|
||||
+ " route='"
|
||||
+ route
|
||||
+ "' — set app.notification.routes."
|
||||
+ channel.name().toLowerCase()
|
||||
+ "."
|
||||
+ route
|
||||
+ "=<providerId>[,<providerId>] and enable that provider"
|
||||
+ " (integration-adapter-templates Layer 3)");
|
||||
}
|
||||
return channelRoutes.get(route);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Google Email template to a real
|
||||
* client (Gmail API / SMTP). The skeleton carries no mail SDK dependency — it is supplied by the
|
||||
* project that enables Google Email.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GoogleEmailClient {
|
||||
|
||||
/**
|
||||
* Sends an email notification. May throw on any transport/provider failure.
|
||||
*
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 gating for the Google Email provider template: registers the Google Email {@link
|
||||
* NotificationProvider} only when {@code app.notification.google-email.enabled=true}. Needs a
|
||||
* project-supplied {@link GoogleEmailClient} bean (the integration seam). Adding another email
|
||||
* provider is a new config like this one — this file never changes.
|
||||
*/
|
||||
@Configuration
|
||||
public class GoogleEmailNotificationAdapterConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.notification.google-email.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public NotificationProvider googleEmailProvider(GoogleEmailClient googleEmailClient) {
|
||||
return new GoogleEmailProvider(googleEmailClient);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.email.google;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Google Email contribution of the {@link NotificationProvider} SPI (channel {@link Channel#EMAIL},
|
||||
* providerId {@code "google-email"}). Delegates the raw send to the project-supplied {@link
|
||||
* GoogleEmailClient} seam; the fail-open policy and PII-safe logging are applied centrally by
|
||||
* {@link dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here
|
||||
* — so this class carries no policy and no mail SDK.
|
||||
*
|
||||
* <p>Adding another email provider (e.g. AWS SES) is a new sibling file with a different {@code
|
||||
* providerId} — this file never changes.
|
||||
*/
|
||||
public class GoogleEmailProvider implements NotificationProvider {
|
||||
|
||||
private static final String PROVIDER_ID = "google-email";
|
||||
|
||||
private final GoogleEmailClient client;
|
||||
|
||||
public GoogleEmailProvider(GoogleEmailClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Channel channel() {
|
||||
return Channel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification) throws Exception {
|
||||
client.send(notification);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
|
||||
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
|
||||
/**
|
||||
* Integration seam the forking project implements to bind the Slack template to a real Slack client
|
||||
* (incoming-webhook / Web API). The skeleton carries no Slack SDK dependency — it is supplied by
|
||||
* the project that enables Slack.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SlackClient {
|
||||
|
||||
/**
|
||||
* Sends a notification to Slack. May throw on any transport/provider failure.
|
||||
*
|
||||
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
|
||||
*/
|
||||
void send(Notification notification) throws Exception;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user