init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
# feature-container-runtime-contract — .dockerignore for src/ build context
#
# The docker build context is src/ (the Gradle root). This file excludes build
# noise that must not enter the image build context, while keeping everything
# the builder stage needs to resolve dependencies and run bootJar.
# ---- Version control --------------------------------------------------------
.git
.gitignore
.gitattributes
# ---- Gradle build output ----------------------------------------------------
# Exclude all module build/ directories; the builder stage produces them inside the container.
build/
**/build/
.gradle/
**/.gradle/
# ---- IDE / editor files -----------------------------------------------------
.idea/
**/.idea/
*.iml
.vscode/
**/.vscode/
*.eclipse
.project
.classpath
.settings/
# ---- Documentation / governance (not needed for image build) ----------------
# Root CLAUDE.md and AGENTS.md are governance docs; not needed at build time.
*.md
docs/
# ---- Environment / secrets (NEVER bake secrets into image layers) -----------
.env
**/.env
*.env
# ---- OS artifacts -----------------------------------------------------------
.DS_Store
**/.DS_Store
Thumbs.db
# ---- Test reports and coverage ----------------------------------------------
**/test-results/
**/reports/
**/jacoco/
# ---- Explicitly keep (negation rules to be safe) ----------------------------
# The Gradle wrapper, source trees, and build configuration are needed.
# Negation rules are not strictly required because the above globs don't
# accidentally exclude src/*, but listed for clarity.
!gradlew
!gradlew.bat
!gradle/
!**/src/
!**/build.gradle
!settings.gradle
+149
View File
@@ -0,0 +1,149 @@
# ----------------------------------------------------------------------------
# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다
# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조.
# ----------------------------------------------------------------------------
# ----- App identity -----
APP_NAME=ca-skeleton
SPRING_PROFILES_ACTIVE=local
# ----- Runtime safety (StartupSafetyValidator, D8) -----
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=true
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_IDEMPOTENCY_TTL=24h
# ----- Async executor -----
APP_ASYNC_EXECUTOR_CORE_SIZE=10
APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- Outbound HTTP client -----
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10
APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB
# ----- Logging: root & app levels -----
APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=DEBUG
# ----- Logging: per-package levels -----
APP_LOG_LEVEL_SPRING=INFO
APP_LOG_LEVEL_WEB=INFO
APP_LOG_LEVEL_SQL=WARN
# ----- Logging: file output + rolling -----
APP_LOG_FILE_ENABLED=false
APP_LOG_FILE_PATH=logs/ca-skeleton.json
APP_LOG_FILE_MAX_SIZE=100MB
APP_LOG_FILE_MAX_HISTORY=14
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
# ----- Logging: async appender -----
APP_LOG_ASYNC_ENABLED=true
APP_LOG_ASYNC_QUEUE_SIZE=512
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
# ----- Logging: JSON encoder -----
APP_LOG_JSON_TIMEZONE=UTC
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
# ----- Logging: sampling -----
APP_LOG_SAMPLING_RATE=1.0
# ----- Distributed tracing -----
OTEL_EXPORTER_OTLP_ENDPOINT=
APP_TRACING_ENABLED=true
APP_TRACING_SAMPLE_RATE=
# ----- Privacy: user_principal pseudonymization -----
APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt
# ----- Spring Boot bootstrap -----
SPRING_BANNER_MODE=console
SPRING_MAIN_LAZY_INITIALIZATION=false
SPRING_MAIN_LOG_STARTUP_INFO=true
SPRING_THREADS_VIRTUAL_ENABLED=true
# ----- Jackson: deserialization policy -----
SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true
SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true
SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true
SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false
# ----- Jackson: serialization policy -----
SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false
# ----- Server / Tomcat -----
APP_SERVER_PORT=8080
APP_SERVER_SHUTDOWN=graceful
APP_SERVER_SHUTDOWN_TIMEOUT=30s
APP_SERVER_TOMCAT_MAX_THREADS=200
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
APP_SERVER_COMPRESSION_ENABLED=true
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
# ----- Presentation -----
PRESENTATION_API_BASE_PATH=/api
# ----- Auth (OIDC resource server) -----
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
APP_SECURITY_CORS_ORIGINS=http://localhost:3000
APP_SECURITY_CORS_ALLOWED_METHODS=
APP_SECURITY_CORS_ALLOWED_HEADERS=*
APP_SECURITY_CORS_ALLOW_CREDENTIALS=true
APP_SECURITY_CORS_MAX_AGE=3600
# ----- Database (Postgres) -----
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
APP_DATASOURCE_USERNAME=ca_skeleton
APP_DATASOURCE_PASSWORD=ca_skeleton
APP_DATASOURCE_DRIVER=org.postgresql.Driver
APP_DATASOURCE_DDL_AUTO=update
APP_DATASOURCE_SHOW_SQL=false
APP_DATASOURCE_FORMAT_SQL=false
APP_DATASOURCE_OPEN_IN_VIEW=false
# ----- Database: HikariCP connection pool -----
APP_DATASOURCE_POOL_MAX_SIZE=10
APP_DATASOURCE_POOL_MIN_IDLE=2
APP_DATASOURCE_CONNECTION_TIMEOUT=30000
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
# ----- Management / Actuator -----
MANAGEMENT_SERVER_PORT=9001
+14
View File
@@ -0,0 +1,14 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
.DS_Store
+139
View File
@@ -0,0 +1,139 @@
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
# =============================================================================
# feature-container-runtime-contract — multi-stage image build
#
# Build requirements:
# docker build -f src/Dockerfile src/ -t caskeleton:local \
# --build-arg RELEASE_VERSION=1.2.3 \
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
# --build-arg GIT_SHA=a1b2c3d4e5f6 \
# --build-arg SOURCE_URL=https://github.com/your-org/your-repo
#
# Design decisions (feature-container-runtime-contract D2/D3/D4):
# D2 — read-only root filesystem: writable mounts must be declared explicitly.
# D3 — non-root user, JRE-only final stage (no full JDK).
# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError.
# =============================================================================
ARG RELEASE_VERSION
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# ---- Stage 1: builder -------------------------------------------------------
# Uses the full JDK only in the build stage, never in the final image.
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
ARG RELEASE_VERSION
ARG GIT_SHA
WORKDIR /build
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
# single structure-preserving glob replaces the former per-module COPY list: new modules are
# picked up automatically and this stage never drifts out of sync with settings.gradle again.
# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below.
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
COPY gradlew ./
COPY gradle/ gradle/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
# while still returning exit code 0 and therefore is not a release gate.
RUN test -n "${RELEASE_VERSION}" \
&& test -n "${GIT_SHA}" \
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Copy full source and build the JAR
COPY . .
RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Locate the produced JAR (avoids hardcoding the version string)
RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (D3: no full JDK in production image).
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# OCI image labels (build-arg placeholders — supply at docker build time).
LABEL org.opencontainers.image.title="caskeleton" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}"
# A release image without source/version metadata is not an artifact this contract permits.
RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}"
# ---- Locale / timezone (D4) -------------------------------------------------
# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages.
# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image.
ENV TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# ---- Writable HOME under read-only root fs (D2) -----------------------------
# The app user is created with --no-create-home and the root filesystem is
# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that
# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches)
# do not fail with a read-only-filesystem error.
ENV HOME=/tmp
# ---- JVM ergonomics (D4) ----------------------------------------------------
# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap.
# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here).
# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart.
# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable
# /var/tmp/heap mount (see tmpfs mounts in compose files, D2).
# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs).
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/tmp/heap \
-Dserver.tomcat.basedir=/tmp"
# ---- Filesystem layout (D2: read-only root filesystem) ----------------------
# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime.
# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above).
# Both directories are declared here so tooling is aware of them; at runtime they
# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files).
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
# ---- Non-root user (D3) -----------------------------------------------------
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=builder --chown=app:app /build/app.jar app.jar
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port (parallel actuator branch wires this endpoint)
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Targets the actuator readiness probe on the management port (9001).
# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented
# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here
# (container-side) and will pass once those branches are merged. In this worktree
# the endpoint may return 404; the container will be UNHEALTHY until merged.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+131
View File
@@ -0,0 +1,131 @@
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
# =============================================================================
# sample-portfolio standalone demo image — twin of src/Dockerfile.
#
# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the
# production composition root (CaSkeletonApplication) that src/Dockerfile builds.
# The sample is the demo-friendly entrypoint: its application.yml self-provides
# defaults for every env placeholder, so the only external dependency it needs
# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations).
#
# Build (no build-args required — this is a disposable demo, not a release artifact):
# docker build -f src/Dockerfile.sample src/ -t ca-sample:local
#
# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown):
# docker run --rm -p 8080:8080 -p 9001:9001 \
# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \
# ca-sample:local
#
# The multi-stage build, --parents descriptor glob, STRICT lock verification,
# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics
# are all identical to src/Dockerfile — only the bootJar target differs. Keep the
# two files' builder stages in sync.
# =============================================================================
# Demo defaults so the image builds with zero build-args. Two format constraints from the root
# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9):
# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21).
# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop.
# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder.
ARG RELEASE_VERSION=0.0.0
ARG BUILD_VERSION=0.0.0-sample
ARG GIT_SHA=0000000
ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample
# ---- Stage 1: builder -------------------------------------------------------
# Uses the full JDK only in the build stage, never in the final image.
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
ARG RELEASE_VERSION
ARG GIT_SHA
WORKDIR /build
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
# single structure-preserving glob replaces a per-module COPY list: new modules are picked up
# automatically and this stage never drifts out of sync with settings.gradle.
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
COPY gradlew ./
COPY gradle/ gradle/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
RUN test -n "${RELEASE_VERSION}" \
&& test -n "${GIT_SHA}" \
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Copy full source and build the sample JAR.
COPY . .
RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# Locate the produced JAR (avoids hardcoding the version string).
RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (no full JDK in the demo image either).
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
ARG BUILD_VERSION
ARG GIT_SHA
ARG SOURCE_URL
# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail
# on missing metadata — the ARG defaults above keep it buildable with no build-args.
LABEL org.opencontainers.image.title="caskeleton-sample" \
org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.version="${BUILD_VERSION}"
# ---- Locale / timezone ------------------------------------------------------
ENV TZ=UTC \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# ---- Writable HOME under read-only root fs ----------------------------------
ENV HOME=/tmp
# ---- JVM ergonomics ---------------------------------------------------------
# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a
# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem.
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75 \
-XX:+UseContainerSupport \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/tmp/heap \
-Dserver.tomcat.basedir=/tmp"
# ---- Filesystem layout (read-only root filesystem) --------------------------
# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir).
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
# ---- Non-root user ----------------------------------------------------------
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=builder --chown=app:app /build/app.jar app.jar
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Actuator readiness probe on the management port (9001). Ignored by Kubernetes,
# which uses its own probes — kept for docker/compose parity with src/Dockerfile.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+428
View File
@@ -0,0 +1,428 @@
# src — 빌드 스크립트 / 환경 변수 참조
`src/` 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 [CLAUDE.md](../CLAUDE.md)
와 [AGENTS.md](../AGENTS.md), 모듈별 규칙은 각 모듈의 `CLAUDE.md` 가 SSOT 입니다.
이 문서는 [build.gradle](build.gradle) 과 [.env](.env) 의 코드 주석에서 덜어낸 **설정 항목 설명과
결정 근거**를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조"
포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다.
---
## build.gradle — 빌드 / 검증 게이트
모든 모듈의 `check` 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가
모두 green 이어야 합니다.
| 게이트 | 하는 일 |
| --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
### Local bootstrap
`./gradlew bootstrap``bootstrapCompile``bootstrapDependencies`
`bootstrapMigrateAndStart``bootstrapSampleContract``bootstrapSmoke`를 순서대로 실행합니다.
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
```bash
./gradlew verifyReadmeCommands
```
### Traceable version + dependency locking
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
`-PreleaseVersion`/`RELEASE_VERSION`, revision은 `-PgitRevision`/`GIT_SHA`/`GITHUB_SHA` 순으로
주입하고, 로컬에서는 현재 Git commit을 읽습니다.
- 모든 JAR manifest에 `Implementation-Version``Build-Revision`을 기록합니다. Git metadata도
revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서
실패합니다.
- 모든 subproject가 `lockAllConfigurations()` + `LockMode.STRICT`를 사용하고, lock state는 Gradle
기본 `<module>/gradle.lockfile`에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와
같습니다.
- lock 갱신은 `./gradlew resolveAndLockAll --write-locks` 한 가지 명령으로 수행합니다. 이 task는
`--write-locks`가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다.
### Reproducible archives (D10)
모든 `AbstractArchiveTask`는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며,
directory/file mode를 각각 `0755`/`0644`로 고정합니다. Java toolchain은 21이고 로컬·CI patch
version은 root `.tool-versions`의 Temurin 값으로 맞춥니다.
두 번의 clean, no-cache `bootJar` SHA-256 비교는 다음 명령으로 실행합니다.
```bash
cd ..
bash .github/scripts/verify-reproducible-build.sh
```
이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK
vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다.
### `-parameters` 컴파일러 플래그
- **결정.** 모든 subproject 의 Java 컴파일에 `-parameters` 플래그를 직접 설정합니다.
- **근거.** Spring MVC 는 `@PathVariable` / `@RequestParam` 의 이름을 reflection 의 parameter
metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이 `arg0`, `arg1` 로 컴파일되어 바인딩이
깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을
`apply false` 로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의 `JavaCompile`
에 직접 설정합니다.
### `verifyCleanArchitectureDependencies`
- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle
프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를
실패시킵니다.
- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit
규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는
보호 기능을 못 합니다.
### `verifyOneTypePerFile` (code-conventions I6)
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
`module-info.java` 는 예외입니다.
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다.
### `verifyEnvKeys`
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로
어긋나지 않게) 으로 유지합니다. `env-keys.yaml``APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
실패시킵니다.
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
- **검사 항목.**
- **A.** `application.yml` 의 placeholder 중 inline default(`${VAR:default}`)가 없는 **필수**
placeholder(`${VAR}`)는 반드시 `.env` 에 존재해야 합니다.
- **B.** `.env` 의 모든 키는 `application.yml` 의 어떤 `${...}` placeholder 가 참조해야 합니다.
(아무도 안 쓰는 키는 orphan 으로 간주해 실패)
- **C.** `.env` 의 모든 `APP_` 키는 registry 에 `- name: <KEY>` 행이 있어야 합니다.
- **`SPRING_*` 키는 왜 registry 추적 대상이 아닌가.** `SPRING_*` 는 Spring Boot 가 정의한 native 키라
프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러 `APP_` prefix 로만 범위를 좁혔습니다.
- **비고.** `src/.env` 가 프로젝트의 커밋된 env 파일이며, 별도의 `.env.example` 템플릿은 두지
않습니다.
### `verifyPublicPathSnapshot`
- **하는 일.** deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다.
- **배경.** 이 앱은 deny-by-default 입니다. 즉 `SECURITY_PUBLIC_PATHS` 가 먹이는 명시적 `permitAll()`
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env``SecuritySettings.publicPaths()`
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange`
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
반영합니다.
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh
checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다.
### `verifyTrivyignore`
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS`*merge 승인*(GitHub
네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가"
만 잡으므로 둘은 대체재가 아니라 보완재입니다.
- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고
기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는
trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle``maxWindowDays`
조정합니다.
- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가
스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은
`.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml`
에 있습니다.
### `verifyQuarantineSunset` + 플래키 격리
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
돕니다.
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check`
연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`),
(2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에
`@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다.
- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study`
강도 — 공식 best practice 아님). fork 는 `src/build.gradle``sunsetDays` 로 조정합니다.
- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 —
CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈
버킷(`quarantined: []`)으로 출고됩니다.
### CI 게이트 배선
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
- **워크플로.** `.github/workflows/ci-quality-gates.yml``release-gate` 잡이 모든 release-blocking
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
GitHub Actions 는 워크플로 간 `needs` 를 못 쓰므로 branch protection 의 required check 합집합으로
묶습니다.
---
## .env — 환경 변수 레퍼런스
`spring-dotenv``src/.env` 를 읽어 외부화 설정을 주입합니다(`bootRun` 의 working dir 가 `src/`
이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 `restart-only`(값 변경 시 재기동
필요)로 간주하세요.
### App identity
- **`APP_NAME`** — `spring.application.name` 과 JSON 로그의 `app` 필드. 자유 문자열.
- **`SPRING_PROFILES_ACTIVE`** — 활성 Spring profile. 보통 `local` | `dev` | `stage` | `prod`. JSON
로그의 `profile` 필드도 이 값을 씁니다.
### Runtime safety (기동 시 `StartupSafetyValidator` 가 fail-fast 검사, D8)
- **`APP_ERROR_DETAIL_EXPOSURE_ENABLED`** — 응답에 내부 에러 상세를 노출할지. `true` | `false`.
**`prod` 프로필에서는 반드시 `false`** 여야 하며, 아니면 기동이 실패합니다.
- **`APP_LOG_BODY_CAPTURE_ENABLED`** — 요청/응답 body 를 로그에 캡처할지. `true` | `false`.
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답).
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
- **`APP_IDEMPOTENCY_TTL`** — idempotency 레코드 기본 TTL. duration(예: `24h`, `72h`). 오래 도는 use
case 는 최대 72h 까지 override 가능. (D6)
### Async executor
`@Async` `ThreadPoolTaskExecutor` 풀 크기 설정입니다.
- **`APP_ASYNC_EXECUTOR_CORE_SIZE`** — 항상 살아있는 워커 수. 1 이상 정수.
- **`APP_ASYNC_EXECUTOR_MAX_SIZE`** — 워커 수 상한. core-size 이상.
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
1 이상 정수.
### Optional integration adapters
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
모듈의 기본값). Layer 1 의 `@ConditionalOnProperty` 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
fail-fast).
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
아니면 빈 값.
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성.
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email
비활성.
### Outbound HTTP client
- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을
강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로
보고 즉시 기동을 실패시킵니다.
- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
`10s`), 필수, non-zero.
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
있어야 하며(D3 가드), 없으면 기동 실패.
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
- **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수.
- **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero.
- **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double.
- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면
`MeterRegistry` 빈 필요(D3), 없으면 기동 실패.
- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j
`ofDefaults()`):
- **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float.
- **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수.
- **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수.
- **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero.
- **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수.
- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를
넘는 응답은 streaming API 를 써야 합니다(D7).
### Logging
**Root / app 레벨** — 허용값은 모두 `TRACE` | `DEBUG` | `INFO` | `WARN` | `ERROR` | `OFF`.
- **`APP_LOG_LEVEL_ROOT`** — root 로거 레벨.
- **`APP_LOG_LEVEL_APP`** — 앱 패키지 레벨.
**패키지별 레벨**(root 를 덮어씀) — 동일 허용값.
- **`APP_LOG_LEVEL_SPRING`** / **`APP_LOG_LEVEL_WEB`** — 각 패키지 레벨.
- **`APP_LOG_LEVEL_SQL`** — `DEBUG` 로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다.
**파일 출력 + rolling**
- **`APP_LOG_FILE_ENABLED`** — `true` 면 rolling JSON 파일 appender 를 붙입니다.
- **`APP_LOG_FILE_PATH`** — `bootRun` working dir(`src/`) 기준 상대 경로 또는 절대 경로.
- **`APP_LOG_FILE_MAX_SIZE`** — 파일 1개 최대 크기(단위 `KB` | `MB` | `GB`).
- **`APP_LOG_FILE_MAX_HISTORY`** — 보관할 rolled archive 개수. 1 이상 정수.
- **`APP_LOG_FILE_TOTAL_SIZE_CAP`** — 전체 rolled 파일 용량 상한(단위 `KB` | `MB` | `GB`, `0` = 비활성).
**Async appender**
- **`APP_LOG_ASYNC_ENABLED`** — `true` 면 appender 를 `AsyncAppender` 로 감싸 non-blocking I/O.
- **`APP_LOG_ASYNC_QUEUE_SIZE`** — back-pressure 전 in-memory 큐 깊이. 1 이상 정수.
- **`APP_LOG_ASYNC_DISCARDING_THRESHOLD`** — 남은 큐 용량이 이 값 미만이면 `TRACE`/`DEBUG`/`INFO`
이벤트를 버립니다(`WARN`/`ERROR` 는 항상 유지). `0` = 절대 버리지 않음. 0 이상 정수.
**JSON 인코더 세부**
- **`APP_LOG_JSON_TIMEZONE`** — IANA timezone(예: `UTC`, `Asia/Seoul`) 또는 `default`(JVM 기본).
- **`APP_LOG_JSON_TIMESTAMP_PATTERN`** — 타임스탬프 패턴. 보통 ISO 8601
(`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`).
- **`APP_LOG_JSON_INCLUDE_CALLER_DATA`** — `true` 면 file/method/line 을 추가. **성능 비용이 큽니다.**
- **`APP_LOG_JSON_LOGGER_NAME_LENGTH`** — `0` = 로거 이름 전체, 양수 = 패키지 축약(예: `36`
`dev.caskeleton.bootstrap.Foo``d.c.bootstrap.Foo` 로).
**Sampling (`SamplingTurboFilter`)**
- **`APP_LOG_SAMPLING_RATE`** — `INFO` 이하 로그를 남길 확률. [0.0, 1.0] float. prod 는 `0.1`(10%
샘플링)이 권장, `WARN`/`ERROR` 는 항상 유지. `1.0` = 샘플링 없음(dev/local/staging 기본).
### Distributed tracing
- **`OTEL_EXPORTER_OTLP_ENDPOINT`** — OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel
SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시 `TracingProperties` 가 검증합니다.
- **`APP_TRACING_ENABLED`** — `false` 면 tracing seam 은 꺼지지만, `meta.traceId`
`RequestLoggingFilter` 가 W3C `traceparent` 로 여전히 생성합니다(D4 disabled-fallback 보장).
- **`APP_TRACING_SAMPLE_RATE`** — per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float.
- per-profile 기본값(D6): prod = `0.01`, staging = `0.10`, dev/local = `1.0`.
- **결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix).** 빈 값이어야 per-profile resolver 의 기본값이
실제 tracer sampler 까지 도달합니다(`TracingSampleRateResolver` 가 SSOT). 값을 박으면 프로필별
기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다.
### Privacy: user_principal pseudonymization
- **`APP_PRIVACY_PSEUDONYMIZATION_SALT`** — secret 등급 HMAC-SHA-256 salt. `__LOCAL_DEV_` prefix 는 로컬 전용 sentinel 값이며,
**prod 에서는 secret-manager 가 주입하는 실제 값**을 써야 합니다.
### Spring Boot bootstrap
- **`SPRING_BANNER_MODE`** — `off` | `console` | `log`.
- **`SPRING_MAIN_LAZY_INITIALIZATION`** — `true` 면 빈 생성을 첫 사용 시점까지 지연.
- **`SPRING_MAIN_LOG_STARTUP_INFO`** — `true``Starting`/`Started` 로그 출력.
- **`SPRING_THREADS_VIRTUAL_ENABLED`** — `true` 면 Tomcat 요청 처리에 Java 21 virtual threads 사용.
### Jackson — deserialization policy
모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 **조용히 강제 변환하지
않고 즉시 실패**하게 만듭니다. 개별 DTO 에 클래스 레벨 `@JsonIgnoreProperties(ignoreUnknown = true)`
로 이 정책을 완화하는 것은 **금지**이며 ArchUnit 규칙으로 막혀 있습니다.
- **`..._FAIL_ON_UNKNOWN_PROPERTIES`** — `true`: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+
기본).
- **`..._FAIL_ON_NULL_FOR_PRIMITIVES`** — `true`: primitive 필드에 JSON `null` 이 와도 `0`/`false`
로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(`Integer`/`Long`/`Boolean`)
`Optional<T>` 를 쓰세요.
- **`..._FAIL_ON_IGNORED_PROPERTIES`** — `true`: JSON 에 `@JsonIgnore` 처리된 필드가 들어오면 throw
(조용히 버리는 대신 계약 drift 를 노출).
- **`..._READ_UNKNOWN_ENUM_VALUES_AS_NULL`** — `false`(Jackson 기본 유지): 모르는 enum 값이 조용히
`null` 이 되지 않고 throw 되어 `VALIDATION_FAILED` 로 드러나게 합니다.
### Jackson — serialization policy
응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 **명시적으로 못박아**, 미래에 Spring
Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(`spring.mvc.problemdetails.enabled=false`
와 같은 근거). `JacksonSerializationPolicyTest` 가 강제하며, `new BigDecimal(double)` 생성자는
`no_bigdecimal_double_constructor` ArchUnit 규칙으로 금지됩니다.
- **`..._WRITE_DATES_AS_TIMESTAMPS`** — `false`(D2 / RFC 3339): `java.time` 값을 ISO-8601 문자열로
직렬화(`OffsetDateTime``"...Z"`, `LocalDate``"YYYY-MM-DD"`). `true` 면 epoch 숫자나
`[y,m,d,...]` 배열로 나가 datetime 계약이 깨집니다.
- **BigDecimal plain output** — Jackson 3에는 별도 `WRITE_BIGDECIMAL_AS_PLAIN` 설정 키가 없습니다.
`JacksonSerializationPolicyTest``"12300000000.00"` plain 출력을 직접 검증하고,
`no_bigdecimal_double_constructor` ArchUnit 규칙이 부정확한 `new BigDecimal(double)` 생성을
금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string,
내부 API 는 number+plain).
### Server / Tomcat
- **`APP_SERVER_PORT`** — 1~65535 정수.
- **`APP_SERVER_SHUTDOWN`** — `graceful` | `immediate`.
- **`APP_SERVER_SHUTDOWN_TIMEOUT`** — duration(예: `30s` | `1m` | `500ms`).
- **`APP_SERVER_TOMCAT_MAX_THREADS`** — 동시 요청 워커 상한. 1 이상 정수.
- **`APP_SERVER_TOMCAT_MIN_SPARE_THREADS`** — idle 워커 풀 하한. 0 이상 정수.
- **`APP_SERVER_TOMCAT_ACCEPT_COUNT`** — 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수.
- **`APP_SERVER_TOMCAT_MAX_CONNECTIONS`** — 동시에 열 수 있는 연결 수 상한. 1 이상 정수.
- **`APP_SERVER_TOMCAT_CONNECTION_TIMEOUT`** — duration(예: `20s` | `1m`).
- **`APP_SERVER_COMPRESSION_ENABLED`** — `true` | `false`.
- **`APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE`** — 이 크기 미만 payload 는 압축하지 않음(bytes 또는
단위, 예: `1024` | `1KB` | `2KB`).
- **`APP_SERVER_FORWARD_HEADERS_STRATEGY`** — `none` | `native` | `framework`. LB/proxy 뒤에서
`X-Forwarded-*` 를 신뢰할지.
- **`APP_SERVER_ERROR_INCLUDE_STACKTRACE`** — `always` | `never` | `on_param`.
- **`APP_SERVER_ERROR_INCLUDE_MESSAGE`** — `always` | `never` | `on_param`.
### Presentation
- **`PRESENTATION_API_BASE_PATH`** — 모든 controller 앞에 붙는 leading-slash 경로(예: `/api` | `/v1` |
`""`).
### Auth (OIDC resource server)
- **`APP_SECURITY_JWT_ISSUER`** — OIDC issuer URI(Keycloak realm, Auth0 tenant 등). **필수** — 없으면
기동 실패. 예: `https://keycloak.example.com/realms/ca-skeleton`.
- **`APP_SECURITY_JWT_AUDIENCE`** — 기대하는 `aud` claim. 빈 값으로 두면 audience 검증을 건너뜁니다.
- **`SECURITY_PUBLIC_PATHS`** — 인증을 우회하는 경로 CSV. `api-base-path` 뒤의 full path 를 씁니다(예:
`/api/healthcheck`). 이 값의 변경은 `verifyPublicPathSnapshot` 게이트가 감시합니다(위 build.gradle
설명 참조).
### CORS
- **`APP_SECURITY_CORS_ENABLED`** — `true` | `false`.
- **`APP_SECURITY_CORS_ORIGINS`** — 허용 origin CSV(예:
`http://localhost:3000,https://app.example.com`).
- **`APP_SECURITY_CORS_ALLOWED_METHODS`** — 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH,
PUT, DELETE, OPTIONS).
- **`APP_SECURITY_CORS_ALLOWED_HEADERS`** — 허용 헤더 CSV. `*` = 모든 헤더 허용.
- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`.
- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초).
### Database (Postgres)
- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`).
- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정.
- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`).
- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod
`validate` 또는 `none`**, local 은 `update` 가 편리합니다.
- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo.
- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음).
- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.**
**HikariCP 커넥션 풀**
- **`APP_DATASOURCE_POOL_MAX_SIZE`** — DB 동시 연결 최대 수. 1 이상 정수.
- **`APP_DATASOURCE_POOL_MIN_IDLE`** — warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수.
- **`APP_DATASOURCE_CONNECTION_TIMEOUT`** — `acquire()` 가 실패하기 전 대기 시간(ms).
- **`APP_DATASOURCE_POOL_IDLE_TIMEOUT`** — idle 연결 회수 임계 시간(ms).
- **`APP_DATASOURCE_POOL_MAX_LIFETIME`** — 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록
설정합니다.
### Management / Actuator
- **결정 — management 포트 분리.** actuator 엔드포인트를 앱 API 와 **같은 소켓에 노출하지 않으려고**
별도 management 포트를 둡니다.
- **`MANAGEMENT_SERVER_PORT`** — 1~65535 정수. **`APP_SERVER_PORT`(8080)와 달라야 합니다.**
+60
View File
@@ -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
```
+76
View File
@@ -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 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
+28
View File
@@ -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'
}
+171
View File
@@ -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=
@@ -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;
};
}
}
@@ -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!
}
@@ -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;
}
}
}
@@ -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);
}
}
+64
View File
@@ -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
```
+85
View File
@@ -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 로 유지된다.
+36
View File
@@ -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'
}
+173
View File
@@ -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=
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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");
}
}
+198
View File
@@ -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
```
+414
View File
@@ -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 으로 라우팅.
+16
View File
@@ -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'
}
+181
View File
@@ -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=
@@ -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");
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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);
};
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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())));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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());
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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());
};
}
}
@@ -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());
}
}
@@ -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()));
}
}
@@ -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);
}
}
@@ -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);
}
}
}
}
@@ -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() {}
}
@@ -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)
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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() {}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
interface ClientIpResolver {
String resolve(HttpServletRequest request);
}
@@ -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();
};
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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
}
@@ -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
}
@@ -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) {}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
};
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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")));
}
}
@@ -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 {}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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));
}
}
@@ -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 {}
}
@@ -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");
}
}
}

Some files were not shown because too many files have changed in this diff Show More