init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-graphql`
|
||||
- Gradle path: `:adapter:inbound:graphql`
|
||||
- Focused test: `./gradlew :adapter:inbound:graphql: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.inbound.graphql`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
|
||||
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
|
||||
자동 합성/바인딩하도록 얹는 얇은 계층이다.
|
||||
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
|
||||
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
||||
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이
|
||||
모듈을 자동 커버 — per-module 규칙 추가 불필요).
|
||||
- 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다.
|
||||
- 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다.
|
||||
|
||||
## Error mapping (`Category → ErrorType`)
|
||||
|
||||
feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면
|
||||
`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
|
||||
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
|
||||
|
||||
## Feature 기여 방법
|
||||
|
||||
- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다.
|
||||
- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다.
|
||||
- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를
|
||||
`PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리.
|
||||
|
||||
`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:graphql:test
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# adapter-graphql — 설계 결정 참조
|
||||
|
||||
인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가
|
||||
|
||||
Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은
|
||||
`src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를
|
||||
싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의
|
||||
`HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로
|
||||
부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다:
|
||||
|
||||
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
|
||||
`worklog.graphqls` 가 스켈레톤의 `skeleton.graphqls` 와 자동으로 합쳐진다.
|
||||
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping`
|
||||
메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 가 스켈레톤을 수정하지 않고 등록된다.
|
||||
|
||||
`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한
|
||||
disposability 보장).
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
|
||||
|
||||
`GraphqlExceptionResolver` 는 `DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가
|
||||
동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을
|
||||
`GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가
|
||||
와이어 계약을 단일 소유한다.
|
||||
|
||||
- **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표).
|
||||
정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게
|
||||
한다(gRPC 가 status trailer 에 싣는 것과 동형).
|
||||
- **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인
|
||||
`ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다.
|
||||
- **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른
|
||||
`DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로
|
||||
넘긴다.
|
||||
|
||||
`Category → ErrorType` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | GraphQL `ErrorType` |
|
||||
|---|---|
|
||||
| VALIDATION | BAD_REQUEST |
|
||||
| AUTH | UNAUTHORIZED |
|
||||
| AUTHZ | FORBIDDEN |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | BAD_REQUEST |
|
||||
| RATE_LIMIT | BAD_REQUEST |
|
||||
| TRANSIENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| DATA_INTEGRITY | INTERNAL_ERROR |
|
||||
| INTERNAL | INTERNAL_ERROR |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은
|
||||
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
|
||||
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
|
||||
|
||||
## 설정 — 프레임워크 `spring.graphql.*`
|
||||
|
||||
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
||||
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
||||
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||
@@ -0,0 +1,28 @@
|
||||
// Driving adapter: GraphQL API (skeleton machinery, transport-only).
|
||||
//
|
||||
// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls
|
||||
// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health
|
||||
// schema + @Controller so the module boots standalone with zero features (an empty schema fails to
|
||||
// start); feature schema/controllers live in the sample module and compose automatically.
|
||||
//
|
||||
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
|
||||
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
|
||||
// coordinates the BOM does not manage).
|
||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||
// ExecutionGraphQlServiceTester.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test'
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,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.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,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.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql: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-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.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-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Map;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
|
||||
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
|
||||
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
|
||||
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
|
||||
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
|
||||
* GrpcExceptionHandlingInterceptor}.
|
||||
*
|
||||
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
|
||||
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
|
||||
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
|
||||
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
|
||||
* returns {@code null}: Spring for GraphQL then merges the other {@link
|
||||
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
|
||||
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
|
||||
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
|
||||
* SQLState or upstream detail.
|
||||
*/
|
||||
@Component
|
||||
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
|
||||
|
||||
@Override
|
||||
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
|
||||
if (!(ex instanceof ApiErrorCarrier carrier)) {
|
||||
return null; // fall through to other resolvers / Spring's default handling
|
||||
}
|
||||
ApiErrorCode code = carrier.errorCode();
|
||||
var builder =
|
||||
GraphqlErrorBuilder.newError()
|
||||
.errorType(classify(code.category()))
|
||||
.message(code.code())
|
||||
.extensions(Map.of("code", code.code(), "category", code.category().name()));
|
||||
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
|
||||
// attach the field path/location when they are present.
|
||||
if (env != null) {
|
||||
builder.path(env.getExecutionStepInfo().getPath());
|
||||
if (env.getField() != null) {
|
||||
builder.location(env.getField().getSourceLocation());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
|
||||
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
|
||||
* until a mapping decision is made.
|
||||
*/
|
||||
private static ErrorType classify(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
|
||||
case AUTH -> ErrorType.UNAUTHORIZED;
|
||||
case AUTHZ -> ErrorType.FORBIDDEN;
|
||||
case NOT_FOUND -> ErrorType.NOT_FOUND;
|
||||
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
|
||||
ErrorType.INTERNAL_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.springframework.graphql.data.method.annotation.QueryMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
/**
|
||||
* Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the
|
||||
* GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query
|
||||
* resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature
|
||||
* queries/mutations are contributed by the sample module's own {@code @Controller} beans and merged
|
||||
* by Spring for GraphQL; this controller never names a feature type.
|
||||
*/
|
||||
@Controller
|
||||
public class HealthGraphqlController {
|
||||
|
||||
/** Stable liveness token, matching the web adapter's {@code status=UP} health semantics. */
|
||||
static final String STATUS_UP = "UP";
|
||||
|
||||
// The schema field is `_health` (a conventional meta-field name); the Java method is `health` so
|
||||
// it satisfies the method-name checkstyle rule, with the field bound explicitly via `name`.
|
||||
@QueryMapping(name = "_health")
|
||||
public String health() {
|
||||
return STATUS_UP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Minimal GraphQL schema for the skeleton machinery module (schema-first).
|
||||
#
|
||||
# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health
|
||||
# schema composes automatically with any feature schema the sample module contributes. It exists so
|
||||
# the module boots standalone with zero features: Spring for GraphQL refuses to start on an empty
|
||||
# schema, and the skeleton must never name a feature type (mirrors web's HealthcheckController).
|
||||
type Query {
|
||||
"Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck."
|
||||
_health: String!
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link ErrorType} classification table (design
|
||||
* Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category
|
||||
* value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against
|
||||
* {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure
|
||||
* mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}.
|
||||
*/
|
||||
class GraphqlExceptionResolverTest {
|
||||
|
||||
private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,BAD_REQUEST",
|
||||
"AUTH,UNAUTHORIZED",
|
||||
"AUTHZ,FORBIDDEN",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,BAD_REQUEST",
|
||||
"RATE_LIMIT,BAD_REQUEST",
|
||||
"TRANSIENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"DATA_INTEGRITY,INTERNAL_ERROR",
|
||||
"INTERNAL,INTERNAL_ERROR",
|
||||
})
|
||||
void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null);
|
||||
|
||||
assertThat(error).isNotNull();
|
||||
assertThat(error.getErrorType()).isEqualTo(expected);
|
||||
assertThat(error.getExtensions())
|
||||
.containsEntry("code", "SOME_CODE")
|
||||
.containsEntry("category", category.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null))
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(
|
||||
new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null);
|
||||
|
||||
assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() {
|
||||
assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(String code, Category category) {
|
||||
super(code);
|
||||
this.errorCode = new TestErrorCode(code, category);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */
|
||||
private record TestErrorCode(String code, Category category) implements ApiErrorCode {
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.graphql.ExecutionGraphQlService;
|
||||
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
|
||||
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
|
||||
import org.springframework.graphql.execution.GraphQlSource;
|
||||
import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
|
||||
import org.springframework.graphql.test.tester.GraphQlTester;
|
||||
|
||||
/**
|
||||
* Assembles the skeleton schema ({@code graphql/skeleton.graphqls}) and the {@link
|
||||
* HealthGraphqlController}'s {@code @QueryMapping} through a real {@link
|
||||
* AnnotatedControllerConfigurer} — the same wiring Spring for GraphQL uses at runtime — and drives
|
||||
* the {@code _health} query with a {@link GraphQlTester}. Self-contained (no Spring Boot context),
|
||||
* so it proves the module stands up a working GraphQL surface (schema + controller binding) with
|
||||
* zero features, the GraphQL sibling of the gRPC skeleton's health boot test.
|
||||
*/
|
||||
class HealthGraphqlControllerTest {
|
||||
|
||||
@Test
|
||||
void healthQueryReturnsUpLivenessToken() {
|
||||
graphQlTester()
|
||||
.document("{ _health }")
|
||||
.execute()
|
||||
.path("_health")
|
||||
.entity(String.class)
|
||||
.isEqualTo("UP");
|
||||
}
|
||||
|
||||
private static GraphQlTester graphQlTester() {
|
||||
AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
|
||||
appContext.registerBean(HealthGraphqlController.class);
|
||||
appContext.refresh();
|
||||
|
||||
AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
|
||||
controllerConfigurer.setApplicationContext(appContext);
|
||||
controllerConfigurer.afterPropertiesSet();
|
||||
|
||||
GraphQlSource source =
|
||||
GraphQlSource.schemaResourceBuilder()
|
||||
.schemaResources(new ClassPathResource("graphql/skeleton.graphqls"))
|
||||
.configureRuntimeWiring(controllerConfigurer)
|
||||
.build();
|
||||
|
||||
ExecutionGraphQlService service = new DefaultExecutionGraphQlService(source);
|
||||
return ExecutionGraphQlServiceTester.create(service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-grpc`
|
||||
- Gradle path: `:adapter:inbound:grpc`
|
||||
- Focused test: `./gradlew :adapter:inbound:grpc: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.inbound.grpc`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
|
||||
프로토콜 에러 매핑(`GrpcStatusMapper` + `GrpcExceptionHandlingInterceptor`), 그리고 `.proto`
|
||||
없이도 부팅하는 최소 표면(standard health + reflection).
|
||||
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록한다. **WorkLog 등
|
||||
구체 기능을 이름으로 알지 않는다.**
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services), `spring-boot-starter`.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
|
||||
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 스키마와 서비스는 feature
|
||||
(sample) 모듈이 소유한다.
|
||||
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙).
|
||||
|
||||
## Config knobs (`ca-skeleton.grpc.*`)
|
||||
|
||||
타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다
|
||||
(모듈별 `yml` 없음).
|
||||
|
||||
| key | default | 의미 |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | gRPC 서버 기동 여부. 프로덕션 composition root 는 property 로 끌 수 있다 |
|
||||
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
|
||||
| `reflectionEnabled` | `true` | v1 server reflection 노출(grpcurl/Postman 편의; 프로덕션에선 끄기) |
|
||||
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초 |
|
||||
|
||||
## Feature 기여 방법
|
||||
|
||||
feature 모듈은 `io.grpc.BindableService` 를 `@Bean` 으로 등록하기만 하면
|
||||
`GrpcServerRunner` 의 `ObjectProvider` 가 자동으로 인터셉터 뒤에 등록한다. 에러는
|
||||
`ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)로 던지면
|
||||
`GrpcExceptionHandlingInterceptor` 가 매핑한다. `sample-portfolio` 를 지워도 스켈레톤은
|
||||
health + reflection 만으로 부팅한다 (disposability).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:grpc:test
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# adapter-grpc — 설계 결정 참조
|
||||
|
||||
인바운드 gRPC 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 self-managed Netty 인가
|
||||
|
||||
- **third-party grpc-spring-boot starter 를 쓰지 않는다.** `GrpcServerRunner` 가 io.grpc Netty
|
||||
`Server` 를 Spring `SmartLifecycle` 빈으로 직접 소유한다. starter 를 쓰면 Spring Boot 릴리스에
|
||||
버전이 커플링되는데, 스켈레톤은 io.grpc 런타임에만 의존해 그 커플링을 피한다(web 어댑터가
|
||||
third-party 없이 서블릿 컨테이너를 쓰는 것과 같은 정신).
|
||||
- **`getPhase()` = `Integer.MAX_VALUE - 1`.** web 서버가 뜬 **뒤에** 시작하고 종료 시 web 서버
|
||||
**전에** 멈춘다(SmartLifecycle: 높은 phase 가 늦게 시작·먼저 종료). gRPC 는 web 과 별개의 TCP
|
||||
포트를 소유하는 부가 전송이므로 애플리케이션 수명주기 맨 바깥에 둔다.
|
||||
- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤
|
||||
`shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어
|
||||
로드밸런서가 드레이닝을 인지하게 한다.
|
||||
- **insecure bind (기본).** 스켈레톤은 참조 포스처와 동일하게 평문으로 바인딩하고 mTLS 는
|
||||
범위 밖(문서화된 knob). 프로덕션 fork 가 전송 보안을 얹는다.
|
||||
|
||||
## 왜 `.proto` 도 protobuf 플러그인도 없는가
|
||||
|
||||
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다** — `com.google.protobuf` 플러그인도,
|
||||
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
|
||||
런타임 jar 에 이미 컴파일된 채 들어 있어, 스켈레톤은 **RPC 0개**로도 동작하는 health +
|
||||
reflection 표면을 갖고 부팅한다. 기능(feature)의 `.proto`/서비스/매퍼는 `sample-portfolio` 의
|
||||
gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다.
|
||||
|
||||
`compileOnly org.apache.tomcat:annotations-api` 는 생성된 stub 이 참조하는
|
||||
`javax.annotation.Generated` 때문 — 스켈레톤 자체는 stub 을 생성하지 않지만 feature 모듈과의
|
||||
패리티를 위해 선언한다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `GrpcServerRunner` 는 생성자에서
|
||||
`ObjectProvider<io.grpc.BindableService>` 를 받아, 컨텍스트에 존재하는 **모든**
|
||||
`BindableService` 빈을 `ServerInterceptors.intercept(service, exceptionInterceptor)` 로 감싸
|
||||
등록한다. 그래서 sample 의 `WorkLogGrpcService` 같은 feature 서비스가 스켈레톤을 수정하지 않고
|
||||
자동 등록된다. `sample-portfolio` 를 지우면 스켈레톤은 여전히 health + reflection 만으로 부팅한다
|
||||
(web 과 동일한 disposability 보장).
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
|
||||
|
||||
`GrpcExceptionHandlingInterceptor` 가 핸들러에서 동기적으로 던져진 `RuntimeException` 을 잡아
|
||||
`ServerCall.close(status, trailers)` 로 변환한다. 서비스 구현은 web 컨트롤러처럼 "그냥 던지기만"
|
||||
하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
|
||||
|
||||
- **와이어 status(coarse)** 는 `GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가
|
||||
coarse 인 것과 동형). 정확한 `code`/`category` 는 `Status` trailer `Metadata`(`error-code` /
|
||||
`error-category`)에 실어 클라이언트가 switch 하게 한다.
|
||||
- **`ApiErrorCode` 추출**: feature 예외는 `ApiErrorCarrier`(이 모듈이 제공하는 전송-중립 hook)를
|
||||
구현해 자신의 `ApiErrorCode` 를 노출한다. shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
|
||||
않은 `RuntimeException` 은 `Status.INTERNAL` + `INTERNAL_ERROR` 로 폴백한다.
|
||||
|
||||
`Category → Status` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | gRPC `Status` |
|
||||
|---|---|
|
||||
| VALIDATION | INVALID_ARGUMENT |
|
||||
| AUTH | UNAUTHENTICATED |
|
||||
| AUTHZ | PERMISSION_DENIED |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | ABORTED |
|
||||
| RATE_LIMIT | RESOURCE_EXHAUSTED |
|
||||
| TRANSIENT_DEPENDENCY | UNAVAILABLE |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL |
|
||||
| DATA_INTEGRITY | INTERNAL |
|
||||
| INTERNAL | INTERNAL |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저장소엔 version catalog 도
|
||||
없다. 그래서 `io.grpc:grpc-bom` + `com.google.protobuf:protobuf-bom` 을 **이 모듈의**
|
||||
`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion`
|
||||
가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만
|
||||
가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다.
|
||||
@@ -0,0 +1,36 @@
|
||||
// Driving adapter: gRPC API (skeleton machinery, transport-only).
|
||||
//
|
||||
// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on
|
||||
// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles
|
||||
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection
|
||||
// come from grpc-services at runtime, and feature `.proto`/services live in the sample module.
|
||||
//
|
||||
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
|
||||
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
|
||||
// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to
|
||||
// this module (the shared root dependencyManagement block stays io.grpc-free).
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
|
||||
mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
|
||||
implementation 'io.grpc:grpc-netty-shaded'
|
||||
implementation 'io.grpc:grpc-protobuf'
|
||||
implementation 'io.grpc:grpc-stub'
|
||||
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
|
||||
|
||||
// grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity
|
||||
// with the feature module (the skeleton itself generates no stubs).
|
||||
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
# 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.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
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=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
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.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,testCompileClasspath
|
||||
com.google.guava:guava:33.2.1-jre=runtimeClasspath,testRuntimeClasspath
|
||||
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,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
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.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,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.tomcat:annotations-api:6.0.53=compileClasspath
|
||||
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.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,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=
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
|
||||
/**
|
||||
* Adapter-level throwable a feature gRPC service throws after mapping a domain exception to a
|
||||
* stable {@link ApiErrorCode} (typically a feature code such as {@code
|
||||
* PortfolioErrorCode.WORKLOG_NOT_FOUND} or a skeleton {@code OperationalError}). It implements the
|
||||
* shared-contract {@link ApiErrorCarrier} hook so the {@link GrpcExceptionHandlingInterceptor}
|
||||
* translates it to the matching gRPC {@code Status} plus {@code code} / {@code category} trailers
|
||||
* through the same single carrier branch that handles the shared-contract infra exceptions.
|
||||
*
|
||||
* <p>This is the gRPC sibling of "the web adapter service just throws and one handler owns the wire
|
||||
* mapping": a feature service does the domain-exception → {@code ApiErrorCode} mapping (its own
|
||||
* concern) and throws this; the transport mapping stays in the interceptor. The supplied message is
|
||||
* server-log-only detail — only {@link #errorCode()} reaches the client.
|
||||
*/
|
||||
public class ApiErrorException extends RuntimeException implements ApiErrorCarrier {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ApiErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* @param errorCode the classified, client-facing code surfaced on the gRPC status trailers
|
||||
* @param message server-log-only diagnostic detail — never surfaced to the client
|
||||
*/
|
||||
public ApiErrorException(ApiErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Centralises the gRPC error contract: a feature service just throws, and this {@link
|
||||
* ServerInterceptor} translates a synchronous {@link RuntimeException} from the handler into a
|
||||
* {@code ServerCall#close(Status, Metadata)} carrying the mapped {@link Status} plus {@code code} /
|
||||
* {@code category} trailers — the gRPC sibling of the web adapter's {@code GlobalExceptionHandler}.
|
||||
*
|
||||
* <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link
|
||||
* ApiErrorCarrier} hook — implemented by a feature throwable (the gRPC {@link ApiErrorException}
|
||||
* carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} /
|
||||
* {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers
|
||||
* them all. An unrecognised {@link RuntimeException} maps to {@link Status#INTERNAL} with {@link
|
||||
* OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the status
|
||||
* description and trailers) — a raw exception message, which may carry a SQLState or upstream
|
||||
* detail, is never surfaced.
|
||||
*/
|
||||
public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcStatusMapper statusMapper;
|
||||
|
||||
public GrpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
this.statusMapper = statusMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
|
||||
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
|
||||
AtomicBoolean closed = new AtomicBoolean(false);
|
||||
ServerCall.Listener<REQT> delegate;
|
||||
try {
|
||||
delegate = next.startCall(call, headers);
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(call, closed, e);
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
return new SimpleForwardingServerCallListener<>(delegate) {
|
||||
@Override
|
||||
public void onMessage(REQT message) {
|
||||
runGuarded(() -> super.onMessage(message));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
runGuarded(super::onHalfClose);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady() {
|
||||
runGuarded(super::onReady);
|
||||
}
|
||||
|
||||
private void runGuarded(Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(call, closed, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void closeWithError(
|
||||
ServerCall<?, ?> call, AtomicBoolean closed, RuntimeException exception) {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return; // the call was already closed once — never double-close.
|
||||
}
|
||||
ApiErrorCode code = errorCodeOf(exception);
|
||||
Status status;
|
||||
if (code != null) {
|
||||
status = statusMapper.toStatus(code.category()).withDescription(code.code());
|
||||
} else {
|
||||
code = OperationalError.INTERNAL_ERROR;
|
||||
status = Status.INTERNAL.withDescription(code.code());
|
||||
}
|
||||
call.close(status, statusMapper.trailersFor(code));
|
||||
}
|
||||
|
||||
private static ApiErrorCode errorCodeOf(Throwable throwable) {
|
||||
if (throwable instanceof ApiErrorCarrier carrier) {
|
||||
return carrier.errorCode();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Wires the gRPC transport machinery, only when {@code ca-skeleton.grpc.enabled=true} (default).
|
||||
* All collaborators are plain objects composed here, mirroring the clean DI style used across the
|
||||
* skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and
|
||||
* registered generically by {@link GrpcServerRunner}. See README.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.grpc",
|
||||
name = "enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
@EnableConfigurationProperties(GrpcServerProperties.class)
|
||||
public class GrpcServerConfig {
|
||||
|
||||
@Bean
|
||||
GrpcStatusMapper grpcStatusMapper() {
|
||||
return new GrpcStatusMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcExceptionHandlingInterceptor grpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
return new GrpcExceptionHandlingInterceptor(statusMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HealthStatusManager grpcHealthStatusManager() {
|
||||
return new HealthStatusManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcServerRunner grpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
return new GrpcServerRunner(services, properties, exceptionInterceptor, healthStatusManager);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no
|
||||
* per-module {@code yml}); values live in the composition-root {@code application.yml}, matching
|
||||
* the ca-skeleton config convention. See README for the self-managed-Netty rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.grpc")
|
||||
public class GrpcServerProperties {
|
||||
|
||||
/** Whether to start the gRPC server at all (feature composition can keep it off by property). */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
|
||||
private int port = 9090;
|
||||
|
||||
/** Expose server reflection (handy for grpcurl / Postman; disable in production). */
|
||||
private boolean reflectionEnabled = true;
|
||||
|
||||
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
|
||||
private int shutdownGraceSeconds = 5;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public void setPort(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public boolean isReflectionEnabled() {
|
||||
return reflectionEnabled;
|
||||
}
|
||||
|
||||
public void setReflectionEnabled(boolean reflectionEnabled) {
|
||||
this.reflectionEnabled = reflectionEnabled;
|
||||
}
|
||||
|
||||
public int getShutdownGraceSeconds() {
|
||||
return shutdownGraceSeconds;
|
||||
}
|
||||
|
||||
public void setShutdownGraceSeconds(int shutdownGraceSeconds) {
|
||||
this.shutdownGraceSeconds = shutdownGraceSeconds;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.InsecureServerCredentials;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerInterceptors;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* Owns the io.grpc Netty {@link Server} lifecycle as a Spring {@link SmartLifecycle} bean — start
|
||||
* on context refresh, graceful shutdown on close. Deliberately avoids any third-party
|
||||
* grpc-spring-boot starter so the skeleton has no Spring Boot version coupling.
|
||||
*
|
||||
* <p>Feature services are discovered generically: every {@link BindableService} bean is registered
|
||||
* behind the {@link GrpcExceptionHandlingInterceptor}, so a feature (e.g. the sample WorkLog
|
||||
* service) auto-registers without the skeleton naming it. The skeleton also registers the standard
|
||||
* {@code grpc.health.v1} health service and, when enabled, the v1 server reflection service, so it
|
||||
* boots with a working surface and ZERO {@code .proto}. See README.
|
||||
*/
|
||||
public class GrpcServerRunner implements SmartLifecycle {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class);
|
||||
|
||||
private final ObjectProvider<BindableService> services;
|
||||
private final GrpcServerProperties properties;
|
||||
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
|
||||
private final HealthStatusManager healthStatusManager;
|
||||
private volatile Server server;
|
||||
|
||||
public GrpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
this.services = services;
|
||||
this.properties = properties;
|
||||
this.exceptionInterceptor = exceptionInterceptor;
|
||||
this.healthStatusManager = healthStatusManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (isRunning()) {
|
||||
return;
|
||||
}
|
||||
var builder =
|
||||
Grpc.newServerBuilderForPort(properties.getPort(), InsecureServerCredentials.create());
|
||||
|
||||
int registered = 0;
|
||||
for (BindableService service : services) {
|
||||
builder.addService(ServerInterceptors.intercept(service, exceptionInterceptor));
|
||||
registered++;
|
||||
}
|
||||
|
||||
healthStatusManager.setStatus(
|
||||
HealthStatusManager.SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
|
||||
builder.addService(healthStatusManager.getHealthService());
|
||||
if (properties.isReflectionEnabled()) {
|
||||
builder.addService(ProtoReflectionServiceV1.newInstance());
|
||||
}
|
||||
|
||||
try {
|
||||
server = builder.build().start();
|
||||
log.info(
|
||||
"gRPC server started on port {} ({} feature service(s), reflection={})",
|
||||
server.getPort(),
|
||||
registered,
|
||||
properties.isReflectionEnabled());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(
|
||||
"failed to start gRPC server on port " + properties.getPort(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
Server current = this.server;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
healthStatusManager.enterTerminalState();
|
||||
try {
|
||||
current.shutdown();
|
||||
if (!current.awaitTermination(properties.getShutdownGraceSeconds(), TimeUnit.SECONDS)) {
|
||||
current.shutdownNow();
|
||||
}
|
||||
log.info("gRPC server stopped");
|
||||
} catch (InterruptedException e) {
|
||||
current.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
Server current = this.server;
|
||||
return current != null && !current.isShutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual bound port — useful when configured with port 0 for tests; {@code -1} when not started.
|
||||
*/
|
||||
public int getListeningPort() {
|
||||
Server current = this.server;
|
||||
return current != null ? current.getPort() : -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
// Start after the web server is up, stop before it during shutdown.
|
||||
return Integer.MAX_VALUE - 1;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
|
||||
/**
|
||||
* Pure translation of the 10-value operational {@link Category} SSOT to an {@link io.grpc.Status},
|
||||
* plus helpers to carry the machine-readable {@code code} / {@code category} on the response
|
||||
* trailer {@link Metadata}. This is the gRPC sibling of the web adapter's error contract: the wire
|
||||
* status (like an HTTP status) is coarse, while the exact {@link ApiErrorCode#code()} and the
|
||||
* category name ride in the trailers for the client to switch on.
|
||||
*
|
||||
* <p>The classification table is fixed by the design spec's Error Mapping section; see README.
|
||||
*/
|
||||
public class GrpcStatusMapper {
|
||||
|
||||
/**
|
||||
* Trailer key carrying the stable {@link ApiErrorCode#code()} (e.g. {@code WORKLOG_NOT_FOUND}).
|
||||
*/
|
||||
static final Metadata.Key<String> CODE_KEY =
|
||||
Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/** Trailer key carrying the {@link Category} enum name (e.g. {@code NOT_FOUND}). */
|
||||
static final Metadata.Key<String> CATEGORY_KEY =
|
||||
Metadata.Key.of("error-category", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/**
|
||||
* Maps an operational {@link Category} to its gRPC {@link Status} (design Error-Mapping table).
|
||||
*/
|
||||
public Status toStatus(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION -> Status.INVALID_ARGUMENT;
|
||||
case AUTH -> Status.UNAUTHENTICATED;
|
||||
case AUTHZ -> Status.PERMISSION_DENIED;
|
||||
case NOT_FOUND -> Status.NOT_FOUND;
|
||||
case CONFLICT -> Status.ABORTED;
|
||||
case RATE_LIMIT -> Status.RESOURCE_EXHAUSTED;
|
||||
case TRANSIENT_DEPENDENCY -> Status.UNAVAILABLE;
|
||||
case PERMANENT_DEPENDENCY -> Status.INTERNAL;
|
||||
case DATA_INTEGRITY -> Status.INTERNAL;
|
||||
case INTERNAL -> Status.INTERNAL;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the {@code code} and {@code category} of {@code errorCode} onto a fresh trailer {@link
|
||||
* Metadata}, returned for {@code ServerCall#close(Status, Metadata)}.
|
||||
*/
|
||||
public Metadata trailersFor(ApiErrorCode errorCode) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(CODE_KEY, errorCode.code());
|
||||
trailers.put(CATEGORY_KEY, errorCode.category().name());
|
||||
return trailers;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies {@link ApiErrorException} carries its {@link dev.caskeleton.shared.error.ApiErrorCode}
|
||||
* through the shared-contract {@link ApiErrorCarrier} hook unchanged, and keeps the diagnostic
|
||||
* message off the carrier surface.
|
||||
*/
|
||||
class ApiErrorExceptionTest {
|
||||
|
||||
@Test
|
||||
void errorCodeRoundTripsThroughTheCarrierHook() {
|
||||
ApiErrorException exception =
|
||||
new ApiErrorException(OperationalError.BAD_PARAMETER, "server-log-only detail");
|
||||
|
||||
assertThat(exception).isInstanceOf(ApiErrorCarrier.class);
|
||||
assertThat(exception.errorCode()).isEqualTo(OperationalError.BAD_PARAMETER);
|
||||
assertThat(exception.getMessage()).isEqualTo("server-log-only detail");
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.MethodDescriptor;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.Status;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies the interceptor translates a synchronous handler exception into a mapped {@code
|
||||
* close(status, trailers)} — recognising the {@link ApiErrorCarrier} feature hook and the
|
||||
* shared-contract {@link PersistenceFailureException}, and falling back to {@link Status#INTERNAL}
|
||||
* for an unrecognised {@link RuntimeException}. Driven with a capturing fake {@link ServerCall}, so
|
||||
* no channel/server is needed.
|
||||
*/
|
||||
class GrpcExceptionHandlingInterceptorTest {
|
||||
|
||||
private final GrpcExceptionHandlingInterceptor interceptor =
|
||||
new GrpcExceptionHandlingInterceptor(new GrpcStatusMapper());
|
||||
|
||||
@Test
|
||||
void mapsApiErrorCarrierToItsCategoryStatusWithTrailers() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(new CarrierException(OperationalError.BAD_PARAMETER));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("BAD_PARAMETER");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("VALIDATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsApiErrorExceptionByItsCarriedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new ApiErrorException(OperationalError.ROUTE_NOT_FOUND, "server-log-only detail"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.NOT_FOUND);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("ROUTE_NOT_FOUND");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsSharedPersistenceFailureByItsClassifiedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new PersistenceFailureException(OperationalError.DB_UNAVAILABLE, "08006", null));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.UNAVAILABLE);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("DB_UNAVAILABLE");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsUnknownRuntimeExceptionToInternal() {
|
||||
CapturingServerCall call = closeAfterThrowing(new IllegalStateException("boom"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INTERNAL);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("INTERNAL_ERROR");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("INTERNAL");
|
||||
}
|
||||
|
||||
private CapturingServerCall closeAfterThrowing(RuntimeException thrown) {
|
||||
CapturingServerCall call = new CapturingServerCall();
|
||||
ServerCallHandler<String, String> handler =
|
||||
(serverCall, headers) ->
|
||||
new ServerCall.Listener<>() {
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
ServerCall.Listener<String> listener = interceptor.interceptCall(call, new Metadata(), handler);
|
||||
listener.onHalfClose();
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(ApiErrorCode errorCode) {
|
||||
super(errorCode.code());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ServerCall} that records the {@code close(status, trailers)} arguments. */
|
||||
private static final class CapturingServerCall extends ServerCall<String, String> {
|
||||
private Status status;
|
||||
private Metadata trailers;
|
||||
|
||||
@Override
|
||||
public void request(int numMessages) {}
|
||||
|
||||
@Override
|
||||
public void sendHeaders(Metadata headers) {}
|
||||
|
||||
@Override
|
||||
public void sendMessage(String message) {}
|
||||
|
||||
@Override
|
||||
public void close(Status status, Metadata trailers) {
|
||||
this.status = status;
|
||||
this.trailers = trailers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MethodDescriptor<String, String> getMethodDescriptor() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.health.v1.HealthCheckRequest;
|
||||
import io.grpc.health.v1.HealthCheckResponse;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.health.v1.HealthGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionRequest;
|
||||
import io.grpc.reflection.v1.ServerReflectionResponse;
|
||||
import io.grpc.reflection.v1.ServiceResponse;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Boots the skeleton gRPC machinery in a real Spring context on an ephemeral port ({@code
|
||||
* ca-skeleton.grpc.port=0}) with ZERO feature services and proves it stands up a working surface:
|
||||
* the {@link GrpcServerRunner} SmartLifecycle starts, the standard {@code grpc.health.v1} health
|
||||
* service reports SERVING, and v1 server reflection lists the built-in services. A real Netty
|
||||
* channel exercises the wire, so this is a genuine transport smoke test, not a wiring mock.
|
||||
*/
|
||||
class GrpcServerRunnerBootTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner =
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(GrpcServerConfig.class)
|
||||
.withPropertyValues("ca-skeleton.grpc.port=0");
|
||||
|
||||
@Test
|
||||
void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() {
|
||||
contextRunner.run(
|
||||
context -> {
|
||||
GrpcServerRunner runner = context.getBean(GrpcServerRunner.class);
|
||||
assertThat(runner.isRunning()).isTrue();
|
||||
|
||||
int port = runner.getListeningPort();
|
||||
assertThat(port).isGreaterThan(0);
|
||||
|
||||
ManagedChannel channel =
|
||||
ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
|
||||
try {
|
||||
HealthCheckResponse health =
|
||||
HealthGrpc.newBlockingStub(channel).check(HealthCheckRequest.newBuilder().build());
|
||||
assertThat(health.getStatus()).isEqualTo(ServingStatus.SERVING);
|
||||
|
||||
assertThat(listServicesViaReflection(channel))
|
||||
.contains("grpc.health.v1.Health", "grpc.reflection.v1.ServerReflection");
|
||||
} finally {
|
||||
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> listServicesViaReflection(ManagedChannel channel)
|
||||
throws InterruptedException {
|
||||
List<String> services = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
|
||||
StreamObserver<ServerReflectionRequest> requests =
|
||||
ServerReflectionGrpc.newStub(channel)
|
||||
.serverReflectionInfo(
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ServerReflectionResponse response) {
|
||||
for (ServiceResponse service :
|
||||
response.getListServicesResponse().getServiceList()) {
|
||||
services.add(service.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
error.set(t);
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
|
||||
requests.onCompleted();
|
||||
|
||||
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(error.get()).isNull();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link Status} classification table (design Error-Mapping)
|
||||
* and the {@code code} / {@code category} trailer helper. One assertion per Category value guards
|
||||
* against a silent remap on a Spring/grpc upgrade.
|
||||
*/
|
||||
class GrpcStatusMapperTest {
|
||||
|
||||
private final GrpcStatusMapper mapper = new GrpcStatusMapper();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,INVALID_ARGUMENT",
|
||||
"AUTH,UNAUTHENTICATED",
|
||||
"AUTHZ,PERMISSION_DENIED",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,ABORTED",
|
||||
"RATE_LIMIT,RESOURCE_EXHAUSTED",
|
||||
"TRANSIENT_DEPENDENCY,UNAVAILABLE",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL",
|
||||
"DATA_INTEGRITY,INTERNAL",
|
||||
"INTERNAL,INTERNAL",
|
||||
})
|
||||
void mapsEachCategoryToItsGrpcStatusCode(Category category, Status.Code expected) {
|
||||
assertThat(mapper.toStatus(category).getCode()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(mapper.toStatus(category)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void trailersCarryStableCodeAndCategoryName() {
|
||||
Metadata trailers = mapper.trailersFor(OperationalError.RATE_LIMIT_EXCEEDED);
|
||||
|
||||
assertThat(trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("RATE_LIMIT_EXCEEDED");
|
||||
assertThat(trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("RATE_LIMIT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# adapter:inbound:web — inbound HTTP adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-web`
|
||||
- Gradle path: `:adapter:inbound:web`
|
||||
- Focused test: `./gradlew :adapter:inbound:web: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.inbound.web`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- HTTP controllers.
|
||||
- Request/response DTOs.
|
||||
- Request DTO to application command mapping.
|
||||
- Authentication, validation, error mapping, filters, and web/security settings.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`
|
||||
- `:domain-core`
|
||||
- `:shared-contract`
|
||||
- Spring Web/Security/Validation dependencies.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Direct dependency on `adapter-persistence` or `adapter-outbound`.
|
||||
- Direct repository or JPA entity access from controllers.
|
||||
- Core business rules in controller, filter, config, mapper, or settings code.
|
||||
- DTO leakage into application or domain.
|
||||
|
||||
## Boundary validation & mapper contract
|
||||
|
||||
`feature-boundary-validation-mapping-contract` (LLM Wiki branch note) fixes the
|
||||
behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit +
|
||||
Jackson config + handler) only catch the static violations — the contract below
|
||||
also drives the runtime patterns reference implementations must follow.
|
||||
|
||||
- **B1 — Jackson policy at the request boundary.** `spring.jackson.deserialization`
|
||||
pins `fail-on-unknown-properties`, `fail-on-null-for-primitives`,
|
||||
`fail-on-ignored-properties` to `true` and `read-unknown-enum-values-as-null`
|
||||
to `false`. Do NOT undo this per-DTO with class-level
|
||||
`@JsonIgnoreProperties(ignoreUnknown = true)` — ArchUnit rule
|
||||
`request_dtos_do_not_silence_unknown_fields` blocks it. Use wrapper types
|
||||
(`Integer`, `Long`, `Boolean`, `Optional<T>`) in request records so JSON
|
||||
`null` cannot become primitive `0`.
|
||||
- **B2 — PATCH semantics.** Do not adopt RFC 7396 `application/merge-patch+json`
|
||||
(`null = deletion`). PATCH endpoints must distinguish *absent* (no change),
|
||||
*explicit null* (clear field), and *value* (replace). Use
|
||||
`org.openapitools:jackson-databind-nullable` (`JsonNullable<T>`) or
|
||||
`Optional<T>` wrappers on request records.
|
||||
- **B3 — Mapper-internal failures.** Map record canonical-constructor
|
||||
`IllegalArgumentException`, MapStruct generated NPE, ACL normalization
|
||||
failures, etc. by throwing `MappingException` (sample implementation in
|
||||
`sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400),
|
||||
never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException`
|
||||
remains `BAD_PARAMETER` for non-mapper callers.
|
||||
- **B4 — Validation layering.** Class-level Bean Validation constraints belong
|
||||
to the *syntax* layer (request DTO). Domain invariants belong to
|
||||
`application-core` / `domain-core`. Use `@GroupSequence(...)` to short-circuit
|
||||
invariant evaluation when syntax fails. Keep `@Valid` cascade depth ≤ 3.
|
||||
- **B5 — Polymorphic deserialization.** Calling
|
||||
`ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping()` or
|
||||
referencing `LaissezFaireSubTypeValidator` is the CVE-2019-14379 RCE entry
|
||||
point and is blocked by ArchUnit (`no_jackson_laissez_faire_subtype_validator`,
|
||||
`no_jackson_enable_default_typing_call`). Sealed `Command` types must use
|
||||
`@JsonTypeInfo(use = NAME)` + `@JsonSubTypes`, or a
|
||||
`BasicPolymorphicTypeValidator` allowlist.
|
||||
- **B6 — Virtual thread context propagation.** With
|
||||
`spring.threads.virtual.enabled=true`, do not use `InheritableThreadLocal`
|
||||
(ArchUnit rule `no_inheritable_thread_local`). Filters and interceptors must
|
||||
propagate `requestId` / `traceId` via SLF4J 2.0+ MDC or
|
||||
`RequestContextHolder`.
|
||||
- **B7 — Outbound ACL mapper scope.** Outbound HTTP / messaging adapter
|
||||
responses must pass through an ACL mapper (normalization, masking, public
|
||||
field selection) before reaching `application-core` or `domain-core` — the
|
||||
same boundary contract as inbound. Raw external response types must not leak
|
||||
into `domain-core`.
|
||||
- **B8 — Bulk endpoint partial success.** Envelope `success = true` only when
|
||||
every item succeeded. Partial failure responds with `success = false` +
|
||||
`error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` (per-item array) —
|
||||
a different shape from the single-item endpoint. Document the shape divergence
|
||||
in the OpenAPI spec.
|
||||
|
||||
Domain `@RestControllerAdvice` in a consuming module must be annotated
|
||||
`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this
|
||||
module's base `GlobalExceptionHandler`), because the base handler's catch-all
|
||||
`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions
|
||||
to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the
|
||||
pattern.
|
||||
|
||||
The base operational handler (`error/GlobalExceptionHandler`), the error-code
|
||||
contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the
|
||||
`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in
|
||||
production modules (`adapter:inbound:web` / `shared-contract`), so the running application
|
||||
provides them without depending on `sample-portfolio`. Domain-specific exception
|
||||
handlers and error codes live in the consuming module (see sample's
|
||||
`DomainExceptionHandler` / `PortfolioErrorCode`).
|
||||
|
||||
## Schema / serialization contract
|
||||
|
||||
`feature-schema-serialization-contract` (LLM Wiki branch note) fixes the
|
||||
*response producer* side of the wire contract — the sibling of the B1 *request
|
||||
consumer* policy above. The deserialization switches (B1) and the
|
||||
null/empty/missing 3-state (`Patch<T>` + `JsonNullable`, B2) already cover the
|
||||
inbound side; the rules below cover the outbound side. The Jackson properties
|
||||
live in `app-bootstrap` (`application.yml` `spring.jackson.serialization.*` /
|
||||
`spring.jackson.generator.*`); ArchUnit + effective-config tests live in
|
||||
`app-bootstrap` (`JacksonSerializationPolicyTest`, `no_bigdecimal_double_constructor`).
|
||||
|
||||
- **S1 — Date / time / timezone (D2).** `WRITE_DATES_AS_TIMESTAMPS=false` is
|
||||
pinned, so `java.time` values serialize as ISO-8601 strings via `JavaTimeModule`
|
||||
(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`), never a numeric
|
||||
epoch or `[y,m,d,...]` array. Server timezone is **UTC**: emit instants as
|
||||
`OffsetDateTime`/`Instant` with a `Z` offset. Use `LocalDate` only for
|
||||
date-only calendar fields. Do **not** put timezone-less `LocalDateTime` on a
|
||||
response DTO — it serializes without an offset and breaks the contract.
|
||||
- **S2 — Money / BigDecimal (D3).** Default scale 2, rounding `HALF_UP` unless
|
||||
the domain documents otherwise (KRW/JPY = scale 0 with a schema note).
|
||||
`WRITE_BIGDECIMAL_AS_PLAIN=true` is pinned so values never serialize in
|
||||
scientific notation. Pick **one** JSON representation per API and state it in
|
||||
the OpenAPI schema: **string** (`@JsonSerialize(using = ToStringSerializer.class)`)
|
||||
for public / financial endpoints (client parses, no precision loss), or
|
||||
**number + plain** for internal service-to-service endpoints. Never rely on
|
||||
the default — decide at endpoint design time.
|
||||
- **S3 — `new BigDecimal(double)` is banned.** The `double`/`float` constructors
|
||||
capture binary floating-point error (`new BigDecimal(0.1)` ≠ `0.1`). Build from
|
||||
a `String` (`new BigDecimal("0.1")`) or `BigDecimal.valueOf(double)`. Enforced
|
||||
by the `no_bigdecimal_double_constructor` ArchUnit rule (D3 / SBMS-C3).
|
||||
- **S4 — Enum / null·empty·missing.** Request-side unknown enum →
|
||||
`VALIDATION_FAILED` (B1 `read-unknown-enum-values-as-null=false`); legacy values
|
||||
map through an explicit adapter, never a silent fallback. The
|
||||
absent / explicit-null / value distinction is owned by the inbound web mapper
|
||||
(Controller DTO → Command), expressed with `Patch<T>` (B2); `domain-core` and
|
||||
`application-core` receive the already-resolved 3-state, never a wire type.
|
||||
- **S5 — Out of this branch's scope.** OpenAPI drift enforcement (D5) is owned by
|
||||
the verification suite / api-contract-baseline; removed-field-reuse ban tooling
|
||||
(D6, `x-removed-fields` vs markdown catalog) is `needs-confirmation`; Avro
|
||||
Schema Registry for outbox/event (D7) and response field rename/versioning
|
||||
(`feature-api-compatibility-deprecation-contract`) are separate branches.
|
||||
|
||||
## Business rule validation contract
|
||||
|
||||
`feature-business-rule-validation-contract` (LLM Wiki branch note) fixes **which
|
||||
rule is validated at which boundary**, so "validation" does not collapse into the
|
||||
controller DTO or a DB constraint. It sits on top of the boundary/mapping contract
|
||||
above and is enforced by ArchUnit + contract tests (not new runtime mechanism).
|
||||
|
||||
| Layer | Owner | Validates | `error.category` | Enforced by |
|
||||
|---|---|---|---|---|
|
||||
| syntax / shape | `adapter:inbound:web` request DTO (`@Valid` / `jakarta.validation`) | request shape, types, required fields | `VALIDATION` | `validation_constraints_stay_at_web_boundary` ArchUnit rule |
|
||||
| use case policy | `application-core` | authorization, cross-aggregate policy, state preconditions | `AUTHZ` / `CONFLICT` | `BusinessRuleValidationContractTest` |
|
||||
| domain invariant | `domain-core` model / value object **constructor** | business invariants (e.g. end ≥ start) | `CONFLICT` / `VALIDATION` | domain unit tests (e.g. `PeriodTest`) — constructor is the sole, immutable construction path |
|
||||
| persistence integrity | `adapter-persistence` (translator owned by `feature-persistence-failure-baseline`) | unique / FK / check / serialization | `DATA_INTEGRITY` / `CONFLICT` | `BusinessRuleValidationContractTest` + leak test |
|
||||
|
||||
- **C1 — Validation annotations stay at the web boundary.** `jakarta.validation`
|
||||
(`@NotNull`, `@Valid`, …) must appear only in `adapter:inbound:web`. `domain-core` and
|
||||
`application-core` express invariants and policy as plain Java. The
|
||||
`validation_constraints_stay_at_web_boundary` ArchUnit rule fails the build if a
|
||||
Bean Validation annotation leaks into `..domain..` or `..application..`.
|
||||
- **C2 — Business invariants live in the domain, un-bypassable.** Enforce invariants
|
||||
in the value-object / entity **constructor** (the sole construction path) and keep
|
||||
the type immutable, so no application-service or persistence path can hand out an
|
||||
invariant-violating instance. A DB constraint is a backstop, never the only check
|
||||
(Forbidden: "DB constraint as only invariant").
|
||||
- **C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free.** A
|
||||
unique/FK/check/serialization failure maps to `DATA_INTEGRITY` / `CONFLICT` with a
|
||||
**client-safe message only**. The raw SQL, constraint/index name, SQLState code,
|
||||
exception class, and stack frame must never reach `error.message` or
|
||||
`error.details`. The base `GlobalExceptionHandler` catch-all already replaces the
|
||||
message with `"Internal server error"` and emits `null` details; the
|
||||
category-correct mapping (23505 → `CONFLICT/DB_UNIQUE_VIOLATION`, …) is owned by
|
||||
`feature-persistence-failure-baseline`'s persistence-adapter translator.
|
||||
- **C8 — Duplicate validation needs a canonical owner.** The same rule MAY be
|
||||
pre-checked at another layer for UX / performance (e.g. an application pre-check
|
||||
mirroring a DB unique constraint), but the **canonical owner** of the rule must be
|
||||
named in a code comment or the relevant `CLAUDE.md`. A duplicate validator with no
|
||||
documented owner is a review failure (silent contradiction risk). This is a process
|
||||
gate (PR review), not an automated rule — `needs-confirmation` until an owner-marker
|
||||
annotation is justified.
|
||||
|
||||
Out of this branch's scope (cross-referenced, not re-implemented here): the
|
||||
SQLState→code 9-row matrix and the `DataAccessException` translator
|
||||
(`feature-persistence-failure-baseline`); the Jackson B1/B2 request-boundary switches
|
||||
and mapper sentinel (`feature-boundary-validation-mapping-contract`); the envelope,
|
||||
`Category` enum, and `OperationalError` codes
|
||||
(`feature-operational-error-observability-foundation`).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:web:test --console=plain
|
||||
```
|
||||
@@ -0,0 +1,414 @@
|
||||
# adapter-web — 설계 결정 참조
|
||||
|
||||
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
|
||||
|
||||
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
|
||||
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
|
||||
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
|
||||
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
|
||||
풀어 둔다.
|
||||
|
||||
---
|
||||
|
||||
## auth — 인증 (OIDC resource server)
|
||||
|
||||
### SecurityConfig
|
||||
- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이
|
||||
HTTP cache 헤더 정책을 소유한다(`CacheControlFilter` 가 `Cache-Control: no-store` + `Vary` 방출).
|
||||
헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
|
||||
- **AuthN/AuthZ 분류기를 `exceptionHandling` 과 `oauth2ResourceServer` 양쪽에 설정.** entry point 는
|
||||
missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied
|
||||
handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한
|
||||
Envelope writer 로 귀결된다.
|
||||
|
||||
### JwtDecoderConfig
|
||||
- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다.
|
||||
- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring
|
||||
업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다.
|
||||
- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`),
|
||||
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
|
||||
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
|
||||
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
|
||||
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
|
||||
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
|
||||
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
|
||||
- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier` 의
|
||||
"aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다.
|
||||
|
||||
### JwtToAuthenticatedPrincipalConverter
|
||||
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
|
||||
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
|
||||
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
|
||||
|
||||
### SecurityErrorClassifier
|
||||
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
|
||||
선언한 세분화 코드를 방출한다.
|
||||
- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로,
|
||||
classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑:
|
||||
- missing token → `InsufficientAuthenticationException` → `AUTH_TOKEN_MISSING`
|
||||
- claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` /
|
||||
`AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH`
|
||||
- decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) →
|
||||
`AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN`
|
||||
- JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient)
|
||||
- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한
|
||||
`AUTH_TOKEN_MALFORMED`(401)로 폴백한다.
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION`
|
||||
만 방출한다.
|
||||
|
||||
### AuthErrorResponseWriter
|
||||
- **토큰/PII 리댁션.** 응답 본문엔 해당
|
||||
코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는
|
||||
절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지
|
||||
않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다.
|
||||
- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description`
|
||||
으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다.
|
||||
|
||||
### EnvelopeAuthenticationEntryPoint
|
||||
- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류).
|
||||
- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서
|
||||
처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler` 가
|
||||
아니라 반드시 이 entry point 에 위치해야 한다.
|
||||
|
||||
### EnvelopeAccessDeniedHandler
|
||||
- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403).
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라
|
||||
일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## authz — 인가 (`@RequiresPermission` 강제)
|
||||
|
||||
### MethodSecurityConfig
|
||||
- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다.
|
||||
- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/
|
||||
`@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래
|
||||
커스텀 advisor 가 되게 하기 위함.
|
||||
- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는
|
||||
프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급.
|
||||
- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어
|
||||
애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
|
||||
|
||||
### RequiresPermissionAuthorizationManager
|
||||
- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`).
|
||||
- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적
|
||||
`AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서
|
||||
application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다.
|
||||
- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된
|
||||
`AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException` →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는
|
||||
메서드는 영향받지 않는다.
|
||||
- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로
|
||||
해석되어 거부된다.
|
||||
|
||||
### AuthorizationAdapter
|
||||
- application `AuthorizationPort` 의 web-adapter 구현체.
|
||||
- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로
|
||||
거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403.
|
||||
|
||||
### RolePermissionRegistry
|
||||
- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다.
|
||||
- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로.
|
||||
- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의
|
||||
`worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B).
|
||||
- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한.
|
||||
|
||||
### RolePermissionPolicy
|
||||
- app-side role→permission 매핑 소스.
|
||||
- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role
|
||||
집합엔 없으므로 붙이지 않는다.
|
||||
- startup-bound static config 라 staleness 가 없다.
|
||||
- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터
|
||||
디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서
|
||||
의도적으로 out-of-scope 인 대안이다.
|
||||
|
||||
---
|
||||
|
||||
## error — 에러 → Envelope 변환
|
||||
|
||||
### GlobalExceptionHandler
|
||||
스켈레톤 공통 기반 에러 → `Envelope` 변환기.
|
||||
|
||||
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
|
||||
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
|
||||
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
|
||||
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
|
||||
제공하도록.
|
||||
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
|
||||
라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired`
|
||||
생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트
|
||||
모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만
|
||||
등록하면 no-op 을 오버라이드한다.
|
||||
|
||||
**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거):
|
||||
|
||||
| 예외 | 코드 | 상태 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
|
||||
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
|
||||
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
|
||||
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | field/message violation 리스트를 details 로 |
|
||||
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
|
||||
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
|
||||
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
|
||||
| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 |
|
||||
| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) |
|
||||
| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 |
|
||||
| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" |
|
||||
| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 |
|
||||
| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 |
|
||||
| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 |
|
||||
| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) |
|
||||
| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor` 로 `Retry-After` 부착 |
|
||||
| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 |
|
||||
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
|
||||
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
|
||||
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
|
||||
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | field/rejectedValue/message 리스트를 details 로 |
|
||||
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
|
||||
| `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | |
|
||||
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
|
||||
|
||||
### ErrorResponseFactory
|
||||
- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게
|
||||
하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출).
|
||||
|
||||
---
|
||||
|
||||
## envelope / filter
|
||||
|
||||
### EnvelopeBodyAdvice
|
||||
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
|
||||
`{success, data | error, traceId}` 로 보장한다.
|
||||
- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지
|
||||
않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다.
|
||||
|
||||
### CacheControlFilter
|
||||
- 스켈레톤 기본 HTTP 캐시 정책.
|
||||
- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization` 로
|
||||
공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다.
|
||||
- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예:
|
||||
`private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록.
|
||||
- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다.
|
||||
- 단일 소유권: Spring Security 기본 `Cache-Control` 은 `SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가
|
||||
헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer.
|
||||
|
||||
### RequestLoggingFilter
|
||||
- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용.
|
||||
- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거)
|
||||
및 길이 제한. 부재/공백은 서버 생성.
|
||||
- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`,
|
||||
`spanId`→`span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId,
|
||||
sampled=false)하여 MDC `trace_id` 가 **항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성
|
||||
상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정.
|
||||
- `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다.
|
||||
- `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트
|
||||
전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을
|
||||
non-zero 로 유지해 W3C non-all-zero 규칙 충족.
|
||||
- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전
|
||||
`UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다.
|
||||
- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE`
|
||||
는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능.
|
||||
- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer
|
||||
스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory` 가
|
||||
이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를
|
||||
발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는
|
||||
필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId` 가
|
||||
실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다.
|
||||
실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing
|
||||
observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지
|
||||
못한다 — 무-tracer 메커니즘만 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## ratelimit
|
||||
|
||||
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
|
||||
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
|
||||
- **OCP(개방-폐쇄)**: `RateLimitInterceptor` 는 `RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
|
||||
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
|
||||
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
|
||||
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
|
||||
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
|
||||
- `limit` — 설정 quota
|
||||
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
|
||||
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
|
||||
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
|
||||
- `allowed` — quota 소진 시 false (→ 429)
|
||||
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
|
||||
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
|
||||
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
|
||||
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
|
||||
|
||||
### RateLimitKeyResolver
|
||||
- 키 형태: service-to-service
|
||||
`apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화).
|
||||
- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은
|
||||
이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다.
|
||||
- tenant prefixing 은 아직 구현하지 않은 확장 지점이다.
|
||||
- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다.
|
||||
- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet
|
||||
filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다.
|
||||
|
||||
### RateLimitClientIpMode
|
||||
- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum.
|
||||
- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시).
|
||||
- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용.
|
||||
|
||||
### RateLimitWebConfig
|
||||
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
|
||||
resolve 되기 때문(RateLimitKeyResolver 참조).
|
||||
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
|
||||
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
|
||||
슬라이스에선 `Clock#systemUTC()` 로 fallback.
|
||||
|
||||
### RateLimitInterceptor
|
||||
- fixed-window rate limit 을 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
|
||||
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
|
||||
의존성 장애로 오분류하는 것을 막는다.
|
||||
|
||||
---
|
||||
|
||||
## settings / config / http
|
||||
|
||||
### CorsSettings
|
||||
- **3계층 검증 전략.**
|
||||
1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로
|
||||
기동 실패(`maxAgeSeconds`).
|
||||
2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값
|
||||
폴백 금지).
|
||||
3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
|
||||
- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록
|
||||
관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다.
|
||||
- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *`
|
||||
는 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
|
||||
시점에 fail-fast 거부.
|
||||
|
||||
### RateLimitSettings
|
||||
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
|
||||
- `enabled` 는 `APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
|
||||
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
|
||||
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
|
||||
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
|
||||
|
||||
### SecuritySettings
|
||||
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
|
||||
기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백.
|
||||
|
||||
### PresentationSettings
|
||||
- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가
|
||||
(/api 없이) 계속 접근 가능하게 유지.
|
||||
|
||||
### JacksonNullableConfig
|
||||
- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를
|
||||
Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다.
|
||||
- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치.
|
||||
|
||||
### ApiHeaders
|
||||
- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`의
|
||||
코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리
|
||||
일관성 테스트가 단일 출처를 참조하게 한다.
|
||||
- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은
|
||||
application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/
|
||||
LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은
|
||||
rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조.
|
||||
|
||||
---
|
||||
|
||||
## observability
|
||||
|
||||
### MdcKeys
|
||||
- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다.
|
||||
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
||||
변환 단일 지점은 `ResponseMetaFactory`.
|
||||
|
||||
### HeaderSanitizer
|
||||
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
||||
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
||||
제거 후 길이 제한.
|
||||
- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는
|
||||
원칙만 규정하고 정규식/한계는 규정하지 않는다.
|
||||
|
||||
### ResponseMetaFactory
|
||||
- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web
|
||||
에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
|
||||
|
||||
### RetryAfterAdvisor
|
||||
- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고,
|
||||
per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다.
|
||||
이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가
|
||||
재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다.
|
||||
- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나,
|
||||
Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적
|
||||
미구현.
|
||||
- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러.
|
||||
이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다.
|
||||
`DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환.
|
||||
|
||||
---
|
||||
|
||||
## pagination
|
||||
|
||||
### PageParams
|
||||
- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable`
|
||||
parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE`
|
||||
는 2000, SPRING-PAGE-C4).
|
||||
- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부
|
||||
트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다.
|
||||
|
||||
### SortParam
|
||||
- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거:
|
||||
JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을
|
||||
깨뜨리므로 금지.
|
||||
|
||||
### PageValidationException
|
||||
- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생.
|
||||
|
||||
---
|
||||
|
||||
## cursor
|
||||
|
||||
### CursorCodec
|
||||
- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5).
|
||||
- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이
|
||||
저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리
|
||||
제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현.
|
||||
- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정).
|
||||
|
||||
### CursorException
|
||||
- 불투명 페이지네이션 커서 검증 실패 시 발생(D22).
|
||||
|
||||
---
|
||||
|
||||
## conditional
|
||||
|
||||
### ETags
|
||||
- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17).
|
||||
`weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다.
|
||||
- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을
|
||||
leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다.
|
||||
strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능.
|
||||
|
||||
### PreconditionFailedException
|
||||
- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과
|
||||
구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현.
|
||||
|
||||
---
|
||||
|
||||
## idempotency
|
||||
|
||||
### IdempotencyKeySupport
|
||||
- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper.
|
||||
- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일
|
||||
표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of`
|
||||
가 `IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지.
|
||||
- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로
|
||||
계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는
|
||||
여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다.
|
||||
- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다.
|
||||
|
||||
### JsonIdempotentResponseCodec
|
||||
- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을
|
||||
소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED`
|
||||
400 으로 라우팅.
|
||||
@@ -0,0 +1,16 @@
|
||||
// HTTP / web adapters. Depends on application, domain, and shared operational contracts.
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
||||
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
||||
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||
// owned by feature-contract-verification-test-suite (planned).
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6'
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,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.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,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
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,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,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,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.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,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.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,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-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security: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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.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=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Writes a classified security failure to the servlet response as the skeleton-wide {@link
|
||||
* Envelope} (same shape as every other error), and logs it safely. The response body and log line
|
||||
* carry only redacted, client-safe metadata. See README for the design rationale.
|
||||
*/
|
||||
class AuthErrorResponseWriter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthErrorResponseWriter.class);
|
||||
|
||||
/** Client-safe messages, aligned with the registry {@code client_safe_message} column. */
|
||||
private static final Map<OperationalError, String> CLIENT_MESSAGES =
|
||||
Map.of(
|
||||
OperationalError.AUTH_TOKEN_MISSING, "Authentication required",
|
||||
OperationalError.AUTH_TOKEN_EXPIRED, "Authentication expired",
|
||||
OperationalError.AUTH_KID_UNKNOWN, "Authentication failed, please retry",
|
||||
OperationalError.AUTH_JWKS_UNAVAILABLE, "Authentication service temporarily unavailable",
|
||||
OperationalError.AUTHZ_INSUFFICIENT_PERMISSION, "Permission denied",
|
||||
OperationalError.AUTHZ_TENANT_MISMATCH, "Permission denied");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
AuthErrorResponseWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
void write(HttpServletRequest request, HttpServletResponse response, OperationalError code)
|
||||
throws IOException {
|
||||
// Log only safe metadata — never the token or the raw failure message.
|
||||
log.warn(
|
||||
"security failure: code={} category={} method={} path={}",
|
||||
code.code(),
|
||||
code.category(),
|
||||
request.getMethod(),
|
||||
request.getRequestURI());
|
||||
|
||||
response.setStatus(code.httpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
// A 401 response carries a minimal WWW-Authenticate header (no issuer / token detail).
|
||||
if (code.httpStatus() == 401) {
|
||||
response.setHeader(
|
||||
HttpHeaders.WWW_AUTHENTICATE,
|
||||
code == OperationalError.AUTH_TOKEN_MISSING
|
||||
? "Bearer"
|
||||
: "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(
|
||||
seconds -> response.setHeader(HttpHeaders.RETRY_AFTER, Integer.toString(seconds)));
|
||||
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, clientMessage(code), null);
|
||||
objectMapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
|
||||
private String clientMessage(OperationalError code) {
|
||||
return CLIENT_MESSAGES.getOrDefault(code, "Authentication failed");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Principal exposed to controllers via {@code @AuthenticationPrincipal}. Carries the IdP-side
|
||||
* identifier ({@code idpUserId}, JWT {@code sub} claim) plus any claims a controller is likely to
|
||||
* want without reaching into the raw Jwt.
|
||||
*/
|
||||
public record AuthenticatedPrincipal(String idpUserId, String email, Set<String> roles) {
|
||||
|
||||
public AuthenticatedPrincipal {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
}
|
||||
|
||||
public boolean hasRole(String role) {
|
||||
return roles.contains(role);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AccessDeniedHandler} that maps an authorization failure (valid token,
|
||||
* insufficient permission) to {@code AUTHZ_INSUFFICIENT_PERMISSION} (403) and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAccessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAccessDenied(accessDeniedException));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AuthenticationEntryPoint} that classifies an authentication failure into a
|
||||
* fine-grained {@link dev.caskeleton.shared.error.OperationalError} and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAuthenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAuthentication(authException));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
|
||||
|
||||
/**
|
||||
* Custom {@link JwtDecoder} for the resource server with an explicit validator chain: timestamp
|
||||
* (60s clock skew) + issuer, plus an optional audience check when configured. JWKS discovery is
|
||||
* deferred via {@link SupplierJwtDecoder} so startup does not require the IdP to be reachable. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
public class JwtDecoderConfig {
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(SecuritySettings settings) {
|
||||
// Lazy: JWKS discovery happens on first decode, not at startup.
|
||||
return new SupplierJwtDecoder(
|
||||
() -> {
|
||||
NimbusJwtDecoder decoder =
|
||||
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
|
||||
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
|
||||
return decoder;
|
||||
});
|
||||
}
|
||||
|
||||
/** The explicit validator chain: timestamp (60s skew) + issuer + optional audience. */
|
||||
static OAuth2TokenValidator<Jwt> jwtValidator(String issuerUri, String audience) {
|
||||
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
|
||||
validators.add(new JwtTimestampValidator(Duration.ofSeconds(60)));
|
||||
validators.add(new JwtIssuerValidator(issuerUri));
|
||||
if (audience != null && !audience.isBlank()) {
|
||||
validators.add(audienceValidator(audience));
|
||||
}
|
||||
return new DelegatingOAuth2TokenValidator<>(validators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> audienceValidator(String audience) {
|
||||
return jwt -> {
|
||||
if (jwt.getAudience() != null && jwt.getAudience().contains(audience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
OAuth2Error error = new OAuth2Error("invalid_token", "The aud claim is not valid", null);
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
};
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Maps an OIDC JWT to a JwtAuthenticationToken whose principal is our {@link
|
||||
* AuthenticatedPrincipal}. We pull {@code sub} as the IdP user id and union Keycloak-style {@code
|
||||
* realm_access.roles} with {@code resource_access[*].roles} into a single role set. Roles also
|
||||
* become Spring authorities (ROLE_*).
|
||||
*/
|
||||
@Component
|
||||
public class JwtToAuthenticatedPrincipalConverter
|
||||
implements Converter<Jwt, AbstractAuthenticationToken> {
|
||||
|
||||
@Override
|
||||
public AbstractAuthenticationToken convert(Jwt jwt) {
|
||||
Set<String> roles = extractRoles(jwt);
|
||||
String email = jwt.getClaimAsString("email");
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal(jwt.getSubject(), email, roles);
|
||||
Collection<GrantedAuthority> authorities =
|
||||
roles.stream()
|
||||
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase()))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
return new AuthenticatedJwtToken(jwt, authorities, principal);
|
||||
}
|
||||
|
||||
private Set<String> extractRoles(Jwt jwt) {
|
||||
Set<String> roles = new HashSet<>();
|
||||
|
||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||
if (realmAccess != null) {
|
||||
Object r = realmAccess.get("roles");
|
||||
if (r instanceof Collection<?> col) {
|
||||
col.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
for (Object client : resourceAccess.values()) {
|
||||
if (client instanceof Map<?, ?> clientMap
|
||||
&& clientMap.get("roles") instanceof Collection<?> rolesCol) {
|
||||
rolesCol.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic OIDC "roles" claim as a fallback
|
||||
List<String> flat = jwt.getClaimAsStringList("roles");
|
||||
if (flat != null) {
|
||||
roles.addAll(flat);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* JwtAuthenticationToken whose {@link #getPrincipal()} is our domain-oriented record instead of
|
||||
* the raw Jwt. Both are kept available — controllers usually want the record; filters/loggers can
|
||||
* still pull the Jwt via {@link #getToken()}.
|
||||
*/
|
||||
public static final class AuthenticatedJwtToken extends JwtAuthenticationToken {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
// transient: the principal is reconstructed by the converter on each authentication,
|
||||
// never round-tripped through Java serialization. See README for the design rationale.
|
||||
private final transient AuthenticatedPrincipal principal;
|
||||
|
||||
AuthenticatedJwtToken(
|
||||
Jwt jwt,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
AuthenticatedPrincipal principal) {
|
||||
super(jwt, authorities);
|
||||
this.principal = principal;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final SecuritySettings securitySettings;
|
||||
private final CorsSettings corsSettings;
|
||||
private final JwtToAuthenticatedPrincipalConverter jwtConverter;
|
||||
|
||||
public SecurityConfig(
|
||||
SecuritySettings securitySettings,
|
||||
CorsSettings corsSettings,
|
||||
JwtToAuthenticatedPrincipalConverter jwtConverter) {
|
||||
this.securitySettings = securitySettings;
|
||||
this.corsSettings = corsSettings;
|
||||
this.jwtConverter = jwtConverter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityErrorClassifier securityErrorClassifier() {
|
||||
return new SecurityErrorClassifier();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationEntryPoint authenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAuthenticationEntryPoint(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AccessDeniedHandler accessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(
|
||||
HttpSecurity http,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
AccessDeniedHandler accessDeniedHandler)
|
||||
throws Exception {
|
||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
|
||||
// owns the cache header policy. See README for the design rationale.
|
||||
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
auth -> {
|
||||
if (publicPaths.length > 0) {
|
||||
auth.requestMatchers(publicPaths).permitAll();
|
||||
}
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
// The entry point and access-denied handler are set on both exceptionHandling and
|
||||
// oauth2ResourceServer so every filter resolves to the same Envelope writer.
|
||||
// See README for the design rationale.
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler))
|
||||
.oauth2ResourceServer(
|
||||
oauth ->
|
||||
oauth
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
if (!corsSettings.enabled()) {
|
||||
return source; // no patterns registered -> Spring uses null config -> CORS inactive
|
||||
}
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(corsSettings.allowedOrigins());
|
||||
cfg.setAllowedMethods(corsSettings.allowedMethods());
|
||||
cfg.setAllowedHeaders(corsSettings.allowedHeaders());
|
||||
cfg.setAllowCredentials(corsSettings.allowCredentials());
|
||||
cfg.setMaxAge(corsSettings.maxAgeSeconds());
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.Locale;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
|
||||
/**
|
||||
* Classifies a resource-server security failure into a fine-grained {@link OperationalError} by
|
||||
* inspecting the exception graph and validator/Nimbus message text. Unmapped failures fall back to
|
||||
* the generic, safe {@code AUTH_TOKEN_MALFORMED} (401) rather than a 500. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class SecurityErrorClassifier {
|
||||
|
||||
/** Classifies an authentication (401-family) failure reaching the AuthenticationEntryPoint. */
|
||||
public OperationalError classifyAuthentication(AuthenticationException ex) {
|
||||
if (ex instanceof OAuth2AuthenticationException oauth) {
|
||||
OperationalError byCause = classifyByCause(oauth.getCause());
|
||||
if (byCause != null) {
|
||||
return byCause;
|
||||
}
|
||||
OperationalError byError = classifyByText(describe(oauth.getError()));
|
||||
return byError != null ? byError : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
if (ex instanceof InsufficientAuthenticationException) {
|
||||
return OperationalError.AUTH_TOKEN_MISSING;
|
||||
}
|
||||
// Unmapped authentication failure: a generic, safe 401 — never an unclassified 500.
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
|
||||
/** Classifies an authorization (403-family) failure reaching the AccessDeniedHandler. */
|
||||
public OperationalError classifyAccessDenied(AccessDeniedException ex) {
|
||||
return OperationalError.AUTHZ_INSUFFICIENT_PERMISSION;
|
||||
}
|
||||
|
||||
private OperationalError classifyByCause(Throwable cause) {
|
||||
if (cause == null) {
|
||||
return null;
|
||||
}
|
||||
if (cause instanceof JwtValidationException validation) {
|
||||
// A JWKS retrieval failure can surface wrapped in validation errors too.
|
||||
OperationalError fromText = null;
|
||||
for (OAuth2Error error : validation.getErrors()) {
|
||||
OperationalError mapped = classifyByText(describe(error));
|
||||
fromText = higherPriority(fromText, mapped);
|
||||
}
|
||||
return fromText != null ? fromText : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
// BadJwtException extends JwtException; both carry the decode/signature/kid/JWKS message.
|
||||
return classifyByText(cause.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single validator/decoder message to a code. Ordered, narrow heuristics; returns {@code
|
||||
* null} when nothing matches so callers can fall back.
|
||||
*/
|
||||
private OperationalError classifyByText(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String m = raw.toLowerCase(Locale.ROOT);
|
||||
// JWKS endpoint outage is a transient dependency failure (check before generic decode text).
|
||||
if (m.contains("jwk set") || m.contains("jwk source") || m.contains("jwkset")) {
|
||||
return OperationalError.AUTH_JWKS_UNAVAILABLE;
|
||||
}
|
||||
if (m.contains("expired") || m.contains("jwt expired")) {
|
||||
return OperationalError.AUTH_TOKEN_EXPIRED;
|
||||
}
|
||||
if (m.contains("iss claim") || m.contains("issuer")) {
|
||||
return OperationalError.AUTH_ISSUER_MISMATCH;
|
||||
}
|
||||
if (m.contains("aud claim") || m.contains("audience")) {
|
||||
return OperationalError.AUTH_AUDIENCE_MISMATCH;
|
||||
}
|
||||
if (m.contains("signature") || m.contains("signed jwt rejected")) {
|
||||
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
|
||||
}
|
||||
if (m.contains("kid") || m.contains("matching key") || m.contains("key id")) {
|
||||
return OperationalError.AUTH_KID_UNKNOWN;
|
||||
}
|
||||
if (m.contains("malformed")
|
||||
|| m.contains("invalid jwt")
|
||||
|| m.contains("invalid compact")
|
||||
|| m.contains("decode")) {
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence among multiple simultaneous validation failures. Expiry is the most common
|
||||
* operational case and is reported first; then issuer, then audience, then anything else.
|
||||
*/
|
||||
private OperationalError higherPriority(OperationalError current, OperationalError candidate) {
|
||||
if (candidate == null) {
|
||||
return current;
|
||||
}
|
||||
if (current == null) {
|
||||
return candidate;
|
||||
}
|
||||
return rank(candidate) < rank(current) ? candidate : current;
|
||||
}
|
||||
|
||||
private int rank(OperationalError e) {
|
||||
return switch (e) {
|
||||
case AUTH_TOKEN_EXPIRED -> 0;
|
||||
case AUTH_ISSUER_MISMATCH -> 1;
|
||||
case AUTH_AUDIENCE_MISMATCH -> 2;
|
||||
default -> 3;
|
||||
};
|
||||
}
|
||||
|
||||
private String describe(OAuth2Error error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String description = error.getDescription();
|
||||
return description != null ? description : error.getErrorCode();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Web-adapter implementation of the application {@link AuthorizationPort}.
|
||||
*
|
||||
* <p>Resolves the caller's raw roles to an effective permission set via {@link
|
||||
* RolePermissionRegistry} and denies (with {@link AuthorizationDeniedException}) when the required
|
||||
* permission is absent. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationAdapter implements AuthorizationPort {
|
||||
|
||||
private final RolePermissionRegistry registry;
|
||||
|
||||
public AuthorizationAdapter(RolePermissionRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requirePermission(AuthorizationPrincipal principal, Permission required) {
|
||||
Set<Permission> effective = registry.effectivePermissions(principal.roles());
|
||||
if (!effective.contains(required)) {
|
||||
throw new AuthorizationDeniedException(principal.subject(), required);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.Pointcut;
|
||||
import org.springframework.aop.support.Pointcuts;
|
||||
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Role;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
|
||||
/**
|
||||
* Wires the {@link RequiresPermission} enforcement point into Spring method security.
|
||||
*
|
||||
* <p>{@code @EnableMethodSecurity(prePostEnabled = false)} enables the method-security
|
||||
* infrastructure without the {@code @PreAuthorize}/{@code @PostAuthorize} interceptors, leaving the
|
||||
* custom advisor below as the only authorization advice. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableMethodSecurity(prePostEnabled = false)
|
||||
public class MethodSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
|
||||
AuthorizationManager<MethodInvocation> manager =
|
||||
new RequiresPermissionAuthorizationManager(authorizationPort);
|
||||
|
||||
Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
|
||||
Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class);
|
||||
Pointcut pointcut = Pointcuts.union(onMethod, onClass);
|
||||
|
||||
return new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
/**
|
||||
* Spring-aware enforcement mechanism for {@link RequiresPermission}.
|
||||
*
|
||||
* <p>Reads the {@link RequiresPermission} annotation off the intercepted method (or its declaring
|
||||
* type), maps the current {@link Authentication} to the framework-free {@link
|
||||
* AuthorizationPrincipal}, and delegates the decision to the application {@link AuthorizationPort}.
|
||||
* A denial from the port becomes a denied {@link AuthorizationDecision}; an absent annotation
|
||||
* returns {@code null} to abstain. See README for the design rationale.
|
||||
*/
|
||||
public final class RequiresPermissionAuthorizationManager
|
||||
implements AuthorizationManager<MethodInvocation> {
|
||||
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) {
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthorizationResult authorize(
|
||||
Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||
RequiresPermission annotation = findAnnotation(invocation);
|
||||
if (annotation == null) {
|
||||
return null; // not guarded by this manager — abstain
|
||||
}
|
||||
Permission required = Permission.parse(annotation.value());
|
||||
|
||||
Authentication auth = authentication.get();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
try {
|
||||
authorizationPort.requirePermission(toPrincipal(auth), required);
|
||||
return new AuthorizationDecision(true);
|
||||
} catch (AuthorizationDeniedException denied) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
}
|
||||
|
||||
private RequiresPermission findAnnotation(MethodInvocation invocation) {
|
||||
Method method = invocation.getMethod();
|
||||
RequiresPermission onMethod = AnnotationUtils.findAnnotation(method, RequiresPermission.class);
|
||||
if (onMethod != null) {
|
||||
return onMethod;
|
||||
}
|
||||
Class<?> targetClass =
|
||||
invocation.getThis() != null
|
||||
? AopUtils.getTargetClass(invocation.getThis())
|
||||
: method.getDeclaringClass();
|
||||
return AnnotationUtils.findAnnotation(targetClass, RequiresPermission.class);
|
||||
}
|
||||
|
||||
private AuthorizationPrincipal toPrincipal(Authentication auth) {
|
||||
if (auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return new AuthorizationPrincipal(user.idpUserId(), user.roles());
|
||||
}
|
||||
// Any other principal type carries no resolvable roles → fail-closed.
|
||||
return new AuthorizationPrincipal(auth.getName(), Set.of());
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* App-side role→permission mapping source.
|
||||
*
|
||||
* <p>Bound from {@code ca-skeleton.authz.role-permissions.<role> = [resource:action, ...]}. Keys
|
||||
* are <em>raw</em> IdP role names (no {@code ROLE_} prefix), e.g.:
|
||||
*
|
||||
* <pre>
|
||||
* ca-skeleton:
|
||||
* authz:
|
||||
* role-permissions:
|
||||
* user: [worklog:read, worklog:write]
|
||||
* admin: [worklog:read, worklog:write, worklog:close]
|
||||
* </pre>
|
||||
*
|
||||
* <p>See README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.authz")
|
||||
public record RolePermissionPolicy(Map<String, List<String>> rolePermissions) {
|
||||
|
||||
public RolePermissionPolicy {
|
||||
rolePermissions =
|
||||
rolePermissions == null
|
||||
? Map.of()
|
||||
: rolePermissions.entrySet().stream()
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey, entry -> List.copyOf(entry.getValue())));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Resolves a caller's raw roles to an effective {@link Permission} set.
|
||||
*
|
||||
* <p>Built once from {@link RolePermissionPolicy} at startup. Role keys are normalized to lower
|
||||
* case so a lookup is case-insensitive. Permissions are the explicitly enumerated set per role;
|
||||
* wildcards are unsupported. An unknown role, an empty role set, or an empty registry all resolve
|
||||
* to zero permissions. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class RolePermissionRegistry {
|
||||
|
||||
private final Map<String, Set<Permission>> permissionsByRole;
|
||||
|
||||
public RolePermissionRegistry(RolePermissionPolicy properties) {
|
||||
Map<String, Set<Permission>> resolved = new HashMap<>();
|
||||
properties
|
||||
.rolePermissions()
|
||||
.forEach(
|
||||
(role, tokens) -> {
|
||||
Set<Permission> permissions =
|
||||
tokens.stream().map(Permission::parse).collect(Collectors.toUnmodifiableSet());
|
||||
resolved.put(normalize(role), permissions);
|
||||
});
|
||||
this.permissionsByRole = Map.copyOf(resolved);
|
||||
}
|
||||
|
||||
/** Union of the permissions granted by each of {@code roles}; empty if none/unknown. */
|
||||
public Set<Permission> effectivePermissions(Set<String> roles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return roles.stream()
|
||||
.filter(role -> role != null && !role.isBlank())
|
||||
.map(role -> permissionsByRole.getOrDefault(normalize(role), Set.of()))
|
||||
.flatMap(Set::stream)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
return role.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Weak-ETag derivation and conditional-request matching for HTTP-layer optimistic concurrency /
|
||||
* cache validation. See README for the design rationale.
|
||||
*
|
||||
* <p>An entity's optimistic-lock version is the ETag source: {@link #weakFromVersion} yields {@code
|
||||
* W/"<version>"}. Reads emit it as the {@code ETag} header; a write carrying {@code If-Match} is
|
||||
* accepted only when {@link #matches} is {@code true}, otherwise the controller raises {@link
|
||||
* PreconditionFailedException} (→ 412); a read carrying {@code If-None-Match} that {@link #matches}
|
||||
* returns 304 (no body).
|
||||
*/
|
||||
public final class ETags {
|
||||
|
||||
private static final String WILDCARD = "*";
|
||||
|
||||
private ETags() {}
|
||||
|
||||
/** {@code W/"<version>"} weak validator from an optimistic-lock version. */
|
||||
public static String weakFromVersion(long version) {
|
||||
return "W/\"" + version + "\"";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient conditional match: {@code true} when {@code header} is {@code *} or any comma-separated
|
||||
* candidate's opaque value equals {@code etag}'s opaque value. Null/blank header → {@code false}
|
||||
* (no precondition supplied).
|
||||
*/
|
||||
public static boolean matches(
|
||||
String header, String etag) { // e.g. If-None-Match: W/"3" on reads, If-Match: W/"3" on writes
|
||||
if (header == null || header.isBlank() || etag == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = header.trim();
|
||||
if (WILDCARD.equals(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
String target = opaque(etag);
|
||||
for (String candidate : trimmed.split(",")) {
|
||||
if (opaque(candidate).equals(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Strips the {@code W/} weak marker and surrounding double quotes. */
|
||||
private static String opaque(String raw) {
|
||||
String v = raw.trim();
|
||||
if (v.startsWith("W/")) {
|
||||
v = v.substring(2).trim();
|
||||
}
|
||||
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Raised when a write request's {@code If-Match} validator does not match the current resource
|
||||
* ETag. The global handler maps it to {@code OperationalError.PRECONDITION_FAILED} (HTTP 412). See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class PreconditionFailedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PreconditionFailedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import org.openapitools.jackson.nullable.JsonNullable;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.databind.BeanProperty;
|
||||
import tools.jackson.databind.DeserializationContext;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.ValueDeserializer;
|
||||
import tools.jackson.databind.ValueSerializer;
|
||||
import tools.jackson.databind.module.SimpleModule;
|
||||
|
||||
/**
|
||||
* Registers Jackson 3 handlers for {@link JsonNullable}. The upstream jackson-databind-nullable
|
||||
* module is still Jackson 2 based, so the template keeps a narrow local adapter for PATCH request
|
||||
* DTOs.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonNullableConfig {
|
||||
|
||||
@Bean
|
||||
public SimpleModule jsonNullableModule() {
|
||||
SimpleModule module = new SimpleModule("JsonNullableJackson3Module");
|
||||
module.addDeserializer(JsonNullable.class, new JsonNullableValueDeserializer());
|
||||
addJsonNullableSerializer(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static void addJsonNullableSerializer(SimpleModule module) {
|
||||
module.addSerializer((Class) JsonNullable.class, new JsonNullableValueSerializer());
|
||||
}
|
||||
|
||||
static final class JsonNullableValueDeserializer extends ValueDeserializer<JsonNullable<Object>> {
|
||||
|
||||
private final JavaType valueType;
|
||||
private final ValueDeserializer<Object> valueDeserializer;
|
||||
|
||||
JsonNullableValueDeserializer() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
private JsonNullableValueDeserializer(
|
||||
JavaType valueType, ValueDeserializer<Object> valueDeserializer) {
|
||||
this.valueType = valueType;
|
||||
this.valueDeserializer = valueDeserializer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValueDeserializer<?> createContextual(
|
||||
DeserializationContext ctxt, BeanProperty property) {
|
||||
JavaType contextualType =
|
||||
property == null ? ctxt.constructType(Object.class) : property.getType();
|
||||
JavaType referencedType =
|
||||
contextualType.containedTypeCount() == 0
|
||||
? ctxt.constructType(Object.class)
|
||||
: contextualType.containedTypeOrUnknown(0);
|
||||
return new JsonNullableValueDeserializer(
|
||||
referencedType, ctxt.findContextualValueDeserializer(referencedType, property));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNullable<Object> deserialize(JsonParser parser, DeserializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (parser.currentToken() == JsonToken.VALUE_NULL) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
Object value =
|
||||
valueDeserializer == null
|
||||
? ctxt.readValue(
|
||||
parser, valueType == null ? ctxt.constructType(Object.class) : valueType)
|
||||
: valueDeserializer.deserialize(parser, ctxt);
|
||||
return JsonNullable.of(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNullValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getAbsentValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.undefined();
|
||||
}
|
||||
}
|
||||
|
||||
static final class JsonNullableValueSerializer extends ValueSerializer<JsonNullable<Object>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonNullable<Object> value, JsonGenerator generator, SerializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (value == null || !value.isPresent()) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
ctxt.writeValue(generator, value.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class PresentationWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final PresentationSettings settings;
|
||||
|
||||
public PresentationWebConfig(PresentationSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
String prefix = settings.apiBasePath();
|
||||
if (prefix == null || prefix.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
configurer.addPathPrefix(prefix, c -> true);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.controller;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/healthcheck")
|
||||
public class HealthcheckController {
|
||||
|
||||
@GetMapping
|
||||
public Envelope<Map<String, String>> healthcheck() {
|
||||
return Envelope.ok(Map.of("status", "UP"), ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Opaque, signed, time-bounded pagination cursor codec. A cursor is {@code base64url(iat + ":" +
|
||||
* payload)} plus an HMAC-SHA256 signature, so it is URL-safe, tamper-evident, and expires after a
|
||||
* fixed TTL (24h). Clients MUST treat the token as opaque. See README for the design rationale.
|
||||
*/
|
||||
public final class CursorCodec {
|
||||
|
||||
/** Default cursor TTL (24h). */
|
||||
public static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
||||
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final char SEP = '.';
|
||||
private static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DEC = Base64.getUrlDecoder();
|
||||
|
||||
private final byte[] key;
|
||||
private final Duration ttl;
|
||||
|
||||
public CursorCodec(byte[] key, Duration ttl) {
|
||||
if (key == null || key.length < 16) {
|
||||
throw new IllegalArgumentException("cursor HMAC key must be at least 16 bytes");
|
||||
}
|
||||
this.key = key.clone();
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
/** Dev / test factory — NOT for production. */
|
||||
public static CursorCodec withDevKey() {
|
||||
return new CursorCodec(
|
||||
"ca-skeleton-dev-cursor-key-0001".getBytes(StandardCharsets.UTF_8), DEFAULT_TTL);
|
||||
}
|
||||
|
||||
/** Encodes an opaque payload string + issue instant into a signed URL-safe token. */
|
||||
public String encode(String payload, Instant issuedAt) {
|
||||
String body = issuedAt.getEpochSecond() + ":" + payload;
|
||||
String b64Body = ENC.encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return b64Body + SEP + ENC.encodeToString(sign(b64Body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies signature + TTL and returns the original payload, or throws {@link CursorException}.
|
||||
*/
|
||||
public String decode(String token, Instant now) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new CursorException("cursor token is missing");
|
||||
}
|
||||
int dot = token.lastIndexOf(SEP);
|
||||
if (dot <= 0 || dot == token.length() - 1) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
String b64Body = token.substring(0, dot);
|
||||
byte[] presented;
|
||||
byte[] expected;
|
||||
String body;
|
||||
try {
|
||||
presented = DEC.decode(token.substring(dot + 1));
|
||||
expected = sign(b64Body);
|
||||
body = new String(DEC.decode(b64Body), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
if (!MessageDigest.isEqual(expected, presented)) {
|
||||
throw new CursorException("cursor token signature is invalid");
|
||||
}
|
||||
int colon = body.indexOf(':');
|
||||
if (colon < 0) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
long issuedAtEpoch;
|
||||
try {
|
||||
issuedAtEpoch = Long.parseLong(body.substring(0, colon));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
if (now.getEpochSecond() - issuedAtEpoch > ttl.toSeconds()) {
|
||||
throw new CursorException("cursor token has expired");
|
||||
}
|
||||
return body.substring(colon + 1);
|
||||
}
|
||||
|
||||
private byte[] sign(String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, HMAC_ALGORITHM));
|
||||
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HMAC computation failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
/**
|
||||
* Raised when an opaque pagination cursor fails verification — tampered HMAC signature, malformed
|
||||
* encoding, or expired TTL. Controllers map it to 400 VALIDATION_FAILED and advise re-requesting
|
||||
* the first page.
|
||||
*/
|
||||
public class CursorException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CursorException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.BulkEnvelope;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
/**
|
||||
* Wraps every JSON controller response in {@link Envelope} unless it is already an envelope
|
||||
* variant, so the wire shape is always {@code {success, data | error, traceId}}. See README for the
|
||||
* design rationale.
|
||||
*
|
||||
* <p>Skipped: already-{@link Envelope}/{@link BulkEnvelope} bodies, null/void (DELETE 204),
|
||||
* non-JSON content types.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class EnvelopeBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(
|
||||
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
if (body instanceof Envelope<?> || body instanceof BulkEnvelope<?>) {
|
||||
return body;
|
||||
}
|
||||
if (selectedContentType != null && !MediaType.APPLICATION_JSON.includes(selectedContentType)) {
|
||||
return body;
|
||||
}
|
||||
return Envelope.ok(body, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
|
||||
final class ClientSafeErrorMessages {
|
||||
|
||||
private ClientSafeErrorMessages() {}
|
||||
|
||||
static String forPersistence(Category category) {
|
||||
return switch (category) {
|
||||
case TRANSIENT_DEPENDENCY -> "Service temporarily unavailable, please retry later";
|
||||
case CONFLICT -> "Request conflicted with the current state, please retry";
|
||||
case DATA_INTEGRITY -> "Request violates a data constraint";
|
||||
default -> "Internal server error";
|
||||
};
|
||||
}
|
||||
|
||||
static String forDependency(ApiErrorCode code) {
|
||||
return switch (code.code()) {
|
||||
case "DEPENDENCY_TIMEOUT" -> "Upstream service did not respond in time, please retry";
|
||||
case "DEPENDENCY_CONNECT_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_DNS_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_4XX_CLIENT" -> "Upstream service rejected the request";
|
||||
case "DEPENDENCY_5XX_SERVER" -> "Upstream service error, please retry";
|
||||
case "DEPENDENCY_CIRCUIT_OPEN" ->
|
||||
"Upstream service temporarily unavailable, please retry later";
|
||||
default -> forPersistence(code.category());
|
||||
};
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.response.ApiError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
/**
|
||||
* Builds a failure {@link Envelope} response from any {@link ApiErrorCode}, mapping the
|
||||
* framework-neutral {@code int httpStatus()} to Spring {@link HttpStatus}, carrying {@code
|
||||
* error.category}, and lifting the {@code meta} object (request/trace/correlation ids) from MDC via
|
||||
* {@link ResponseMetaFactory}.
|
||||
*/
|
||||
public final class ErrorResponseFactory {
|
||||
|
||||
private ErrorResponseFactory() {}
|
||||
|
||||
public static ResponseEntity<Envelope<Void>> envelope(
|
||||
ApiErrorCode code, String message, Object details) {
|
||||
return ResponseEntity.status(HttpStatus.valueOf(code.httpStatus()))
|
||||
.body(body(code, message, details));
|
||||
}
|
||||
|
||||
/** Body-only variant for ResponseEntityExceptionHandler hooks that set status separately. */
|
||||
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
|
||||
ApiError err =
|
||||
details == null
|
||||
? ApiError.of(code.code(), code.category().name(), message, code.retryable())
|
||||
: ApiError.withDetails(
|
||||
code.code(), code.category().name(), message, code.retryable(), details);
|
||||
return Envelope.failure(err, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.SecurityErrorClassifier;
|
||||
import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException;
|
||||
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyInFlightException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
/**
|
||||
* Skeleton-wide base error → {@link Envelope} converter. Handles operational, transport, and
|
||||
* security exceptions only; domain exceptions are handled by a separate
|
||||
* {@code @RestControllerAdvice} in the consuming module. See README for the design rationale.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** Stateless classifier shared with the filter-layer access-denied handler. */
|
||||
private static final SecurityErrorClassifier ACCESS_DENIED_CLASSIFIER =
|
||||
new SecurityErrorClassifier();
|
||||
|
||||
/** Records span errors via a tracer-neutral seam (default {@link SpanErrorRecorder#NOOP}). */
|
||||
private final SpanErrorRecorder spanErrorRecorder;
|
||||
|
||||
/**
|
||||
* Spring entry point. Self-defaults to {@link SpanErrorRecorder#NOOP} when no {@code
|
||||
* SpanErrorRecorder} bean is present. See README for the design rationale.
|
||||
*/
|
||||
@Autowired
|
||||
public GlobalExceptionHandler(ObjectProvider<SpanErrorRecorder> spanErrorRecorderProvider) {
|
||||
this(spanErrorRecorderProvider.getIfAvailable(() -> SpanErrorRecorder.NOOP));
|
||||
}
|
||||
|
||||
/** Direct constructor for tests and explicit wiring (e.g. a capturing recorder). */
|
||||
public GlobalExceptionHandler(SpanErrorRecorder spanErrorRecorder) {
|
||||
this.spanErrorRecorder = spanErrorRecorder;
|
||||
}
|
||||
|
||||
@ExceptionHandler(MappingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fail-fast for a disabled optional adapter that was invoked → 500 {@link
|
||||
* OperationalError#ADAPTER_DISABLED}. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AdapterDisabledException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleAdapterDisabled(AdapterDisabledException ex) {
|
||||
log.error(
|
||||
"disabled optional adapter invoked at runtime: adapter={} (Layer 3 fail-fast)",
|
||||
ex.adapterName(),
|
||||
ex);
|
||||
return ErrorResponseFactory.envelope(OperationalError.ADAPTER_DISABLED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.BAD_PARAMETER, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
List<Map<String, Object>> violations =
|
||||
ex.getConstraintViolations().stream()
|
||||
.map(
|
||||
v ->
|
||||
Map.<String, Object>of(
|
||||
"field", v.getPropertyPath().toString(),
|
||||
"message", v.getMessage()))
|
||||
.toList();
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, "Request validation failed", violations);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
Map<String, Object> details =
|
||||
ex.getRequiredType() == null
|
||||
? null
|
||||
: Map.of("expectedType", ex.getRequiredType().getSimpleName());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.BAD_PARAMETER,
|
||||
"Parameter '" + ex.getName() + "' has invalid value '" + ex.getValue() + "'",
|
||||
details);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidBearerTokenException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.INVALID_TOKEN, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.UNAUTHENTICATED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a method-security {@link AccessDeniedException} that escaped the controller, delegating
|
||||
* to {@link SecurityErrorClassifier} for the fine-grained authorization code. See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleForbidden(AccessDeniedException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex), ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/** Handles a failed {@code If-Match} precondition → 412 PRECONDITION_FAILED. */
|
||||
@ExceptionHandler(PreconditionFailedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePreconditionFailed(PreconditionFailedException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.PRECONDITION_FAILED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an out-of-bounds pagination / sort / filter parameter → 400 VALIDATION_FAILED carrying
|
||||
* the offending field + reason code.
|
||||
*/
|
||||
@ExceptionHandler(PageValidationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePageValidation(PageValidationException ex) {
|
||||
Map<String, Object> details = Map.of("field", ex.field(), "code", ex.reasonCode());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, ex.getMessage(), details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a tampered / expired / malformed opaque cursor → 400 VALIDATION_FAILED advising the
|
||||
* client to re-request the first page.
|
||||
*/
|
||||
@ExceptionHandler(CursorException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleCursor(CursorException ex) {
|
||||
Map<String, Object> details = Map.of("field", "cursor", "code", "CURSOR_INVALID");
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
ex.getMessage() + "; re-request the first page",
|
||||
details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a duplicate request whose original is still in flight → 409 IDEMPOTENT_IN_FLIGHT with a
|
||||
* fixed client-safe message. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyInFlightException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentInFlight(IdempotencyInFlightException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_IN_FLIGHT,
|
||||
"A previous identical request is still being processed, please poll for result",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an {@code Idempotency-Key} reused with a different body → 422
|
||||
* IDEMPOTENT_REQUEST_MISMATCH with a client-safe message only.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyRequestMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentMismatch(
|
||||
IdempotencyRequestMismatchException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_REQUEST_MISMATCH,
|
||||
"Idempotency key reused with different request body",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an idempotency key applied without a resolvable scope → 400 VALIDATION_FAILED. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyScopeMissingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotencyScopeMissing(
|
||||
IdempotencyScopeMissingException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
"Idempotency key cannot be applied to this request",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link PersistenceFailureException}: its {@link
|
||||
* PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* category-derived safe string and the raw cause is logged server-side. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@ExceptionHandler(PersistenceFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"persistence failure classified as {} (category={}, retryable={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable(),
|
||||
ex);
|
||||
spanErrorRecorder.recordException(ex, code.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link DependencyFailureException}: its {@link
|
||||
* DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* per-code safe string and a {@code Retry-After} header is attached via {@link RetryAfterAdvisor}
|
||||
* when applicable. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(DependencyFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleDependencyFailure(DependencyFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"dependency failure classified as {} (category={}, retryable={}, dependency={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable(),
|
||||
ex.dependencyName(),
|
||||
ex);
|
||||
spanErrorRecorder.recordException(ex, code.code());
|
||||
String message = ClientSafeErrorMessages.forDependency(code);
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, message, null);
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.status(code.httpStatus());
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(seconds -> builder.header(ApiHeaders.RETRY_AFTER, String.valueOf(seconds)));
|
||||
return builder.body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnknown(Exception ex, WebRequest req) {
|
||||
log.error("unhandled exception on {}", req.getDescription(false), ex);
|
||||
spanErrorRecorder.recordException(ex, OperationalError.INTERNAL_ERROR.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.INTERNAL_ERROR, "Internal server error", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
List<Map<String, Object>> fields =
|
||||
ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(
|
||||
fe -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("field", fe.getField());
|
||||
m.put("rejectedValue", String.valueOf(fe.getRejectedValue()));
|
||||
m.put("message", fe.getDefaultMessage());
|
||||
return m;
|
||||
})
|
||||
.toList();
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body failed validation", fields),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMessageNotReadable(
|
||||
HttpMessageNotReadableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
Map<String, Object> details =
|
||||
ex.getCause() != null ? Map.of("cause", ex.getCause().getClass().getSimpleName()) : null;
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body is malformed or unparsable", details),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
|
||||
HttpRequestMethodNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 405 carries the `Allow` header listing the supported methods.
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
if (ex.getSupportedHttpMethods() != null) {
|
||||
responseHeaders.setAllow(new LinkedHashSet<>(ex.getSupportedHttpMethods()));
|
||||
}
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedHttpMethods() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMethods",
|
||||
ex.getSupportedHttpMethods().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.METHOD_NOT_ALLOWED,
|
||||
"HTTP method " + ex.getMethod() + " not allowed for this route",
|
||||
details),
|
||||
responseHeaders,
|
||||
HttpStatusCode.valueOf(OperationalError.METHOD_NOT_ALLOWED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 415: request body format unsupported.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.UNSUPPORTED_MEDIA_TYPE,
|
||||
"Content-Type " + ex.getContentType() + " is not supported",
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.UNSUPPORTED_MEDIA_TYPE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMaxUploadSizeExceededException(
|
||||
MaxUploadSizeExceededException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// An oversized request body classifies as 413 inside the envelope.
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.PAYLOAD_TOO_LARGE,
|
||||
"Request payload exceeds the maximum allowed size",
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.PAYLOAD_TOO_LARGE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotAcceptable(
|
||||
HttpMediaTypeNotAcceptableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 406: no representation matches the Accept header.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.NOT_ACCEPTABLE,
|
||||
"No acceptable representation for the requested Accept header",
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.NOT_ACCEPTABLE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleNoHandlerFoundException(
|
||||
NoHandlerFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.ROUTE_NOT_FOUND,
|
||||
"No handler for " + ex.getHttpMethod() + " " + ex.getRequestURL(),
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus()));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Applies the skeleton's default HTTP cache policy: every response gets {@code Cache-Control:
|
||||
* no-store} and a {@code Vary: Accept, Accept-Encoding, Authorization} header. Defaults are set
|
||||
* before the chain so a cacheable endpoint can opt in by returning a {@code ResponseEntity} whose
|
||||
* {@code Cache-Control} header overwrites the default. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
||||
public class CacheControlFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String DEFAULT_CACHE_CONTROL = "no-store";
|
||||
static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
|
||||
response.setHeader(ApiHeaders.VARY, DEFAULT_VARY);
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.HeaderSanitizer;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
||||
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
|
||||
import dev.caskeleton.shared.tracing.TraceParent;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
/**
|
||||
* Logs one structured line per HTTP request and threads the correlation ids onto MDC using the
|
||||
* snake_case keys of {@link MdcKeys}. Inbound {@code X-Request-Id} / {@code X-Correlation-Id} are
|
||||
* sanitized and length-capped before use; absent/blank values are server-generated. A valid inbound
|
||||
* W3C {@code traceparent} is adopted onto MDC ({@code trace_id} / {@code span_id}), otherwise a
|
||||
* fresh ROOT traceparent is generated; the resolved value is set on the response header. {@code
|
||||
* user_principal} is pseudonymized via {@link UserPrincipalPseudonymizerPort} before being placed
|
||||
* on MDC.
|
||||
*
|
||||
* <p>See README for the design rationale, including the fork landmine around the generated {@code
|
||||
* trace_id} when a real tracer is later wired in.
|
||||
*/
|
||||
@Component
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
|
||||
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_TRACEPARENT = "traceparent";
|
||||
private static final int MAX_ID_LENGTH = 200;
|
||||
|
||||
private final UserPrincipalPseudonymizerPort pseudonymizer;
|
||||
|
||||
public RequestLoggingFilter(UserPrincipalPseudonymizerPort pseudonymizer) {
|
||||
this.pseudonymizer = pseudonymizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String requestId = resolveOrGenerate(req.getHeader(HEADER_REQUEST_ID));
|
||||
String correlationId = resolveOrGenerate(req.getHeader(HEADER_CORRELATION_ID));
|
||||
res.setHeader(HEADER_REQUEST_ID, requestId);
|
||||
res.setHeader(HEADER_CORRELATION_ID, correlationId);
|
||||
|
||||
MDC.put(MdcKeys.REQUEST_ID, requestId);
|
||||
MDC.put(MdcKeys.CORRELATION_ID, correlationId);
|
||||
|
||||
// W3C traceparent: adopt inbound if valid, otherwise generate a fresh ROOT.
|
||||
TraceParent traceParent = resolveOrGenerateTraceParent(req.getHeader(HEADER_TRACEPARENT));
|
||||
MDC.put(MdcKeys.TRACE_ID, traceParent.traceId());
|
||||
MDC.put(MdcKeys.SPAN_ID, traceParent.spanId());
|
||||
res.setHeader(HEADER_TRACEPARENT, traceParent.toHeader());
|
||||
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
putUserPrincipalIfAvailable();
|
||||
long durationMs = (System.nanoTime() - start) / 1_000_000L;
|
||||
log.info(
|
||||
"http_request method={} uri_template={} status={} duration_ms={}",
|
||||
req.getMethod(),
|
||||
resolveUriTemplate(req),
|
||||
res.getStatus(),
|
||||
durationMs);
|
||||
MDC.remove(MdcKeys.REQUEST_ID);
|
||||
MDC.remove(MdcKeys.CORRELATION_ID);
|
||||
MDC.remove(MdcKeys.TRACE_ID);
|
||||
MDC.remove(MdcKeys.SPAN_ID);
|
||||
MDC.remove(MdcKeys.USER_PRINCIPAL);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanitize an inbound id header; generate a fresh one when absent/blank. */
|
||||
private static String resolveOrGenerate(String inbound) {
|
||||
String clean = HeaderSanitizer.sanitize(inbound, MAX_ID_LENGTH);
|
||||
return (clean == null || clean.isBlank()) ? UUID.randomUUID().toString() : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a W3C {@code traceparent} for this request: adopts a present, valid inbound header
|
||||
* unchanged, otherwise generates a fresh ROOT traceparent (32-hex {@code traceId}, 16-hex {@code
|
||||
* spanId}, {@code sampled=false}).
|
||||
*/
|
||||
private static TraceParent resolveOrGenerateTraceParent(String header) {
|
||||
return TraceParent.parse(header)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
String traceId = freshHex32();
|
||||
String spanId = freshHex16();
|
||||
return TraceParent.of(traceId, spanId, false);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns a 32-char lowercase hex string from a random UUID (dashes stripped). */
|
||||
private static String freshHex32() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 16-char lowercase hex span id (64 random bits), derived from a fresh UUID's
|
||||
* least-significant bits and zero-padded. See README for the design rationale.
|
||||
*/
|
||||
private static String freshHex16() {
|
||||
return String.format("%016x", UUID.randomUUID().getLeastSignificantBits());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the low-cardinality matched route template ({@code BEST_MATCHING_PATTERN_ATTRIBUTE}),
|
||||
* falling back to the raw request URI for unmatched requests (e.g. 404s).
|
||||
*/
|
||||
private static String resolveUriTemplate(HttpServletRequest req) {
|
||||
Object pattern = req.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
return pattern instanceof String s ? s : req.getRequestURI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pseudonymizes the authenticated user's raw principal via {@link UserPrincipalPseudonymizerPort}
|
||||
* before placing the value on the {@code user_principal} MDC key. The raw {@code idpUserId()} is
|
||||
* never written to MDC or logs.
|
||||
*/
|
||||
private void putUserPrincipalIfAvailable() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
String pseudo = pseudonymizer.pseudonymize(user.idpUserId());
|
||||
if (pseudo != null) {
|
||||
MDC.put(MdcKeys.USER_PRINCIPAL, pseudo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.inbound.web.http;
|
||||
|
||||
/**
|
||||
* HTTP header-name constants used across the inbound web adapter. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public final class ApiHeaders {
|
||||
|
||||
// versioning + idempotency name
|
||||
public static final String X_API_VERSION = "X-Api-Version";
|
||||
public static final String IDEMPOTENCY_KEY = "Idempotency-Key";
|
||||
|
||||
// conditional requests
|
||||
public static final String ETAG = "ETag";
|
||||
public static final String IF_MATCH = "If-Match";
|
||||
public static final String IF_NONE_MATCH = "If-None-Match";
|
||||
|
||||
// cache policy
|
||||
public static final String CACHE_CONTROL = "Cache-Control";
|
||||
public static final String VARY = "Vary";
|
||||
|
||||
// method / negotiation / LRO
|
||||
public static final String ALLOW = "Allow";
|
||||
public static final String LOCATION = "Location";
|
||||
public static final String RETRY_AFTER = "Retry-After";
|
||||
|
||||
// rate-limit signaling
|
||||
public static final String X_RATELIMIT_LIMIT = "X-RateLimit-Limit";
|
||||
public static final String X_RATELIMIT_REMAINING = "X-RateLimit-Remaining";
|
||||
public static final String X_RATELIMIT_RESET = "X-RateLimit-Reset";
|
||||
|
||||
// deep-offset deprecation marker
|
||||
public static final String DEPRECATION = "Deprecation";
|
||||
|
||||
// always-emitted
|
||||
public static final String DATE = "Date";
|
||||
|
||||
private ApiHeaders() {}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.adapter.inbound.web.idempotency;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScope;
|
||||
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
|
||||
import dev.caskeleton.application.idempotency.RequestFingerprint;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Web-side helper that assembles the inputs the application {@code IdempotencyExecutor} needs from
|
||||
* an HTTP request: the {@code Idempotency-Key} header, the scope (principal + use case name, tenant
|
||||
* seam), the request body fingerprint, and a JSON response codec. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Component
|
||||
public class IdempotencyKeySupport {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public IdempotencyKeySupport(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** The trimmed {@code Idempotency-Key} header value, or empty when absent/blank. */
|
||||
public Optional<String> idempotencyKey(HttpServletRequest request) {
|
||||
String value = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
|
||||
return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value.trim());
|
||||
}
|
||||
|
||||
/** Build the scope for the current authenticated caller (tenant left null). */
|
||||
public IdempotencyScope scope(String idempotencyKey, String useCaseName) {
|
||||
return IdempotencyScope.of(currentPrincipal(), idempotencyKey, useCaseName);
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprint of the serialized request payload. */
|
||||
public RequestFingerprint fingerprint(Object requestPayload) {
|
||||
try {
|
||||
return RequestFingerprint.ofSha256(objectMapper.writeValueAsBytes(requestPayload));
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to fingerprint idempotent request payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** A JSON codec for replaying a use case result of type {@code R}. */
|
||||
public <R> IdempotentResponseCodec<R> codec(Class<R> responseType) {
|
||||
return new JsonIdempotentResponseCodec<>(objectMapper, responseType);
|
||||
}
|
||||
|
||||
private static String currentPrincipal() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null
|
||||
&& auth.isAuthenticated()
|
||||
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return user.idpUserId();
|
||||
}
|
||||
return null; // IdempotencyScope.of → IdempotencyScopeMissingException (400)
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.adapter.inbound.web.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Jackson-backed {@link IdempotentResponseCodec}: the web adapter owns the JSON wire format the
|
||||
* application executor stores and replays. A (de)serialization failure surfaces as {@link
|
||||
* MappingException}, which the base handler routes to {@code MAPPING_FAILED} 400. See README for
|
||||
* the design rationale.
|
||||
*
|
||||
* @param <R> the use case result type made idempotent
|
||||
*/
|
||||
public final class JsonIdempotentResponseCodec<R> implements IdempotentResponseCodec<R> {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JavaType type;
|
||||
|
||||
public JsonIdempotentResponseCodec(ObjectMapper objectMapper, Class<R> type) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.type = objectMapper.getTypeFactory().constructType(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String serialize(R result) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(result);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to serialize idempotent response for replay", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public R deserialize(String payload) {
|
||||
try {
|
||||
return objectMapper.readValue(payload, type);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to deserialize stored idempotent response", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
/**
|
||||
* Neutralizes inbound header values before they reach MDC / logs by stripping every {@code \r},
|
||||
* {@code \n} and ASCII control char ({@code < 0x20}) and length-capping the result. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public final class HeaderSanitizer {
|
||||
|
||||
private HeaderSanitizer() {}
|
||||
|
||||
public static String sanitize(String raw, int maxLength) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(Math.min(raw.length(), maxLength));
|
||||
for (int i = 0; i < raw.length() && sb.length() < maxLength; i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (c >= 0x20) {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
/** snake_case MDC key names. See README for the design rationale. */
|
||||
public final class MdcKeys {
|
||||
|
||||
public static final String REQUEST_ID = "request_id";
|
||||
public static final String TRACE_ID = "trace_id";
|
||||
public static final String SPAN_ID = "span_id";
|
||||
public static final String CORRELATION_ID = "correlation_id";
|
||||
public static final String USER_PRINCIPAL = "user_principal";
|
||||
|
||||
private MdcKeys() {}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import dev.caskeleton.shared.response.ResponseMeta;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Projects the snake_case MDC diagnostic keys onto the camelCase {@link ResponseMeta} wire object.
|
||||
* See README for the design rationale.
|
||||
*/
|
||||
public final class ResponseMetaFactory {
|
||||
|
||||
private ResponseMetaFactory() {}
|
||||
|
||||
public static ResponseMeta fromMdc() {
|
||||
return new ResponseMeta(
|
||||
MDC.get(MdcKeys.REQUEST_ID), MDC.get(MdcKeys.TRACE_ID), MDC.get(MdcKeys.CORRELATION_ID));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
/**
|
||||
* Answers whether a retryable error code warrants a {@code Retry-After} header and supplies its
|
||||
* per-code value (seconds). See README for the design rationale.
|
||||
*/
|
||||
public final class RetryAfterAdvisor {
|
||||
|
||||
/** Per-code {@code Retry-After} values (seconds). */
|
||||
private static final Map<String, Integer> RETRY_AFTER_SECONDS =
|
||||
Map.of(
|
||||
"RATE_LIMIT_EXCEEDED", 1,
|
||||
"AUTH_KID_UNKNOWN", 5,
|
||||
"AUTH_JWKS_UNAVAILABLE", 30,
|
||||
"DEPENDENCY_TIMEOUT", 2,
|
||||
"DEPENDENCY_CONNECT_FAILED", 2,
|
||||
"DEPENDENCY_DNS_FAILED", 5,
|
||||
"DEPENDENCY_5XX_SERVER", 2,
|
||||
"DEPENDENCY_CIRCUIT_OPEN", 10);
|
||||
|
||||
private RetryAfterAdvisor() {}
|
||||
|
||||
/** True when a code's response should carry a {@code Retry-After} header. */
|
||||
public static boolean shouldAdvise(ApiErrorCode code) {
|
||||
return code.retryable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@code Retry-After} value (seconds) for a code, or empty when the code is
|
||||
* non-retryable or has no registry value.
|
||||
*/
|
||||
public static OptionalInt retryAfterSeconds(ApiErrorCode code) {
|
||||
if (!shouldAdvise(code)) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
Integer seconds = RETRY_AFTER_SECONDS.get(code.code());
|
||||
return seconds == null ? OptionalInt.empty() : OptionalInt.of(seconds);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
import dev.caskeleton.shared.response.PageMeta;
|
||||
|
||||
/**
|
||||
* Validated offset-pagination parameters. See README for the design rationale.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code page} 0-indexed; {@code page < 0} → {@link PageValidationException}.
|
||||
* <li>{@code size} default 20, min 1, max 100; out of range → {@link PageValidationException}
|
||||
* (mapped to 400 VALIDATION_FAILED).
|
||||
* <li>{@link #isDeepOffset()} flags {@code page > 10000} so the controller can emit a {@code
|
||||
* Deprecation} header and recommend cursor pagination.
|
||||
* </ul>
|
||||
*/
|
||||
public record PageParams(int page, int size) {
|
||||
|
||||
public static final int DEFAULT_SIZE = 20;
|
||||
public static final int MIN_SIZE = 1;
|
||||
public static final int MAX_SIZE = 100;
|
||||
public static final int DEEP_OFFSET_THRESHOLD = 10000;
|
||||
|
||||
public static PageParams of(Integer page, Integer size) {
|
||||
int p = page == null ? 0 : page;
|
||||
int s = size == null ? DEFAULT_SIZE : size;
|
||||
if (p < 0) {
|
||||
throw new PageValidationException("page", "PAGE_NEGATIVE", "page must be >= 0");
|
||||
}
|
||||
if (s < MIN_SIZE) {
|
||||
throw new PageValidationException("size", "SIZE_BELOW_MIN", "size must be >= " + MIN_SIZE);
|
||||
}
|
||||
if (s > MAX_SIZE) {
|
||||
throw new PageValidationException("size", "SIZE_EXCEEDS_MAX", "size must be <= " + MAX_SIZE);
|
||||
}
|
||||
return new PageParams(p, s);
|
||||
}
|
||||
|
||||
public boolean isDeepOffset() {
|
||||
return page > DEEP_OFFSET_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Builds the response {@code meta.page} object for the given total + applied sort. */
|
||||
public PageMeta toPageMeta(long total, String sort) {
|
||||
return new PageMeta(page, size, total, sort);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
/**
|
||||
* Raised when a pagination / sort request parameter violates its bounds (e.g. {@code size > 100},
|
||||
* {@code page < 0}, non-native sort syntax). The global handler maps it to {@code
|
||||
* OperationalError.VALIDATION_FAILED} (HTTP 400) and surfaces {@code error.details.{field,code}} so
|
||||
* a client can correct the exact parameter.
|
||||
*/
|
||||
public class PageValidationException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String field;
|
||||
private final String reasonCode;
|
||||
|
||||
public PageValidationException(String field, String reasonCode, String message) {
|
||||
super(message);
|
||||
this.field = field;
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public String field() {
|
||||
return field;
|
||||
}
|
||||
|
||||
public String reasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A single sort term in Spring {@code Pageable} native syntax {@code field,direction} (e.g. {@code
|
||||
* createdAt,desc}). Direction is optional and defaults to {@code asc}. Non-native syntaxes are
|
||||
* rejected with {@link PageValidationException} (→ 400 VALIDATION_FAILED); multi-sort is expressed
|
||||
* by repeating the {@code sort} query parameter, parsed term-by-term. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public record SortParam(String field, boolean ascending) {
|
||||
|
||||
public static SortParam parse(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new PageValidationException("sort", "SORT_EMPTY", "sort term must not be blank");
|
||||
}
|
||||
String term = raw.trim();
|
||||
if (term.contains(" ") || term.contains(":") || term.startsWith("-") || term.startsWith("+")) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_SYNTAX_INVALID",
|
||||
"sort must be 'field,direction' (Spring native); '" + raw + "' is not allowed");
|
||||
}
|
||||
String[] parts = term.split(",", -1);
|
||||
if (parts.length > 2 || parts[0].isBlank()) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_SYNTAX_INVALID",
|
||||
"sort must be 'field' or 'field,direction'; got '" + raw + "'");
|
||||
}
|
||||
String field = parts[0].trim();
|
||||
if (!field.matches("[a-zA-Z][a-zA-Z0-9]*")) {
|
||||
throw new PageValidationException(
|
||||
"sort", "SORT_FIELD_INVALID", "sort field '" + field + "' is not a valid identifier");
|
||||
}
|
||||
boolean ascending = true;
|
||||
if (parts.length == 2) {
|
||||
String dir = parts[1].trim().toLowerCase(Locale.ROOT);
|
||||
if (dir.equals("desc")) {
|
||||
ascending = false;
|
||||
} else if (!dir.equals("asc")) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_DIRECTION_INVALID",
|
||||
"sort direction must be 'asc' or 'desc'; got '" + parts[1] + "'");
|
||||
}
|
||||
}
|
||||
return new SortParam(field, ascending);
|
||||
}
|
||||
|
||||
/** Re-renders this term in canonical Spring native form for {@code meta.page.sort}. */
|
||||
public String canonical() {
|
||||
return field + "," + (ascending ? "asc" : "desc");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
interface ClientIpResolver {
|
||||
|
||||
String resolve(HttpServletRequest request);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
final class ClientIpResolverFactory {
|
||||
|
||||
private ClientIpResolverFactory() {}
|
||||
|
||||
static ClientIpResolver create(RateLimitClientIpMode mode) {
|
||||
return switch (mode) {
|
||||
case REMOTE_ADDR_ONLY -> new RemoteAddrClientIpResolver();
|
||||
case FORWARDED_HEADERS_TRUSTED -> new ForwardedHeaderClientIpResolver();
|
||||
};
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window
|
||||
* {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
public final class FixedWindowRateLimiter implements RateLimiter {
|
||||
|
||||
private final int limit;
|
||||
private final long windowSeconds;
|
||||
private final Clock clock;
|
||||
private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>();
|
||||
|
||||
public FixedWindowRateLimiter(int limit, Duration window, Clock clock) {
|
||||
this.limit = Math.max(1, limit);
|
||||
this.windowSeconds = Math.max(1L, window.toSeconds());
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RateLimitDecision decide(String key) {
|
||||
long nowSecond = clock.instant().getEpochSecond();
|
||||
long windowId = nowSecond / windowSeconds;
|
||||
Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds);
|
||||
|
||||
Window window =
|
||||
windows.compute(
|
||||
key,
|
||||
(k, current) ->
|
||||
(current == null || current.id != windowId) ? new Window(windowId) : current);
|
||||
|
||||
int count = window.count.incrementAndGet();
|
||||
boolean allowed = count <= limit;
|
||||
int remaining = Math.max(0, limit - count);
|
||||
return new RateLimitDecision(allowed, limit, remaining, resetAt);
|
||||
}
|
||||
|
||||
private static final class Window {
|
||||
private final long id;
|
||||
private final AtomicInteger count = new AtomicInteger();
|
||||
|
||||
private Window(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
final class ForwardedHeaderClientIpResolver implements ClientIpResolver {
|
||||
|
||||
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
|
||||
|
||||
@Override
|
||||
public String resolve(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader(X_FORWARDED_FOR);
|
||||
if (forwarded == null || forwarded.isBlank()) {
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
String firstHop = forwarded.split(",", 2)[0].trim();
|
||||
return firstHop.isEmpty() ? request.getRemoteAddr() : firstHop;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README
|
||||
* for the design rationale.
|
||||
*/
|
||||
public enum RateLimitAlgorithm {
|
||||
|
||||
/** Fixed-window counter — the default single-node implementation. */
|
||||
FIXED_WINDOW
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Selects the client-IP source for unauthenticated rate-limit keys. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public enum RateLimitClientIpMode {
|
||||
/** Uses {@code remoteAddr} only; ignores forwarded headers. */
|
||||
REMOTE_ADDR_ONLY,
|
||||
|
||||
/** Trusts forwarded headers. */
|
||||
FORWARDED_HEADERS_TRUSTED
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*}
|
||||
* signaling headers. See README for the design rationale.
|
||||
*
|
||||
* @param allowed false when the caller has exceeded the limit this window (→ 429)
|
||||
* @param limit the window quota ({@code X-RateLimit-Limit})
|
||||
* @param remaining requests left in the current window, floored at 0 ({@code
|
||||
* X-RateLimit-Remaining})
|
||||
* @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339)
|
||||
*/
|
||||
public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Applies the rate limit before a mapped handler runs. Every response carries the {@code
|
||||
* X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429
|
||||
* {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers.
|
||||
* See README for the design rationale.
|
||||
*/
|
||||
public final class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String CLIENT_SAFE_MESSAGE =
|
||||
"Too many requests, please retry after the indicated interval";
|
||||
|
||||
private final boolean enabled;
|
||||
private final RateLimiter limiter;
|
||||
private final RateLimitKeyResolver keyResolver;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final int retryAfterSeconds;
|
||||
|
||||
public RateLimitInterceptor(
|
||||
boolean enabled,
|
||||
RateLimiter limiter,
|
||||
RateLimitKeyResolver keyResolver,
|
||||
ObjectMapper objectMapper,
|
||||
int retryAfterSeconds) {
|
||||
this.enabled = enabled;
|
||||
this.limiter = limiter;
|
||||
this.keyResolver = keyResolver;
|
||||
this.objectMapper = objectMapper;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
RateLimitDecision decision = limiter.decide(keyResolver.resolve(request));
|
||||
applySignalingHeaders(response, decision);
|
||||
if (decision.allowed()) {
|
||||
return true;
|
||||
}
|
||||
rejectWith429(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) {
|
||||
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit()));
|
||||
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining()));
|
||||
response.setHeader(
|
||||
ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt()));
|
||||
}
|
||||
|
||||
private void rejectWith429(HttpServletResponse response) throws Exception {
|
||||
response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus());
|
||||
response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds));
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
Envelope<Void> body =
|
||||
ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null);
|
||||
objectMapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
/**
|
||||
* Derives the rate-limit key from a request:
|
||||
*
|
||||
* <ul>
|
||||
* <li>authenticated user → {@code user:<principal>}
|
||||
* <li>service-to-service (a {@code service}-role principal) → {@code apikey:<id>}
|
||||
* <li>unauthenticated → {@code ip:<source-ip>:<METHOD route-template>}
|
||||
* </ul>
|
||||
*
|
||||
* <p>See README for the design rationale.
|
||||
*/
|
||||
public final class RateLimitKeyResolver {
|
||||
|
||||
private static final String SERVICE_ROLE = "service";
|
||||
|
||||
private final ClientIpResolver clientIpResolver;
|
||||
|
||||
public RateLimitKeyResolver(ClientIpResolver clientIpResolver) {
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
}
|
||||
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null
|
||||
&& auth.isAuthenticated()
|
||||
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId();
|
||||
}
|
||||
return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request);
|
||||
}
|
||||
|
||||
private static String routeTemplate(HttpServletRequest request) {
|
||||
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
String route = pattern instanceof String s ? s : request.getRequestURI();
|
||||
return request.getMethod() + " " + route;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.time.Clock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken
|
||||
* from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With
|
||||
* no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a
|
||||
* pass-through. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RateLimitSettings.class)
|
||||
public class RateLimitWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final RateLimitInterceptor rateLimitInterceptor;
|
||||
|
||||
public RateLimitWebConfig(
|
||||
RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) {
|
||||
RateLimiter limiter =
|
||||
RateLimiterFactory.create(
|
||||
properties.algorithm(),
|
||||
properties.limit(),
|
||||
properties.window(),
|
||||
clock.getIfAvailable(Clock::systemUTC));
|
||||
int retryAfter =
|
||||
RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1);
|
||||
ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode());
|
||||
this.rateLimitInterceptor =
|
||||
new RateLimitInterceptor(
|
||||
properties.enabled(),
|
||||
limiter,
|
||||
new RateLimitKeyResolver(clientIpResolver),
|
||||
objectMapper,
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(rateLimitInterceptor);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code
|
||||
* X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design
|
||||
* rationale and the algorithm-neutral output contract.
|
||||
*/
|
||||
public interface RateLimiter {
|
||||
|
||||
/** Register one request for {@code key} and report whether it is within the limit. */
|
||||
RateLimitDecision decide(String key);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */
|
||||
public final class RateLimiterFactory {
|
||||
|
||||
private RateLimiterFactory() {}
|
||||
|
||||
public static RateLimiter create(
|
||||
RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) {
|
||||
return switch (algorithm) {
|
||||
case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock);
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
final class RemoteAddrClientIpResolver implements ClientIpResolver {
|
||||
|
||||
@Override
|
||||
public String resolve(HttpServletRequest request) {
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* CORS settings bound from {@code ca-skeleton.cors.*}. See README for the validation-policy
|
||||
* rationale.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.cors")
|
||||
public record CorsSettings(
|
||||
boolean enabled,
|
||||
List<String> allowedOrigins,
|
||||
List<String> allowedMethods,
|
||||
List<String> allowedHeaders,
|
||||
boolean allowCredentials,
|
||||
@PositiveOrZero(
|
||||
message = "APP_SECURITY_CORS_MAX_AGE (ca-skeleton.cors.max-age-seconds) must be >= 0")
|
||||
long maxAgeSeconds) {
|
||||
|
||||
public CorsSettings {
|
||||
// When CORS is enabled at least one allowed origin is required.
|
||||
if (enabled && (allowedOrigins == null || allowedOrigins.isEmpty())) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_CORS_ENABLED=true requires a non-empty APP_SECURITY_CORS_ORIGINS "
|
||||
+ "(ca-skeleton.cors.allowed-origins)");
|
||||
}
|
||||
// A wildcard origin must not be combined with credentials.
|
||||
if (allowCredentials && allowedOrigins != null && allowedOrigins.contains("*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_CORS_ALLOW_CREDENTIALS=true must not be combined with a wildcard "
|
||||
+ "\"*\" in APP_SECURITY_CORS_ORIGINS (ca-skeleton.cors.allowed-origins); "
|
||||
+ "list explicit origins instead");
|
||||
}
|
||||
if (allowedOrigins == null) {
|
||||
allowedOrigins = List.of();
|
||||
} else {
|
||||
allowedOrigins = List.copyOf(allowedOrigins);
|
||||
}
|
||||
if (allowedMethods == null || allowedMethods.isEmpty()) {
|
||||
allowedMethods = List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS");
|
||||
} else {
|
||||
allowedMethods = List.copyOf(allowedMethods);
|
||||
}
|
||||
if (allowedHeaders == null || allowedHeaders.isEmpty()) {
|
||||
allowedHeaders = List.of("*");
|
||||
} else {
|
||||
allowedHeaders = List.copyOf(allowedHeaders);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Bound from {@code ca-skeleton.presentation.*}. See README for the validation-policy rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.presentation")
|
||||
public record PresentationSettings(String apiBasePath) {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PresentationSettings.class);
|
||||
private static final String DEFAULT_API_BASE_PATH = "";
|
||||
|
||||
public PresentationSettings {
|
||||
if (apiBasePath == null) {
|
||||
log.warn("PRESENTATION_API_BASE_PATH is missing; using '{}'", DEFAULT_API_BASE_PATH);
|
||||
apiBasePath = DEFAULT_API_BASE_PATH;
|
||||
} else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) {
|
||||
String fixed = "/" + apiBasePath;
|
||||
log.warn(
|
||||
"PRESENTATION_API_BASE_PATH '{}' must start with '/'; using '{}'", apiBasePath, fixed);
|
||||
apiBasePath = fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
|
||||
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design
|
||||
* rationale.
|
||||
*
|
||||
* @param enabled whether the rate-limit interceptor enforces limits
|
||||
* @param limit max requests allowed per key within one window
|
||||
* @param window the fixed time window over which {@code limit} is counted
|
||||
* @param algorithm the rate-limit strategy to use
|
||||
* @param clientIpMode client-IP source for unauthenticated rate-limit keys
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.rate-limit")
|
||||
public record RateLimitSettings(
|
||||
boolean enabled,
|
||||
Integer limit,
|
||||
Duration window,
|
||||
RateLimitAlgorithm algorithm,
|
||||
RateLimitClientIpMode clientIpMode) {
|
||||
|
||||
public RateLimitSettings {
|
||||
if (limit == null || limit < 1) {
|
||||
limit = 100;
|
||||
}
|
||||
if (window == null || window.isZero() || window.isNegative()) {
|
||||
window = Duration.ofSeconds(1);
|
||||
}
|
||||
if (algorithm == null) {
|
||||
algorithm = RateLimitAlgorithm.FIXED_WINDOW;
|
||||
}
|
||||
if (clientIpMode == null) {
|
||||
clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.security")
|
||||
public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class);
|
||||
|
||||
public SecuritySettings {
|
||||
if (issuerUri == null || issuerUri.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required");
|
||||
}
|
||||
if (audience == null) {
|
||||
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
|
||||
audience = "";
|
||||
}
|
||||
if (publicPaths == null) {
|
||||
publicPaths = List.of();
|
||||
} else {
|
||||
publicPaths = List.copyOf(publicPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AuthenticatedPrincipalTest {
|
||||
|
||||
@Test
|
||||
void rolesAreSnapshotAndImmutable() {
|
||||
Set<String> roles = new HashSet<>();
|
||||
roles.add("user");
|
||||
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", roles);
|
||||
roles.add("admin");
|
||||
|
||||
assertThat(principal.roles()).containsExactly("user");
|
||||
assertThat(principal.hasRole("admin")).isFalse();
|
||||
assertThatThrownBy(() -> principal.roles().add("admin"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullRolesDefaultToEmptySet() {
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", null);
|
||||
|
||||
assertThat(principal.roles()).isEmpty();
|
||||
assertThat(principal.hasRole("user")).isFalse();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class EnvelopeAccessDeniedHandlerTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final EnvelopeAccessDeniedHandler handler =
|
||||
new EnvelopeAccessDeniedHandler(new SecurityErrorClassifier(), mapper);
|
||||
|
||||
@Test
|
||||
void accessDeniedWrites403InsufficientPermissionEnvelope() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.handle(
|
||||
new MockHttpServletRequest("POST", "/v1/things"),
|
||||
response,
|
||||
new AccessDeniedException("Access is denied"));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull();
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull();
|
||||
JsonNode body = mapper.readTree(response.getContentAsString());
|
||||
assertThat(body.get("success").asBoolean()).isFalse();
|
||||
assertThat(body.path("error").path("code").asString())
|
||||
.isEqualTo("AUTHZ_INSUFFICIENT_PERMISSION");
|
||||
assertThat(body.path("error").path("category").asString()).isEqualTo("AUTHZ");
|
||||
assertThat(body.path("error").path("message").asString()).isEqualTo("Permission denied");
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class EnvelopeAuthenticationEntryPointTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final EnvelopeAuthenticationEntryPoint entryPoint =
|
||||
new EnvelopeAuthenticationEntryPoint(new SecurityErrorClassifier(), mapper);
|
||||
|
||||
private JsonNode bodyOf(MockHttpServletResponse response) throws Exception {
|
||||
return mapper.readTree(response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenWrites401EnvelopeWithWwwAuthenticate() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InsufficientAuthenticationException("Full authentication is required"));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isEqualTo("Bearer");
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull();
|
||||
JsonNode body = bodyOf(response);
|
||||
assertThat(body.get("success").asBoolean()).isFalse();
|
||||
assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_TOKEN_MISSING");
|
||||
assertThat(body.path("error").path("category").asString()).isEqualTo("AUTH");
|
||||
assertThat(body.path("error").path("message").asString()).isEqualTo("Authentication required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenWrites401WithInvalidTokenChallenge() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null);
|
||||
JwtValidationException cause = new JwtValidationException("expired", List.of(expired));
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException("invalid", cause));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE))
|
||||
.isEqualTo("Bearer error=\"invalid_token\"");
|
||||
assertThat(bodyOf(response).path("error").path("code").asString())
|
||||
.isEqualTo("AUTH_TOKEN_EXPIRED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKidCarriesRetryAfter5() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException(
|
||||
"invalid", new BadJwtException("Unable to find a matching key with kid 'abc'")));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("5");
|
||||
JsonNode body = bodyOf(response);
|
||||
assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_KID_UNKNOWN");
|
||||
assertThat(body.path("error").path("retryable").asBoolean()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwksOutageIs503WithRetryAfter30AndNoChallenge() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException(
|
||||
"invalid", new JwtException("Couldn't retrieve remote JWK set: connect timed out")));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(503);
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("30");
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull();
|
||||
assertThat(bodyOf(response).path("error").path("code").asString())
|
||||
.isEqualTo("AUTH_JWKS_UNAVAILABLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenValueNeverLeaksIntoResponseBodyOrLogs(CapturedOutput output) throws Exception {
|
||||
// §테스트 계약: a JWT value must never appear in the response or the log output.
|
||||
String token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJsZWFrIn0.SECRETSIGNATURE";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/things");
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
// The decode failure message embeds the raw token, as Nimbus messages sometimes do.
|
||||
InvalidBearerTokenException ex =
|
||||
new InvalidBearerTokenException(
|
||||
"An error occurred while attempting to decode the Jwt: " + token);
|
||||
|
||||
entryPoint.commence(request, response, ex);
|
||||
|
||||
assertThat(response.getContentAsString())
|
||||
.as("response body must not echo the bearer token")
|
||||
.doesNotContain(token)
|
||||
.doesNotContain("eyJ");
|
||||
assertThat(output.getOut())
|
||||
.as("log output must not contain the bearer token")
|
||||
.doesNotContain(token)
|
||||
.doesNotContain("eyJ");
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* feature-security-operational-baseline §구현가이드 2 — the explicit JWT validator chain (60s clock skew
|
||||
* + issuer + audience). Unit-tested directly (no IdP / network), which also answers the branch-note
|
||||
* Claim "61s expired token is rejected (auto-config path)".
|
||||
*/
|
||||
class JwtDecoderConfigTest {
|
||||
|
||||
private static final String ISSUER = "https://issuer.example/realms/ca-skeleton";
|
||||
private static final String AUDIENCE = "ca-skeleton-api";
|
||||
|
||||
private final OAuth2TokenValidator<Jwt> validator =
|
||||
JwtDecoderConfig.jwtValidator(ISSUER, AUDIENCE);
|
||||
|
||||
private Jwt.Builder validJwt() {
|
||||
Instant now = Instant.now();
|
||||
return Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("user-1")
|
||||
.issuer(ISSUER)
|
||||
.audience(List.of(AUDIENCE))
|
||||
.issuedAt(now.minus(5, ChronoUnit.MINUTES))
|
||||
.expiresAt(now.plus(5, ChronoUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFullyValidTokenPasses() {
|
||||
OAuth2TokenValidatorResult result = validator.validate(validJwt().build());
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredWithin60sSkewIsAccepted() {
|
||||
Jwt jwt = validJwt().expiresAt(Instant.now().minus(30, ChronoUnit.SECONDS)).build();
|
||||
assertThat(validator.validate(jwt).hasErrors())
|
||||
.as("a token expired 30s ago is within the 60s clock-skew tolerance")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredBeyond60sSkewIsRejected() {
|
||||
Jwt jwt = validJwt().expiresAt(Instant.now().minus(90, ChronoUnit.SECONDS)).build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).as("a token expired 90s ago exceeds the 60s skew").isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(
|
||||
e ->
|
||||
e.getDescription() != null && e.getDescription().toLowerCase().contains("expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerMismatchIsRejected() {
|
||||
Jwt jwt = validJwt().issuer("https://evil.example/realms/other").build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(
|
||||
e -> e.getDescription() != null && e.getDescription().toLowerCase().contains("iss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void audienceMismatchIsRejectedWithAudClaimDescription() {
|
||||
Jwt jwt = validJwt().audience(List.of("some-other-api")).build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(e -> "The aud claim is not valid".equals(e.getDescription()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAudienceSkipsTheAudienceCheck() {
|
||||
OAuth2TokenValidator<Jwt> noAud = JwtDecoderConfig.jwtValidator(ISSUER, "");
|
||||
Jwt jwt = validJwt().audience(List.of("anything")).build();
|
||||
assertThat(noAud.validate(jwt).hasErrors())
|
||||
.as("a blank configured audience disables audience validation")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
|
||||
/**
|
||||
* feature-security-operational-baseline §구현가이드 3 — the resource-server exception → fine-grained
|
||||
* {@link OperationalError} classifier that resolves the CODE_GRANULARITY_DRIFT.
|
||||
*
|
||||
* <p>The classification is best-effort over Spring Security's exception shapes: a missing token
|
||||
* surfaces as {@link InsufficientAuthenticationException}; a present-but-invalid token surfaces as
|
||||
* an {@link org.springframework.security.oauth2.core.OAuth2AuthenticationException} whose cause is
|
||||
* a {@link JwtValidationException} (claim validators: exp / iss / aud) or a {@link BadJwtException}
|
||||
* (decode / signature / unknown kid). The heuristics key off the validator/Nimbus message text the
|
||||
* branch note documents — see the per-case comments.
|
||||
*/
|
||||
class SecurityErrorClassifierTest {
|
||||
|
||||
private final SecurityErrorClassifier classifier = new SecurityErrorClassifier();
|
||||
|
||||
private OperationalError classify(AuthenticationException ex) {
|
||||
return classifier.classifyAuthentication(ex);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenIsTokenMissing() {
|
||||
// ExceptionTranslationFilter raises InsufficientAuthenticationException when an
|
||||
// unauthenticated request hits an authenticated() endpoint (no bearer token present).
|
||||
assertThat(classify(new InsufficientAuthenticationException("Full authentication is required")))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MISSING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsTokenExpired() {
|
||||
// JwtTimestampValidator description: "Jwt expired at 2024-..."
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(
|
||||
OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at 2024-01-01T00:00:00Z", null);
|
||||
JwtValidationException cause = new JwtValidationException("token expired", List.of(expired));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerMismatchIsIssuerMismatch() {
|
||||
// JwtIssuerValidator description: "The iss claim is not valid"
|
||||
OAuth2Error iss =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("bad iss", List.of(iss));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_ISSUER_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void audienceMismatchIsAudienceMismatch() {
|
||||
OAuth2Error aud =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("bad aud", List.of(aud));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_AUDIENCE_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTakesPrecedenceOverAudienceWhenBothFail() {
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null);
|
||||
OAuth2Error aud =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("multi", List.of(aud, expired));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignatureIsInvalidSignature() {
|
||||
BadJwtException cause = new BadJwtException("Signed JWT rejected: Invalid signature");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_INVALID_SIGNATURE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unparsableTokenIsMalformed() {
|
||||
BadJwtException cause =
|
||||
new BadJwtException(
|
||||
"An error occurred while attempting to decode the Jwt: Malformed token");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKidIsKidUnknown() {
|
||||
// Nimbus: no JWK matches the token's kid header after a key rotation.
|
||||
BadJwtException cause =
|
||||
new BadJwtException(
|
||||
"An error occurred while attempting to decode the Jwt: "
|
||||
+ "Unable to find a matching key with kid 'abc123'");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_KID_UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwksEndpointOutageIsJwksUnavailable() {
|
||||
JwtException cause =
|
||||
new JwtException(
|
||||
"An error occurred while attempting to decode the Jwt: Couldn't retrieve remote JWK set: connect timed out");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_JWKS_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownAuthenticationFailureFallsBackToMalformed() {
|
||||
// A novel/unmapped AuthenticationException must never leak as a 500; the safe default
|
||||
// is a generic 401 AUTH classification rather than an unclassified error.
|
||||
assertThat(classify(new AuthenticationException("weird") {}))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDeniedIsInsufficientPermission() {
|
||||
assertThat(classifier.classifyAccessDenied(new AccessDeniedException("denied")))
|
||||
.isEqualTo(OperationalError.AUTHZ_INSUFFICIENT_PERMISSION);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AuthorizationAdapterTest {
|
||||
|
||||
private final AuthorizationAdapter adapter =
|
||||
new AuthorizationAdapter(
|
||||
new RolePermissionRegistry(
|
||||
new RolePermissionPolicy(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
|
||||
|
||||
@Test
|
||||
void grantsWhenAnEffectivePermissionCoversTheRequirement() {
|
||||
AuthorizationPrincipal admin = new AuthorizationPrincipal("sub-admin", Set.of("admin"));
|
||||
|
||||
assertThatCode(() -> adapter.requirePermission(admin, Permission.parse("worklog:close")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenRoleLacksTheRequiredPermission() {
|
||||
AuthorizationPrincipal user = new AuthorizationPrincipal("sub-user", Set.of("user"));
|
||||
|
||||
assertThatThrownBy(() -> adapter.requirePermission(user, Permission.parse("worklog:close")))
|
||||
.isInstanceOf(AuthorizationDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesAPrincipalWithNoRolesFailClosed() {
|
||||
AuthorizationPrincipal anon = new AuthorizationPrincipal("sub-anon", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> adapter.requirePermission(anon, Permission.parse("worklog:read")))
|
||||
.isInstanceOf(AuthorizationDeniedException.class);
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
|
||||
class RequiresPermissionAuthorizationManagerTest {
|
||||
|
||||
private final AuthorizationPort port =
|
||||
new AuthorizationAdapter(
|
||||
new RolePermissionRegistry(
|
||||
new RolePermissionPolicy(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
|
||||
|
||||
private final RequiresPermissionAuthorizationManager manager =
|
||||
new RequiresPermissionAuthorizationManager(port);
|
||||
|
||||
// --- fixtures: a guarded and an unguarded method ----------------------------------
|
||||
|
||||
static class Guarded {
|
||||
@RequiresPermission("worklog:close")
|
||||
public void close() {}
|
||||
|
||||
public void unguarded() {}
|
||||
}
|
||||
|
||||
private MethodInvocation invocationOf(String methodName) throws Exception {
|
||||
Method method = Guarded.class.getMethod(methodName);
|
||||
Guarded target = new Guarded();
|
||||
MethodInvocation mi = mock(MethodInvocation.class);
|
||||
when(mi.getMethod()).thenReturn(method);
|
||||
when(mi.getThis()).thenReturn(target);
|
||||
return mi;
|
||||
}
|
||||
|
||||
private static Supplier<Authentication> principalWithRoles(String... roles) {
|
||||
AuthenticatedPrincipal user =
|
||||
new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles));
|
||||
TestingAuthenticationToken auth =
|
||||
new TestingAuthenticationToken(user, "n/a", AuthorityUtils.NO_AUTHORITIES);
|
||||
auth.setAuthenticated(true);
|
||||
return () -> auth;
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsWhenPrincipalHoldsRequiredPermission() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("admin"), invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenPrincipalLacksRequiredPermission() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("user"), invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void abstainsOnUnguardedMethod() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("admin"), invocationOf("unguarded"));
|
||||
|
||||
assertThat(decision).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesUnauthenticatedRequestFailClosed() throws Exception {
|
||||
Supplier<Authentication> anonymous =
|
||||
() -> {
|
||||
AnonymousAuthenticationToken token =
|
||||
new AnonymousAuthenticationToken(
|
||||
"key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
token.setAuthenticated(false);
|
||||
return token;
|
||||
};
|
||||
|
||||
AuthorizationResult decision = manager.authorize(anonymous, invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenPrincipalIsNotOurAuthenticatedUser() throws Exception {
|
||||
Supplier<Authentication> foreignPrincipal =
|
||||
() -> {
|
||||
TestingAuthenticationToken auth =
|
||||
new TestingAuthenticationToken("just-a-string", "n/a", AuthorityUtils.NO_AUTHORITIES);
|
||||
auth.setAuthenticated(true);
|
||||
return auth;
|
||||
};
|
||||
|
||||
AuthorizationResult decision = manager.authorize(foreignPrincipal, invocationOf("close"));
|
||||
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void portDenialCarriesRequiredPermission() {
|
||||
AuthorizationPrincipal user = new AuthorizationPrincipal("sub-1", Set.of("user"));
|
||||
|
||||
assertThatThrownBy(() -> port.requirePermission(user, Permission.parse("worklog:close")))
|
||||
.isInstanceOfSatisfying(
|
||||
AuthorizationDeniedException.class,
|
||||
denied ->
|
||||
assertThat(denied.requiredPermission())
|
||||
.isEqualTo(Permission.parse("worklog:close")));
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Binding test for {@link RolePermissionPolicy} (AGENTS.md §설정과 런타임: every new
|
||||
* {@code @ConfigurationProperties} class gets a binding test). Verifies the {@code
|
||||
* ca-skeleton.authz.role-permissions.<role>=<comma list>} YAML/env shape binds to the {@code
|
||||
* Map<String, List<String>>} the registry consumes, and that an absent block is null-safe.
|
||||
*/
|
||||
class RolePermissionPolicyTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner().withUserConfiguration(EnableProperties.class);
|
||||
|
||||
@Test
|
||||
void bindsRoleToPermissionLists() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.authz.role-permissions.user=worklog:read,worklog:write",
|
||||
"ca-skeleton.authz.role-permissions.admin=worklog:read,worklog:write,worklog:close")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
RolePermissionPolicy props = context.getBean(RolePermissionPolicy.class);
|
||||
assertThat(props.rolePermissions()).containsKeys("user", "admin");
|
||||
assertThat(props.rolePermissions().get("admin"))
|
||||
.containsExactly("worklog:read", "worklog:write", "worklog:close");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentBlockBindsToEmptyMap() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBean(RolePermissionPolicy.class).rolePermissions()).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolePermissionListsAreDeepCopied() {
|
||||
List<String> permissions = new ArrayList<>(List.of("worklog:read"));
|
||||
Map<String, List<String>> rolePermissions = new HashMap<>();
|
||||
rolePermissions.put("user", permissions);
|
||||
|
||||
RolePermissionPolicy policy = new RolePermissionPolicy(rolePermissions);
|
||||
permissions.add("worklog:write");
|
||||
rolePermissions.put("admin", List.of("worklog:close"));
|
||||
|
||||
assertThat(policy.rolePermissions()).containsOnlyKeys("user");
|
||||
assertThat(policy.rolePermissions().get("user")).containsExactly("worklog:read");
|
||||
assertThat(policy.rolePermissions()).isUnmodifiable();
|
||||
assertThat(policy.rolePermissions().get("user")).isUnmodifiable();
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RolePermissionPolicy.class)
|
||||
static class EnableProperties {}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RolePermissionRegistryTest {
|
||||
|
||||
private static RolePermissionRegistry registry(Map<String, List<String>> roles) {
|
||||
return new RolePermissionRegistry(new RolePermissionPolicy(roles));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesASingleRoleToItsDeclaredPermissions() {
|
||||
RolePermissionRegistry reg = registry(Map.of("user", List.of("worklog:read", "worklog:write")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("user")))
|
||||
.containsExactlyInAnyOrder(
|
||||
Permission.parse("worklog:read"), Permission.parse("worklog:write"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unionsPermissionsAcrossMultipleRoles() {
|
||||
RolePermissionRegistry reg =
|
||||
registry(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("user", "admin")))
|
||||
.containsExactlyInAnyOrder(
|
||||
Permission.parse("worklog:read"),
|
||||
Permission.parse("worklog:write"),
|
||||
Permission.parse("worklog:close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleLookupIsCaseInsensitive() {
|
||||
// D3: Keycloak does not guarantee role casing; registry lookup normalises so a
|
||||
// raw role of "ADMIN" still resolves to the "admin" bundle.
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("ADMIN")))
|
||||
.containsExactly(Permission.parse("worklog:close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRoleResolvesToZeroPermissionsFailClosed() {
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("ghost"))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrNullRoleSetResolvesToZeroPermissions() {
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of())).isEmpty();
|
||||
assertThat(reg.effectivePermissions(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyRegistryGrantsNothing() {
|
||||
assertThat(registry(Map.of()).effectivePermissions(Set.of("admin"))).isEmpty();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ETagsTest {
|
||||
|
||||
@Test
|
||||
void weakEtagFromVersionUsesTheBranchNoteForm() {
|
||||
assertThat(ETags.weakFromVersion(7)).isEqualTo("W/\"7\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesSameVersionLenientlyAcrossWeakMarker() {
|
||||
String etag = ETags.weakFromVersion(3);
|
||||
assertThat(ETags.matches("W/\"3\"", etag)).isTrue();
|
||||
assertThat(ETags.matches("\"3\"", etag)).as("strong form of same value still matches").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardMatchesAnyExistingEtag() {
|
||||
assertThat(ETags.matches("*", ETags.weakFromVersion(99))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleCandidatesMatchWhenAnyMatches() {
|
||||
assertThat(ETags.matches("W/\"1\", W/\"2\", W/\"3\"", ETags.weakFromVersion(2))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersionDoesNotMatch() {
|
||||
assertThat(ETags.matches("W/\"1\"", ETags.weakFromVersion(2))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrBlankHeaderDoesNotMatch() {
|
||||
assertThat(ETags.matches(null, ETags.weakFromVersion(1))).isFalse();
|
||||
assertThat(ETags.matches(" ", ETags.weakFromVersion(1))).isFalse();
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CursorCodecTest {
|
||||
|
||||
private final CursorCodec codec = CursorCodec.withDevKey();
|
||||
private static final Instant T0 = Instant.parse("2026-06-02T00:00:00Z");
|
||||
|
||||
@Test
|
||||
void roundTripsThePayload() {
|
||||
String token = codec.encode("01HZX9-after", T0);
|
||||
assertThat(codec.decode(token, T0.plusSeconds(60))).isEqualTo("01HZX9-after");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIsUrlSafeAndNotPlainlyThePayload() {
|
||||
String token = codec.encode("offset=40", T0);
|
||||
assertThat(token).doesNotContain("offset=40"); // opacity: not the raw payload
|
||||
assertThat(token).matches("[A-Za-z0-9_.\\-]+"); // URL-safe (base64url + '.')
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedTokenIsRejected() {
|
||||
String token = codec.encode("01HZX9", T0);
|
||||
String tampered = token.substring(0, token.length() - 1) + (token.endsWith("A") ? "B" : "A");
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode(tampered, T0.plusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsRejectedAfterTtl() {
|
||||
String token = codec.encode("01HZX9", T0);
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode(token, T0.plus(Duration.ofHours(24)).plusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedTokenIsRejected() {
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode("not-a-valid-token", T0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortKeyIsRefused() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> new CursorCodec("short".getBytes(), CursorCodec.DEFAULT_TTL));
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.caskeleton.shared.response.BulkEnvelope;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.response.ResponseMeta;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@WebMvcTest(
|
||||
controllers = EnvelopeBodyAdviceTest.Probe.class,
|
||||
excludeAutoConfiguration = SecurityAutoConfiguration.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@Import({EnvelopeBodyAdviceTest.Probe.class, EnvelopeBodyAdvice.class})
|
||||
class EnvelopeBodyAdviceTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@RestController
|
||||
static class Probe {
|
||||
@GetMapping("/probe/raw")
|
||||
public Map<String, String> raw() {
|
||||
return Map.of("k", "v");
|
||||
}
|
||||
|
||||
@GetMapping("/probe/already-enveloped")
|
||||
public Envelope<Map<String, String>> enveloped() {
|
||||
return Envelope.ok(Map.of("k", "v"), new ResponseMeta("r", "t", "c"));
|
||||
}
|
||||
|
||||
@GetMapping("/probe/bulk")
|
||||
public BulkEnvelope<Map<String, String>> bulk() {
|
||||
return BulkEnvelope.allOk(List.of(Map.of("k", "v")), new ResponseMeta("r", "t", "c"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawBodyIsWrappedInEnvelope() throws Exception {
|
||||
mvc.perform(get("/probe/raw"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.k").value("v"))
|
||||
.andExpect(jsonPath("$.error").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedBodyCarriesAMetaObject() throws Exception {
|
||||
mvc.perform(get("/probe/raw"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.meta").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyEnvelopedBodyIsNotDoubleWrapped() throws Exception {
|
||||
mvc.perform(get("/probe/already-enveloped"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.k").value("v"))
|
||||
.andExpect(jsonPath("$.data.data").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkEnvelopeBodyIsNotDoubleWrapped() throws Exception {
|
||||
mvc.perform(get("/probe/bulk"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.results[0].k").value("v"))
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
static class TestBootstrap {}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
||||
import dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilter;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* End-to-end pin of the foundation envelope contract across the adapter-web chain: {@link
|
||||
* RequestLoggingFilter} sets the SNAKE_CASE MDC ids, {@link EnvelopeBodyAdvice} (success) and
|
||||
* {@link GlobalExceptionHandler} (5xx) project them onto a camelCase {@code meta} object.
|
||||
* Standalone MockMvc — no Spring context, so it neither needs the production {@code
|
||||
* application.yml} placeholders nor a security filter chain.
|
||||
*/
|
||||
class EnvelopeMetaIntegrationTest {
|
||||
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc =
|
||||
MockMvcBuilders.standaloneSetup(new Probe())
|
||||
.addFilter(new RequestLoggingFilter(rawPrincipal -> rawPrincipal))
|
||||
.setControllerAdvice(
|
||||
new EnvelopeBodyAdvice(), new GlobalExceptionHandler(SpanErrorRecorder.NOOP))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successResponseCarriesNonEmptyMetaRequestAndTraceId() throws Exception {
|
||||
mvc.perform(get("/__meta/ok"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.probe").value("ok"))
|
||||
.andExpect(jsonPath("$.meta.requestId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thrown5xxCarriesEnvelopeWithMetaAndCategoryAndNoStacktrace() throws Exception {
|
||||
mvc.perform(get("/__meta/boom"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.error.category").value("INTERNAL"))
|
||||
.andExpect(jsonPath("$.error.message").value("Internal server error"))
|
||||
.andExpect(jsonPath("$.error.retryable").value(true))
|
||||
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Probe {
|
||||
|
||||
@GetMapping("/__meta/ok")
|
||||
Map<String, String> ok() {
|
||||
return Map.of("probe", "ok");
|
||||
}
|
||||
|
||||
@GetMapping("/__meta/boom")
|
||||
Map<String, String> boom() {
|
||||
throw new RuntimeException("kaboom");
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import java.sql.SQLException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP);
|
||||
|
||||
@Test
|
||||
void mappingExceptionRoutesToMappingFailedEnvelope() {
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleMapping(new MappingException("cannot map field 'role'"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.success()).isFalse();
|
||||
assertThat(body.error().code()).isEqualTo("MAPPING_FAILED");
|
||||
assertThat(body.error().category()).isEqualTo("VALIDATION");
|
||||
assertThat(body.error().message()).isEqualTo("cannot map field 'role'");
|
||||
assertThat(body.error().retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalArgumentMapsToBadParameterEnvelope() {
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleIllegalArgument(new IllegalArgumentException("bad offset"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().error().code()).isEqualTo("BAD_PARAMETER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adapterDisabledMapsToAdapterDisabled500NotRetryable() {
|
||||
// integration-adapter-templates Layer 3 / §Audit A2 — runtime fail-fast code,
|
||||
// distinct from the startup REQUIRED_ADAPTER_DISABLED.
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleAdapterDisabled(new AdapterDisabledException("kafka"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().success()).isFalse();
|
||||
assertThat(response.getBody().error().code()).isEqualTo("ADAPTER_DISABLED");
|
||||
assertThat(response.getBody().error().category()).isEqualTo("INTERNAL");
|
||||
assertThat(response.getBody().error().retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void metaIsLiftedFromSnakeCaseMdc() {
|
||||
try {
|
||||
MDC.put("request_id", "req-xyz");
|
||||
MDC.put("trace_id", "trace-xyz");
|
||||
MDC.put("correlation_id", "corr-xyz");
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleMapping(new MappingException("x"));
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().meta().requestId()).isEqualTo("req-xyz");
|
||||
assertThat(response.getBody().meta().traceId()).isEqualTo("trace-xyz");
|
||||
assertThat(response.getBody().meta().correlationId()).isEqualTo("corr-xyz");
|
||||
} finally {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalErrorIsRetryableAndHidesMessage() {
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleUnknown(
|
||||
new RuntimeException("boom"),
|
||||
new ServletWebRequest(new MockHttpServletRequest("GET", "/x")));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().error().retryable()).isTrue();
|
||||
assertThat(response.getBody().error().message()).isEqualTo("Internal server error");
|
||||
}
|
||||
|
||||
// feature-authentication-authorization-contract D5/§4: an authorization denial that
|
||||
// escapes the controller (a @RequiresPermission the caller could not satisfy) reaches this
|
||||
// controller-advice path as an AccessDeniedException. It must emit the same fine-grained
|
||||
// AUTHZ_INSUFFICIENT_PERMISSION code as the filter-layer handler, not a coarse FORBIDDEN.
|
||||
@Test
|
||||
void accessDeniedMapsToFineGrainedInsufficientPermission() {
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleForbidden(new AccessDeniedException("Access Denied"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.success()).isFalse();
|
||||
assertThat(body.error().code()).isEqualTo("AUTHZ_INSUFFICIENT_PERMISSION");
|
||||
assertThat(body.error().category()).isEqualTo("AUTHZ");
|
||||
assertThat(body.error().retryable()).isFalse();
|
||||
}
|
||||
|
||||
// feature-persistence-failure-baseline D1 + business-rule-validation C7/D9: a classified
|
||||
// persistence carrier sets the envelope from its DB_* code, but the client message is a fixed
|
||||
// category-derived safe string — the raw diagnostic (SQLState / constraint name) the translator
|
||||
// stored as the carrier's message must never reach the client.
|
||||
@Test
|
||||
void persistenceFailureCarrierSetsEnvelopeFromCodeWithSafeMessage() {
|
||||
PersistenceFailureException carrier =
|
||||
new PersistenceFailureException(
|
||||
OperationalError.DB_UNIQUE_VIOLATION,
|
||||
"persistence failure classified from SQLState=23505 constraint=\"uq_worklog_title\"",
|
||||
new SQLException(
|
||||
"duplicate key value violates unique constraint \"uq_worklog_title\"", "23505"));
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.success()).isFalse();
|
||||
assertThat(body.error().code()).isEqualTo("DB_UNIQUE_VIOLATION");
|
||||
assertThat(body.error().category()).isEqualTo("CONFLICT");
|
||||
assertThat(body.error().retryable()).isFalse();
|
||||
// the safe message must not echo the SQLState, constraint name, or "unique constraint".
|
||||
assertThat(body.error().message())
|
||||
.as("D1/C7: client message is the fixed category-safe string")
|
||||
.isEqualTo("Request conflicted with the current state, please retry");
|
||||
assertThat(body.error().details()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureTransientMapsTo503RetryableWithSafeMessage() {
|
||||
PersistenceFailureException carrier =
|
||||
new PersistenceFailureException(
|
||||
OperationalError.DB_UNAVAILABLE,
|
||||
"persistence failure classified from SQLState=08006",
|
||||
new SQLException("connection refused", "08006"));
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DB_UNAVAILABLE");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message())
|
||||
.isEqualTo("Service temporarily unavailable, please retry later");
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureIntegrityMapsTo409DataIntegritySafeMessage() {
|
||||
PersistenceFailureException carrier =
|
||||
new PersistenceFailureException(
|
||||
OperationalError.DB_NULL_VIOLATION,
|
||||
"persistence failure classified from SQLState=23502",
|
||||
new SQLException("null value in column \"owner_id\"", "23502"));
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().category()).isEqualTo("DATA_INTEGRITY");
|
||||
assertThat(body.error().message()).isEqualTo("Request violates a data constraint");
|
||||
}
|
||||
|
||||
// feature-business-rule-validation-contract C7 / D9 + persistence-failure-baseline
|
||||
// 테스트 계약: a persistence-layer exception whose message embeds the raw SQL / constraint
|
||||
// name must never reach the client. The base handler's catch-all replaces the message with
|
||||
// a fixed client-safe string and emits null details, so no constraint name, SQLState, table
|
||||
// name, or stack frame can leak through the envelope. (The category-correct persistence
|
||||
// mapping itself — 23505 → CONFLICT/DB_UNIQUE_VIOLATION — is owned by
|
||||
// feature-persistence-failure-baseline; this test pins only the leak-prevention guarantee.)
|
||||
@Test
|
||||
void persistenceExceptionMessageWithRawConstraintNameDoesNotLeakToClient() {
|
||||
String rawDbMessage =
|
||||
"ERROR: duplicate key value violates unique constraint "
|
||||
+ "\"uq_worklog_title\"; SQLState: 23505; Detail: Key (title)=(DB tuning) already exists.";
|
||||
|
||||
ResponseEntity<Envelope<Void>> response =
|
||||
handler.handleUnknown(
|
||||
new RuntimeException(rawDbMessage),
|
||||
new ServletWebRequest(new MockHttpServletRequest("POST", "/work-logs")));
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
// The catch-all replaces the message with a fixed safe string, so none of the leaked
|
||||
// tokens (constraint name "uq_worklog_title", SQLState 23505, "unique constraint", the
|
||||
// column "title") can survive — isEqualTo pins this exactly, doesNotContain would be
|
||||
// logically subsumed.
|
||||
assertThat(body.error().message())
|
||||
.as("C7/D9: client message must be the fixed safe string, never the raw DB message")
|
||||
.isEqualTo("Internal server error");
|
||||
assertThat(body.error().details())
|
||||
.as("C7/D9: no raw object / body / SQL detail in error.details")
|
||||
.isNull();
|
||||
}
|
||||
|
||||
// ── feature-outbound-http-client-baseline D12: upstream dependency failure ──
|
||||
|
||||
// Each of the 6 dependency codes maps to the correct HTTP status, code name, category,
|
||||
// and retryable flag (registry SSOT rows 636~711, docs/registries/error-codes.yaml).
|
||||
|
||||
@Test
|
||||
void dependencyTimeoutMapsTo504TransientRetryable() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_TIMEOUT,
|
||||
"payment-service",
|
||||
"connect timed out after 3000 ms",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.success()).isFalse();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_TIMEOUT");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message())
|
||||
.isEqualTo("Upstream service did not respond in time, please retry");
|
||||
// Retry-After header must be present (registry: 2 s)
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyConnectFailedMapsTo503RetryableWithRetryAfter() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CONNECT_FAILED,
|
||||
"inventory-api",
|
||||
"Connection refused to 10.0.0.1:8080",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_CONNECT_FAILED");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message()).isEqualTo("Upstream service unreachable, please retry");
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyDnsFailedMapsTo503RetryableWithRetryAfter5s() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_DNS_FAILED,
|
||||
"notification-service",
|
||||
"DNS resolution failed for notification-service.internal",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_DNS_FAILED");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message()).isEqualTo("Upstream service unreachable, please retry");
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependency4xxClientMapsTo502PermanentNonRetryableNoRetryAfter() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_4XX_CLIENT,
|
||||
"github",
|
||||
"upstream returned 422 Unprocessable Entity",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_4XX_CLIENT");
|
||||
assertThat(body.error().category()).isEqualTo("PERMANENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isFalse();
|
||||
assertThat(body.error().message()).isEqualTo("Upstream service rejected the request");
|
||||
// non-retryable → no Retry-After header
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependency5xxServerMapsTo502TransientRetryableWithRetryAfter() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_5XX_SERVER,
|
||||
"github",
|
||||
"upstream returned HTTP 503 Service Unavailable",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_5XX_SERVER");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message()).isEqualTo("Upstream service error, please retry");
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyCircuitOpenMapsTo503RetryableWithRetryAfter10s() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CIRCUIT_OPEN,
|
||||
"payment-service",
|
||||
"circuit breaker OPEN for payment-service",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.error().code()).isEqualTo("DEPENDENCY_CIRCUIT_OPEN");
|
||||
assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
assertThat(body.error().retryable()).isTrue();
|
||||
assertThat(body.error().message())
|
||||
.isEqualTo("Upstream service temporarily unavailable, please retry later");
|
||||
assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("10");
|
||||
}
|
||||
|
||||
// feature-outbound-http-client-baseline D12 spec contract:
|
||||
// "upstream raw error body가 response/log에 노출되면 실패" — web side.
|
||||
// The diagnosticMessage and dependencyName must never appear in the response body.
|
||||
@Test
|
||||
void dependencyFailureDiagnosticAndDependencyNameDoNotLeakToClient() {
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_5XX_SERVER,
|
||||
"github",
|
||||
"status 500 from upstream UPSTREAM_DIAG",
|
||||
null);
|
||||
|
||||
ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex);
|
||||
|
||||
Envelope<Void> body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
String bodyMessage = body.error().message();
|
||||
assertThat(bodyMessage)
|
||||
.as("D12: diagnostic message must not leak to client")
|
||||
.doesNotContain("UPSTREAM_DIAG");
|
||||
assertThat(bodyMessage)
|
||||
.as("D12: dependency name must not leak to client")
|
||||
.doesNotContain("github");
|
||||
assertThat(body.error().details())
|
||||
.as("D12: no raw diagnostic detail in error.details")
|
||||
.isNull();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.idempotency.IdempotencyInFlightException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScope;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
class IdempotencyExceptionMappingTest {
|
||||
|
||||
private static final IdempotencyScope SCOPE =
|
||||
IdempotencyScope.of("user-1", "key-1", "CreateWorkLogUseCase");
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP);
|
||||
|
||||
@Test
|
||||
void inFlightMapsTo409ConflictNotRetryable() {
|
||||
ResponseEntity<Envelope<Void>> res =
|
||||
handler.handleIdempotentInFlight(new IdempotencyInFlightException(SCOPE));
|
||||
|
||||
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(res.getBody().error().code()).isEqualTo("IDEMPOTENT_IN_FLIGHT");
|
||||
assertThat(res.getBody().error().category()).isEqualTo("CONFLICT");
|
||||
assertThat(res.getBody().error().retryable()).isFalse();
|
||||
// diagnostic (with scope/principal) must not leak into the client message
|
||||
assertThat(res.getBody().error().message()).doesNotContain("user-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchMapsTo422ValidationNotRetryable() {
|
||||
ResponseEntity<Envelope<Void>> res =
|
||||
handler.handleIdempotentMismatch(new IdempotencyRequestMismatchException(SCOPE));
|
||||
|
||||
assertThat(res.getStatusCode().value()).isEqualTo(422);
|
||||
assertThat(res.getBody().error().code()).isEqualTo("IDEMPOTENT_REQUEST_MISMATCH");
|
||||
assertThat(res.getBody().error().category()).isEqualTo("VALIDATION");
|
||||
assertThat(res.getBody().error().retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopeMissingMapsTo400Validation() {
|
||||
ResponseEntity<Envelope<Void>> res =
|
||||
handler.handleIdempotencyScopeMissing(new IdempotencyScopeMissingException("principal"));
|
||||
|
||||
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(res.getBody().error().code()).isEqualTo("VALIDATION_FAILED");
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
|
||||
/**
|
||||
* feature-distributed-tracing-contract D12: verifies that the {@link GlobalExceptionHandler}
|
||||
* invokes {@link SpanErrorRecorder#recordException} with the correct error code on each exception
|
||||
* path where a code is in hand.
|
||||
*/
|
||||
class SpanErrorRecorderHookTest {
|
||||
|
||||
/** Capturing fake — records every (throwable, errorCode) pair received. */
|
||||
static final class CapturingRecorder implements SpanErrorRecorder {
|
||||
record Recorded(Throwable error, String errorCode) {}
|
||||
|
||||
final List<Recorded> calls = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void recordException(Throwable error, String errorCode) {
|
||||
calls.add(new Recorded(error, errorCode));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void catchAllHandlerRecordsExceptionWithInternalErrorCode() {
|
||||
CapturingRecorder recorder = new CapturingRecorder();
|
||||
GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder);
|
||||
|
||||
RuntimeException boom = new RuntimeException("kaboom");
|
||||
handler.handleUnknown(boom, new ServletWebRequest(new MockHttpServletRequest("GET", "/x")));
|
||||
|
||||
assertThat(recorder.calls).hasSize(1);
|
||||
assertThat(recorder.calls.get(0).error()).isSameAs(boom);
|
||||
assertThat(recorder.calls.get(0).errorCode()).isEqualTo(OperationalError.INTERNAL_ERROR.code());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureHandlerRecordsExceptionWithClassifiedCode() {
|
||||
CapturingRecorder recorder = new CapturingRecorder();
|
||||
GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder);
|
||||
|
||||
PersistenceFailureException ex =
|
||||
new PersistenceFailureException(
|
||||
OperationalError.DB_UNIQUE_VIOLATION,
|
||||
"SQLState=23505",
|
||||
new SQLException("unique key violation", "23505"));
|
||||
|
||||
handler.handlePersistenceFailure(ex);
|
||||
|
||||
assertThat(recorder.calls).hasSize(1);
|
||||
assertThat(recorder.calls.get(0).error()).isSameAs(ex);
|
||||
assertThat(recorder.calls.get(0).errorCode())
|
||||
.isEqualTo(OperationalError.DB_UNIQUE_VIOLATION.code());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyFailureHandlerRecordsExceptionWithClassifiedCode() {
|
||||
CapturingRecorder recorder = new CapturingRecorder();
|
||||
GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder);
|
||||
|
||||
DependencyFailureException ex =
|
||||
new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_TIMEOUT, "payment-service", "connect timed out", null);
|
||||
|
||||
handler.handleDependencyFailure(ex);
|
||||
|
||||
assertThat(recorder.calls).hasSize(1);
|
||||
assertThat(recorder.calls.get(0).error()).isSameAs(ex);
|
||||
assertThat(recorder.calls.get(0).errorCode())
|
||||
.isEqualTo(OperationalError.DEPENDENCY_TIMEOUT.code());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noopRecorderDoesNotThrowAndHandlerStillReturnsResponse() {
|
||||
// Verify the happy path: NOOP recorder wired — no exceptions thrown, response still built.
|
||||
GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP);
|
||||
|
||||
var response =
|
||||
handler.handleUnknown(
|
||||
new RuntimeException("noop test"),
|
||||
new ServletWebRequest(new MockHttpServletRequest("GET", "/noop")));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().error().code()).isEqualTo(OperationalError.INTERNAL_ERROR.code());
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException;
|
||||
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
|
||||
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
||||
import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
/**
|
||||
* Pins the transport-shape failure classification owned by feature-api-contract-baseline: 405 +
|
||||
* {@code Allow} (D12), 406 vs 415 distinct (D9), 413 (D8), 412 (D15), and the pagination/cursor
|
||||
* 400s (D18/D22). Standalone MockMvc routes the Spring exceptions to {@link
|
||||
* GlobalExceptionHandler}.
|
||||
*/
|
||||
class TransportErrorHandlingTest {
|
||||
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc =
|
||||
MockMvcBuilders.standaloneSetup(new Probe())
|
||||
.setControllerAdvice(
|
||||
new GlobalExceptionHandler(SpanErrorRecorder.NOOP), new EnvelopeBodyAdvice())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void methodNotAllowedIs405WithAllowHeaderAndEnvelope() throws Exception {
|
||||
mvc.perform(post("/t/get-only").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(header().string("Allow", containsString("GET")))
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.error.code").value("METHOD_NOT_ALLOWED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedMediaTypeIs415() throws Exception {
|
||||
mvc.perform(post("/t/json").contentType(MediaType.TEXT_PLAIN).content("hello"))
|
||||
.andExpect(status().isUnsupportedMediaType())
|
||||
.andExpect(jsonPath("$.error.code").value("UNSUPPORTED_MEDIA_TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notAcceptableIs406DistinctFrom415() throws Exception {
|
||||
// Trigger the exception directly: the produces/Accept-mismatch path would
|
||||
// also fail to serialize the 406 envelope itself to the rejected media type.
|
||||
mvc.perform(get("/t/not-acceptable"))
|
||||
.andExpect(status().isNotAcceptable())
|
||||
.andExpect(jsonPath("$.error.code").value("NOT_ACCEPTABLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preconditionFailedIs412Conflict() throws Exception {
|
||||
mvc.perform(get("/t/precondition"))
|
||||
.andExpect(status().isPreconditionFailed())
|
||||
.andExpect(jsonPath("$.error.code").value("PRECONDITION_FAILED"))
|
||||
.andExpect(jsonPath("$.error.category").value("CONFLICT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedPayloadIs413NotRaw500() throws Exception {
|
||||
mvc.perform(get("/t/too-large"))
|
||||
.andExpect(status().isContentTooLarge())
|
||||
.andExpect(jsonPath("$.error.code").value("PAYLOAD_TOO_LARGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void badPaginationIs400ValidationWithFieldAndCode() throws Exception {
|
||||
mvc.perform(get("/t/bad-page"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.error.details.field").value("size"))
|
||||
.andExpect(jsonPath("$.error.details.code").value("SIZE_EXCEEDS_MAX"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void badCursorIs400Validation() throws Exception {
|
||||
mvc.perform(get("/t/bad-cursor"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.error.details.code").value("CURSOR_INVALID"));
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Probe {
|
||||
|
||||
@GetMapping("/t/get-only")
|
||||
Map<String, String> getOnly() {
|
||||
return Map.of("ok", "ok");
|
||||
}
|
||||
|
||||
@PostMapping(value = "/t/json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
Map<String, String> json(@RequestBody Map<String, Object> body) {
|
||||
return Map.of("ok", "ok");
|
||||
}
|
||||
|
||||
@GetMapping("/t/not-acceptable")
|
||||
Map<String, String> notAcceptable() throws HttpMediaTypeNotAcceptableException {
|
||||
throw new HttpMediaTypeNotAcceptableException(List.of(MediaType.APPLICATION_JSON));
|
||||
}
|
||||
|
||||
@GetMapping("/t/precondition")
|
||||
Map<String, String> precondition() {
|
||||
throw new PreconditionFailedException("If-Match did not match current ETag");
|
||||
}
|
||||
|
||||
@GetMapping("/t/too-large")
|
||||
Map<String, String> tooLarge() {
|
||||
throw new MaxUploadSizeExceededException(1024L);
|
||||
}
|
||||
|
||||
@GetMapping("/t/bad-page")
|
||||
Map<String, String> badPage() {
|
||||
throw new PageValidationException("size", "SIZE_EXCEEDS_MAX", "size must be <= 100");
|
||||
}
|
||||
|
||||
@GetMapping("/t/bad-cursor")
|
||||
Map<String, String> badCursor() {
|
||||
throw new CursorException("cursor token signature is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
class CacheControlFilterTest {
|
||||
|
||||
private final CacheControlFilter filter = new CacheControlFilter();
|
||||
|
||||
@Test
|
||||
void setsNoStoreAndVaryByDefault() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(req, res, (rq, rs) -> {});
|
||||
|
||||
assertThat(res.getHeader("Cache-Control")).isEqualTo("no-store");
|
||||
assertThat(res.getHeader("Vary")).isEqualTo("Accept, Accept-Encoding, Authorization");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheableEndpointCanOptInByOverwritingCacheControl() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
req,
|
||||
res,
|
||||
(rq, rs) -> ((HttpServletResponse) rs).setHeader("Cache-Control", "private, max-age=60"));
|
||||
|
||||
assertThat(res.getHeader("Cache-Control")).isEqualTo("private, max-age=60");
|
||||
assertThat(res.getHeader("Vary")).isEqualTo("Accept, Accept-Encoding, Authorization");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotStripAContainerSetDateHeader() throws Exception {
|
||||
// feature-api-contract-baseline D24: the servlet container (Tomcat) emits Date on
|
||||
// every response; the cache-policy filter must not remove it. (The full
|
||||
// 200/204/400/404/500 Date matrix is verified by a running container — `planned`.)
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
res.setHeader("Date", "Mon, 02 Jun 2026 00:00:00 GMT");
|
||||
|
||||
filter.doFilter(req, res, (rq, rs) -> {});
|
||||
|
||||
assertThat(res.getHeader("Date")).isEqualTo("Mon, 02 Jun 2026 00:00:00 GMT");
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
class RequestLoggingFilterTest {
|
||||
|
||||
private final RequestLoggingFilter filter = new RequestLoggingFilter(raw -> "pseudo-" + raw);
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesSnakeCaseRequestIdAndEchoesHeaderWhenAbsent() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
AtomicReference<String> seen = new AtomicReference<>();
|
||||
FilterChain chain = (rq, rs) -> seen.set(MDC.get(MdcKeys.REQUEST_ID));
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(seen.get()).as("request_id present on MDC during the chain").isNotBlank();
|
||||
assertThat(res.getHeader("X-Request-Id")).isEqualTo(seen.get());
|
||||
assertThat(MDC.get(MdcKeys.REQUEST_ID)).as("MDC cleared after request").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void honoursClientRequestIdAndCorrelationIdHeaders() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
req.addHeader("X-Request-Id", "client-req-1");
|
||||
req.addHeader("X-Correlation-Id", "client-corr-1");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
AtomicReference<String> reqId = new AtomicReference<>();
|
||||
AtomicReference<String> corrId = new AtomicReference<>();
|
||||
FilterChain chain =
|
||||
(rq, rs) -> {
|
||||
reqId.set(MDC.get(MdcKeys.REQUEST_ID));
|
||||
corrId.set(MDC.get(MdcKeys.CORRELATION_ID));
|
||||
};
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(reqId.get()).isEqualTo("client-req-1");
|
||||
assertThat(corrId.get()).isEqualTo("client-corr-1");
|
||||
assertThat(res.getHeader("X-Correlation-Id")).isEqualTo("client-corr-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesCrlfInjectionInInboundRequestId() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
req.addHeader("X-Request-Id", "foo\r\nFAKE LOG");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
AtomicReference<String> reqId = new AtomicReference<>();
|
||||
FilterChain chain = (rq, rs) -> reqId.set(MDC.get(MdcKeys.REQUEST_ID));
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(reqId.get()).isEqualTo("fooFAKE LOG");
|
||||
assertThat(reqId.get()).doesNotContain("\r").doesNotContain("\n");
|
||||
}
|
||||
|
||||
// DRIFT-3: logs the route template, not the raw concrete path
|
||||
@Test
|
||||
void logsUriTemplateNotRawPathWhenHandlerMappingAttributeSet() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123");
|
||||
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
ch.qos.logback.classic.Logger logger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
|
||||
listAppender.start();
|
||||
logger.addAppender(listAppender);
|
||||
|
||||
try {
|
||||
filter.doFilter(req, res, (rq, rs) -> {});
|
||||
|
||||
List<ILoggingEvent> events = listAppender.list;
|
||||
assertThat(events).isNotEmpty();
|
||||
ILoggingEvent event =
|
||||
events.stream()
|
||||
.filter(e -> e.getFormattedMessage().contains("http_request"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("No http_request log event found"));
|
||||
|
||||
String msg = event.getFormattedMessage();
|
||||
assertThat(msg).contains("uri_template=/v1/worklogs/{id}");
|
||||
assertThat(msg).doesNotContain("uri_template=/v1/worklogs/123");
|
||||
} finally {
|
||||
logger.detachAppender(listAppender);
|
||||
}
|
||||
}
|
||||
|
||||
// feature-distributed-tracing-contract D5/D7/D4: W3C traceparent handling
|
||||
|
||||
/**
|
||||
* D5/D7: a valid inbound {@code traceparent} header is adopted — its traceId and spanId are
|
||||
* placed on MDC and the same traceparent value is echoed on the response. D4: {@link
|
||||
* ResponseMetaFactory#fromMdc()} returns a non-null traceId.
|
||||
*/
|
||||
@Test
|
||||
void validInboundTraceparentIsAdoptedIntoMdcAndEchoedOnResponse() throws Exception {
|
||||
String validTraceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x");
|
||||
req.addHeader("traceparent", validTraceparent);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
AtomicReference<String> capturedTraceId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedSpanId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedMetaTraceId = new AtomicReference<>();
|
||||
FilterChain chain =
|
||||
(rq, rs) -> {
|
||||
capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID));
|
||||
capturedSpanId.set(MDC.get(MdcKeys.SPAN_ID));
|
||||
capturedMetaTraceId.set(ResponseMetaFactory.fromMdc().traceId());
|
||||
};
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(capturedTraceId.get())
|
||||
.as("MDC trace_id must be adopted from traceparent traceId")
|
||||
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(capturedSpanId.get())
|
||||
.as("MDC span_id must be adopted from traceparent spanId")
|
||||
.isEqualTo("00f067aa0ba902b7");
|
||||
assertThat(res.getHeader("traceparent"))
|
||||
.as("response traceparent must echo the resolved value")
|
||||
.isEqualTo(validTraceparent);
|
||||
assertThat(capturedMetaTraceId.get())
|
||||
.as("D4: ResponseMetaFactory.fromMdc().traceId() must be non-null when traceparent adopted")
|
||||
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
// MDC must be cleaned up after filter
|
||||
assertThat(MDC.get(MdcKeys.TRACE_ID)).isNull();
|
||||
assertThat(MDC.get(MdcKeys.SPAN_ID)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* D4 / disabled-tracing fallback: when no inbound {@code traceparent} header is present, the
|
||||
* filter generates a fresh ROOT traceparent — 32-hex traceId (non-null, non-all-zeros), so {@link
|
||||
* ResponseMetaFactory#fromMdc()} keeps {@code meta.traceId} non-null even when no tracer/exporter
|
||||
* is wired.
|
||||
*/
|
||||
@Test
|
||||
void absentTraceparentGeneratesFreshW3cRootAndMetaTraceIdIsNonNull() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/y");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
AtomicReference<String> capturedTraceId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedSpanId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedMetaTraceId = new AtomicReference<>();
|
||||
FilterChain chain =
|
||||
(rq, rs) -> {
|
||||
capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID));
|
||||
capturedSpanId.set(MDC.get(MdcKeys.SPAN_ID));
|
||||
capturedMetaTraceId.set(ResponseMetaFactory.fromMdc().traceId());
|
||||
};
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(capturedTraceId.get())
|
||||
.as("generated trace_id must be non-null, 32 hex chars")
|
||||
.isNotNull()
|
||||
.hasSize(32)
|
||||
.matches("[0-9a-f]{32}");
|
||||
assertThat(capturedTraceId.get())
|
||||
.as("generated trace_id must not be all zeros")
|
||||
.isNotEqualTo("00000000000000000000000000000000");
|
||||
assertThat(capturedSpanId.get())
|
||||
.as("generated span_id must be non-null, 16 hex chars")
|
||||
.isNotNull()
|
||||
.hasSize(16)
|
||||
.matches("[0-9a-f]{16}");
|
||||
assertThat(res.getHeader("traceparent"))
|
||||
.as("response traceparent header must be set")
|
||||
.isNotNull()
|
||||
.startsWith("00-");
|
||||
assertThat(capturedMetaTraceId.get())
|
||||
.as("D4: meta.traceId must be non-null even with no tracer (disabled-tracing fallback)")
|
||||
.isNotNull()
|
||||
.hasSize(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* D4 fallback: an INVALID inbound {@code traceparent} header (malformed) yields the same
|
||||
* generated-root behavior — traceId is a fresh 32-hex value.
|
||||
*/
|
||||
@Test
|
||||
void invalidTraceparentGeneratesFreshRootTraceId() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/z");
|
||||
req.addHeader("traceparent", "NOT-A-VALID-TRACEPARENT");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
AtomicReference<String> capturedTraceId = new AtomicReference<>();
|
||||
FilterChain chain = (rq, rs) -> capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID));
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(capturedTraceId.get())
|
||||
.as("invalid traceparent must yield a fresh generated 32-hex traceId")
|
||||
.isNotNull()
|
||||
.hasSize(32)
|
||||
.matches("[0-9a-f]{32}");
|
||||
}
|
||||
|
||||
/**
|
||||
* §테스트계약: request_id and correlation_id MDC keys are still set regardless of traceparent
|
||||
* handling, keeping existing observability contracts intact.
|
||||
*/
|
||||
@Test
|
||||
void requestIdAndCorrelationIdMdcStillSetAlongsideTraceparent() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/w");
|
||||
req.addHeader("X-Request-Id", "my-req-id");
|
||||
req.addHeader("X-Correlation-Id", "my-corr-id");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
AtomicReference<String> capturedReqId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedCorrId = new AtomicReference<>();
|
||||
AtomicReference<String> capturedTraceId = new AtomicReference<>();
|
||||
FilterChain chain =
|
||||
(rq, rs) -> {
|
||||
capturedReqId.set(MDC.get(MdcKeys.REQUEST_ID));
|
||||
capturedCorrId.set(MDC.get(MdcKeys.CORRELATION_ID));
|
||||
capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID));
|
||||
};
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(capturedReqId.get()).isEqualTo("my-req-id");
|
||||
assertThat(capturedCorrId.get()).isEqualTo("my-corr-id");
|
||||
assertThat(capturedTraceId.get())
|
||||
.as("trace_id must be set (W3C generated root since no traceparent header)")
|
||||
.isNotNull()
|
||||
.hasSize(32)
|
||||
.matches("[0-9a-f]{32}");
|
||||
}
|
||||
|
||||
// DRIFT-6: user_principal on MDC must be pseudonymized, never raw
|
||||
@Test
|
||||
void putsPseudonymizedUserPrincipalOnMdcNotRawId() throws Exception {
|
||||
AuthenticatedPrincipal user =
|
||||
new AuthenticatedPrincipal("raw-user-1", "user@example.com", Set.of());
|
||||
TestingAuthenticationToken auth = new TestingAuthenticationToken(user, null);
|
||||
auth.setAuthenticated(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/test");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
ch.qos.logback.classic.Logger logger =
|
||||
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
|
||||
listAppender.start();
|
||||
logger.addAppender(listAppender);
|
||||
|
||||
try {
|
||||
// user_principal is set in the filter's finally block (after chain.doFilter) and is
|
||||
// captured on the http_request log event's MDC snapshot, asserted below.
|
||||
filter.doFilter(req, res, (rq, rs) -> {});
|
||||
|
||||
// Capture the MDC value from the http_request log event (which fires after putUserPrincipal)
|
||||
List<ILoggingEvent> events = listAppender.list;
|
||||
ILoggingEvent event =
|
||||
events.stream()
|
||||
.filter(e -> e.getFormattedMessage().contains("http_request"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("No http_request log event found"));
|
||||
|
||||
String userPrincipalOnMdc = event.getMDCPropertyMap().get("user_principal");
|
||||
assertThat(userPrincipalOnMdc).isEqualTo("pseudo-raw-user-1");
|
||||
assertThat(userPrincipalOnMdc).isNotEqualTo("raw-user-1");
|
||||
} finally {
|
||||
logger.detachAppender(listAppender);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user