chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 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
+233
View File
@@ -0,0 +1,233 @@
# ----------------------------------------------------------------------------
# 외부화 설정의 단일 출처. 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=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- 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_CANONICAL_DEFAULT_PROVIDER=disabled
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
# which capabilities compose once Redis is on; none of them turns Redis on.
APP_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- 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_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
# ----- 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
# ----- Fileserver HTTP platform (app.fileserver-platform.*) -----
# Off by default. While false nothing below is bound: the platform auto-configuration binds this
# block itself and is not processed until the master switch is true.
APP_FILESERVER_PLATFORM_ENABLED=false
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
# Storage root must be an absolute path on its own volume, never under a web or config root.
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree.
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
# required | role-based | unenforced (unenforced is refused under a production profile).
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible.
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
# ----- HTTP Client platform (app.httpclient.*) -----
# The single switch for outbound HTTP. False means no HTTP client property is bound, and no
# transport provider, connection pool, TLS context, credential, thread or gateway is created.
# The per-client surface is indexed and per-deployment, so it is set directly in the environment
# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an
# APP_HTTPCLIENT_ variable that is not in that registry fails startup.
APP_HTTPCLIENT_ENABLED=false
+17
View File
@@ -0,0 +1,17 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# jqwik property-test runtime state
.jqwik-database
# OS
.DS_Store
+148
View File
@@ -0,0 +1,148 @@
# 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/src
# 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 config/ ./config/
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 stage the executable JAR at Gradle's declared Docker output path.
COPY . .
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# ---- 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
# ---- Fileserver storage root ------------------------------------------------
# Created in the image with the runtime user's ownership and 0750, so a fresh named volume
# mounted here inherits both. Without it the Fileserver platform's default root does not exist
# on a read-only root filesystem, and the capability fails on its first upload rather than at
# startup. This directory is a mount point, not a place to keep data in the image: an unmounted
# container writes into the container layer and loses everything on replacement.
RUN mkdir -p /var/lib/backend/files \
&& chown app:app /var/lib/backend/files \
&& chmod 0750 /var/lib/backend/files
VOLUME ["/var/lib/backend/files"]
WORKDIR /app
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/application.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"]
+129
View File
@@ -0,0 +1,129 @@
# 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/src
# 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 config/ ./config/
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 stage the executable sample JAR at Gradle's declared Docker output path.
COPY . .
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# ---- 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/src/sample-portfolio/build/docker/application.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"]
+496
View File
@@ -0,0 +1,496 @@
# 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` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
| `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`
- **하는 일.** [config/architecture/modules.json](config/architecture/modules.json)의
`allowed_dependencies`를 읽고, 실제 Gradle 프로젝트 의존(`api` / `implementation` /
`compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 실패시킵니다.
- **JSON registry가 의존 방향의 SSOT 입니다.** 새 모듈이나 새 production 의존 edge를 추가하면
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
### `verifyRuntimeModuleMembership`
- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어
`app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly`
project dependency와 정확히 대조합니다.
- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두
shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는
실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다.
- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`,
`runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다.
세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는
다음 release-blocking aggregate로 실행합니다.
```bash
./gradlew conditionalTransportQualification
```
### `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 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤
`./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` 로 snapshot 을 명시적으로 다시
생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다.
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
뒤 보안 리뷰와 함께 커밋합니다.
### `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`** — provider-neutral edge rate-limit interceptor 활성화
(429 + `Retry-After` + `X-RateLimit-*` 응답). 기본값은 `false`이며, `true`로 바꿀 때는
`APP_RATE_LIMIT_PROVIDER=redis`와 canonical coordination role을 함께 구성해야 합니다.
- **`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_CACHE_CANONICAL_DEFAULT_PROVIDER`** — canonical default semantic region 선택.
`disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다.
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
제공한 `RedisClient` bean을 사용합니다.
- **`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
현재 canonical activation은 다음 두 설정 트리만 사용합니다.
```yaml
ca-skeleton:
capabilities:
http-client:
expected-state: DISABLED
bindings: {}
providers:
http-client: {}
```
- 기본 `DISABLED`는 binding/provider definition이 모두 비어 있어야 하며
`DISABLED_VERIFIED`만 게시하고 client, executor, pool, retry/CB registry를 만들지 않습니다.
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
`app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에
입력하면 상태와 무관하게 기동을 거부합니다.
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
가리킨다고 읽히지 않도록 범위를 명시합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
readiness를 증명하지 않습니다.
### 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(초).
### 프로파일과 데이터베이스
프로파일마다 데이터스토어가 다르고, 그 차이는 `app-bootstrap/src/main/resources/application-*.yml`
가 소유합니다.
| 프로파일 | 데이터스토어 | 스키마 소유자 | 외부 인프라 |
| --- | --- | --- | --- |
| `local` (기본) | H2 in-memory | Hibernate `ddl-auto: create-drop` | 없음 |
| `dev` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
| `prod` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
`local``./gradlew :app-bootstrap:bootRun` 의 기본값(`src/.env``SPRING_PROFILES_ACTIVE=local`)
이라 Docker 없이 바로 뜹니다. 아래 `APP_DATASOURCE_*` 값은 `local` 에서는 쓰이지 않고
`application-local.yml` 이 덮어씁니다.
`local` 이 검증하는 것은 wiring·요청/응답·애플리케이션 로직이고, **검증하지 않는 것은 migration 과
vendor 동작**입니다. H2 에는 migration tree 가 없어 migration 에만 존재하는 테이블(capability schema
registry, polling-delivery·inbox stream, Spring Integration lock)이 만들어지지 않습니다. 해당
capability 는 `local` 기본값에서 꺼져 있고, 켜면 테이블 없음으로 실패합니다.
`dev` 를 호스트에서 띄우려면(`SPRING_PROFILES_ACTIVE=dev`) PostgreSQL 이 필요합니다.
`docker-compose.local.yml``db` 서비스가 루프백(`127.0.0.1:5433`)에만 게시하며, 이 주소가
아래 `APP_DATASOURCE_URL` 의 커밋된 기본값입니다. 저장소 루트에서 실행합니다.
```bash
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --wait db
```
컨테이너로 띄우는 `./gradlew bootstrap` 경로는 이 호스트 포트를 쓰지 않습니다. compose 가 app
컨테이너의 `APP_DATASOURCE_URL` 을 내부 네트워크 주소 `jdbc:postgresql://db:5432/...` 로 덮어씁니다.
`ca-skeleton.persistence.vendor`(`postgresql` | `h2`)가 어느 vendor 구성을 조립할지 고르는 단일
스위치입니다. 값이 둘 중 하나가 아니면 기동이 실패하고, prod 에서 `h2` 이거나 datasource URL 이
`jdbc:h2:` 이면 `PersistenceVendorProdSafetyValidator` 가 기동을 거부합니다(env 로 덮어써도 동일).
### Database (Postgres)
- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). `dev`·`prod` 에서
쓰이며, 커밋된 기본값 `jdbc:postgresql://localhost:5433/ca_skeleton` 은 위 compose `db` 서비스의
호스트 주소입니다.
- **`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`**(`JpaSchemaSafetyValidator` 가 기동 시 강제). `local` 은 이 값을 쓰지
않습니다 — `application-local.yml``create-drop` 으로 고정합니다.
- **`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)와 달라야 합니다.**
+80
View File
@@ -0,0 +1,80 @@
# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery)
## Registered identity
- Module ID: `adapter-inbound-graphql`
- Gradle path: `:adapter:inbound:graphql`
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
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 등 구체 기능을 이름으로 알지 않는다.**
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책,
GraphiQL/introspection 운영 설정을 함께 명시해야 한다.
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
## 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).
## 향후 adopter 의 feature 기여 방법
- **스키마**: adopter feature 가 `src/main/resources/graphql/*.graphqls` 를 두면
`classpath:graphql/**` 병합으로 합칠 수 있다.
- **핸들러**: adopter 가 `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동
바인딩된다.
- **도메인 예외 매핑**: adopter 는 자신의 `DataFetcherExceptionResolver` 를 추가하거나
`ApiErrorCarrier` 를 사용해 안정적 코드로 매핑할 수 있다.
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
## 명시적 미구현 범위(P2)
- feature GraphQL schema/resolver
- query depth/cost 제한
- persisted operation
- DataLoader/batching
- subscription
이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
## Test
```bash
cd src
./gradlew :adapter:inbound:graphql:test --console=plain
./gradlew :adapter:inbound:graphql:test \
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
--console=plain
```
+92
View File
@@ -0,0 +1,92 @@
# 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 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에
명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다:
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다.
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller``@QueryMapping`/`@MutationMapping`
메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수
있다.
현재 `app-bootstrap``sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다.
즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는
뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
## 에러 매핑 — 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 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
비활성화, 오류 redaction 을 검증한다. 이 테스트 구성은 production 정책 bean 이 아니다. 실제
composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을 함께 제공하고
`spring.graphql.graphiql.enabled=false`,
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
## 아직 구현하지 않은 P2 범위
이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
+38
View File
@@ -0,0 +1,38 @@
// 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); a future consuming feature can contribute schema/controllers that 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)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
// 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'
// The HTTP boundary qualification test boots a real random-port servlet server and supplies
// a test-only authentication/CORS composition. Security remains a composition-root concern;
// this dependency does not add production security policy to the opt-in GraphQL adapter.
testImplementation 'org.springframework.boot:spring-boot-starter-security'
}
registerStrictQualificationTest(
name: 'graphqlTransportQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: [
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
],
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
+172
View File
@@ -0,0 +1,172 @@
# 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.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=mockitoAgent,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-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-security: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-security:4.0.0=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.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=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 may be contributed by a future consuming feature's {@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 can compose with schemas contributed by a future consuming feature. 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,103 @@
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 static final long serialVersionUID = 1L;
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,266 @@
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 java.net.URI;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Controller;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Release qualification for the opt-in GraphQL adapter's real servlet HTTP boundary.
*
* <p>The nested application deliberately owns only test authentication and CORS policy. A real
* composition root must make those choices when it opts into this adapter; the adapter itself
* remains free of an unconditional production security policy.
*/
@SpringBootTest(
classes = GraphqlHttpBoundaryQualificationTest.TestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.graphql.graphiql.enabled=false",
"spring.graphql.schema.introspection.enabled=false",
"spring.graphql.schema.locations=classpath:graphql-qualification-no-discovery/",
"spring.graphql.schema.additional-files="
+ "classpath:graphql/skeleton.graphqls,"
+ "classpath:graphql-qualification/qualification.graphqls"
})
@AutoConfigureTestRestTemplate
class GraphqlHttpBoundaryQualificationTest {
private static final String USERNAME = "qualification-user";
private static final String PASSWORD = "qualification-password";
private static final String ALLOWED_ORIGIN = "https://allowed.example";
private static final String DISALLOWED_ORIGIN = "https://disallowed.example";
private static final String STABLE_CODE = "QUALIFICATION_NOT_FOUND";
private static final String CARRIER_SECRET = "carrier-secret-sqlstate-zz9";
private static final String UNKNOWN_SECRET = "unknown-secret-upstream-token-yy8";
@LocalServerPort int port;
@Autowired TestRestTemplate http;
@Test
void unauthenticatedGraphqlRequestIsRejected() {
ResponseEntity<String> response = graphql("{ _health }", false, null);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
@Test
void authenticatedHealthQuerySucceedsOverHttp() {
ResponseEntity<String> response = graphql("{ _health }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("\"_health\":\"UP\"");
}
@Test
void allowedOriginReceivesCorsPermission() {
ResponseEntity<String> response = graphql("{ _health }", true, ALLOWED_ORIGIN);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isEqualTo(ALLOWED_ORIGIN);
}
@Test
void disallowedOriginIsRejected() {
ResponseEntity<String> response = graphql("{ _health }", true, DISALLOWED_ORIGIN);
assertThat(response.getStatusCode().value()).isEqualTo(403);
assertThat(response.getHeaders().getAccessControlAllowOrigin()).isNull();
}
@Test
void graphiqlIsDisabledAtTheHttpBoundary() {
ResponseEntity<String> response =
http.withBasicAuth(USERNAME, PASSWORD).getForEntity(endpoint("/graphiql"), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
@Test
void schemaIntrospectionIsDisabledAtTheHttpBoundary() {
ResponseEntity<String> response = graphql("{ __schema { queryType { name } } }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("\"errors\"").doesNotContain("\"queryType\"");
}
@Test
void carrierErrorExposesStableCodeAndCategoryWithoutRawMessage() {
ResponseEntity<String> response = graphql("{ carrierFailure }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("\"message\":\"" + STABLE_CODE + "\"")
.contains("\"code\":\"" + STABLE_CODE + "\"")
.contains("\"category\":\"NOT_FOUND\"")
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
}
@Test
void unknownErrorUsesFrameworkFallbackWithoutRawMessage() {
ResponseEntity<String> response = graphql("{ unknownFailure }", true, null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("\"classification\":\"INTERNAL_ERROR\"")
.doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET);
}
private ResponseEntity<String> graphql(String query, boolean authenticated, String origin) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (authenticated) {
headers.setBasicAuth(USERNAME, PASSWORD);
}
if (origin != null) {
headers.setOrigin(origin);
}
RequestEntity<String> request =
new RequestEntity<>(
"{\"query\":\"" + query + "\"}", headers, HttpMethod.POST, endpoint("/graphql"));
return http.exchange(request, String.class);
}
private URI endpoint(String path) {
return URI.create("http://localhost:" + port + path);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({
HealthGraphqlController.class,
GraphqlExceptionResolver.class,
QualificationController.class,
TestSecurityConfiguration.class
})
static class TestApplication {}
@Controller
static class QualificationController {
@QueryMapping
String carrierFailure() {
throw new QualificationCarrierException(CARRIER_SECRET);
}
@QueryMapping
String unknownFailure() {
throw new IllegalStateException(UNKNOWN_SECRET);
}
}
@Configuration(proxyBeanMethods = false)
static class TestSecurityConfiguration {
/**
* Boot's schema condition does not inspect additional-files. This no-op customizer activates
* auto-configuration while the exact shipped schema and test extension are supplied above.
*/
@Bean
GraphQlSourceBuilderCustomizer qualificationSchemaActivation() {
return builder -> {};
}
@Bean
SecurityFilterChain qualificationSecurityFilterChain(
HttpSecurity http,
@Qualifier("qualificationCorsConfigurationSource")
CorsConfigurationSource corsConfigurationSource)
throws Exception {
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
UserDetailsService qualificationUsers() {
return new InMemoryUserDetailsManager(
User.withUsername(USERNAME).password("{noop}" + PASSWORD).roles("QUALIFICATION").build());
}
@Bean
CorsConfigurationSource qualificationCorsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(ALLOWED_ORIGIN));
configuration.setAllowedMethods(List.of("POST"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/graphql", configuration);
return source;
}
}
private static final class QualificationCarrierException extends RuntimeException
implements ApiErrorCarrier {
private static final long serialVersionUID = 1L;
QualificationCarrierException(String message) {
super(message);
}
@Override
public ApiErrorCode errorCode() {
return QualificationErrorCode.INSTANCE;
}
}
private enum QualificationErrorCode implements ApiErrorCode {
INSTANCE;
@Override
public String code() {
return STABLE_CODE;
}
@Override
public Category category() {
return Category.NOT_FOUND;
}
@Override
public int httpStatus() {
return 404;
}
@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);
}
}
@@ -0,0 +1,4 @@
extend type Query {
carrierFailure: String
unknownFailure: String
}
+81
View File
@@ -0,0 +1,81 @@
# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery)
## Registered identity
- Module ID: `adapter-inbound-grpc`
- Gradle path: `:adapter:inbound:grpc`
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:grpc:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.inbound.grpc`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
규칙 SSOT).
## Responsibility
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
feature 인증 정책 경계, 프로토콜 에러 매핑(`GrpcStatusMapper` +
`GrpcExceptionHandlingInterceptor`), 그리고 `.proto` 없이도 부팅하는 최소 표면(standard
health, 명시적으로 켠 경우에만 reflection).
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록하며 구체 기능을
이름으로 알지 않는다.
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services),
`spring-boot-starter`, `spring-boot-starter-validation`.
## Forbidden
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 향후 도입하는 스키마와
서비스는 consuming feature 모듈이 소유한다.
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
`HealthcheckController` 와 동일 원칙).
## Config knobs (`ca-skeleton.grpc.*`)
타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다
(모듈별 `yml` 없음).
| key | default | 의미 |
|---|---|---|
| `enabled` | `false` | `true`를 명시해야만 관련 빈과 listener가 생긴다 |
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
| `bindAddress` | `127.0.0.1` | P1 insecure listener 바인드. loopback 주소만 허용한다 |
| `allowInsecureLocal` | `false` | local plaintext 위험을 명시적으로 승인하는 개발용 override |
| `reflectionEnabled` | `false` | v1 server reflection 노출을 독립적으로 opt-in 한다 |
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초(0 이상) |
`port`는 0..65535 범위여야 한다. 현재 transport credential은 plaintext뿐이므로
`enabled=true``allowInsecureLocal=true`와 실제 loopback `bindAddress`가 함께 없으면
configuration binding/startup 단계에서 실패한다.
## Feature 기여 방법
현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할
때는 `.proto`/generated stub/`BindableService`를 해당 feature가 소유하고, 서비스 빈과 정확히 한
개의 caller-supplied `GrpcAuthenticationPolicy` 빈을 함께 제공한다. 정책이 없거나 여러 개면
listener 시작이 실패한다. 정책이 `false`를 반환하거나 예외를 던진 요청은 feature handler에 닿지
않고 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다.
## P1 증거와 한계
- `GrpcSafeActivationTest`: 기본 비활성, 관련 빈 부재, 설정 검증, feature 인증 정책 필수 조건.
- `GrpcP1BoundaryWireTest`: 실제 loopback ephemeral Netty unary service의 auth 성공/실패,
reflection-off, handler/listener/observer/raw-status 오류 sanitization과 sentinel redaction.
이 증거는 local insecure unary qualification일 뿐 production-ready 근거가 아니다. TLS/mTLS,
external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2로 남아 있다.
## Test
```bash
cd src
./gradlew :adapter:inbound:grpc:test
```
+94
View File
@@ -0,0 +1,94 @@
# 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)로 뒤집어
로드밸런서가 드레이닝을 인지하게 한다.
- **fail-closed local insecure bind.** 기본은 서버 비활성·reflection 비활성이다. 현재 구현의
plaintext credential은 `allowInsecureLocal=true`를 명시하고 실제 loopback 주소에 바인딩할
때만 허용한다. wildcard/외부 주소의 insecure 시작은 실패한다.
## 왜 `.proto` 도 protobuf 플러그인도 없는가
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다**`com.google.protobuf` 플러그인도,
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
런타임 jar에 이미 컴파일된 채 들어 있다. 서버를 명시적으로 켜면 feature RPC가 없어도 health로
수명주기를 확인할 수 있고, reflection은 별도 flag를 켠 경우에만 등록된다. 현재 저장소에는 feature
`.proto`, generated stub, feature gRPC service가 없다. 향후 도입하는 feature 모듈이 이들을
소유해야 한다.
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
향후 feature 모듈은 `BindableService`와 정확히 한 개의 `GrpcAuthenticationPolicy`를 Spring
빈으로 함께 기여한다. runner는 feature 이름을 알지 않고 generic하게 등록하되, 서비스가 하나라도
있는데 인증 정책이 없거나 단일하지 않으면 listener 시작을 거부한다. 정책은 gRPC `Metadata`만 받아
Spring Security에 결합되지 않으며, `false` 반환과 policy 예외는 동일한 안정적
`UNAUTHENTICATED` 계약으로 끝난다.
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
`GrpcExceptionHandlingInterceptor`는 forwarding `ServerCall``close`까지 감싼다. 동기
handler throw, listener callback throw, `responseObserver.onError(...)`, raw
`StatusRuntimeException`이 모두 같은 sanitizer를 거친다. 서비스 구현은 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/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
않은 예외와 raw gRPC status/description은 원래 status를 신뢰하지 않고 `Status.INTERNAL` +
`INTERNAL_ERROR`로 폴백한다. 입력 trailers도 폐기한다.
`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 로 유지된다.
## P1 qualification과 P2 유보
`GrpcSafeActivationTest`는 disabled bean/listener 부재와 설정/인증-policy fail-closed를 검증한다.
`GrpcP1BoundaryWireTest`는 실제 loopback ephemeral Netty unary service로 auth 성공/실패,
reflection-off, 모든 오류 경로의 안정 code/category 및 sentinel redaction을 검증한다.
이는 local plaintext unary 경계에 대한 P1 증거이며 production-ready 주장 근거가 아니다.
TLS/mTLS, external bind, deadline, streaming/backpressure, generated protobuf 호환성은 P2에서 별도
설계·검증해야 한다.
+49
View File
@@ -0,0 +1,49 @@
// 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 a future consuming feature owns its `.proto`/services.
//
// 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).
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}"
}
}
dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Keep the direct versions in the outgoing project metadata as well as importing the BOM.
// Spring dependency-management constraints are local to this leaf and are not propagated to
// a consumer's custom qualification source set.
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
implementation "io.grpc:grpc-services:${grpcVersion}" // health + reflection
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
testImplementation "io.grpc:grpc-protobuf:${grpcVersion}"
// Wire qualification directly uses ClientCalls/ServerCalls/MetadataUtils without generated stubs.
testImplementation "io.grpc:grpc-stub:${grpcVersion}"
}
registerStrictQualificationTest(
name: 'grpcTransportQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: [
'dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest',
'dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest'
],
description: 'Runs exact no-skip gRPC conditional transport wire evidence.')
+178
View File
@@ -0,0 +1,178 @@
# 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.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.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
com.google.api.grpc:proto-google-common-protos:2.41.0=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,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=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.validation:jakarta.validation-api:3.1.1=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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.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.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=mockitoAgent,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-validation: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=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=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,42 @@
package dev.caskeleton.adapter.inbound.grpc;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
/** Applies the caller-supplied authentication policy before a feature RPC can reach its handler. */
final class GrpcAuthenticationInterceptor implements ServerInterceptor {
private final GrpcAuthenticationPolicy authenticationPolicy;
private final GrpcStatusMapper statusMapper;
GrpcAuthenticationInterceptor(
GrpcAuthenticationPolicy authenticationPolicy, GrpcStatusMapper statusMapper) {
this.authenticationPolicy = authenticationPolicy;
this.statusMapper = statusMapper;
}
@Override
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
if (isAuthenticated(headers)) {
return next.startCall(call, headers);
}
call.close(
Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()),
statusMapper.trailersFor(OperationalError.UNAUTHENTICATED));
return new ServerCall.Listener<>() {};
}
private boolean isAuthenticated(Metadata headers) {
try {
return authenticationPolicy.isAuthenticated(headers);
} catch (RuntimeException ignored) {
return false;
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.Metadata;
/**
* Caller-supplied policy that authenticates feature RPC metadata without Spring Security coupling.
*/
@FunctionalInterface
public interface GrpcAuthenticationPolicy {
/**
* Returns {@code true} only when the request metadata represents an authenticated caller. A
* {@code false} result or policy exception becomes the same stable {@code UNAUTHENTICATED} wire
* contract; policy diagnostics never reach the client.
*/
boolean isAuthenticated(Metadata metadata);
}
@@ -0,0 +1,131 @@
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.ForwardingServerCall.SimpleForwardingServerCall;
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 or calls {@code onError}, and
* this {@link ServerInterceptor} wraps handler/listener throws and every non-OK {@link
* ServerCall#close(Status, Metadata)} behind one sanitizer. The result carries a mapped {@link
* Status} plus stable {@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 exception or raw gRPC status maps to {@link Status#INTERNAL} with
* {@link OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the
* status description and trailers) — raw descriptions and input trailers, which may carry a
* SQLState or upstream detail, are 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<REQT, RESPT> sanitizingCall = sanitizingCall(call, closed);
ServerCall.Listener<REQT> delegate;
try {
delegate = next.startCall(sanitizingCall, headers);
} catch (RuntimeException e) {
closeWithError(sanitizingCall, 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);
}
@Override
public void onCancel() {
runGuarded(super::onCancel);
}
@Override
public void onComplete() {
runGuarded(super::onComplete);
}
private void runGuarded(Runnable action) {
if (closed.get()) {
return;
}
try {
action.run();
} catch (RuntimeException e) {
closeWithError(sanitizingCall, e);
}
}
};
}
private <REQT, RESPT> ServerCall<REQT, RESPT> sanitizingCall(
ServerCall<REQT, RESPT> delegate, AtomicBoolean closed) {
return new SimpleForwardingServerCall<>(delegate) {
@Override
public void close(Status status, Metadata trailers) {
if (!closed.compareAndSet(false, true)) {
return;
}
if (status.isOk()) {
super.close(status, trailers);
return;
}
ApiErrorCode code = errorCodeOf(status.getCause());
if (code == null) {
code = OperationalError.INTERNAL_ERROR;
}
super.close(
statusMapper.toStatus(code.category()).withDescription(code.code()),
statusMapper.trailersFor(code));
}
};
}
private static void closeWithError(ServerCall<?, ?> call, RuntimeException exception) {
call.close(Status.fromThrowable(exception).withCause(exception), new Metadata());
}
private static ApiErrorCode errorCodeOf(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof ApiErrorCarrier carrier) {
return carrier.errorCode();
}
if (current.getCause() == current) {
break;
}
current = current.getCause();
}
return null;
}
}
@@ -0,0 +1,57 @@
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} is explicit.
* 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 = false)
@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,
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
GrpcStatusMapper statusMapper,
HealthStatusManager healthStatusManager) {
return new GrpcServerRunner(
services,
authenticationPolicies,
properties,
exceptionInterceptor,
statusMapper,
healthStatusManager);
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.inbound.grpc;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* 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", ignoreUnknownFields = false)
@Validated
public class GrpcServerProperties {
/** Whether to start the gRPC server at all. Activation must always be explicit. */
private boolean enabled;
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
@Min(0)
@Max(65535)
private int port = 9090;
/** Loopback address used by the P1 local-only insecure listener. */
@NotBlank private String bindAddress = "127.0.0.1";
/** Explicit acknowledgement that the enabled P1 listener is plaintext and local-only. */
private boolean allowInsecureLocal;
/** Expose server reflection only when explicitly requested for local development. */
private boolean reflectionEnabled;
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
@Min(0)
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 String getBindAddress() {
return bindAddress;
}
public void setBindAddress(String bindAddress) {
this.bindAddress = bindAddress;
}
public boolean isAllowInsecureLocal() {
return allowInsecureLocal;
}
public void setAllowInsecureLocal(boolean allowInsecureLocal) {
this.allowInsecureLocal = allowInsecureLocal;
}
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;
}
@AssertTrue(
message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
public boolean isInsecureLocalConfigurationValid() {
return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
}
InetAddress resolvedBindAddress() {
try {
return InetAddress.getByName(bindAddress);
} catch (UnknownHostException e) {
throw new IllegalStateException("gRPC bind address cannot be resolved", e);
}
}
private boolean isLoopbackBindAddress() {
if (bindAddress == null || bindAddress.isBlank()) {
return false;
}
try {
return resolvedBindAddress().isLoopbackAddress();
} catch (IllegalStateException ignored) {
return false;
}
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.adapter.inbound.grpc;
import io.grpc.BindableService;
import io.grpc.InsecureServerCredentials;
import io.grpc.Server;
import io.grpc.ServerInterceptors;
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.protobuf.services.HealthStatusManager;
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.util.List;
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 caller-supplied authentication and the {@link GrpcExceptionHandlingInterceptor}. The
* skeleton also registers standard {@code grpc.health.v1} health and, only when explicitly enabled,
* v1 server reflection, so it needs no feature {@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 ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies;
private final GrpcServerProperties properties;
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
private final GrpcStatusMapper statusMapper;
private final HealthStatusManager healthStatusManager;
private volatile Server server;
public GrpcServerRunner(
ObjectProvider<BindableService> services,
ObjectProvider<GrpcAuthenticationPolicy> authenticationPolicies,
GrpcServerProperties properties,
GrpcExceptionHandlingInterceptor exceptionInterceptor,
GrpcStatusMapper statusMapper,
HealthStatusManager healthStatusManager) {
this.services = services;
this.authenticationPolicies = authenticationPolicies;
this.properties = properties;
this.exceptionInterceptor = exceptionInterceptor;
this.statusMapper = statusMapper;
this.healthStatusManager = healthStatusManager;
}
@Override
public void start() {
if (isRunning()) {
return;
}
List<BindableService> featureServices = services.orderedStream().toList();
List<GrpcAuthenticationPolicy> policies = authenticationPolicies.orderedStream().toList();
if (!featureServices.isEmpty() && policies.size() != 1) {
throw new IllegalStateException(
"feature gRPC services require exactly one caller-supplied authentication policy");
}
GrpcAuthenticationPolicy authenticationPolicy =
policies.size() == 1 ? policies.getFirst() : null;
var address = new InetSocketAddress(properties.resolvedBindAddress(), properties.getPort());
var builder = NettyServerBuilder.forAddress(address, InsecureServerCredentials.create());
var authenticationInterceptor =
authenticationPolicy == null
? null
: new GrpcAuthenticationInterceptor(authenticationPolicy, statusMapper);
int registered = 0;
for (BindableService service : featureServices) {
builder.addService(
ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor));
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,132 @@
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 static final long serialVersionUID = 1L;
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,355 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import dev.caskeleton.shared.error.OperationalError;
import io.grpc.BindableService;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerServiceDefinition;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.reflection.v1.ServerReflectionGrpc;
import io.grpc.reflection.v1.ServerReflectionRequest;
import io.grpc.reflection.v1.ServerReflectionResponse;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.MetadataUtils;
import io.grpc.stub.ServerCalls;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class GrpcP1BoundaryWireTest {
private static final String SERVICE_NAME = "test.p1.Feature";
private static final String AUTH_TOKEN = "Bearer p1-test-token";
private static final String INVALID_TOKEN_SENTINEL = "invalid-token-secret-sentinel";
private static final String HANDLER_SENTINEL = "handler-secret-sentinel";
private static final String LISTENER_SENTINEL = "listener-secret-sentinel";
private static final String CARRIER_SENTINEL = "carrier-secret-sentinel";
private static final String RAW_STATUS_SENTINEL = "raw-status-secret-sentinel";
private static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private static final MethodDescriptor<String, String> UNARY_METHOD = unaryMethod("UnaryFeature");
private static final MethodDescriptor<String, String> HANDLER_THROW_METHOD =
unaryMethod("HandlerThrow");
private static final MethodDescriptor<String, String> LISTENER_THROW_METHOD =
unaryMethod("ListenerThrow");
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withUserConfiguration(GrpcServerConfig.class, FeatureConfiguration.class)
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.reflection-enabled=false");
@Test
void missingAuthenticationMetadataIsRejectedWithAStableContract() {
withChannel(
channel ->
assertFailure(
channel,
UNARY_METHOD,
"ok",
Status.Code.UNAUTHENTICATED,
"UNAUTHENTICATED",
"AUTH",
null));
}
@Test
void invalidAuthenticationMetadataIsRejectedWithoutEchoingIt() {
withChannel(
channel -> {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Bearer " + INVALID_TOKEN_SENTINEL);
assertFailure(
attach(channel, headers),
UNARY_METHOD,
"ok",
Status.Code.UNAUTHENTICATED,
"UNAUTHENTICATED",
"AUTH",
INVALID_TOKEN_SENTINEL);
});
}
@Test
void validAuthenticationMetadataReachesTheFeatureService() {
withChannel(
channel ->
assertThat(unary(authenticated(channel), UNARY_METHOD, "ok"))
.isEqualTo("authorized-ok"));
}
@Test
void reflectionRemainsUnavailableWhenItsIndependentFlagIsFalse() {
withChannel(
channel -> {
Throwable failure = reflectionFailure(channel);
assertThat(Status.fromThrowable(failure).getCode()).isEqualTo(Status.Code.UNIMPLEMENTED);
});
}
@Test
void synchronousHandlerThrowUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
HANDLER_THROW_METHOD,
"ignored",
Status.Code.INVALID_ARGUMENT,
"BAD_PARAMETER",
"VALIDATION",
HANDLER_SENTINEL));
}
@Test
void listenerThrowUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
LISTENER_THROW_METHOD,
"ignored",
Status.Code.NOT_FOUND,
"ROUTE_NOT_FOUND",
"NOT_FOUND",
LISTENER_SENTINEL));
}
@Test
void responseObserverCarrierErrorUsesTheStableCarrierContract() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
UNARY_METHOD,
"carrier-error",
Status.Code.RESOURCE_EXHAUSTED,
"RATE_LIMIT_EXCEEDED",
"RATE_LIMIT",
CARRIER_SENTINEL));
}
@Test
void rawStatusRuntimeExceptionIsSanitizedToInternal() {
withChannel(
channel ->
assertFailure(
authenticated(channel),
UNARY_METHOD,
"raw-status-error",
Status.Code.INTERNAL,
"INTERNAL_ERROR",
"INTERNAL",
RAW_STATUS_SENTINEL));
}
private void withChannel(Consumer<ManagedChannel> assertion) {
contextRunner.run(
context -> {
assertThat(context.getStartupFailure()).isNull();
int port = context.getBean(GrpcServerRunner.class).getListeningPort();
ManagedChannel channel =
ManagedChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build();
try {
assertion.accept(channel);
} finally {
channel.shutdownNow();
awaitChannelTermination(channel);
}
});
}
private static void awaitChannelTermination(ManagedChannel channel) {
try {
assertThat(channel.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while closing test channel", e);
}
}
private static Channel authenticated(Channel channel) {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, AUTH_TOKEN);
return attach(channel, headers);
}
private static Channel attach(Channel channel, Metadata headers) {
return ClientInterceptors.intercept(
channel, MetadataUtils.newAttachHeadersInterceptor(headers));
}
private static String unary(
Channel channel, MethodDescriptor<String, String> method, String request) {
return ClientCalls.blockingUnaryCall(
channel, method, CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS), request);
}
private static void assertFailure(
Channel channel,
MethodDescriptor<String, String> method,
String request,
Status.Code expectedStatus,
String expectedCode,
String expectedCategory,
String forbiddenSentinel) {
StatusRuntimeException failure =
catchThrowableOfType(StatusRuntimeException.class, () -> unary(channel, method, request));
assertThat(failure).isNotNull();
assertThat(failure.getStatus().getCode()).isEqualTo(expectedStatus);
assertThat(failure.getStatus().getDescription()).isEqualTo(expectedCode);
assertThat(failure.getTrailers()).isNotNull();
assertThat(failure.getTrailers().get(GrpcStatusMapper.CODE_KEY)).isEqualTo(expectedCode);
assertThat(failure.getTrailers().get(GrpcStatusMapper.CATEGORY_KEY))
.isEqualTo(expectedCategory);
if (forbiddenSentinel != null) {
assertThat(failure.toString()).doesNotContain(forbiddenSentinel);
assertThat(failure.getTrailers().toString()).doesNotContain(forbiddenSentinel);
}
}
private static Throwable reflectionFailure(ManagedChannel channel) {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
StreamObserver<ServerReflectionRequest> requests =
ServerReflectionGrpc.newStub(channel)
.serverReflectionInfo(
new StreamObserver<>() {
@Override
public void onNext(ServerReflectionResponse value) {}
@Override
public void onError(Throwable throwable) {
failure.set(throwable);
done.countDown();
}
@Override
public void onCompleted() {
done.countDown();
}
});
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
requests.onCompleted();
try {
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting for reflection response", e);
}
return failure.get();
}
private static MethodDescriptor<String, String> unaryMethod(String methodName) {
return MethodDescriptor.<String, String>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName(MethodDescriptor.generateFullMethodName(SERVICE_NAME, methodName))
.setRequestMarshaller(StringMarshaller.INSTANCE)
.setResponseMarshaller(StringMarshaller.INSTANCE)
.build();
}
@Configuration(proxyBeanMethods = false)
static class FeatureConfiguration {
@Bean
GrpcAuthenticationPolicy grpcAuthenticationPolicy() {
return metadata -> AUTH_TOKEN.equals(metadata.get(AUTHORIZATION));
}
@Bean
BindableService p1FeatureService() {
return () ->
ServerServiceDefinition.builder(SERVICE_NAME)
.addMethod(
UNARY_METHOD,
ServerCalls.asyncUnaryCall(
(String request, StreamObserver<String> observer) -> {
if ("carrier-error".equals(request)) {
observer.onError(
new ApiErrorException(
OperationalError.RATE_LIMIT_EXCEEDED, CARRIER_SENTINEL));
return;
}
if ("raw-status-error".equals(request)) {
observer.onError(
Status.ABORTED
.withDescription(RAW_STATUS_SENTINEL)
.asRuntimeException());
return;
}
observer.onNext("authorized-" + request);
observer.onCompleted();
}))
.addMethod(
HANDLER_THROW_METHOD,
(ServerCallHandler<String, String>)
(call, headers) -> {
throw new ApiErrorException(
OperationalError.BAD_PARAMETER, HANDLER_SENTINEL);
})
.addMethod(
LISTENER_THROW_METHOD,
(ServerCallHandler<String, String>)
(call, headers) -> {
call.request(1);
return new ServerCall.Listener<>() {
@Override
public void onHalfClose() {
throw new ApiErrorException(
OperationalError.ROUTE_NOT_FOUND, LISTENER_SENTINEL);
}
};
})
.build();
}
}
private enum StringMarshaller implements MethodDescriptor.Marshaller<String> {
INSTANCE;
@Override
public InputStream stream(String value) {
return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
}
@Override
public String parse(InputStream stream) {
try {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("failed to decode test request", e);
}
}
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.inbound.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import io.grpc.BindableService;
import io.grpc.ServerServiceDefinition;
import io.grpc.protobuf.services.HealthStatusManager;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class GrpcSafeActivationTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner().withUserConfiguration(GrpcServerConfig.class);
@Test
void defaultsKeepTheTransportAndReflectionDisabled() {
GrpcServerProperties properties = new GrpcServerProperties();
assertThat(properties.isEnabled()).isFalse();
assertThat(properties.isReflectionEnabled()).isFalse();
}
@Test
void missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener() {
contextRunner
.withPropertyValues("ca-skeleton.grpc.port=0")
.run(
context -> {
assertThat(context).doesNotHaveBean(GrpcServerProperties.class);
assertThat(context).doesNotHaveBean(GrpcServerRunner.class);
assertThat(context).doesNotHaveBean(HealthStatusManager.class);
assertThat(context).doesNotHaveBean(GrpcExceptionHandlingInterceptor.class);
});
}
@Test
void enabledTransportRequiresAnExplicitLocalInsecureOverride() {
contextRunner
.withPropertyValues("ca-skeleton.grpc.enabled=true", "ca-skeleton.grpc.port=0")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "insecure"));
}
@Test
void insecureTransportRejectsANonLoopbackBindAddress() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=0.0.0.0",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "loopback"));
}
@Test
void enabledTransportRejectsAPortOutsideTheTcpRange() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=65536",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "port"));
}
@Test
void enabledTransportRejectsANegativeShutdownGrace() {
contextRunner
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.shutdown-grace-seconds=-1")
.run(
context ->
assertRootCauseContains(context.getStartupFailure(), "shutdownGraceSeconds"));
}
@Test
void featureServiceRequiresACallerSuppliedAuthenticationPolicy() {
contextRunner
.withUserConfiguration(FeatureServiceConfiguration.class)
.withPropertyValues(
"ca-skeleton.grpc.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true")
.run(context -> assertRootCauseContains(context.getStartupFailure(), "authentication"));
}
private static void assertRootCauseContains(Throwable failure, String expected) {
assertThat(failure).isNotNull();
Throwable rootCause = failure;
while (rootCause.getCause() != null) {
rootCause = rootCause.getCause();
}
assertThat(rootCause).hasMessageContaining(expected);
}
@Configuration(proxyBeanMethods = false)
static class FeatureServiceConfiguration {
@Bean
BindableService featureService() {
return () -> ServerServiceDefinition.builder("test.Feature").build();
}
}
}
@@ -0,0 +1,104 @@
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.enabled=true",
"ca-skeleton.grpc.port=0",
"ca-skeleton.grpc.bind-address=127.0.0.1",
"ca-skeleton.grpc.allow-insecure-local=true",
"ca-skeleton.grpc.reflection-enabled=true");
@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");
}
}
+201
View File
@@ -0,0 +1,201 @@
# adapter:inbound:web — inbound HTTP adapter
## Registered identity
- Module ID: `adapter-inbound-web`
- Gradle path: `:adapter:inbound:web`
- Focused test (derived from Gradle path): `./gradlew :adapter:inbound:web:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
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.
- Sanitized request correlation context exposed through application-owned `CorrelationIdPort`.
- Transport-owned OpenAPI customization that keeps `ApiError.details` as `type: object` without
leaking Swagger dependencies into `shared-contract`.
## 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
```
+463
View File
@@ -0,0 +1,463 @@
# adapter-web — 설계 결정 참조
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
풀어 둔다.
---
## OpenAPI contract stabilization
Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`).
`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details`
field and publishes it as `type: object`. This preserves the committed HTTP contract without adding
Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this
production configuration and compare the result with the committed snapshot.
---
## 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 동작).
lazy 초기화 중 외부 discovery/JWKS I/O 실패는 필터 밖 runtime exception으로 탈출시키지 않고
`AUTH_JWKS_UNAVAILABLE`로 분류하며, 비-I/O 초기화 실패는 `INTERNAL_AUTH_MISCONFIGURATION`으로
fail-closed 한다. 두 carrier 모두 고정 진단만 가지며 원격 응답/URL은 공개 응답에 넣지 않는다.
- **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 되지 않는다. Redis session mode에서도 아래 primitive snapshot
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### JWT / Redis session 상호배타 모드
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
`PrimitiveSessionSecurityContextRepository``AuthenticatedPrincipal`의 bounded
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
응답 본문 flush/redirect/error가 새 session보다 먼저 commit되지 않도록 repository가 Spring Security의
commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML 로그인 복귀용 request cache를
사용하지 않는 API 경계이므로 request cache를 명시적으로 비활성화한다. 따라서 미인증 요청이
`DefaultSavedRequest` 같은 framework object를 session에 넣지 않는다. app-bootstrap의
`redisSessionHttpIntegrationTest`가 TLS/ACL Redis와 서로 다른 세 개의 web context를 사용해 생성,
복구, logout tombstone, stale save 거부, 장애 시 controller 이전 fail-closed를 검증한다.
### 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)` 규칙 참조).
- **클라이언트 메시지는 allowlist다.** 예외 메시지, validation interpolated message, rejected
request value, raw request URL은
`error.message`/`details`에 넣지 않는다. `ClientSafeErrorMessages`의 코드별 고정 문구와
정규화된 server-owned field, allowlisted reason code/fixed message, expectedType,
supported-method 같은 bounded 구조 메타데이터만 공개한다. collection/map index와 key는 field
path에서 제거한다.
- 코드별 문구가 명시되지 않은 operational code는 category 기반 고정 문구로 fail-closed 한다. 이
fallback은 새 코드를 실수로 진단 문자열에 연결하는 대신 transient/conflict/data-integrity 또는
`Internal server error`만 공개한다.
- `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 + allowlisted reason code/fixed message 리스트를 details 로. interpolated message와 iterable key/index는 미노출 |
| `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 + allowlisted reason code/fixed message 리스트를 details 로. rejectedValue/defaultMessage/iterable key/index는 secret/PII 가능성이 있어 미노출 |
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
| `NoHandlerFoundException`, `NoResourceFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | controller/static-resource 어느 404 경로도 같은 Envelope를 사용하고 raw request URL을 echo하지 않음 |
| `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
### provider-neutral edge contract
- inbound web은 `shared-contract``EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
provider 설정을 알지 못한다.
- outbound provider activation SSOT는
`ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
`app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
실패시킨다.
- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
silent primary fallback은 허용하지 않는다.
- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
### 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`
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`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 거부.
### EdgeRateLimitTransportSettings
- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
caller deadline, trusted client-IP mode만 소유한다.
- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
복제하지 않는다.
### 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`.
### MdcCorrelationIdPortAdapter
- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의
`CorrelationIdPort`로 투영한다.
- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접
참조하지 않고 event-id fallback 정책만 소유한다.
### 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 으로 라우팅.
+74
View File
@@ -0,0 +1,74 @@
// HTTP / web adapters. Depends on application and shared operational contracts.
dependencies {
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.springframework.session:spring-session-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
}
// 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:3.0.0'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
// WebApplicationType deduction keeps resolving SERVLET; the reactive handlers are wired only
// when the fileserver reactive profile is selected.
implementation 'org.springframework:spring-webflux'
implementation 'io.projectreactor:reactor-core'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.register('jpaPersistenceRedactionContractTest', Test) {
group = 'verification'
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching(
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails')
includeTestsMatching(
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.named('test') {
useJUnitPlatform {
excludeTags 'security-boundary'
}
}
tasks.register('webSecurityBoundaryTest', Test) {
group = 'verification'
description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'security-boundary'
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC'
afterSuite { descriptor, result ->
if (descriptor.parent == null && result.skippedTestCount > 0) {
throw new GradleException(
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}")
}
}
}
tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
+188
View File
@@ -0,0 +1,188 @@
# 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.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=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=mockitoAgent,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.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.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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=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-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux: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,110 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.JwtDecoderInitializationException;
import org.springframework.security.oauth2.jwt.JwtException;
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;
import org.springframework.web.client.RestClientException;
/**
* 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
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "jwt",
matchIfMissing = true)
public class JwtDecoderConfig {
@Bean
public JwtDecoder jwtDecoder(SecuritySettings settings) {
// Lazy: JWKS discovery happens on first decode, not at startup.
SupplierJwtDecoder lazyDecoder =
new SupplierJwtDecoder(
() -> {
NimbusJwtDecoder decoder =
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
return decoder;
});
return token -> {
try {
return lazyDecoder.decode(token);
} catch (JwtDecoderInitializationException exception) {
if (causedByExternalKeyService(exception)) {
throw new AuthenticationKeyServiceUnavailableException(exception);
}
throw new AuthenticationDecoderMisconfigurationException(exception);
}
};
}
private static boolean causedByExternalKeyService(Throwable failure) {
Throwable current = failure;
for (int depth = 0; current != null && depth < 32; depth++) {
if (current instanceof RestClientException || current instanceof IOException) {
return true;
}
current = current.getCause();
}
return false;
}
/** 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);
};
}
static final class AuthenticationKeyServiceUnavailableException extends JwtException {
private static final long serialVersionUID = 1L;
AuthenticationKeyServiceUnavailableException(Throwable cause) {
super("Authentication key service unavailable", cause);
}
}
static final class AuthenticationDecoderMisconfigurationException extends JwtException {
private static final long serialVersionUID = 1L;
AuthenticationDecoderMisconfigurationException(Throwable cause) {
super("Authentication decoder configuration is invalid", cause);
}
}
}
@@ -0,0 +1,95 @@
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.Locale;
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(Locale.ROOT)))
.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,327 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.util.WebUtils;
/**
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
*
* <p>Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the
* Spring Session serialization boundary.
*/
final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository {
static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1";
private static final int MAGIC = 0x43534543;
private static final int VERSION = 1;
private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384;
private static final int MAXIMUM_PRINCIPAL_BYTES = 256;
private static final int MAXIMUM_EMAIL_BYTES = 320;
private static final int MAXIMUM_TOKEN_BYTES = 128;
private static final int MAXIMUM_ROLES = 64;
private static final int MAXIMUM_AUTHORITIES = 128;
@Override
@SuppressWarnings("deprecation")
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
SecurityContext context = load(request);
HttpServletResponse response = requestResponseHolder.getResponse();
if (response != null) {
CommitSaveResponseWrapper wrappedResponse = new CommitSaveResponseWrapper(response, request);
wrappedResponse.setSecurityContextHolderStrategy(
SecurityContextHolder.getContextHolderStrategy());
requestResponseHolder.setResponse(wrappedResponse);
requestResponseHolder.setRequest(new AsyncAwareRequestWrapper(request, wrappedResponse));
}
return context;
}
@Override
public void saveContext(
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
CommitSaveResponseWrapper wrapper =
WebUtils.getNativeResponse(response, CommitSaveResponseWrapper.class);
if (wrapper != null) {
wrapper.reconcileFinalContext(context);
return;
}
saveSnapshot(context, request);
}
private static void saveSnapshot(SecurityContext context, HttpServletRequest request) {
Objects.requireNonNull(request, "request");
Authentication authentication = context == null ? null : context.getAuthentication();
if (authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
HttpSession existing = request.getSession(false);
if (existing != null) {
existing.removeAttribute(SNAPSHOT_ATTRIBUTE);
}
return;
}
request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication));
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[];
}
private static SecurityContext load(HttpServletRequest request) {
SecurityContext empty = SecurityContextHolder.createEmptyContext();
HttpSession session = request.getSession(false);
if (session == null) {
return empty;
}
Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE);
if (!(stored instanceof byte[] snapshot)) {
return empty;
}
try {
PrimitiveAuthentication decoded = decode(snapshot);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles);
List<GrantedAuthority> authorities =
decoded.authorities.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
empty.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
return empty;
} catch (IllegalArgumentException exception) {
session.removeAttribute(SNAPSHOT_ATTRIBUTE);
return empty;
}
}
private static byte[] encode(Authentication authentication) {
if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) {
throw new IllegalArgumentException(
"redis-session authentication requires an AuthenticatedPrincipal");
}
Set<String> roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles");
Set<String> authorities =
boundedTokens(
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
MAXIMUM_AUTHORITIES,
"authorities");
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(MAGIC);
output.writeByte(VERSION);
writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID");
writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email");
writeTokens(output, roles);
writeTokens(output, authorities);
}
byte[] snapshot = bytes.toByteArray();
if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw new IllegalArgumentException("security context snapshot exceeds the byte bound");
}
return snapshot;
} catch (IOException exception) {
throw new IllegalStateException("in-memory security context encoding failed", exception);
}
}
private static PrimitiveAuthentication decode(byte[] snapshot) {
if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw invalidSnapshot();
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) {
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
throw invalidSnapshot();
}
String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES);
String email = readNullableText(input, MAXIMUM_EMAIL_BYTES);
Set<String> roles = readTokens(input, MAXIMUM_ROLES);
Set<String> authorities = readTokens(input, MAXIMUM_AUTHORITIES);
if (input.available() != 0) {
throw invalidSnapshot();
}
return new PrimitiveAuthentication(principalId, email, roles, authorities);
} catch (IOException | IllegalArgumentException exception) {
throw invalidSnapshot();
}
}
private static void writeTokens(DataOutputStream output, Set<String> values) throws IOException {
output.writeInt(values.size());
for (String value : values) {
writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token");
}
}
private static Set<String> readTokens(DataInputStream input, int maximumCount)
throws IOException {
int count = input.readInt();
if (count < 0 || count > maximumCount) {
throw invalidSnapshot();
}
Set<String> values = new LinkedHashSet<>();
for (int index = 0; index < count; index++) {
if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) {
throw invalidSnapshot();
}
}
return Set.copyOf(values);
}
private static Set<String> boundedTokens(
Collection<String> values, int maximumCount, String field) {
if (values == null || values.size() > maximumCount) {
throw new IllegalArgumentException(field + " exceed the configured count bound");
}
TreeSet<String> bounded = new TreeSet<>();
for (String value : values) {
requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field);
bounded.add(value);
}
return Set.copyOf(bounded);
}
private static void writeNullableText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
output.writeBoolean(value != null);
if (value != null) {
writeText(output, value, maximumBytes, field);
}
}
private static String readNullableText(DataInputStream input, int maximumBytes)
throws IOException {
return input.readBoolean() ? readText(input, maximumBytes) : null;
}
private static void writeText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
byte[] encoded = requireBoundedText(value, maximumBytes, field);
output.writeInt(encoded.length);
output.write(encoded);
}
private static String readText(DataInputStream input, int maximumBytes) throws IOException {
int length = input.readInt();
if (length < 1 || length > maximumBytes || length > input.available()) {
throw new EOFException("invalid security context text length");
}
byte[] encoded = input.readNBytes(length);
String value = new String(encoded, StandardCharsets.UTF_8);
byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value");
if (!java.util.Arrays.equals(canonical, encoded)) {
throw invalidSnapshot();
}
return value;
}
private static byte[] requireBoundedText(String value, int maximumBytes, String field) {
if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must be non-blank text without controls");
}
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
if (encoded.length > maximumBytes) {
throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound");
}
return encoded;
}
private static IllegalArgumentException invalidSnapshot() {
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
}
@SuppressWarnings("deprecation")
private static final class CommitSaveResponseWrapper
extends SaveContextOnUpdateOrErrorResponseWrapper {
private final HttpServletRequest request;
private CommitSaveResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
super(response, true);
this.request = request;
}
@Override
protected void saveContext(SecurityContext context) {
saveSnapshot(context, request);
}
private void reconcileFinalContext(SecurityContext context) {
saveContext(context);
}
}
@SuppressWarnings("deprecation")
private static final class AsyncAwareRequestWrapper extends HttpServletRequestWrapper {
private final CommitSaveResponseWrapper response;
private AsyncAwareRequestWrapper(
HttpServletRequest request, CommitSaveResponseWrapper response) {
super(request);
this.response = response;
}
@Override
public AsyncContext startAsync() {
response.disableSaveOnResponseCommitted();
return super.startAsync();
}
@Override
public AsyncContext startAsync(ServletRequest request, ServletResponse response) {
this.response.disableSaveOnResponseCommitted();
return super.startAsync(request, response);
}
}
private static final class PrimitiveAuthentication {
private final String principalId;
private final String email;
private final Set<String> roles;
private final Set<String> authorities;
private PrimitiveAuthentication(
String principalId, String email, Set<String> roles, Set<String> authorities) {
this.principalId = principalId;
this.email = email;
this.roles = roles;
this.authorities = authorities;
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
/** Provider-neutral servlet session filter and hardened host-only cookie composition. */
@Configuration(proxyBeanMethods = false)
@EnableSpringHttpSession
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
public class RedisSessionWebConfig {
@Bean
CookieSerializer sessionCookieSerializer(SecuritySettings settings) {
SecuritySettings.SessionCookieSettings policy = settings.session();
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName(policy.cookieName());
serializer.setUseSecureCookie(policy.secure());
serializer.setUseHttpOnlyCookie(policy.httpOnly());
serializer.setSameSite(policy.sameSite());
serializer.setCookiePath(policy.path());
serializer.setCookieMaxAge(-1);
serializer.setUseBase64Encoding(true);
// No domain or domain pattern is configured: the session cookie remains host-only.
return serializer;
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.auth;
import java.util.List;
import java.util.Objects;
/**
* A path that needs more than authentication.
*
* <p>The base chain ends in {@code anyRequest().authenticated()}, which is the right default for a
* data plane and the wrong one for a management plane: it makes every authenticated caller a
* potential administrator, and an application-level policy consulted later cannot recover from a
* transport that already let the request through.
*
* <p>Modules that own a privileged surface contribute one of these instead of assembling a second
* filter chain. A second chain would have to restate the whole authentication mechanism — JWT
* decoding, session handling, the envelope entry point — and any drift between the two copies is a
* silent authorization hole.
*
* @param pathPattern Ant-style pattern the rule applies to, for example {@code /internal/x/**}
* @param requiredAuthorities any one of which admits the request; never empty
*/
public record RestrictedPathRule(String pathPattern, List<String> requiredAuthorities) {
public RestrictedPathRule {
Objects.requireNonNull(pathPattern, "pathPattern");
Objects.requireNonNull(requiredAuthorities, "requiredAuthorities");
if (pathPattern.isBlank()) {
throw new IllegalArgumentException("pathPattern must be non-blank");
}
if (requiredAuthorities.isEmpty()) {
throw new IllegalArgumentException(
"requiredAuthorities must not be empty: a rule that requires nothing is weaker than the "
+ "authenticated default it replaces");
}
requiredAuthorities = List.copyOf(requiredAuthorities);
}
String[] authorities() {
return requiredAuthorities.toArray(new String[0]);
}
}
@@ -0,0 +1,172 @@
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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.ObjectPostProcessor;
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.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
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
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() {
return new PrimitiveSessionSecurityContextRepository();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository,
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
http.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()))
// This is an API boundary: never persist framework SavedRequest graphs in a session.
.requestCache(cache -> cache.disable())
.authorizeHttpRequests(
auth -> {
if (publicPaths.length > 0) {
auth.requestMatchers(publicPaths).permitAll();
}
// Ordered before the authenticated catch-all: a management path must be refused at
// the transport, not by an application policy the request has already passed.
for (RestrictedPathRule rule : restricted) {
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
}
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));
if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) {
http.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.withObjectPostProcessor(forwardServiceFailuresTo(authenticationEntryPoint))
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
} else {
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
csrfRepository.setCookieName(sessionSettings.csrfCookieName());
csrfRepository.setHeaderName(sessionSettings.csrfHeaderName());
csrfRepository.setCookieCustomizer(
cookie ->
cookie
.secure(true)
.httpOnly(false)
.sameSite(sessionSettings.sameSite())
.path(sessionSettings.path()));
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
http.csrf(
csrf ->
csrf.csrfTokenRepository(csrfRepository)
.csrfTokenRequestHandler(csrfRequestHandler))
.sessionManagement(
session ->
session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.migrateSession()))
.securityContext(
securityContext ->
securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false));
}
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;
}
private static ObjectPostProcessor<BearerTokenAuthenticationFilter> forwardServiceFailuresTo(
AuthenticationEntryPoint authenticationEntryPoint) {
return new ObjectPostProcessor<>() {
@Override
public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
filter.setAuthenticationFailureHandler(
(request, response, exception) ->
authenticationEntryPoint.commence(request, response, exception));
return filter;
}
};
}
}
@@ -0,0 +1,132 @@
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) {
OperationalError byCause = classifyByCause(ex.getCause());
if (byCause != null) {
return byCause;
}
if (ex instanceof OAuth2AuthenticationException oauth) {
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 JwtDecoderConfig.AuthenticationKeyServiceUnavailableException) {
return OperationalError.AUTH_JWKS_UNAVAILABLE;
}
if (cause instanceof JwtDecoderConfig.AuthenticationDecoderMisconfigurationException) {
return OperationalError.INTERNAL_AUTH_MISCONFIGURATION;
}
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("kid") || m.contains("matching key") || m.contains("key id")) {
return OperationalError.AUTH_KID_UNKNOWN;
}
if (m.contains("signature") || m.contains("signed jwt rejected")) {
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
}
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,91 @@
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);
int candidateStart = 0;
boolean inQuotes = false;
boolean matched = false;
for (int index = 0; index < trimmed.length(); index++) {
char current = trimmed.charAt(index);
if (current == '"') {
inQuotes = !inQuotes;
} else if (current == ',' && !inQuotes) {
String candidate = trimmed.substring(candidateStart, index);
if (!isWellFormedCandidate(candidate)) {
return false;
}
matched |= opaque(candidate).equals(target);
candidateStart = index + 1;
}
}
if (inQuotes) {
return false;
}
String candidate = trimmed.substring(candidateStart);
if (!isWellFormedCandidate(candidate)) {
return false;
}
return matched || opaque(candidate).equals(target);
}
private static boolean isWellFormedCandidate(String raw) {
String value = raw.trim();
if (value.startsWith("W/")) {
value = value.substring(2).trim();
}
int firstQuote = value.indexOf('"');
if (firstQuote < 0) {
return true;
}
return firstQuote == 0
&& value.length() >= 2
&& value.charAt(value.length() - 1) == '"'
&& value.substring(1, value.length() - 1).indexOf('"') < 0;
}
/** 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,37 @@
package dev.caskeleton.adapter.inbound.web.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.media.ObjectSchema;
import io.swagger.v3.oas.models.media.Schema;
import java.util.Map;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Keeps transport-owned OpenAPI schema details stable across springdoc library upgrades.
*
* <p>{@code ApiError.details} is represented by {@code Object} in the shared response contract.
* Springdoc 3 renders an untyped Java {@code Object} as an unconstrained OAS 3.1 schema. The public
* HTTP contract remains object-shaped, so the web adapter restores that transport-specific type
* without adding Swagger dependencies or annotations to {@code shared-contract}.
*/
@Configuration(proxyBeanMethods = false)
public class OpenApiContractConfig {
// Swagger's Components.getSchemas() is declared with a raw Schema, so a parameterized local would
// not compile against it. The rawness comes from the library, not from this code.
@SuppressWarnings("rawtypes")
@Bean
OpenApiCustomizer apiErrorDetailsObjectSchemaCustomizer() {
return openApi -> {
Components components = openApi.getComponents();
Map<String, Schema> schemas = components == null ? null : components.getSchemas();
Schema<?> apiError = schemas == null ? null : schemas.get("ApiError");
Map<String, Schema> properties = apiError == null ? null : apiError.getProperties();
if (properties != null && properties.containsKey("details")) {
properties.put("details", new ObjectSchema());
}
};
}
}
@@ -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,65 @@
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 forOperational(ApiErrorCode code) {
return switch (code.code()) {
case "MAPPING_FAILED" -> "Request data could not be mapped";
case "BAD_PARAMETER" -> "Request parameter is invalid";
case "VALIDATION_FAILED" -> "Request validation failed";
case "INVALID_TOKEN",
"AUTH_TOKEN_MALFORMED",
"AUTH_TOKEN_EXPIRED",
"AUTH_TOKEN_INVALID_SIGNATURE",
"AUTH_ISSUER_MISMATCH",
"AUTH_AUDIENCE_MISMATCH",
"AUTH_KID_UNKNOWN",
"AUTH_CLAIM_MAPPING_FAILED" ->
"Authentication token is invalid";
case "UNAUTHENTICATED", "AUTH_TOKEN_MISSING" -> "Authentication is required";
case "FORBIDDEN",
"AUTHZ_INSUFFICIENT_PERMISSION",
"AUTHZ_TENANT_MISMATCH",
"ACTUATOR_FORBIDDEN" ->
"Access is denied";
case "AUTH_JWKS_UNAVAILABLE" ->
"Authentication service temporarily unavailable, please retry";
case "PRECONDITION_FAILED" -> "Resource state changed; refresh and retry";
case "METHOD_NOT_ALLOWED" -> "HTTP method is not allowed for this route";
case "NOT_ACCEPTABLE" -> "No acceptable response representation is available";
case "PAYLOAD_TOO_LARGE" -> "Request payload exceeds the maximum allowed size";
case "UNSUPPORTED_MEDIA_TYPE" -> "Request content type is not supported";
case "ROUTE_NOT_FOUND" -> "Requested route was not found";
case "ADAPTER_DISABLED", "INTERNAL_ERROR", "INTERNAL_AUTH_MISCONFIGURATION" ->
"Internal server error";
default -> forPersistence(code.category());
};
}
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,130 @@
package dev.caskeleton.adapter.inbound.web.error;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Path;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.validation.FieldError;
/**
* Builds bounded validation details without reflecting rejected values or interpolated messages.
*/
final class ClientSafeValidationDetails {
private static final Rule INVALID = new Rule("INVALID", "Invalid value");
private static final Map<String, Rule> RULES =
Map.ofEntries(
Map.entry("NotNull", new Rule("NOT_NULL", "Required value is missing")),
Map.entry("NotBlank", new Rule("NOT_BLANK", "Value must not be blank")),
Map.entry("NotEmpty", new Rule("NOT_EMPTY", "Value must not be empty")),
Map.entry("Size", new Rule("SIZE", "Value size is outside the allowed range")),
Map.entry("Min", new Rule("MIN", "Value is below the allowed minimum")),
Map.entry("DecimalMin", new Rule("MIN", "Value is below the allowed minimum")),
Map.entry("Max", new Rule("MAX", "Value exceeds the allowed maximum")),
Map.entry("DecimalMax", new Rule("MAX", "Value exceeds the allowed maximum")),
Map.entry("Positive", new Rule("POSITIVE", "Value must be positive")),
Map.entry("PositiveOrZero", new Rule("POSITIVE_OR_ZERO", "Value must not be negative")),
Map.entry("Negative", new Rule("NEGATIVE", "Value must be negative")),
Map.entry("NegativeOrZero", new Rule("NEGATIVE_OR_ZERO", "Value must not be positive")),
Map.entry("Pattern", new Rule("PATTERN", "Value has an invalid format")),
Map.entry("Email", new Rule("EMAIL", "Value has an invalid format")),
Map.entry("Past", new Rule("PAST", "Value must be in the past")),
Map.entry(
"PastOrPresent", new Rule("PAST_OR_PRESENT", "Value must not be in the future")),
Map.entry("Future", new Rule("FUTURE", "Value must be in the future")),
Map.entry(
"FutureOrPresent", new Rule("FUTURE_OR_PRESENT", "Value must not be in the past")),
Map.entry("AssertTrue", new Rule("ASSERT_TRUE", "Value must be true")),
Map.entry("AssertFalse", new Rule("ASSERT_FALSE", "Value must be false")),
Map.entry("typeMismatch", new Rule("TYPE_MISMATCH", "Value has an invalid type")));
private ClientSafeValidationDetails() {}
static Map<String, Object> from(ConstraintViolation<?> violation) {
Annotation annotation = violation.getConstraintDescriptor().getAnnotation();
Rule rule = ruleFor(annotation == null ? null : annotation.annotationType().getSimpleName());
return detail(normalize(violation.getPropertyPath()), rule);
}
static Map<String, Object> from(FieldError fieldError) {
return detail(normalize(fieldError.getField()), ruleFor(fieldError.getCode()));
}
private static Map<String, Object> detail(String field, Rule rule) {
return Map.of("field", field, "code", rule.code(), "message", rule.message());
}
private static Rule ruleFor(String rawCode) {
if (rawCode == null || rawCode.isBlank()) {
return INVALID;
}
int qualifier = rawCode.indexOf('.');
String simpleCode = qualifier < 0 ? rawCode : rawCode.substring(0, qualifier);
return RULES.getOrDefault(simpleCode, INVALID);
}
private static String normalize(Path path) {
if (path == null) {
return "request";
}
List<String> names = new ArrayList<>();
for (Path.Node node : path) {
if (isPropertyName(node.getName())) {
names.add(node.getName());
}
}
return names.isEmpty() ? normalize(path.toString()) : String.join(".", names);
}
private static String normalize(String rawPath) {
if (rawPath == null || rawPath.isBlank()) {
return "request";
}
StringBuilder withoutIterableParts = new StringBuilder(rawPath.length());
int bracketDepth = 0;
for (int i = 0; i < rawPath.length(); i++) {
char current = rawPath.charAt(i);
if (current == '[') {
bracketDepth++;
} else if (current == ']') {
if (bracketDepth > 0) {
bracketDepth--;
}
} else if (bracketDepth == 0) {
withoutIterableParts.append(current);
}
}
List<String> names = new ArrayList<>();
int segmentStart = 0;
for (int i = 0; i <= withoutIterableParts.length(); i++) {
if (i == withoutIterableParts.length() || withoutIterableParts.charAt(i) == '.') {
String candidate = withoutIterableParts.substring(segmentStart, i);
if (isPropertyName(candidate)) {
names.add(candidate);
}
segmentStart = i + 1;
}
}
return names.isEmpty() ? "request" : String.join(".", names);
}
private static boolean isPropertyName(String candidate) {
if (candidate == null || candidate.isBlank() || candidate.length() > 128) {
return false;
}
if (!Character.isJavaIdentifierStart(candidate.charAt(0))) {
return false;
}
for (int i = 1; i < candidate.length(); i++) {
if (!Character.isJavaIdentifierPart(candidate.charAt(i))) {
return false;
}
}
return true;
}
private record Rule(String code, String message) {}
}
@@ -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,417 @@
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.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;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* 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,
ClientSafeErrorMessages.forOperational(OperationalError.MAPPING_FAILED),
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,
ClientSafeErrorMessages.forOperational(OperationalError.ADAPTER_DISABLED),
null);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) {
return ErrorResponseFactory.envelope(
OperationalError.BAD_PARAMETER,
ClientSafeErrorMessages.forOperational(OperationalError.BAD_PARAMETER),
null);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) {
List<Map<String, Object>> violations =
ex.getConstraintViolations().stream().map(ClientSafeValidationDetails::from).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() + "' is invalid", details);
}
@ExceptionHandler(InvalidBearerTokenException.class)
public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) {
return ErrorResponseFactory.envelope(
OperationalError.INVALID_TOKEN,
ClientSafeErrorMessages.forOperational(OperationalError.INVALID_TOKEN),
null);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) {
return ErrorResponseFactory.envelope(
OperationalError.UNAUTHENTICATED,
ClientSafeErrorMessages.forOperational(OperationalError.UNAUTHENTICATED),
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) {
ApiErrorCode code = ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex);
return ErrorResponseFactory.envelope(code, ClientSafeErrorMessages.forOperational(code), 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,
ClientSafeErrorMessages.forOperational(OperationalError.PRECONDITION_FAILED),
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, "Pagination parameter is invalid", 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,
"Cursor is invalid or expired; 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. Logs and traces receive only the stable classification because a
* JDBC cause can contain SQL values, constraints, credentials, and endpoints.
*/
@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());
spanErrorRecorder.recordException(sanitizedPersistenceFailure(code), code.code());
return ErrorResponseFactory.envelope(
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
}
private static PersistenceFailureException sanitizedPersistenceFailure(ApiErrorCode code) {
return new PersistenceFailureException(
code, "persistence failure classified as " + code.code(), 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(ClientSafeValidationDetails::from)
.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,
ClientSafeErrorMessages.forOperational(OperationalError.METHOD_NOT_ALLOWED),
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,
ClientSafeErrorMessages.forOperational(OperationalError.UNSUPPORTED_MEDIA_TYPE),
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 routeNotFound();
}
@Override
protected ResponseEntity<Object> handleNoResourceFoundException(
NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return routeNotFound();
}
private static ResponseEntity<Object> routeNotFound() {
return new ResponseEntity<>(
ErrorResponseFactory.body(
OperationalError.ROUTE_NOT_FOUND,
ClientSafeErrorMessages.forOperational(OperationalError.ROUTE_NOT_FOUND),
null),
HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus()));
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.admin.FileserverAdminService;
import dev.caskeleton.application.fileserver.admin.ForceDeleteCommand;
import dev.caskeleton.application.fileserver.admin.IncompleteUploadView;
import dev.caskeleton.application.fileserver.admin.OrphanObject;
import dev.caskeleton.application.fileserver.admin.OrphanReconcileCommand;
import dev.caskeleton.application.fileserver.admin.OrphanReconcileReport;
import dev.caskeleton.application.fileserver.admin.RuntimeCapabilityReport;
import dev.caskeleton.application.fileserver.admin.StorageHealthReport;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.cleanup.CleanupBatchResult;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The management plane, reachable only where it is explicitly enabled.
*
* <p>These routes live under {@code /internal/} and behind their own enablement property because
* they are not part of the public API surface: a deployment that exposes the public application
* port to the internet must be able to keep these off it entirely.
*
* <p>A reconcile without an explicit {@code dryRun=false} is always a dry run. That default lives
* here as well as in the command, because the most dangerous request is the one that omits a field.
*/
@RestController
@ConditionalOnProperty(
prefix = "app.fileserver-platform",
name = {"enabled", "admin.enabled"},
havingValue = "true")
public class FileserverAdminController {
private static final int DEFAULT_PAGE = 100;
private final FileserverAdminService adminService;
private final FileserverRequestContextFactory contextFactory;
public FileserverAdminController(
FileserverAdminService adminService, FileserverRequestContextFactory contextFactory) {
this.adminService = adminService;
this.contextFactory = contextFactory;
}
@GetMapping(
path = "/internal/fileserver/storage-health",
produces = MediaType.APPLICATION_JSON_VALUE)
public StorageHealthReport storageHealth() {
return adminService.storageHealth(contextFactory.current());
}
@GetMapping(
path = "/internal/fileserver/capabilities",
produces = MediaType.APPLICATION_JSON_VALUE)
public RuntimeCapabilityReport capabilities() {
return adminService.capabilities(contextFactory.current());
}
@GetMapping(path = "/internal/fileserver/orphans", produces = MediaType.APPLICATION_JSON_VALUE)
public List<OrphanObject> orphans(@RequestParam(name = "limit", defaultValue = "100") int limit) {
return adminService.orphans(limit, contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/orphans:reconcile",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public OrphanReconcileReport reconcileOrphans(@RequestBody OrphanReconcileHttpRequest request) {
return adminService.reconcileOrphans(toCommand(request), contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/files/{fileId}:reverify",
produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse reverify(@PathVariable("fileId") String fileId) {
return UploadedFileResponse.from(
adminService.reverify(FileId.parse(fileId), contextFactory.current()));
}
@PostMapping(
path = "/internal/fileserver/files/{fileId}:force-delete",
consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> forceDelete(
@PathVariable("fileId") String fileId, @Valid @RequestBody ForceDeleteHttpRequest request) {
adminService.forceDelete(
new ForceDeleteCommand(FileId.parse(fileId), request.reasonCode()),
contextFactory.current());
return ResponseEntity.accepted().build();
}
@GetMapping(
path = "/internal/fileserver/uploads/incomplete",
produces = MediaType.APPLICATION_JSON_VALUE)
public List<IncompleteUploadView> incompleteUploads(
@RequestParam(name = "limit", defaultValue = "100") int limit) {
return adminService.incompleteUploads(limit, contextFactory.current());
}
@PostMapping(
path = "/internal/fileserver/uploads:cleanup",
produces = MediaType.APPLICATION_JSON_VALUE)
public CleanupBatchResult cleanupUploads(
@RequestParam(name = "maxItems", defaultValue = "100") int maxItems,
@RequestParam(name = "maxBytes", defaultValue = "1073741824") long maxBytes) {
return adminService.cleanupUploads(maxItems, maxBytes, contextFactory.current());
}
/**
* Reads the wire request conservatively.
*
* <p>Every absent field resolves to the safe value: a dry run, the default page, and no
* fingerprints. Nothing here can be omitted into a destructive default.
*/
private static OrphanReconcileCommand toCommand(OrphanReconcileHttpRequest request) {
boolean dryRun = request.dryRun() == null || request.dryRun();
int limit = request.limit() == null ? DEFAULT_PAGE : request.limit();
if (dryRun) {
return OrphanReconcileCommand.dryRun(limit);
}
return new OrphanReconcileCommand(
false,
limit,
request.maxBytes() == null ? 1L << 30 : request.maxBytes(),
request.expectedFingerprints() == null ? List.of() : request.expectedFingerprints(),
request.reasonCode() == null ? "ORPHAN_APPLY" : request.reasonCode());
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** Wire form of a force delete; the reason is mandatory and is recorded in the audit trail. */
public record ForceDeleteHttpRequest(@NotBlank @Size(min = 8, max = 200) String reasonCode) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.inbound.web.fileserver.admin;
import java.util.List;
/**
* Wire form of a reconcile request.
*
* <p>{@code dryRun} is a wrapper type on purpose: an absent field must mean "dry run", and a
* primitive would silently turn a missing value into {@code false}, which is an apply.
*/
public record OrphanReconcileHttpRequest(
Boolean dryRun,
Integer limit,
Long maxBytes,
List<String> expectedFingerprints,
String reasonCode) {}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.TransferAdmissionRejectedException;
import dev.caskeleton.application.fileserver.api.error.TransferTimeoutException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Admission control for blocking transfers.
*
* <p>The point of running transfers on their own bounded pool is not extra parallelism — the
* servlet thread blocks on the result either way — it is that the pool plus its bounded queue caps
* how many transfers can be in flight. Beyond that cap the request is rejected fast with a
* retryable {@code 429} instead of pinning a container thread until the container itself runs out.
*/
public final class BlockingTransferExecutor {
private final ThreadPoolTaskExecutor executor;
private final int awaitSeconds;
public BlockingTransferExecutor(ThreadPoolTaskExecutor executor, int awaitSeconds) {
this.executor = executor;
this.awaitSeconds = awaitSeconds;
}
/**
* Runs {@code work} on the transfer pool, translating saturation and timeout to design codes.
*
* <p>Submitted through {@link ThreadPoolTaskExecutor#submit} rather than {@code
* CompletableFuture.supplyAsync}. That distinction is the whole timeout contract: {@code
* CompletableFuture#cancel} ignores its {@code mayInterruptIfRunning} argument and never touches
* the worker thread, so a timed-out transfer used to return {@code 504} to the client while the
* worker kept streaming bytes into an abandoned response — holding a pool slot, a buffer and an
* open channel for as long as the copy took. A real {@code Future} interrupts, and an interrupted
* {@code FileChannel} closes itself, so the transfer actually stops.
*/
public <T> T call(Supplier<T> work) {
Future<T> future;
try {
future = executor.submit(work::get);
} catch (TaskRejectedException rejected) {
throw new TransferAdmissionRejectedException(
"transfer pool is saturated",
rejected,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_ADMISSION_REJECTED, true));
}
try {
return future.get(awaitSeconds, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
future.cancel(true);
throw new TransferTimeoutException(
"transfer was interrupted before completing",
interrupted,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
} catch (TimeoutException timeout) {
// Interrupting is the point: the caller is about to answer 504, and a worker still copying
// into that response would keep a pool slot and an open channel for the rest of the transfer.
future.cancel(true);
throw new TransferTimeoutException(
"transfer did not complete within the configured budget",
timeout,
FileserverFailureContext.of(FileserverErrorCode.TRANSFER_TIMEOUT, true));
} catch (ExecutionException failure) {
throw rethrow(failure);
}
}
/**
* Unwraps the worker failure.
*
* <p>A Fileserver failure keeps its own context — wrapping it in an execution exception here
* would lose the code, the ambiguity flag, and the correct status.
*/
private static RuntimeException rethrow(ExecutionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof FileserverException fileserverFailure) {
return fileserverFailure;
}
if (cause instanceof RuntimeException runtime) {
return runtime;
}
if (cause instanceof Error error) {
throw error;
}
return new IllegalStateException("transfer failed", cause);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import java.time.Duration;
import java.util.Objects;
/**
* Transport-side Fileserver settings.
*
* <p>These are the values a controller needs and that the application layer must not read for
* itself: the default namespace for an unscoped request, the upload resource lifetime, and the
* batch part ceiling.
*/
public record FileserverWebProperties(
StorageNamespace defaultNamespace,
Duration uploadTtl,
int maxBatchParts,
boolean contentLengthRequired) {
private static final int DESIGN_MAX_BATCH_PARTS = 16;
public FileserverWebProperties {
Objects.requireNonNull(defaultNamespace, "defaultNamespace");
Objects.requireNonNull(uploadTtl, "uploadTtl");
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
throw new IllegalArgumentException("uploadTtl must be positive");
}
if (maxBatchParts < 1 || maxBatchParts > DESIGN_MAX_BATCH_PARTS) {
throw new IllegalArgumentException("maxBatchParts must be between 1 and 16");
}
}
/** Design standard profile: 1 h upload lifetime, 16 batch parts, length optional. */
public static FileserverWebProperties standard(StorageNamespace defaultNamespace) {
return new FileserverWebProperties(
defaultNamespace, Duration.ofHours(1), DESIGN_MAX_BATCH_PARTS, false);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* The bounded pool every blocking Fileserver transfer runs on.
*
* <p>The abort policy is the design decision, not a leftover default: silently running the transfer
* on the caller's thread would defeat the bound, and an unbounded queue would trade a fast {@code
* 429} for an eventual heap exhaustion. Rejection is translated into a retryable response by {@link
* BlockingTransferExecutor}.
*
* <p>The pool is decorated so a transfer running on a worker thread still carries the caller's
* correlation context; without it every transfer log line would be untraceable back to its request.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class MvcTransferExecutorConfiguration {
@Bean(destroyMethod = "shutdown")
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public ThreadPoolTaskExecutor fileserverTransferExecutor(
TransferExecutorProperties properties, TaskDecorator taskDecorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(taskDecorator);
executor.setCorePoolSize(properties.coreSize());
executor.setMaxPoolSize(properties.maxSize());
executor.setQueueCapacity(properties.queueCapacity());
executor.setThreadNamePrefix("fs-transfer-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(properties.awaitSeconds());
executor.initialize();
return executor;
}
@Bean
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public BlockingTransferExecutor blockingTransferExecutor(
ThreadPoolTaskExecutor fileserverTransferExecutor, TransferExecutorProperties properties) {
return new BlockingTransferExecutor(fileserverTransferExecutor, properties.awaitSeconds());
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.fileserver.config;
/**
* Bounds on the blocking transfer pool.
*
* <p>Every value is a hard bound. An unbounded queue would turn a saturation event into a heap
* exhaustion instead of a fast {@code 429}, which is why there is no "unlimited" option here.
*/
public record TransferExecutorProperties(
int coreSize, int maxSize, int queueCapacity, int awaitSeconds) {
public TransferExecutorProperties {
if (coreSize < 1 || maxSize < coreSize) {
throw new IllegalArgumentException("maxSize must be at least coreSize and both positive");
}
if (queueCapacity < 1) {
throw new IllegalArgumentException("queueCapacity must be positive");
}
if (awaitSeconds < 1) {
throw new IllegalArgumentException("awaitSeconds must be positive");
}
}
/** Design standard profile: core 8, max 32, queue 64. */
public static TransferExecutorProperties standard() {
return new TransferExecutorProperties(8, 32, 64, 300);
}
}
@@ -0,0 +1,165 @@
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcConditionalRequestFactory;
import dev.caskeleton.adapter.inbound.web.fileserver.http.MvcDownloadResponseWriter;
import dev.caskeleton.adapter.inbound.web.fileserver.http.ZeroCopyEligibility;
import dev.caskeleton.adapter.inbound.web.fileserver.nginx.NginxDownloadStrategy;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
import dev.caskeleton.application.fileserver.download.DownloadApplicationService;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import dev.caskeleton.application.fileserver.download.DownloadRequest;
import dev.caskeleton.application.fileserver.download.ZeroCopyTransferResult;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Metadata and content download endpoints.
*
* <p>GET and HEAD share one handler so their headers are identical by construction rather than by
* convention. Content is opened only after the decision says a body is expected, so a {@code 304},
* {@code 412}, or {@code 416} answer never reaches storage.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileDownloadController {
private final DownloadApplicationService downloadService;
private final MvcConditionalRequestFactory conditionalFactory;
private final MvcDownloadResponseWriter responseWriter;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final NginxDownloadStrategy delegationStrategy;
private final ZeroCopyEligibility zeroCopy;
/**
* The only constructor.
*
* <p>There is deliberately no shorter overload defaulting {@code zeroCopy} to disabled. Two
* constructors leave component scanning with no way to choose one, so the controller could not be
* instantiated at all; and a caller that took the short form would silently lose the optimization
* without saying so. Every construction site names its zero-copy policy.
*/
public FileDownloadController(
DownloadApplicationService downloadService,
MvcConditionalRequestFactory conditionalFactory,
MvcDownloadResponseWriter responseWriter,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
NginxDownloadStrategy delegationStrategy,
ZeroCopyEligibility zeroCopy) {
this.downloadService = downloadService;
this.conditionalFactory = conditionalFactory;
this.responseWriter = responseWriter;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.delegationStrategy = delegationStrategy;
this.zeroCopy = zeroCopy;
}
@GetMapping(path = "/v1/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse describe(@PathVariable("fileId") String fileId) {
return UploadedFileResponse.from(
downloadService.describeFile(FileId.parse(fileId), contextFactory.current()));
}
@RequestMapping(
path = "/v1/files/{fileId}/content",
method = {RequestMethod.GET, RequestMethod.HEAD})
public void download(
@PathVariable("fileId") String fileId,
@RequestParam(name = "inline", required = false, defaultValue = "false") boolean inline,
HttpServletRequest request,
HttpServletResponse response) {
boolean headOnly = RequestMethod.HEAD.name().equalsIgnoreCase(request.getMethod());
ConditionalRequest conditional = conditionalFactory.from(request, headOnly);
RequestContext context = contextFactory.current();
DownloadDescriptor descriptor =
downloadService.describe(
new DownloadRequest(FileId.parse(fileId), conditional, inline), context);
responseWriter.writeHeaders(descriptor, response);
if (!descriptor.bodyExpected()) {
return;
}
// Delegation is decided only after authorization and the READY gate, so the internal redirect
// can only ever name content this caller was already allowed to read.
if (delegationStrategy.shouldDelegate(descriptor)) {
delegationStrategy.delegate(descriptor, response);
return;
}
// `isSecure` is read here, on the container thread, because the request may be recycled before
// the transfer task runs.
boolean secure = request.isSecure();
transferExecutor.call(() -> stream(descriptor, response, secure));
}
/**
* Writes the described bytes.
*
* <p>A full representation is treated as a single range so the read path has exactly one shape;
* there is no separate "whole file" branch that could drift from the partial one.
*
* <p>A large plaintext response is offered to storage for a direct kernel transfer first. The
* gateway may decline for any reason, and the streaming write below is then used unchanged — the
* response is identical either way, which is what keeps this an optimization rather than a second
* contract.
*
* <p>The fallback is taken only when nothing reached the socket. A transfer that moved some bytes
* and then stopped has already committed the response, and streaming the representation on top of
* it would send the prefix twice — a body that is longer than its own {@code Content-Length} and
* matches neither the length nor the digest the client was promised. That case aborts instead.
*/
private Void stream(DownloadDescriptor descriptor, HttpServletResponse response, boolean secure) {
if (!descriptor.isPartial() && descriptor.representation().length() == 0) {
// A zero-length representation has no range at all. Clamping produced 0..0 — a one-byte
// request over an empty object — which storage correctly refused as unsatisfiable, so a
// legitimately empty file answered 416 instead of an empty 200.
return null;
}
ByteRange range =
descriptor.isPartial()
? descriptor.singleRange()
: ByteRange.entire(descriptor.representation().length());
try {
if (zeroCopy.isEligible(true, range.length(), secure)) {
ZeroCopyTransferResult transfer =
downloadService.transferContent(
descriptor, range, Channels.newChannel(response.getOutputStream()));
if (transfer.isComplete()) {
return null;
}
if (!transfer.allowsFallback()) {
throw new UncheckedIOException(
new IOException(
"direct transfer stopped after "
+ transfer.transferredBytes()
+ " bytes; the response is already committed and must not be re-sent"));
}
}
try (ReadableByteChannel content = downloadService.openContent(descriptor, range)) {
responseWriter.writeBody(content, response);
}
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
return null;
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.inbound.web.fileserver.controller;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadItemResult;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.BatchUploadResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.MultipartUploadRequestMapper;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.RawUploadRequestMapper;
import dev.caskeleton.adapter.inbound.web.fileserver.mapper.UploadIntent;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.FileState;
import dev.caskeleton.application.fileserver.api.error.FileTooLargeException;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FileView;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.SingleShotUploadService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Streaming upload endpoints.
*
* <p>Bytes are never materialized: the raw path wraps the servlet input stream and the multipart
* path wraps each part's stream, so a 2 GiB upload costs a bounded buffer rather than 2 GiB of
* heap. Every transfer goes through the bounded transfer pool, which turns overload into a fast
* retryable rejection instead of container-thread exhaustion.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileUploadController {
private final SingleShotUploadService uploadService;
private final RawUploadRequestMapper rawMapper;
private final MultipartUploadRequestMapper multipartMapper;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final FileserverWebProperties properties;
private final Clock clock;
public FileUploadController(
SingleShotUploadService uploadService,
RawUploadRequestMapper rawMapper,
MultipartUploadRequestMapper multipartMapper,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
FileserverWebProperties properties,
Clock clock) {
this.uploadService = uploadService;
this.rawMapper = rawMapper;
this.multipartMapper = multipartMapper;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.properties = properties;
this.clock = clock;
}
/** Raw streaming upload; the whole request body is the file. */
// The channel wraps the servlet request body. Closing it would close the container's input
// stream, which the container owns and reuses for keep-alive; the upload must read the body
// and leave the stream alone.
@SuppressWarnings("resource")
@PostMapping(path = "/v1/files:raw", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> uploadRaw(HttpServletRequest request)
throws IOException {
UploadIntent intent = rawMapper.map(request);
RequestContext context = contextFactory.current();
InputStream body = request.getInputStream();
FileView view =
transferExecutor.call(
() ->
upload(
intent,
Channels.newChannel(body),
declaredLength(intent),
context,
UploadProtocol.RAW));
return created(view);
}
/** Multipart single upload. */
@PostMapping(
path = "/v1/files",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> uploadMultipart(
@RequestParam("file") MultipartFile file) throws IOException {
UploadIntent intent = multipartMapper.map(file);
RequestContext context = contextFactory.current();
// Closed on every path, including a rejection thrown inside the transfer. A multipart part is
// backed by a temporary file or a buffer the container only releases when the stream is closed.
try (InputStream body = file.getInputStream()) {
ReadableByteChannel content = Channels.newChannel(body);
FileView view =
transferExecutor.call(
() -> upload(intent, content, file.getSize(), context, UploadProtocol.MULTIPART));
return created(view);
}
}
/**
* Bounded multi-file upload.
*
* <p>The batch is explicitly non-atomic: each part is an independent file, a failure never rolls
* back a sibling that already succeeded, and the response is always {@code 200} carrying the
* ordered per-part outcome.
*/
@PostMapping(
path = "/v1/files:batch",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<BatchUploadResponse> uploadBatch(
@RequestParam("files") List<MultipartFile> files) {
requirePartCountWithinPolicy(files.size());
RequestContext context = contextFactory.current();
List<BatchUploadItemResult> results = new ArrayList<>(files.size());
for (int index = 0; index < files.size(); index++) {
results.add(uploadPart(files.get(index), index, context));
}
return ResponseEntity.ok(new BatchUploadResponse(results));
}
/**
* Uploads one batch part.
*
* <p>A part failure is converted to a per-part problem instead of aborting the request, which is
* what makes the endpoint's non-atomic contract observable rather than merely documented.
*/
private BatchUploadItemResult uploadPart(MultipartFile part, int index, RequestContext context) {
String clientPartId = partId(part, index);
try (InputStream body = part.getInputStream()) {
UploadIntent intent = multipartMapper.map(part);
ReadableByteChannel content = Channels.newChannel(body);
FileView view =
transferExecutor.call(
() -> upload(intent, content, part.getSize(), context, UploadProtocol.BATCH));
return BatchUploadItemResult.accepted(clientPartId, view);
} catch (FileserverException failure) {
return BatchUploadItemResult.rejected(clientPartId, failure.code());
} catch (IOException failure) {
return BatchUploadItemResult.rejected(clientPartId, FileserverErrorCode.STORAGE_UNAVAILABLE);
}
}
/**
* Records the protocol the request actually used.
*
* <p>Every endpoint previously persisted {@code RAW}. The recorded protocol is what a resume, an
* audit, and a reconciliation read to decide how an upload was produced, so labelling a batch
* part as a raw upload makes all three describe something that never happened.
*/
private FileView upload(
UploadIntent intent,
ReadableByteChannel content,
long contentLength,
RequestContext context,
UploadProtocol protocol) {
CreateUploadRequest request =
new CreateUploadRequest(
properties.defaultNamespace(),
intent.originalFilename(),
intent.claimedMediaType(),
intent.declaredLength(),
intent.expectedSha256(),
protocol,
clock.instant().plus(properties.uploadTtl()));
return uploadService.upload(
request,
content,
contentLength,
new FinalizeUploadRequest(intent.expectedSha256(), false),
context);
}
/**
* Renders the completed upload.
*
* <p>READY is a finished object, so it answers {@code 201}; anything still under verification is
* {@code 202} with no public content behind it yet.
*/
private static ResponseEntity<UploadedFileResponse> created(FileView view) {
HttpStatus status = view.state() == FileState.READY ? HttpStatus.CREATED : HttpStatus.ACCEPTED;
return ResponseEntity.status(status)
.location(URI.create("/v1/files/" + view.fileId().canonicalText()))
.body(UploadedFileResponse.from(view));
}
private void requirePartCountWithinPolicy(int partCount) {
if (partCount > properties.maxBatchParts()) {
throw new FileTooLargeException(
"batch exceeds the configured maximum part count",
FileserverFailureContext.of(FileserverErrorCode.FILE_TOO_LARGE, false));
}
}
/** Stable per-part identity so a caller can correlate a result with what it sent. */
private static String partId(MultipartFile part, int index) {
String name = part.getOriginalFilename();
return name == null || name.isBlank() ? String.valueOf(index) : name;
}
private static long declaredLength(UploadIntent intent) {
return intent.declaredLength().isPresent() ? intent.declaredLength().getAsLong() : -1;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
/**
* The draft-12 header and media-type vocabulary.
*
* <p>Deliberately separate from the tus vocabulary even where the names coincide: sharing the
* constants would couple a Stable protocol to an unratified one, so a draft revision could silently
* change tus behaviour.
*/
public final class Draft12Headers {
public static final String UPLOAD_OFFSET = "Upload-Offset";
public static final String UPLOAD_COMPLETE = "Upload-Complete";
public static final String UPLOAD_LIMIT = "Upload-Limit";
/** Media type a draft-12 append carries. */
public static final String PARTIAL_UPLOAD = "application/partial-upload";
private Draft12Headers() {}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
/**
* The draft's offset-mismatch problem document.
*
* <p>It reports both offsets so the client can resume without a second round trip. This shape is
* the draft's own and is intentionally not the Fileserver problem document: a draft revision must
* be able to change it without touching the Stable contract.
*/
public record Draft12OffsetProblem(
String type, String title, int status, long expectedOffset, long providedOffset) {
public static final String TYPE =
"https://iana.org/assignments/http-problem-types#mismatching-upload-offset";
public static Draft12OffsetProblem of(long expectedOffset, long providedOffset) {
return new Draft12OffsetProblem(
TYPE, "Mismatching upload offset", 409, expectedOffset, providedOffset);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import java.time.Duration;
import java.util.Objects;
/**
* Settings for the experimental draft-12 protocol.
*
* <p>Disabled by default. An unratified protocol that shipped enabled would make every deployment
* carry a surface whose contract can change without notice.
*/
public record Draft12Properties(
boolean enabled, long maxSize, Duration uploadTtl, boolean interimResponsesSupported) {
public Draft12Properties {
Objects.requireNonNull(uploadTtl, "uploadTtl");
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize must be positive");
}
if (uploadTtl.isNegative() || uploadTtl.isZero()) {
throw new IllegalArgumentException("uploadTtl must be positive");
}
}
/** Disabled profile, which is the shipped default. */
public static Draft12Properties disabled() {
return new Draft12Properties(false, 100L * 1024 * 1024, Duration.ofHours(1), false);
}
}
@@ -0,0 +1,178 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import dev.caskeleton.adapter.inbound.web.fileserver.config.BlockingTransferExecutor;
import dev.caskeleton.adapter.inbound.web.fileserver.config.FileserverWebProperties;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.api.error.MalformedRequestException;
import dev.caskeleton.application.fileserver.api.error.UploadOffsetMismatchException;
import dev.caskeleton.application.fileserver.api.security.RequestContext;
import dev.caskeleton.application.fileserver.api.transfer.UploadProtocol;
import dev.caskeleton.application.fileserver.upload.AppendUploadResult;
import dev.caskeleton.application.fileserver.upload.CreateUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadRequest;
import dev.caskeleton.application.fileserver.upload.FinalizeUploadService;
import dev.caskeleton.application.fileserver.upload.UploadApplicationService;
import dev.caskeleton.application.fileserver.upload.UploadSessionView;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.Channels;
import java.time.Clock;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* HTTP resumable uploads, draft-12. Experimental.
*
* <p>It shares no path, no header constant, and no response type with the tus adapter. That
* separation is the point: the draft is unratified, and a future revision must be able to change
* this surface without touching a Stable protocol that clients already depend on.
*
* <p>Only the researched part of the draft is implemented — {@code Upload-Offset}, {@code
* Upload-Complete}, the partial-upload media type, and the offset-mismatch problem type. Nothing is
* guessed from a later revision.
*/
@RestController
@ExperimentalApi(specification = "draft-ietf-httpbis-resumable-upload-12")
@ConditionalOnProperty(
prefix = "app.fileserver-platform",
name = {"enabled", "httpbis-draft12.enabled"},
havingValue = "true")
public class Draft12UploadController {
private static final String DRAFT_PATH = "/v1/experimental/draft12/uploads";
private final UploadApplicationService uploadService;
private final FinalizeUploadService finalizeService;
private final FileserverRequestContextFactory contextFactory;
private final BlockingTransferExecutor transferExecutor;
private final FileserverWebProperties webProperties;
private final Draft12Properties draftProperties;
private final Clock clock;
public Draft12UploadController(
UploadApplicationService uploadService,
FinalizeUploadService finalizeService,
FileserverRequestContextFactory contextFactory,
BlockingTransferExecutor transferExecutor,
FileserverWebProperties webProperties,
Draft12Properties draftProperties,
Clock clock) {
this.uploadService = uploadService;
this.finalizeService = finalizeService;
this.contextFactory = contextFactory;
this.transferExecutor = transferExecutor;
this.webProperties = webProperties;
this.draftProperties = draftProperties;
this.clock = clock;
}
@PostMapping(DRAFT_PATH)
public ResponseEntity<Void> create(HttpServletRequest request) {
UploadSessionView created =
uploadService.create(
new CreateUploadRequest(
webProperties.defaultNamespace(),
filename(request),
Optional.empty(),
declaredLength(request),
Optional.empty(),
UploadProtocol.HTTPBIS_DRAFT12,
clock.instant().plus(draftProperties.uploadTtl())),
contextFactory.current());
return ResponseEntity.created(URI.create(DRAFT_PATH + "/" + created.uploadId().canonicalText()))
.header(Draft12Headers.UPLOAD_OFFSET, "0")
.header(Draft12Headers.UPLOAD_LIMIT, "max-size=" + draftProperties.maxSize())
.build();
}
@PatchMapping(path = DRAFT_PATH + "/{uploadId}", consumes = Draft12Headers.PARTIAL_UPLOAD)
// The channel wraps the servlet request body. Closing it would close the container's input
// stream, which the container owns and reuses for keep-alive; the upload must read the body
// and leave the stream alone.
@SuppressWarnings("resource")
public ResponseEntity<Void> append(
@PathVariable("uploadId") String uploadId, HttpServletRequest request) throws IOException {
UploadId id = UploadId.parse(uploadId);
long expectedOffset = requiredOffset(request);
boolean complete = isComplete(request);
RequestContext context = contextFactory.current();
InputStream body = request.getInputStream();
long declared = request.getContentLengthLong();
AppendUploadResult appended =
transferExecutor.call(
() ->
uploadService.append(
id, expectedOffset, Channels.newChannel(body), declared, context));
if (complete) {
finalizeService.finalizeUpload(id, FinalizeUploadRequest.synchronousWithoutDigest(), context);
}
return ResponseEntity.noContent()
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(appended.committedOffset()))
.header(Draft12Headers.UPLOAD_COMPLETE, complete ? "?1" : "?0")
.build();
}
/**
* Renders the draft's own offset-mismatch problem document.
*
* <p>The draft defines a specific problem type carrying both offsets; mapping this through the
* shared Fileserver problem handler would answer the right status with the wrong body.
*/
@ExceptionHandler(UploadOffsetMismatchException.class)
public ResponseEntity<Draft12OffsetProblem> offsetMismatch(
UploadOffsetMismatchException failure) {
return ResponseEntity.status(409)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.header(Draft12Headers.UPLOAD_OFFSET, String.valueOf(failure.currentOffset()))
.body(Draft12OffsetProblem.of(failure.currentOffset(), failure.expectedOffset()));
}
/**
* Reads the structured-field boolean {@code Upload-Complete}.
*
* <p>An absent header means the upload continues; only the explicit {@code ?1} form completes it,
* so a truncated request can never publish a partial object.
*/
private static boolean isComplete(HttpServletRequest request) {
return "?1".equals(request.getHeader(Draft12Headers.UPLOAD_COMPLETE));
}
private static long requiredOffset(HttpServletRequest request) {
String header = request.getHeader(Draft12Headers.UPLOAD_OFFSET);
if (header == null || header.isBlank()) {
throw MalformedRequestException.of("draft-12 append requires Upload-Offset");
}
try {
long value = Long.parseLong(header.trim());
if (value < 0) {
throw new NumberFormatException("negative");
}
return value;
} catch (NumberFormatException malformed) {
throw MalformedRequestException.of("Upload-Offset is not a non-negative integer");
}
}
private static OptionalLong declaredLength(HttpServletRequest request) {
long length = request.getContentLengthLong();
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
}
private static String filename(HttpServletRequest request) {
String header = request.getHeader("X-Filename");
return header == null || header.isBlank() ? "upload.bin" : header;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.fileserver.draft12;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a type that implements an unratified specification.
*
* <p>An experimental protocol changes between drafts, so anything marked here may break on a
* specification revision even though this project's own contract did not change. The marker exists
* so that is visible in code review rather than discovered in production.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ExperimentalApi {
/** The exact draft this type implements. */
String specification();
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
/**
* Failure of one batch part, in the same vocabulary the single-file endpoints use.
*
* <p>Only the stable code and its problem URN are exposed; the server-side message never crosses
* this boundary.
*/
public record BatchUploadItemProblem(String code, String type, int status) {
public static BatchUploadItemProblem of(FileserverErrorCode code) {
return new BatchUploadItemProblem(code.name(), code.problemType(), code.httpStatus());
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.upload.FileView;
/**
* Result of one batch part.
*
* <p>A batch is explicitly non-atomic, so each part reports its own outcome and a failure never
* rolls back a sibling that already succeeded.
*/
public record BatchUploadItemResult(
String clientPartId, String status, String fileId, BatchUploadItemProblem problem) {
public static BatchUploadItemResult accepted(String clientPartId, FileView view) {
return new BatchUploadItemResult(
clientPartId, view.state().name(), view.fileId().canonicalText(), null);
}
public static BatchUploadItemResult rejected(String clientPartId, FileserverErrorCode code) {
return new BatchUploadItemResult(
clientPartId, "REJECTED", null, BatchUploadItemProblem.of(code));
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import java.util.List;
/**
* Ordered per-part outcome of one batch upload.
*
* <p>The endpoint answers {@code 200} even when some parts failed: the batch has no request-wide
* atomicity, and pretending otherwise with a single status would hide the parts that succeeded.
*/
public record BatchUploadResponse(List<BatchUploadItemResult> results) {
public BatchUploadResponse {
results = List.copyOf(results);
}
/** True when every part succeeded, which the envelope layer reports as a plain success. */
public boolean fullySucceeded() {
return results.stream().allMatch(result -> result.problem() == null);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* Target of a copy or move.
*
* <p>The namespace pattern is enforced at the boundary as syntax; the value object enforces it
* again as an invariant. {@code filename} is untrusted display data that the application layer
* sanitizes — it is never used to build a physical key.
*/
public record RelocateFileRequest(
@NotBlank @Pattern(regexp = "[a-z][a-z0-9-]{1,62}") String namespace, String filename) {}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.web.fileserver.dto;
import dev.caskeleton.application.fileserver.upload.FileView;
/**
* Wire projection of one completed upload.
*
* <p>It carries only what the public descriptor already exposes; no content key, physical path, or
* container temporary path ever appears here.
*/
public record UploadedFileResponse(
String fileId,
String state,
String filename,
String mediaType,
long size,
String sha256,
String etag) {
public static UploadedFileResponse from(FileView view) {
return new UploadedFileResponse(
view.fileId().canonicalText(),
view.state().name(),
view.descriptor().originalFilename(),
view.descriptor().mediaType(),
view.descriptor().size(),
view.descriptor().sha256(),
view.descriptor().strongEtag());
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import dev.caskeleton.application.fileserver.api.transfer.ConditionalRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
/**
* Translates servlet headers into the transport-neutral conditional request.
*
* <p>Doing the translation here — and only here — is what lets MVC, WebFlux, and the delegation
* path share one decision implementation instead of each re-deriving the precedence rules.
*
* <p>An unparseable HTTP-date is treated as absent rather than as a failure, which is what RFC 9110
* requires: a malformed conditional header must be ignored, not turned into an error.
*/
public final class MvcConditionalRequestFactory {
public ConditionalRequest from(HttpServletRequest request, boolean headOnly) {
return new ConditionalRequest(
header(request, HttpHeaders.IF_MATCH),
header(request, HttpHeaders.IF_NONE_MATCH),
date(request, HttpHeaders.IF_MODIFIED_SINCE),
date(request, HttpHeaders.IF_UNMODIFIED_SINCE),
header(request, HttpHeaders.IF_RANGE),
header(request, HttpHeaders.RANGE),
headOnly);
}
private static Optional<String> header(HttpServletRequest request, String name) {
String value = request.getHeader(name);
return value == null || value.isBlank() ? Optional.empty() : Optional.of(value);
}
private static Optional<Instant> date(HttpServletRequest request, String name) {
Optional<String> raw = header(request, name);
if (raw.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(
ZonedDateTime.parse(raw.get(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant());
} catch (DateTimeParseException malformed) {
return Optional.empty();
}
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import dev.caskeleton.application.fileserver.api.ByteRange;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.http.HttpHeaders;
/**
* Writes one download decision onto a servlet response.
*
* <p>GET and HEAD render exactly the same header set; only the body differs. That is deliberate: a
* HEAD whose {@code Content-Length} or {@code ETag} disagreed with the GET would make range
* resumption and cache revalidation unreliable.
*
* <p>The body is streamed through one bounded buffer, so a large object costs a fixed amount of
* heap rather than its own size.
*/
public final class MvcDownloadResponseWriter {
/** Header that stops a browser re-sniffing a declared media type. */
public static final String CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
private static final int BUFFER_BYTES = 64 * 1024;
private static final DateTimeFormatter HTTP_DATE =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
/** Writes status and headers; {@code 304} deliberately carries no representation metadata. */
public void writeHeaders(DownloadDescriptor descriptor, HttpServletResponse response) {
response.setStatus(descriptor.status());
response.setHeader(HttpHeaders.ETAG, descriptor.representation().strongEtag());
response.setHeader(
HttpHeaders.LAST_MODIFIED,
HTTP_DATE.format(
ZonedDateTime.ofInstant(descriptor.representation().lastModified(), ZoneOffset.UTC)));
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
response.setHeader(HttpHeaders.CACHE_CONTROL, descriptor.cacheControl());
// Uploaded content never gets to describe itself: without nosniff a browser may re-interpret a
// declared octet-stream as HTML and execute it from this origin.
response.setHeader(CONTENT_TYPE_OPTIONS, "nosniff");
if (descriptor.status() == HttpServletResponse.SC_NOT_MODIFIED) {
return;
}
response.setHeader(HttpHeaders.CONTENT_TYPE, descriptor.representation().mediaType());
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, descriptor.contentDisposition());
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(payloadLength(descriptor)));
if (descriptor.isPartial()) {
ByteRange range = descriptor.singleRange();
response.setHeader(
HttpHeaders.CONTENT_RANGE,
"bytes "
+ range.startInclusive()
+ "-"
+ range.endInclusive()
+ "/"
+ descriptor.representation().length());
}
}
/** Streams {@code content} into the response through a single bounded buffer. */
public void writeBody(ReadableByteChannel content, HttpServletResponse response)
throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES);
try (ReadableByteChannel source = content) {
OutputStream target = response.getOutputStream();
while (source.read(buffer) >= 0) {
buffer.flip();
target.write(buffer.array(), buffer.arrayOffset(), buffer.limit());
buffer.clear();
}
target.flush();
}
}
/**
* Length the body would carry.
*
* <p>A HEAD reports the length it would have sent, so the header set matches the GET exactly even
* though no bytes follow.
*/
private static long payloadLength(DownloadDescriptor descriptor) {
if (descriptor.isPartial()) {
return descriptor.singleRange().length();
}
return descriptor.representation().length();
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.web.fileserver.http;
import org.springframework.http.ZeroCopyHttpOutputMessage;
import org.springframework.http.server.reactive.ServerHttpResponse;
/**
* Decides whether a response may be written with a kernel-level file transfer.
*
* <p>Zero copy is an optimization and never a contract change, so it is taken only when every
* precondition holds at once: the response implementation supports it, the body needs no
* transformation, and the connection is not encrypted in user space (TLS has to see the plaintext,
* so a {@code sendfile} would bypass the very layer that must transform it).
*/
public final class ZeroCopyEligibility {
private final boolean enabled;
private final long minimumBytes;
public ZeroCopyEligibility(boolean enabled, long minimumBytes) {
if (minimumBytes < 0) {
throw new IllegalArgumentException("minimumBytes must not be negative");
}
this.enabled = enabled;
this.minimumBytes = minimumBytes;
}
/** Design default: enabled above 16 MiB. */
public static ZeroCopyEligibility standard() {
return new ZeroCopyEligibility(true, 16L * 1024 * 1024);
}
public static ZeroCopyEligibility disabled() {
return new ZeroCopyEligibility(false, Long.MAX_VALUE);
}
public boolean isEligible(ServerHttpResponse response, long payloadBytes, boolean secure) {
return isEligible(supportsZeroCopy(response), payloadBytes, secure);
}
/**
* Decides eligibility from already-resolved facts.
*
* <p>Separating the capability probe from the policy keeps the policy testable without
* constructing a server response, and keeps the probe in exactly one place.
*/
public boolean isEligible(boolean responseCapable, long payloadBytes, boolean secure) {
return enabled && responseCapable && !secure && payloadBytes >= minimumBytes;
}
/** True when the response implementation can hand a file straight to the kernel. */
public static boolean supportsZeroCopy(ServerHttpResponse response) {
return response instanceof ZeroCopyHttpOutputMessage;
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.fileserver.lifecycle;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.RelocateFileRequest;
import dev.caskeleton.adapter.inbound.web.fileserver.dto.UploadedFileResponse;
import dev.caskeleton.adapter.inbound.web.fileserver.security.FileserverRequestContextFactory;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.StorageNamespace;
import dev.caskeleton.application.fileserver.lifecycle.CopyFileCommand;
import dev.caskeleton.application.fileserver.lifecycle.DeleteOutcome;
import dev.caskeleton.application.fileserver.lifecycle.FileLifecycleService;
import dev.caskeleton.application.fileserver.upload.FileView;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/**
* Delete, copy, and move endpoints.
*
* <p>The status codes carry meaning that the client needs. A delete that still has physical content
* to reclaim answers {@code 202}, not {@code 204}: the file is already unreadable, but the
* operation is not finished, and a caller waiting for storage to be freed must be able to tell the
* difference.
*
* <p>These routes live outside the {@code controller} package because AIP-136's colon verb is
* applied to a path variable here — the copy and move paths append the verb to the file-id segment
* — which the repository's AIP-122 segment rule does not model. The design fixes those paths, so
* the code moves rather than the contract.
*/
@RestController
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
public class FileLifecycleController {
private final FileLifecycleService lifecycleService;
private final FileserverRequestContextFactory contextFactory;
public FileLifecycleController(
FileLifecycleService lifecycleService, FileserverRequestContextFactory contextFactory) {
this.lifecycleService = lifecycleService;
this.contextFactory = contextFactory;
}
@DeleteMapping("/v1/files/{fileId}")
public ResponseEntity<Void> delete(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
DeleteOutcome outcome =
lifecycleService.delete(FileId.parse(fileId), optional(ifMatch), contextFactory.current());
return outcome.physicalCleanupScheduled()
? ResponseEntity.accepted().build()
: ResponseEntity.noContent().build();
}
@PostMapping(
path = "/v1/files/{fileId}:copy",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UploadedFileResponse> copy(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@Valid @RequestBody RelocateFileRequest request) {
FileView copied =
lifecycleService.copy(
new CopyFileCommand(
FileId.parse(fileId),
StorageNamespace.of(request.namespace()),
Optional.ofNullable(request.filename()),
optional(ifMatch)),
contextFactory.current());
return ResponseEntity.accepted()
.location(URI.create("/v1/files/" + copied.fileId().canonicalText()))
.body(UploadedFileResponse.from(copied));
}
@PostMapping(
path = "/v1/files/{fileId}:move",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public UploadedFileResponse move(
@PathVariable("fileId") String fileId,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@Valid @RequestBody RelocateFileRequest request) {
return UploadedFileResponse.from(
lifecycleService.move(
FileId.parse(fileId),
StorageNamespace.of(request.namespace()),
optional(ifMatch),
contextFactory.current()));
}
private static Optional<String> optional(String header) {
return header == null || header.isBlank() ? Optional.empty() : Optional.of(header);
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.web.multipart.MultipartFile;
/**
* Reads one multipart part's intent.
*
* <p>The part is never materialized here: {@code getBytes()} would pull the whole file into the
* heap, which is exactly the failure mode the streaming design exists to avoid. Only the part's
* declared metadata is read.
*/
public final class MultipartUploadRequestMapper {
private static final String FALLBACK_FILENAME = "upload.bin";
public UploadIntent map(MultipartFile part) {
return new UploadIntent(
filename(part), claimedMediaType(part), OptionalLong.of(part.getSize()), Optional.empty());
}
private static String filename(MultipartFile part) {
String submitted = part.getOriginalFilename();
return submitted == null || submitted.isBlank() ? FALLBACK_FILENAME : submitted;
}
private static Optional<String> claimedMediaType(MultipartFile part) {
String contentType = part.getContentType();
return contentType == null || contentType.isBlank()
? Optional.empty()
: Optional.of(contentType);
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import dev.caskeleton.application.fileserver.api.error.FileserverErrorCode;
import dev.caskeleton.application.fileserver.api.error.FileserverFailureContext;
import dev.caskeleton.application.fileserver.api.error.UnsupportedMediaTypeException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Reads a raw streaming upload's intent from the request headers.
*
* <p>The filename arrives in a header and is treated exactly like a multipart filename: untrusted
* display data that the application layer sanitizes. This mapper never opens the body, so a
* rejected request costs no bytes.
*/
public final class RawUploadRequestMapper {
public static final String FILENAME_HEADER = "X-Filename";
public static final String DIGEST_HEADER = "X-Content-Sha256";
private static final String FALLBACK_FILENAME = "upload.bin";
private final boolean contentLengthRequired;
public RawUploadRequestMapper(boolean contentLengthRequired) {
this.contentLengthRequired = contentLengthRequired;
}
public UploadIntent map(HttpServletRequest request) {
OptionalLong declaredLength = declaredLength(request);
if (contentLengthRequired && declaredLength.isEmpty()) {
throw new UnsupportedMediaTypeException(
"this profile requires a declared Content-Length",
FileserverFailureContext.of(FileserverErrorCode.CONTENT_LENGTH_REQUIRED, false));
}
return new UploadIntent(
filename(request), claimedMediaType(request), declaredLength, digest(request));
}
private static String filename(HttpServletRequest request) {
String header = request.getHeader(FILENAME_HEADER);
return header == null || header.isBlank() ? FALLBACK_FILENAME : header;
}
private static Optional<String> claimedMediaType(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType == null || contentType.isBlank()
? Optional.empty()
: Optional.of(contentType);
}
/**
* Reads the declared length.
*
* <p>A chunked request has no length; that is legal and the streamed hard limit still applies, so
* an absent value is reported as absent rather than as zero.
*/
private static OptionalLong declaredLength(HttpServletRequest request) {
long length = request.getContentLengthLong();
return length < 0 ? OptionalLong.empty() : OptionalLong.of(length);
}
private static Optional<String> digest(HttpServletRequest request) {
String header = request.getHeader(DIGEST_HEADER);
if (header == null || header.isBlank()) {
return Optional.empty();
}
return Optional.of(header.trim().toLowerCase(Locale.ROOT));
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.inbound.web.fileserver.mapper;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Transport-side reading of one upload's headers or part metadata.
*
* <p>It exists so the raw and multipart paths converge on one shape before anything reaches the
* application layer. Every field is untrusted client input; nothing here is used to build a
* physical key.
*/
public record UploadIntent(
String originalFilename,
Optional<String> claimedMediaType,
OptionalLong declaredLength,
Optional<String> expectedSha256) {
public UploadIntent {
Objects.requireNonNull(originalFilename, "originalFilename");
Objects.requireNonNull(claimedMediaType, "claimedMediaType");
Objects.requireNonNull(declaredLength, "declaredLength");
Objects.requireNonNull(expectedSha256, "expectedSha256");
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.api.ContentKey;
import dev.caskeleton.application.fileserver.api.error.InvalidPathException;
import java.util.regex.Pattern;
/**
* Rebuilds the internal URI from a validated content key.
*
* <p>Nothing here concatenates client input. The key has already been through {@link ContentKey}'s
* character class, and this class re-checks the sharded shape before emitting a URI, because a
* header that reaches Nginx as an internal redirect is effectively a filesystem lookup: a traversal
* that survived to this point would be served, not rejected.
*/
public final class DefaultNginxInternalUriMapper implements NginxInternalUriMapper {
private static final Pattern SHARDED_KEY =
Pattern.compile("[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}");
/** A well-formed key that names nothing; only the mapping's shape is under test. */
private static final String ATTESTATION_KEY = "00/00/startup-attestation";
private final NginxDelegationProperties properties;
public DefaultNginxInternalUriMapper(NginxDelegationProperties properties) {
this.properties = properties;
}
@Override
public String map(ContentKey key) {
return mapUnchecked(key.value());
}
/**
* Round-trips a representative key through the configured prefix and suffix.
*
* <p>A representative key rather than a real one: the attestation has to run before any object
* exists, and what it checks is the shape of the configuration, not the presence of content.
*/
@Override
public boolean attestMapping() {
try {
String uri = mapUnchecked(ATTESTATION_KEY);
return uri.startsWith("/")
&& uri.startsWith(properties.internalPrefix())
&& uri.endsWith(properties.objectSuffix())
&& uri.contains(ATTESTATION_KEY);
} catch (InvalidPathException misconfigured) {
return false;
}
}
@Override
public String mapUnchecked(String rawKey) {
if (rawKey == null || !SHARDED_KEY.matcher(rawKey).matches()) {
throw InvalidPathException.of("content key is not a valid sharded object key");
}
String uri = properties.internalPrefix() + rawKey + properties.objectSuffix();
if (uri.contains("..") || uri.contains("//") || uri.indexOf('\\') >= 0) {
throw InvalidPathException.of("internal uri failed its post-construction check");
}
return uri;
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Settings for handing large transfers to the front proxy.
*
* <p>The internal prefix must match an Nginx {@code location} marked {@code internal}; if it is
* not, the prefix becomes a publicly reachable path to raw content, so startup validates it rather
* than trusting configuration.
*/
public record NginxDelegationProperties(
boolean enabled, String internalPrefix, String objectSuffix, long minimumBytes) {
private static final Pattern SAFE_PREFIX = Pattern.compile("/[A-Za-z0-9_/-]{1,64}");
private static final Pattern SAFE_SUFFIX = Pattern.compile("(\\.[a-z0-9]{1,8})?");
public NginxDelegationProperties {
Objects.requireNonNull(internalPrefix, "internalPrefix");
Objects.requireNonNull(objectSuffix, "objectSuffix");
if (!SAFE_PREFIX.matcher(internalPrefix).matches() || !internalPrefix.endsWith("/")) {
throw new IllegalArgumentException("internalPrefix must be a safe rooted path ending in '/'");
}
if (!SAFE_SUFFIX.matcher(objectSuffix).matches()) {
throw new IllegalArgumentException("objectSuffix must be empty or a short lowercase suffix");
}
if (minimumBytes < 0) {
throw new IllegalArgumentException("minimumBytes must not be negative");
}
}
/** Design default: disabled, {@code /__files/} prefix, {@code .bin} objects, 16 MiB threshold. */
public static NginxDelegationProperties disabled() {
return new NginxDelegationProperties(false, "/__files/", ".bin", 16L * 1024 * 1024);
}
public static NginxDelegationProperties enabledWithDefaults() {
return new NginxDelegationProperties(true, "/__files/", ".bin", 16L * 1024 * 1024);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.download.DownloadDescriptor;
import jakarta.servlet.http.HttpServletResponse;
/**
* Decides whether one authorized download is handed to the front proxy, and writes the handoff.
*
* <p>Delegation happens strictly <em>after</em> authorization and the READY gate, so the internal
* redirect can only ever name content the caller was already allowed to read. A partial or
* conditional answer is never delegated: the proxy would have to re-derive the range and validator
* decisions, and two implementations of that logic is exactly the drift this design forbids.
*/
public final class NginxDownloadStrategy {
/** Header Nginx consumes; it must never be copied through to the client. */
public static final String ACCEL_REDIRECT_HEADER = "X-Accel-Redirect";
private final NginxDelegationProperties properties;
private final NginxInternalUriMapper uriMapper;
public NginxDownloadStrategy(
NginxDelegationProperties properties, NginxInternalUriMapper uriMapper) {
this.properties = properties;
this.uriMapper = uriMapper;
}
/** True when this descriptor should be transferred by the proxy rather than in-process. */
public boolean shouldDelegate(DownloadDescriptor descriptor) {
return properties.enabled()
&& descriptor.bodyExpected()
&& !descriptor.isPartial()
&& descriptor.status() == HttpServletResponse.SC_OK
&& descriptor.representation().length() >= properties.minimumBytes();
}
/**
* Writes the handoff.
*
* <p>{@code Content-Length} is deliberately cleared: the proxy sets it from the file it actually
* sends, and a stale value from the metadata store would truncate or hang the response if the two
* ever disagreed.
*/
public void delegate(DownloadDescriptor descriptor, HttpServletResponse response) {
response.setHeader(ACCEL_REDIRECT_HEADER, uriMapper.map(descriptor.contentKey()));
response.setHeader("Content-Length", null);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.inbound.web.fileserver.nginx;
import dev.caskeleton.application.fileserver.api.ContentKey;
/**
* Turns a server-generated content key into the internal URI the front proxy serves.
*
* <p>The result is always relative and always below the configured internal prefix. An absolute
* physical path never crosses this boundary — the proxy resolves the prefix to a filesystem root
* itself, so the application never has to disclose where content lives.
*/
public interface NginxInternalUriMapper {
String map(ContentKey key);
/**
* Maps a raw string, validating it first.
*
* <p>This exists because internal callers are exactly where an unvalidated key would otherwise
* slip through; it validates rather than trusting the caller.
*/
String mapUnchecked(String rawKey);
/**
* Proves the configured mapping actually produces a usable internal URI.
*
* <p>Called once at startup instead of reading a setting in which a deployment asserts its own
* correctness. The failure this catches is silent by nature: a prefix the proxy does not resolve
* makes the server answer {@code 200} with an empty body, so the client believes it received the
* file. Better to refuse to start.
*/
boolean attestMapping();
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import dev.caskeleton.application.fileserver.api.error.FileserverException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Maps every Fileserver failure onto its design status and problem document.
*
* <p>It is ordered ahead of the base operational handler, whose catch-all would otherwise resolve
* these to a generic internal error and lose the code. The status comes from the error code itself,
* so Spring MVC, Spring WebFlux, and the Nginx delegation path cannot drift apart.
*
* <p>Two headers are part of the contract rather than decoration: {@code Retry-After} on a
* retryable rejection, and the unsatisfied-range form of {@code Content-Range} on {@code 416},
* which is how a client learns the real representation length.
*/
@RestControllerAdvice
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class FileserverExceptionHandler {
private final FileserverProblemFactory problemFactory;
public FileserverExceptionHandler(FileserverProblemFactory problemFactory) {
this.problemFactory = problemFactory;
}
@ExceptionHandler(FileserverException.class)
public ResponseEntity<FileserverProblem> handle(
FileserverException failure, HttpServletRequest request) {
FileserverProblem problem = problemFactory.create(failure.context(), request.getRequestURI());
ResponseEntity.BodyBuilder response =
ResponseEntity.status(problem.status()).contentType(MediaType.APPLICATION_PROBLEM_JSON);
// The header policy is shared with the reactive router; neither transport owns it.
FileserverProblemHeaders.of(failure).forEach(response::header);
return response.body(problem);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.web.fileserver.problem;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* RFC 9457 problem document for a Fileserver failure.
*
* <p>This is a transport-owned record rather than the framework's problem type, and it carries only
* the stable code plus the correlation fields the client can act on. The server-side exception
* message, physical path, mount, scanner credential, and filename never appear here.
*
* <p>{@code ambiguous} and {@code reconciliationRequired} are exposed deliberately: a client that
* gets an ambiguous failure must not blindly retry, because the operation may already have taken
* effect.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FileserverProblem(
String type,
String title,
int status,
String code,
String detail,
String instance,
String traceId,
boolean retryable,
boolean ambiguous,
boolean reconciliationRequired,
String fileId,
String uploadId,
Long expectedOffset,
Long currentOffset,
String state) {}

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