init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,149 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다
|
||||
# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----- App identity -----
|
||||
APP_NAME=ca-skeleton
|
||||
SPRING_PROFILES_ACTIVE=local
|
||||
|
||||
# ----- Runtime safety (StartupSafetyValidator, D8) -----
|
||||
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
|
||||
APP_LOG_BODY_CAPTURE_ENABLED=false
|
||||
APP_MULTI_INSTANCE_ENABLED=false
|
||||
APP_MIGRATION_ON_STARTUP=true
|
||||
APP_RATE_LIMIT_ENABLED=true
|
||||
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
|
||||
APP_IDEMPOTENCY_TTL=24h
|
||||
|
||||
# ----- Async executor -----
|
||||
APP_ASYNC_EXECUTOR_CORE_SIZE=10
|
||||
APP_ASYNC_EXECUTOR_MAX_SIZE=50
|
||||
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
||||
|
||||
# ----- Optional integration adapters (default: all disabled) -----
|
||||
APP_CACHE_REDIS_ENABLED=false
|
||||
APP_MESSAGING_BROKER=
|
||||
APP_MESSAGING_KAFKA_BROKERS=
|
||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||
APP_NOTIFICATION_EMAIL_PROVIDER=
|
||||
|
||||
# ----- Outbound HTTP client -----
|
||||
APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s
|
||||
APP_OUTBOUND_HTTP_READ_TIMEOUT=5s
|
||||
APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s
|
||||
APP_OUTBOUND_HTTP_RETRY_ENABLED=false
|
||||
APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3
|
||||
APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms
|
||||
APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s
|
||||
APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10
|
||||
APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB
|
||||
|
||||
# ----- Logging: root & app levels -----
|
||||
APP_LOG_LEVEL_ROOT=INFO
|
||||
APP_LOG_LEVEL_APP=DEBUG
|
||||
|
||||
# ----- Logging: per-package levels -----
|
||||
APP_LOG_LEVEL_SPRING=INFO
|
||||
APP_LOG_LEVEL_WEB=INFO
|
||||
APP_LOG_LEVEL_SQL=WARN
|
||||
|
||||
# ----- Logging: file output + rolling -----
|
||||
APP_LOG_FILE_ENABLED=false
|
||||
APP_LOG_FILE_PATH=logs/ca-skeleton.json
|
||||
APP_LOG_FILE_MAX_SIZE=100MB
|
||||
APP_LOG_FILE_MAX_HISTORY=14
|
||||
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
|
||||
|
||||
# ----- Logging: async appender -----
|
||||
APP_LOG_ASYNC_ENABLED=true
|
||||
APP_LOG_ASYNC_QUEUE_SIZE=512
|
||||
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
|
||||
|
||||
# ----- Logging: JSON encoder -----
|
||||
APP_LOG_JSON_TIMEZONE=UTC
|
||||
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
|
||||
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
|
||||
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
|
||||
|
||||
# ----- Logging: sampling -----
|
||||
APP_LOG_SAMPLING_RATE=1.0
|
||||
|
||||
# ----- Distributed tracing -----
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
APP_TRACING_ENABLED=true
|
||||
APP_TRACING_SAMPLE_RATE=
|
||||
|
||||
# ----- Privacy: user_principal pseudonymization -----
|
||||
APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt
|
||||
|
||||
# ----- Spring Boot bootstrap -----
|
||||
SPRING_BANNER_MODE=console
|
||||
SPRING_MAIN_LAZY_INITIALIZATION=false
|
||||
SPRING_MAIN_LOG_STARTUP_INFO=true
|
||||
SPRING_THREADS_VIRTUAL_ENABLED=true
|
||||
|
||||
# ----- Jackson: deserialization policy -----
|
||||
SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true
|
||||
SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true
|
||||
SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true
|
||||
SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false
|
||||
|
||||
# ----- Jackson: serialization policy -----
|
||||
SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false
|
||||
|
||||
# ----- Server / Tomcat -----
|
||||
APP_SERVER_PORT=8080
|
||||
APP_SERVER_SHUTDOWN=graceful
|
||||
APP_SERVER_SHUTDOWN_TIMEOUT=30s
|
||||
APP_SERVER_TOMCAT_MAX_THREADS=200
|
||||
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
|
||||
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
|
||||
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
|
||||
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
|
||||
APP_SERVER_COMPRESSION_ENABLED=true
|
||||
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024
|
||||
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
|
||||
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
|
||||
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
|
||||
|
||||
# ----- Presentation -----
|
||||
PRESENTATION_API_BASE_PATH=/api
|
||||
|
||||
# ----- Auth (OIDC resource server) -----
|
||||
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
|
||||
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
|
||||
SECURITY_PUBLIC_PATHS=/api/healthcheck
|
||||
|
||||
# ----- CORS -----
|
||||
APP_SECURITY_CORS_ENABLED=true
|
||||
APP_SECURITY_CORS_ORIGINS=http://localhost:3000
|
||||
APP_SECURITY_CORS_ALLOWED_METHODS=
|
||||
APP_SECURITY_CORS_ALLOWED_HEADERS=*
|
||||
APP_SECURITY_CORS_ALLOW_CREDENTIALS=true
|
||||
APP_SECURITY_CORS_MAX_AGE=3600
|
||||
|
||||
# ----- Database (Postgres) -----
|
||||
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
|
||||
APP_DATASOURCE_USERNAME=ca_skeleton
|
||||
APP_DATASOURCE_PASSWORD=ca_skeleton
|
||||
APP_DATASOURCE_DRIVER=org.postgresql.Driver
|
||||
APP_DATASOURCE_DDL_AUTO=update
|
||||
APP_DATASOURCE_SHOW_SQL=false
|
||||
APP_DATASOURCE_FORMAT_SQL=false
|
||||
APP_DATASOURCE_OPEN_IN_VIEW=false
|
||||
|
||||
# ----- Database: HikariCP connection pool -----
|
||||
APP_DATASOURCE_POOL_MAX_SIZE=10
|
||||
APP_DATASOURCE_POOL_MIN_IDLE=2
|
||||
APP_DATASOURCE_CONNECTION_TIMEOUT=30000
|
||||
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
|
||||
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
|
||||
|
||||
# ----- Management / Actuator -----
|
||||
MANAGEMENT_SERVER_PORT=9001
|
||||
@@ -0,0 +1,14 @@
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
|
||||
# =============================================================================
|
||||
# feature-container-runtime-contract — multi-stage image build
|
||||
#
|
||||
# Build requirements:
|
||||
# docker build -f src/Dockerfile src/ -t caskeleton:local \
|
||||
# --build-arg RELEASE_VERSION=1.2.3 \
|
||||
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
|
||||
# --build-arg GIT_SHA=a1b2c3d4e5f6 \
|
||||
# --build-arg SOURCE_URL=https://github.com/your-org/your-repo
|
||||
#
|
||||
# Design decisions (feature-container-runtime-contract D2/D3/D4):
|
||||
# D2 — read-only root filesystem: writable mounts must be declared explicitly.
|
||||
# D3 — non-root user, JRE-only final stage (no full JDK).
|
||||
# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError.
|
||||
# =============================================================================
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# ---- Stage 1: builder -------------------------------------------------------
|
||||
# Uses the full JDK only in the build stage, never in the final image.
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
|
||||
# single structure-preserving glob replaces the former per-module COPY list: new modules are
|
||||
# picked up automatically and this stage never drifts out of sync with settings.gradle again.
|
||||
# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below.
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
||||
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
|
||||
# while still returning exit code 0 and therefore is not a release gate.
|
||||
RUN test -n "${RELEASE_VERSION}" \
|
||||
&& test -n "${GIT_SHA}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and build the JAR
|
||||
COPY . .
|
||||
RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Locate the produced JAR (avoids hardcoding the version string)
|
||||
RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (D3: no full JDK in production image).
|
||||
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# OCI image labels (build-arg placeholders — supply at docker build time).
|
||||
LABEL org.opencontainers.image.title="caskeleton" \
|
||||
org.opencontainers.image.source="${SOURCE_URL}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
|
||||
# A release image without source/version metadata is not an artifact this contract permits.
|
||||
RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}"
|
||||
|
||||
# ---- Locale / timezone (D4) -------------------------------------------------
|
||||
# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages.
|
||||
# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image.
|
||||
ENV TZ=UTC \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# ---- Writable HOME under read-only root fs (D2) -----------------------------
|
||||
# The app user is created with --no-create-home and the root filesystem is
|
||||
# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that
|
||||
# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches)
|
||||
# do not fail with a read-only-filesystem error.
|
||||
ENV HOME=/tmp
|
||||
|
||||
# ---- JVM ergonomics (D4) ----------------------------------------------------
|
||||
# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap.
|
||||
# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here).
|
||||
# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart.
|
||||
# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable
|
||||
# /var/tmp/heap mount (see tmpfs mounts in compose files, D2).
|
||||
# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs).
|
||||
ENV JAVA_TOOL_OPTIONS="\
|
||||
-XX:MaxRAMPercentage=75 \
|
||||
-XX:+UseContainerSupport \
|
||||
-XX:+ExitOnOutOfMemoryError \
|
||||
-XX:+HeapDumpOnOutOfMemoryError \
|
||||
-XX:HeapDumpPath=/var/tmp/heap \
|
||||
-Dserver.tomcat.basedir=/tmp"
|
||||
|
||||
# ---- Filesystem layout (D2: read-only root filesystem) ----------------------
|
||||
# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime.
|
||||
# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above).
|
||||
# Both directories are declared here so tooling is aware of them; at runtime they
|
||||
# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files).
|
||||
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
||||
|
||||
# ---- Non-root user (D3) -----------------------------------------------------
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
# ---- Ports ------------------------------------------------------------------
|
||||
# 8080 — application HTTP port
|
||||
# 9001 — management / actuator port (parallel actuator branch wires this endpoint)
|
||||
EXPOSE 8080 9001
|
||||
|
||||
# ---- Health check -----------------------------------------------------------
|
||||
# Targets the actuator readiness probe on the management port (9001).
|
||||
# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented
|
||||
# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here
|
||||
# (container-side) and will pass once those branches are merged. In this worktree
|
||||
# the endpoint may return 404; the container will be UNHEALTHY until merged.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider \
|
||||
http://localhost:9001/actuator/health/readiness || exit 1
|
||||
|
||||
# ---- Entrypoint -------------------------------------------------------------
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,131 @@
|
||||
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
|
||||
# =============================================================================
|
||||
# sample-portfolio standalone demo image — twin of src/Dockerfile.
|
||||
#
|
||||
# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the
|
||||
# production composition root (CaSkeletonApplication) that src/Dockerfile builds.
|
||||
# The sample is the demo-friendly entrypoint: its application.yml self-provides
|
||||
# defaults for every env placeholder, so the only external dependency it needs
|
||||
# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations).
|
||||
#
|
||||
# Build (no build-args required — this is a disposable demo, not a release artifact):
|
||||
# docker build -f src/Dockerfile.sample src/ -t ca-sample:local
|
||||
#
|
||||
# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown):
|
||||
# docker run --rm -p 8080:8080 -p 9001:9001 \
|
||||
# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \
|
||||
# ca-sample:local
|
||||
#
|
||||
# The multi-stage build, --parents descriptor glob, STRICT lock verification,
|
||||
# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics
|
||||
# are all identical to src/Dockerfile — only the bootJar target differs. Keep the
|
||||
# two files' builder stages in sync.
|
||||
# =============================================================================
|
||||
|
||||
# Demo defaults so the image builds with zero build-args. Two format constraints from the root
|
||||
# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9):
|
||||
# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21).
|
||||
# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop.
|
||||
# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder.
|
||||
ARG RELEASE_VERSION=0.0.0
|
||||
ARG BUILD_VERSION=0.0.0-sample
|
||||
ARG GIT_SHA=0000000
|
||||
ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample
|
||||
|
||||
# ---- Stage 1: builder -------------------------------------------------------
|
||||
# Uses the full JDK only in the build stage, never in the final image.
|
||||
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
ARG GIT_SHA
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
|
||||
# single structure-preserving glob replaces a per-module COPY list: new modules are picked up
|
||||
# automatically and this stage never drifts out of sync with settings.gradle.
|
||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||
COPY gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||
|
||||
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
|
||||
RUN test -n "${RELEASE_VERSION}" \
|
||||
&& test -n "${GIT_SHA}" \
|
||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Copy full source and build the sample JAR.
|
||||
COPY . .
|
||||
RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \
|
||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||
|
||||
# Locate the produced JAR (avoids hardcoding the version string).
|
||||
RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
||||
|
||||
# ---- Stage 2: runtime image -------------------------------------------------
|
||||
# JRE-only slim image (no full JDK in the demo image either).
|
||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG SOURCE_URL
|
||||
|
||||
# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail
|
||||
# on missing metadata — the ARG defaults above keep it buildable with no build-args.
|
||||
LABEL org.opencontainers.image.title="caskeleton-sample" \
|
||||
org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \
|
||||
org.opencontainers.image.source="${SOURCE_URL}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
|
||||
# ---- Locale / timezone ------------------------------------------------------
|
||||
ENV TZ=UTC \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# ---- Writable HOME under read-only root fs ----------------------------------
|
||||
ENV HOME=/tmp
|
||||
|
||||
# ---- JVM ergonomics ---------------------------------------------------------
|
||||
# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a
|
||||
# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem.
|
||||
ENV JAVA_TOOL_OPTIONS="\
|
||||
-XX:MaxRAMPercentage=75 \
|
||||
-XX:+UseContainerSupport \
|
||||
-XX:+ExitOnOutOfMemoryError \
|
||||
-XX:+HeapDumpOnOutOfMemoryError \
|
||||
-XX:HeapDumpPath=/var/tmp/heap \
|
||||
-Dserver.tomcat.basedir=/tmp"
|
||||
|
||||
# ---- Filesystem layout (read-only root filesystem) --------------------------
|
||||
# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir).
|
||||
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
||||
|
||||
# ---- Non-root user ----------------------------------------------------------
|
||||
RUN groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
||||
|
||||
USER app
|
||||
|
||||
# ---- Ports ------------------------------------------------------------------
|
||||
# 8080 — application HTTP port
|
||||
# 9001 — management / actuator port
|
||||
EXPOSE 8080 9001
|
||||
|
||||
# ---- Health check -----------------------------------------------------------
|
||||
# Actuator readiness probe on the management port (9001). Ignored by Kubernetes,
|
||||
# which uses its own probes — kept for docker/compose parity with src/Dockerfile.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider \
|
||||
http://localhost:9001/actuator/health/readiness || exit 1
|
||||
|
||||
# ---- Entrypoint -------------------------------------------------------------
|
||||
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
# src — 빌드 스크립트 / 환경 변수 참조
|
||||
|
||||
`src/` 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 [CLAUDE.md](../CLAUDE.md)
|
||||
와 [AGENTS.md](../AGENTS.md), 모듈별 규칙은 각 모듈의 `CLAUDE.md` 가 SSOT 입니다.
|
||||
|
||||
이 문서는 [build.gradle](build.gradle) 과 [.env](.env) 의 코드 주석에서 덜어낸 **설정 항목 설명과
|
||||
결정 근거**를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조"
|
||||
포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## build.gradle — 빌드 / 검증 게이트
|
||||
|
||||
모든 모듈의 `check` 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가
|
||||
모두 green 이어야 합니다.
|
||||
|
||||
| 게이트 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
||||
| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 |
|
||||
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
|
||||
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
|
||||
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
|
||||
|
||||
### Local bootstrap
|
||||
|
||||
`./gradlew bootstrap`은 `bootstrapCompile` → `bootstrapDependencies` →
|
||||
`bootstrapMigrateAndStart` → `bootstrapSampleContract` → `bootstrapSmoke`를 순서대로 실행합니다.
|
||||
DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가
|
||||
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
|
||||
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
|
||||
|
||||
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
|
||||
|
||||
```bash
|
||||
./gradlew verifyReadmeCommands
|
||||
```
|
||||
|
||||
### Traceable version + dependency locking
|
||||
|
||||
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
|
||||
`-PreleaseVersion`/`RELEASE_VERSION`, revision은 `-PgitRevision`/`GIT_SHA`/`GITHUB_SHA` 순으로
|
||||
주입하고, 로컬에서는 현재 Git commit을 읽습니다.
|
||||
- 모든 JAR manifest에 `Implementation-Version`과 `Build-Revision`을 기록합니다. Git metadata도
|
||||
revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서
|
||||
실패합니다.
|
||||
- 모든 subproject가 `lockAllConfigurations()` + `LockMode.STRICT`를 사용하고, lock state는 Gradle
|
||||
기본 `<module>/gradle.lockfile`에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와
|
||||
같습니다.
|
||||
- lock 갱신은 `./gradlew resolveAndLockAll --write-locks` 한 가지 명령으로 수행합니다. 이 task는
|
||||
`--write-locks`가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다.
|
||||
|
||||
### Reproducible archives (D10)
|
||||
|
||||
모든 `AbstractArchiveTask`는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며,
|
||||
directory/file mode를 각각 `0755`/`0644`로 고정합니다. Java toolchain은 21이고 로컬·CI patch
|
||||
version은 root `.tool-versions`의 Temurin 값으로 맞춥니다.
|
||||
|
||||
두 번의 clean, no-cache `bootJar` SHA-256 비교는 다음 명령으로 실행합니다.
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
bash .github/scripts/verify-reproducible-build.sh
|
||||
```
|
||||
|
||||
이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK
|
||||
vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다.
|
||||
|
||||
### `-parameters` 컴파일러 플래그
|
||||
|
||||
- **결정.** 모든 subproject 의 Java 컴파일에 `-parameters` 플래그를 직접 설정합니다.
|
||||
- **근거.** Spring MVC 는 `@PathVariable` / `@RequestParam` 의 이름을 reflection 의 parameter
|
||||
metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이 `arg0`, `arg1` 로 컴파일되어 바인딩이
|
||||
깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을
|
||||
`apply false` 로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의 `JavaCompile`
|
||||
에 직접 설정합니다.
|
||||
|
||||
### `verifyCleanArchitectureDependencies`
|
||||
|
||||
- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle
|
||||
프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를
|
||||
실패시킵니다.
|
||||
- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit
|
||||
규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는
|
||||
보호 기능을 못 합니다.
|
||||
|
||||
### `verifyOneTypePerFile` (code-conventions I6)
|
||||
|
||||
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
|
||||
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
|
||||
`module-info.java` 는 예외입니다.
|
||||
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
|
||||
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
|
||||
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
|
||||
파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다.
|
||||
|
||||
### `verifyEnvKeys`
|
||||
|
||||
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로
|
||||
어긋나지 않게) 으로 유지합니다. `env-keys.yaml` 이 `APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
|
||||
실패시킵니다.
|
||||
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
|
||||
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
|
||||
- **검사 항목.**
|
||||
- **A.** `application.yml` 의 placeholder 중 inline default(`${VAR:default}`)가 없는 **필수**
|
||||
placeholder(`${VAR}`)는 반드시 `.env` 에 존재해야 합니다.
|
||||
- **B.** `.env` 의 모든 키는 `application.yml` 의 어떤 `${...}` placeholder 가 참조해야 합니다.
|
||||
(아무도 안 쓰는 키는 orphan 으로 간주해 실패)
|
||||
- **C.** `.env` 의 모든 `APP_` 키는 registry 에 `- name: <KEY>` 행이 있어야 합니다.
|
||||
- **`SPRING_*` 키는 왜 registry 추적 대상이 아닌가.** `SPRING_*` 는 Spring Boot 가 정의한 native 키라
|
||||
프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러 `APP_` prefix 로만 범위를 좁혔습니다.
|
||||
- **비고.** `src/.env` 가 프로젝트의 커밋된 env 파일이며, 별도의 `.env.example` 템플릿은 두지
|
||||
않습니다.
|
||||
|
||||
### `verifyPublicPathSnapshot`
|
||||
|
||||
- **하는 일.** deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다.
|
||||
- **배경.** 이 앱은 deny-by-default 입니다. 즉 `SECURITY_PUBLIC_PATHS` 가 먹이는 명시적 `permitAll()`
|
||||
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` →
|
||||
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
|
||||
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
||||
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로
|
||||
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
|
||||
반영합니다.
|
||||
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
||||
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
||||
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
||||
`RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()`
|
||||
을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로
|
||||
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh
|
||||
checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다.
|
||||
|
||||
### `verifyTrivyignore`
|
||||
|
||||
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
|
||||
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
|
||||
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
|
||||
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
|
||||
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
|
||||
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
|
||||
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS` 는 *merge 승인*(GitHub
|
||||
네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가"
|
||||
만 잡으므로 둘은 대체재가 아니라 보완재입니다.
|
||||
- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고
|
||||
기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는
|
||||
trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle` 의 `maxWindowDays` 로
|
||||
조정합니다.
|
||||
- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가
|
||||
스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은
|
||||
`.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml`
|
||||
에 있습니다.
|
||||
|
||||
### `verifyQuarantineSunset` + 플래키 격리
|
||||
|
||||
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
|
||||
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
|
||||
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
|
||||
돕니다.
|
||||
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check` 에
|
||||
연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`),
|
||||
(2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에
|
||||
`@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다.
|
||||
- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만
|
||||
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study`
|
||||
강도 — 공식 best practice 아님). fork 는 `src/build.gradle` 의 `sunsetDays` 로 조정합니다.
|
||||
- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 —
|
||||
CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈
|
||||
버킷(`quarantined: []`)으로 출고됩니다.
|
||||
|
||||
### CI 게이트 배선
|
||||
|
||||
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
|
||||
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
|
||||
SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
|
||||
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
|
||||
- **워크플로.** `.github/workflows/ci-quality-gates.yml` 의 `release-gate` 잡이 모든 release-blocking
|
||||
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
|
||||
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
|
||||
GitHub Actions 는 워크플로 간 `needs` 를 못 쓰므로 branch protection 의 required check 합집합으로
|
||||
묶습니다.
|
||||
|
||||
---
|
||||
|
||||
## .env — 환경 변수 레퍼런스
|
||||
|
||||
`spring-dotenv` 가 `src/.env` 를 읽어 외부화 설정을 주입합니다(`bootRun` 의 working dir 가 `src/` 라
|
||||
이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 `restart-only`(값 변경 시 재기동
|
||||
필요)로 간주하세요.
|
||||
|
||||
### App identity
|
||||
|
||||
- **`APP_NAME`** — `spring.application.name` 과 JSON 로그의 `app` 필드. 자유 문자열.
|
||||
- **`SPRING_PROFILES_ACTIVE`** — 활성 Spring profile. 보통 `local` | `dev` | `stage` | `prod`. JSON
|
||||
로그의 `profile` 필드도 이 값을 씁니다.
|
||||
|
||||
### Runtime safety (기동 시 `StartupSafetyValidator` 가 fail-fast 검사, D8)
|
||||
|
||||
- **`APP_ERROR_DETAIL_EXPOSURE_ENABLED`** — 응답에 내부 에러 상세를 노출할지. `true` | `false`.
|
||||
**`prod` 프로필에서는 반드시 `false`** 여야 하며, 아니면 기동이 실패합니다.
|
||||
- **`APP_LOG_BODY_CAPTURE_ENABLED`** — 요청/응답 body 를 로그에 캡처할지. `true` | `false`.
|
||||
**`prod` 에서는 반드시 `false`**, 아니면 기동 실패.
|
||||
- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede /
|
||||
leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다.
|
||||
- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화
|
||||
(429 + `Retry-After` + `X-RateLimit-*` 응답).
|
||||
- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` |
|
||||
`forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만**
|
||||
`forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다.
|
||||
- **`APP_IDEMPOTENCY_TTL`** — idempotency 레코드 기본 TTL. duration(예: `24h`, `72h`). 오래 도는 use
|
||||
case 는 최대 72h 까지 override 가능. (D6)
|
||||
|
||||
### Async executor
|
||||
|
||||
`@Async` `ThreadPoolTaskExecutor` 풀 크기 설정입니다.
|
||||
|
||||
- **`APP_ASYNC_EXECUTOR_CORE_SIZE`** — 항상 살아있는 워커 수. 1 이상 정수.
|
||||
- **`APP_ASYNC_EXECUTOR_MAX_SIZE`** — 워커 수 상한. core-size 이상.
|
||||
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
|
||||
1 이상 정수.
|
||||
|
||||
### Optional integration adapters
|
||||
|
||||
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
|
||||
모듈의 기본값). Layer 1 의 `@ConditionalOnProperty` 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면
|
||||
fail-fast sentinel 이 포트를 충족합니다(Layer 3).
|
||||
|
||||
- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`.
|
||||
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시
|
||||
fail-fast).
|
||||
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
|
||||
아니면 빈 값.
|
||||
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성.
|
||||
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email
|
||||
비활성.
|
||||
|
||||
### Outbound HTTP client
|
||||
|
||||
- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을
|
||||
강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로
|
||||
보고 즉시 기동을 실패시킵니다.
|
||||
- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero.
|
||||
- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero.
|
||||
- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예:
|
||||
`10s`), 필수, non-zero.
|
||||
- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이
|
||||
있어야 하며(D3 가드), 없으면 기동 실패.
|
||||
- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존):
|
||||
- **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수.
|
||||
- **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero.
|
||||
- **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double.
|
||||
- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면
|
||||
`MeterRegistry` 빈 필요(D3), 없으면 기동 실패.
|
||||
- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j
|
||||
`ofDefaults()`):
|
||||
- **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float.
|
||||
- **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수.
|
||||
- **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수.
|
||||
- **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero.
|
||||
- **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수.
|
||||
- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를
|
||||
넘는 응답은 streaming API 를 써야 합니다(D7).
|
||||
|
||||
### Logging
|
||||
|
||||
**Root / app 레벨** — 허용값은 모두 `TRACE` | `DEBUG` | `INFO` | `WARN` | `ERROR` | `OFF`.
|
||||
|
||||
- **`APP_LOG_LEVEL_ROOT`** — root 로거 레벨.
|
||||
- **`APP_LOG_LEVEL_APP`** — 앱 패키지 레벨.
|
||||
|
||||
**패키지별 레벨**(root 를 덮어씀) — 동일 허용값.
|
||||
|
||||
- **`APP_LOG_LEVEL_SPRING`** / **`APP_LOG_LEVEL_WEB`** — 각 패키지 레벨.
|
||||
- **`APP_LOG_LEVEL_SQL`** — `DEBUG` 로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다.
|
||||
|
||||
**파일 출력 + rolling**
|
||||
|
||||
- **`APP_LOG_FILE_ENABLED`** — `true` 면 rolling JSON 파일 appender 를 붙입니다.
|
||||
- **`APP_LOG_FILE_PATH`** — `bootRun` working dir(`src/`) 기준 상대 경로 또는 절대 경로.
|
||||
- **`APP_LOG_FILE_MAX_SIZE`** — 파일 1개 최대 크기(단위 `KB` | `MB` | `GB`).
|
||||
- **`APP_LOG_FILE_MAX_HISTORY`** — 보관할 rolled archive 개수. 1 이상 정수.
|
||||
- **`APP_LOG_FILE_TOTAL_SIZE_CAP`** — 전체 rolled 파일 용량 상한(단위 `KB` | `MB` | `GB`, `0` = 비활성).
|
||||
|
||||
**Async appender**
|
||||
|
||||
- **`APP_LOG_ASYNC_ENABLED`** — `true` 면 appender 를 `AsyncAppender` 로 감싸 non-blocking I/O.
|
||||
- **`APP_LOG_ASYNC_QUEUE_SIZE`** — back-pressure 전 in-memory 큐 깊이. 1 이상 정수.
|
||||
- **`APP_LOG_ASYNC_DISCARDING_THRESHOLD`** — 남은 큐 용량이 이 값 미만이면 `TRACE`/`DEBUG`/`INFO`
|
||||
이벤트를 버립니다(`WARN`/`ERROR` 는 항상 유지). `0` = 절대 버리지 않음. 0 이상 정수.
|
||||
|
||||
**JSON 인코더 세부**
|
||||
|
||||
- **`APP_LOG_JSON_TIMEZONE`** — IANA timezone(예: `UTC`, `Asia/Seoul`) 또는 `default`(JVM 기본).
|
||||
- **`APP_LOG_JSON_TIMESTAMP_PATTERN`** — 타임스탬프 패턴. 보통 ISO 8601
|
||||
(`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`).
|
||||
- **`APP_LOG_JSON_INCLUDE_CALLER_DATA`** — `true` 면 file/method/line 을 추가. **성능 비용이 큽니다.**
|
||||
- **`APP_LOG_JSON_LOGGER_NAME_LENGTH`** — `0` = 로거 이름 전체, 양수 = 패키지 축약(예: `36` →
|
||||
`dev.caskeleton.bootstrap.Foo` 가 `d.c.bootstrap.Foo` 로).
|
||||
|
||||
**Sampling (`SamplingTurboFilter`)**
|
||||
|
||||
- **`APP_LOG_SAMPLING_RATE`** — `INFO` 이하 로그를 남길 확률. [0.0, 1.0] float. prod 는 `0.1`(10%
|
||||
샘플링)이 권장, `WARN`/`ERROR` 는 항상 유지. `1.0` = 샘플링 없음(dev/local/staging 기본).
|
||||
|
||||
### Distributed tracing
|
||||
|
||||
- **`OTEL_EXPORTER_OTLP_ENDPOINT`** — OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel
|
||||
SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시 `TracingProperties` 가 검증합니다.
|
||||
- **`APP_TRACING_ENABLED`** — `false` 면 tracing seam 은 꺼지지만, `meta.traceId` 는
|
||||
`RequestLoggingFilter` 가 W3C `traceparent` 로 여전히 생성합니다(D4 disabled-fallback 보장).
|
||||
- **`APP_TRACING_SAMPLE_RATE`** — per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float.
|
||||
- per-profile 기본값(D6): prod = `0.01`, staging = `0.10`, dev/local = `1.0`.
|
||||
- **결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix).** 빈 값이어야 per-profile resolver 의 기본값이
|
||||
실제 tracer sampler 까지 도달합니다(`TracingSampleRateResolver` 가 SSOT). 값을 박으면 프로필별
|
||||
기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다.
|
||||
|
||||
### Privacy: user_principal pseudonymization
|
||||
|
||||
- **`APP_PRIVACY_PSEUDONYMIZATION_SALT`** — secret 등급 HMAC-SHA-256 salt. `__LOCAL_DEV_` prefix 는 로컬 전용 sentinel 값이며,
|
||||
**prod 에서는 secret-manager 가 주입하는 실제 값**을 써야 합니다.
|
||||
|
||||
### Spring Boot bootstrap
|
||||
|
||||
- **`SPRING_BANNER_MODE`** — `off` | `console` | `log`.
|
||||
- **`SPRING_MAIN_LAZY_INITIALIZATION`** — `true` 면 빈 생성을 첫 사용 시점까지 지연.
|
||||
- **`SPRING_MAIN_LOG_STARTUP_INFO`** — `true` 면 `Starting`/`Started` 로그 출력.
|
||||
- **`SPRING_THREADS_VIRTUAL_ENABLED`** — `true` 면 Tomcat 요청 처리에 Java 21 virtual threads 사용.
|
||||
|
||||
### Jackson — deserialization policy
|
||||
|
||||
모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 **조용히 강제 변환하지
|
||||
않고 즉시 실패**하게 만듭니다. 개별 DTO 에 클래스 레벨 `@JsonIgnoreProperties(ignoreUnknown = true)`
|
||||
로 이 정책을 완화하는 것은 **금지**이며 ArchUnit 규칙으로 막혀 있습니다.
|
||||
|
||||
- **`..._FAIL_ON_UNKNOWN_PROPERTIES`** — `true`: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+
|
||||
기본).
|
||||
- **`..._FAIL_ON_NULL_FOR_PRIMITIVES`** — `true`: primitive 필드에 JSON `null` 이 와도 `0`/`false`
|
||||
로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(`Integer`/`Long`/`Boolean`)
|
||||
과 `Optional<T>` 를 쓰세요.
|
||||
- **`..._FAIL_ON_IGNORED_PROPERTIES`** — `true`: JSON 에 `@JsonIgnore` 처리된 필드가 들어오면 throw
|
||||
(조용히 버리는 대신 계약 drift 를 노출).
|
||||
- **`..._READ_UNKNOWN_ENUM_VALUES_AS_NULL`** — `false`(Jackson 기본 유지): 모르는 enum 값이 조용히
|
||||
`null` 이 되지 않고 throw 되어 `VALIDATION_FAILED` 로 드러나게 합니다.
|
||||
|
||||
### Jackson — serialization policy
|
||||
|
||||
응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 **명시적으로 못박아**, 미래에 Spring
|
||||
Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(`spring.mvc.problemdetails.enabled=false`
|
||||
와 같은 근거). `JacksonSerializationPolicyTest` 가 강제하며, `new BigDecimal(double)` 생성자는
|
||||
`no_bigdecimal_double_constructor` ArchUnit 규칙으로 금지됩니다.
|
||||
|
||||
- **`..._WRITE_DATES_AS_TIMESTAMPS`** — `false`(D2 / RFC 3339): `java.time` 값을 ISO-8601 문자열로
|
||||
직렬화(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`). `true` 면 epoch 숫자나
|
||||
`[y,m,d,...]` 배열로 나가 datetime 계약이 깨집니다.
|
||||
- **BigDecimal plain output** — Jackson 3에는 별도 `WRITE_BIGDECIMAL_AS_PLAIN` 설정 키가 없습니다.
|
||||
`JacksonSerializationPolicyTest` 가 `"12300000000.00"` plain 출력을 직접 검증하고,
|
||||
`no_bigdecimal_double_constructor` ArchUnit 규칙이 부정확한 `new BigDecimal(double)` 생성을
|
||||
금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string,
|
||||
내부 API 는 number+plain).
|
||||
|
||||
### Server / Tomcat
|
||||
|
||||
- **`APP_SERVER_PORT`** — 1~65535 정수.
|
||||
- **`APP_SERVER_SHUTDOWN`** — `graceful` | `immediate`.
|
||||
- **`APP_SERVER_SHUTDOWN_TIMEOUT`** — duration(예: `30s` | `1m` | `500ms`).
|
||||
- **`APP_SERVER_TOMCAT_MAX_THREADS`** — 동시 요청 워커 상한. 1 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_MIN_SPARE_THREADS`** — idle 워커 풀 하한. 0 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_ACCEPT_COUNT`** — 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_MAX_CONNECTIONS`** — 동시에 열 수 있는 연결 수 상한. 1 이상 정수.
|
||||
- **`APP_SERVER_TOMCAT_CONNECTION_TIMEOUT`** — duration(예: `20s` | `1m`).
|
||||
- **`APP_SERVER_COMPRESSION_ENABLED`** — `true` | `false`.
|
||||
- **`APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE`** — 이 크기 미만 payload 는 압축하지 않음(bytes 또는
|
||||
단위, 예: `1024` | `1KB` | `2KB`).
|
||||
- **`APP_SERVER_FORWARD_HEADERS_STRATEGY`** — `none` | `native` | `framework`. LB/proxy 뒤에서
|
||||
`X-Forwarded-*` 를 신뢰할지.
|
||||
- **`APP_SERVER_ERROR_INCLUDE_STACKTRACE`** — `always` | `never` | `on_param`.
|
||||
- **`APP_SERVER_ERROR_INCLUDE_MESSAGE`** — `always` | `never` | `on_param`.
|
||||
|
||||
### Presentation
|
||||
|
||||
- **`PRESENTATION_API_BASE_PATH`** — 모든 controller 앞에 붙는 leading-slash 경로(예: `/api` | `/v1` |
|
||||
`""`).
|
||||
|
||||
### Auth (OIDC resource server)
|
||||
|
||||
- **`APP_SECURITY_JWT_ISSUER`** — OIDC issuer URI(Keycloak realm, Auth0 tenant 등). **필수** — 없으면
|
||||
기동 실패. 예: `https://keycloak.example.com/realms/ca-skeleton`.
|
||||
- **`APP_SECURITY_JWT_AUDIENCE`** — 기대하는 `aud` claim. 빈 값으로 두면 audience 검증을 건너뜁니다.
|
||||
- **`SECURITY_PUBLIC_PATHS`** — 인증을 우회하는 경로 CSV. `api-base-path` 뒤의 full path 를 씁니다(예:
|
||||
`/api/healthcheck`). 이 값의 변경은 `verifyPublicPathSnapshot` 게이트가 감시합니다(위 build.gradle
|
||||
설명 참조).
|
||||
|
||||
### CORS
|
||||
|
||||
- **`APP_SECURITY_CORS_ENABLED`** — `true` | `false`.
|
||||
- **`APP_SECURITY_CORS_ORIGINS`** — 허용 origin CSV(예:
|
||||
`http://localhost:3000,https://app.example.com`).
|
||||
- **`APP_SECURITY_CORS_ALLOWED_METHODS`** — 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH,
|
||||
PUT, DELETE, OPTIONS).
|
||||
- **`APP_SECURITY_CORS_ALLOWED_HEADERS`** — 허용 헤더 CSV. `*` = 모든 헤더 허용.
|
||||
- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`.
|
||||
- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초).
|
||||
|
||||
### Database (Postgres)
|
||||
|
||||
- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`).
|
||||
- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정.
|
||||
- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`).
|
||||
- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod
|
||||
는 `validate` 또는 `none`**, local 은 `update` 가 편리합니다.
|
||||
- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo.
|
||||
- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음).
|
||||
- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.**
|
||||
|
||||
**HikariCP 커넥션 풀**
|
||||
|
||||
- **`APP_DATASOURCE_POOL_MAX_SIZE`** — DB 동시 연결 최대 수. 1 이상 정수.
|
||||
- **`APP_DATASOURCE_POOL_MIN_IDLE`** — warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수.
|
||||
- **`APP_DATASOURCE_CONNECTION_TIMEOUT`** — `acquire()` 가 실패하기 전 대기 시간(ms).
|
||||
- **`APP_DATASOURCE_POOL_IDLE_TIMEOUT`** — idle 연결 회수 임계 시간(ms).
|
||||
- **`APP_DATASOURCE_POOL_MAX_LIFETIME`** — 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록
|
||||
설정합니다.
|
||||
|
||||
### Management / Actuator
|
||||
|
||||
- **결정 — management 포트 분리.** actuator 엔드포인트를 앱 API 와 **같은 소켓에 노출하지 않으려고**
|
||||
별도 management 포트를 둡니다.
|
||||
- **`MANAGEMENT_SERVER_PORT`** — 1~65535 정수. **`APP_SERVER_PORT`(8080)와 달라야 합니다.**
|
||||
@@ -0,0 +1,60 @@
|
||||
# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-graphql`
|
||||
- Gradle path: `:adapter:inbound:graphql`
|
||||
- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
|
||||
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
|
||||
자동 합성/바인딩하도록 얹는 얇은 계층이다.
|
||||
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
|
||||
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
||||
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이
|
||||
모듈을 자동 커버 — per-module 규칙 추가 불필요).
|
||||
- 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다.
|
||||
- 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다.
|
||||
|
||||
## Error mapping (`Category → ErrorType`)
|
||||
|
||||
feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면
|
||||
`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
|
||||
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
|
||||
|
||||
## Feature 기여 방법
|
||||
|
||||
- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다.
|
||||
- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다.
|
||||
- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를
|
||||
`PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리.
|
||||
|
||||
`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:graphql:test
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# adapter-graphql — 설계 결정 참조
|
||||
|
||||
인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.graphql`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가
|
||||
|
||||
Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은
|
||||
`src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를
|
||||
싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의
|
||||
`HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로
|
||||
부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다:
|
||||
|
||||
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
|
||||
`worklog.graphqls` 가 스켈레톤의 `skeleton.graphqls` 와 자동으로 합쳐진다.
|
||||
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping`
|
||||
메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 가 스켈레톤을 수정하지 않고 등록된다.
|
||||
|
||||
`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한
|
||||
disposability 보장).
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
|
||||
|
||||
`GraphqlExceptionResolver` 는 `DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가
|
||||
동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을
|
||||
`GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가
|
||||
와이어 계약을 단일 소유한다.
|
||||
|
||||
- **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표).
|
||||
정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게
|
||||
한다(gRPC 가 status trailer 에 싣는 것과 동형).
|
||||
- **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인
|
||||
`ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다.
|
||||
- **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른
|
||||
`DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로
|
||||
넘긴다.
|
||||
|
||||
`Category → ErrorType` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | GraphQL `ErrorType` |
|
||||
|---|---|
|
||||
| VALIDATION | BAD_REQUEST |
|
||||
| AUTH | UNAUTHORIZED |
|
||||
| AUTHZ | FORBIDDEN |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | BAD_REQUEST |
|
||||
| RATE_LIMIT | BAD_REQUEST |
|
||||
| TRANSIENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL_ERROR |
|
||||
| DATA_INTEGRITY | INTERNAL_ERROR |
|
||||
| INTERNAL | INTERNAL_ERROR |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은
|
||||
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
|
||||
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
|
||||
|
||||
## 설정 — 프레임워크 `spring.graphql.*`
|
||||
|
||||
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
||||
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
||||
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||
@@ -0,0 +1,28 @@
|
||||
// Driving adapter: GraphQL API (skeleton machinery, transport-only).
|
||||
//
|
||||
// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls
|
||||
// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health
|
||||
// schema + @Controller so the module boots standalone with zero features (an empty schema fails to
|
||||
// start); feature schema/controllers live in the sample module and compose automatically.
|
||||
//
|
||||
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
|
||||
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
|
||||
// coordinates the BOM does not manage).
|
||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||
// ExecutionGraphQlServiceTester.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test'
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Map;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
|
||||
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
|
||||
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
|
||||
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
|
||||
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
|
||||
* GrpcExceptionHandlingInterceptor}.
|
||||
*
|
||||
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
|
||||
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
|
||||
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
|
||||
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
|
||||
* returns {@code null}: Spring for GraphQL then merges the other {@link
|
||||
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
|
||||
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
|
||||
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
|
||||
* SQLState or upstream detail.
|
||||
*/
|
||||
@Component
|
||||
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
|
||||
|
||||
@Override
|
||||
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
|
||||
if (!(ex instanceof ApiErrorCarrier carrier)) {
|
||||
return null; // fall through to other resolvers / Spring's default handling
|
||||
}
|
||||
ApiErrorCode code = carrier.errorCode();
|
||||
var builder =
|
||||
GraphqlErrorBuilder.newError()
|
||||
.errorType(classify(code.category()))
|
||||
.message(code.code())
|
||||
.extensions(Map.of("code", code.code(), "category", code.category().name()));
|
||||
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
|
||||
// attach the field path/location when they are present.
|
||||
if (env != null) {
|
||||
builder.path(env.getExecutionStepInfo().getPath());
|
||||
if (env.getField() != null) {
|
||||
builder.location(env.getField().getSourceLocation());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
|
||||
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
|
||||
* until a mapping decision is made.
|
||||
*/
|
||||
private static ErrorType classify(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
|
||||
case AUTH -> ErrorType.UNAUTHORIZED;
|
||||
case AUTHZ -> ErrorType.FORBIDDEN;
|
||||
case NOT_FOUND -> ErrorType.NOT_FOUND;
|
||||
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
|
||||
ErrorType.INTERNAL_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.springframework.graphql.data.method.annotation.QueryMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
/**
|
||||
* Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the
|
||||
* GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query
|
||||
* resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature
|
||||
* queries/mutations are contributed by the sample module's own {@code @Controller} beans and merged
|
||||
* by Spring for GraphQL; this controller never names a feature type.
|
||||
*/
|
||||
@Controller
|
||||
public class HealthGraphqlController {
|
||||
|
||||
/** Stable liveness token, matching the web adapter's {@code status=UP} health semantics. */
|
||||
static final String STATUS_UP = "UP";
|
||||
|
||||
// The schema field is `_health` (a conventional meta-field name); the Java method is `health` so
|
||||
// it satisfies the method-name checkstyle rule, with the field bound explicitly via `name`.
|
||||
@QueryMapping(name = "_health")
|
||||
public String health() {
|
||||
return STATUS_UP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Minimal GraphQL schema for the skeleton machinery module (schema-first).
|
||||
#
|
||||
# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health
|
||||
# schema composes automatically with any feature schema the sample module contributes. It exists so
|
||||
# the module boots standalone with zero features: Spring for GraphQL refuses to start on an empty
|
||||
# schema, and the skeleton must never name a feature type (mirrors web's HealthcheckController).
|
||||
type Query {
|
||||
"Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck."
|
||||
_health: String!
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link ErrorType} classification table (design
|
||||
* Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category
|
||||
* value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against
|
||||
* {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure
|
||||
* mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}.
|
||||
*/
|
||||
class GraphqlExceptionResolverTest {
|
||||
|
||||
private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,BAD_REQUEST",
|
||||
"AUTH,UNAUTHORIZED",
|
||||
"AUTHZ,FORBIDDEN",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,BAD_REQUEST",
|
||||
"RATE_LIMIT,BAD_REQUEST",
|
||||
"TRANSIENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL_ERROR",
|
||||
"DATA_INTEGRITY,INTERNAL_ERROR",
|
||||
"INTERNAL,INTERNAL_ERROR",
|
||||
})
|
||||
void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null);
|
||||
|
||||
assertThat(error).isNotNull();
|
||||
assertThat(error.getErrorType()).isEqualTo(expected);
|
||||
assertThat(error.getExtensions())
|
||||
.containsEntry("code", "SOME_CODE")
|
||||
.containsEntry("category", category.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null))
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() {
|
||||
GraphQLError error =
|
||||
resolver.resolveToSingleError(
|
||||
new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null);
|
||||
|
||||
assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() {
|
||||
assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(String code, Category category) {
|
||||
super(code);
|
||||
this.errorCode = new TestErrorCode(code, category);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */
|
||||
private record TestErrorCode(String code, Category category) implements ApiErrorCode {
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.graphql.ExecutionGraphQlService;
|
||||
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
|
||||
import org.springframework.graphql.execution.DefaultExecutionGraphQlService;
|
||||
import org.springframework.graphql.execution.GraphQlSource;
|
||||
import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
|
||||
import org.springframework.graphql.test.tester.GraphQlTester;
|
||||
|
||||
/**
|
||||
* Assembles the skeleton schema ({@code graphql/skeleton.graphqls}) and the {@link
|
||||
* HealthGraphqlController}'s {@code @QueryMapping} through a real {@link
|
||||
* AnnotatedControllerConfigurer} — the same wiring Spring for GraphQL uses at runtime — and drives
|
||||
* the {@code _health} query with a {@link GraphQlTester}. Self-contained (no Spring Boot context),
|
||||
* so it proves the module stands up a working GraphQL surface (schema + controller binding) with
|
||||
* zero features, the GraphQL sibling of the gRPC skeleton's health boot test.
|
||||
*/
|
||||
class HealthGraphqlControllerTest {
|
||||
|
||||
@Test
|
||||
void healthQueryReturnsUpLivenessToken() {
|
||||
graphQlTester()
|
||||
.document("{ _health }")
|
||||
.execute()
|
||||
.path("_health")
|
||||
.entity(String.class)
|
||||
.isEqualTo("UP");
|
||||
}
|
||||
|
||||
private static GraphQlTester graphQlTester() {
|
||||
AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
|
||||
appContext.registerBean(HealthGraphqlController.class);
|
||||
appContext.refresh();
|
||||
|
||||
AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
|
||||
controllerConfigurer.setApplicationContext(appContext);
|
||||
controllerConfigurer.afterPropertiesSet();
|
||||
|
||||
GraphQlSource source =
|
||||
GraphQlSource.schemaResourceBuilder()
|
||||
.schemaResources(new ClassPathResource("graphql/skeleton.graphqls"))
|
||||
.configureRuntimeWiring(controllerConfigurer)
|
||||
.build();
|
||||
|
||||
ExecutionGraphQlService service = new DefaultExecutionGraphQlService(source);
|
||||
return ExecutionGraphQlServiceTester.create(service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery)
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-grpc`
|
||||
- Gradle path: `:adapter:inbound:grpc`
|
||||
- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
|
||||
규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`),
|
||||
프로토콜 에러 매핑(`GrpcStatusMapper` + `GrpcExceptionHandlingInterceptor`), 그리고 `.proto`
|
||||
없이도 부팅하는 최소 표면(standard health + reflection).
|
||||
- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록한다. **WorkLog 등
|
||||
구체 기능을 이름으로 알지 않는다.**
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services), `spring-boot-starter`.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드
|
||||
포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit
|
||||
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`).
|
||||
- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 스키마와 서비스는 feature
|
||||
(sample) 모듈이 소유한다.
|
||||
- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의
|
||||
`HealthcheckController` 와 동일 원칙).
|
||||
|
||||
## Config knobs (`ca-skeleton.grpc.*`)
|
||||
|
||||
타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다
|
||||
(모듈별 `yml` 없음).
|
||||
|
||||
| key | default | 의미 |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | gRPC 서버 기동 여부. 프로덕션 composition root 는 property 로 끌 수 있다 |
|
||||
| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) |
|
||||
| `reflectionEnabled` | `true` | v1 server reflection 노출(grpcurl/Postman 편의; 프로덕션에선 끄기) |
|
||||
| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초 |
|
||||
|
||||
## Feature 기여 방법
|
||||
|
||||
feature 모듈은 `io.grpc.BindableService` 를 `@Bean` 으로 등록하기만 하면
|
||||
`GrpcServerRunner` 의 `ObjectProvider` 가 자동으로 인터셉터 뒤에 등록한다. 에러는
|
||||
`ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)로 던지면
|
||||
`GrpcExceptionHandlingInterceptor` 가 매핑한다. `sample-portfolio` 를 지워도 스켈레톤은
|
||||
health + reflection 만으로 부팅한다 (disposability).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:grpc:test
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# adapter-grpc — 설계 결정 참조
|
||||
|
||||
인바운드 gRPC 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트:
|
||||
`dev.caskeleton.adapter.inbound.grpc`.
|
||||
|
||||
허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은
|
||||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||||
모아둔 참조용 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 왜 self-managed Netty 인가
|
||||
|
||||
- **third-party grpc-spring-boot starter 를 쓰지 않는다.** `GrpcServerRunner` 가 io.grpc Netty
|
||||
`Server` 를 Spring `SmartLifecycle` 빈으로 직접 소유한다. starter 를 쓰면 Spring Boot 릴리스에
|
||||
버전이 커플링되는데, 스켈레톤은 io.grpc 런타임에만 의존해 그 커플링을 피한다(web 어댑터가
|
||||
third-party 없이 서블릿 컨테이너를 쓰는 것과 같은 정신).
|
||||
- **`getPhase()` = `Integer.MAX_VALUE - 1`.** web 서버가 뜬 **뒤에** 시작하고 종료 시 web 서버
|
||||
**전에** 멈춘다(SmartLifecycle: 높은 phase 가 늦게 시작·먼저 종료). gRPC 는 web 과 별개의 TCP
|
||||
포트를 소유하는 부가 전송이므로 애플리케이션 수명주기 맨 바깥에 둔다.
|
||||
- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤
|
||||
`shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어
|
||||
로드밸런서가 드레이닝을 인지하게 한다.
|
||||
- **insecure bind (기본).** 스켈레톤은 참조 포스처와 동일하게 평문으로 바인딩하고 mTLS 는
|
||||
범위 밖(문서화된 knob). 프로덕션 fork 가 전송 보안을 얹는다.
|
||||
|
||||
## 왜 `.proto` 도 protobuf 플러그인도 없는가
|
||||
|
||||
이 모듈은 protobuf 를 **하나도 컴파일하지 않는다** — `com.google.protobuf` 플러그인도,
|
||||
`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services`
|
||||
런타임 jar 에 이미 컴파일된 채 들어 있어, 스켈레톤은 **RPC 0개**로도 동작하는 health +
|
||||
reflection 표면을 갖고 부팅한다. 기능(feature)의 `.proto`/서비스/매퍼는 `sample-portfolio` 의
|
||||
gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다.
|
||||
|
||||
`compileOnly org.apache.tomcat:annotations-api` 는 생성된 stub 이 참조하는
|
||||
`javax.annotation.Generated` 때문 — 스켈레톤 자체는 stub 을 생성하지 않지만 feature 모듈과의
|
||||
패리티를 위해 선언한다.
|
||||
|
||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||
|
||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `GrpcServerRunner` 는 생성자에서
|
||||
`ObjectProvider<io.grpc.BindableService>` 를 받아, 컨텍스트에 존재하는 **모든**
|
||||
`BindableService` 빈을 `ServerInterceptors.intercept(service, exceptionInterceptor)` 로 감싸
|
||||
등록한다. 그래서 sample 의 `WorkLogGrpcService` 같은 feature 서비스가 스켈레톤을 수정하지 않고
|
||||
자동 등록된다. `sample-portfolio` 를 지우면 스켈레톤은 여전히 health + reflection 만으로 부팅한다
|
||||
(web 과 동일한 disposability 보장).
|
||||
|
||||
## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제
|
||||
|
||||
`GrpcExceptionHandlingInterceptor` 가 핸들러에서 동기적으로 던져진 `RuntimeException` 을 잡아
|
||||
`ServerCall.close(status, trailers)` 로 변환한다. 서비스 구현은 web 컨트롤러처럼 "그냥 던지기만"
|
||||
하고, 이 인터셉터가 와이어 계약을 단일 소유한다.
|
||||
|
||||
- **와이어 status(coarse)** 는 `GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가
|
||||
coarse 인 것과 동형). 정확한 `code`/`category` 는 `Status` trailer `Metadata`(`error-code` /
|
||||
`error-category`)에 실어 클라이언트가 switch 하게 한다.
|
||||
- **`ApiErrorCode` 추출**: feature 예외는 `ApiErrorCarrier`(이 모듈이 제공하는 전송-중립 hook)를
|
||||
구현해 자신의 `ApiErrorCode` 를 노출한다. shared-contract 의 `PersistenceFailureException` /
|
||||
`DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다.
|
||||
- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw
|
||||
예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지
|
||||
않은 `RuntimeException` 은 `Status.INTERNAL` + `INTERNAL_ERROR` 로 폴백한다.
|
||||
|
||||
`Category → Status` 표(설계 스펙 Error Mapping SSOT):
|
||||
|
||||
| `Category` | gRPC `Status` |
|
||||
|---|---|
|
||||
| VALIDATION | INVALID_ARGUMENT |
|
||||
| AUTH | UNAUTHENTICATED |
|
||||
| AUTHZ | PERMISSION_DENIED |
|
||||
| NOT_FOUND | NOT_FOUND |
|
||||
| CONFLICT | ABORTED |
|
||||
| RATE_LIMIT | RESOURCE_EXHAUSTED |
|
||||
| TRANSIENT_DEPENDENCY | UNAVAILABLE |
|
||||
| PERMANENT_DEPENDENCY | INTERNAL |
|
||||
| DATA_INTEGRITY | INTERNAL |
|
||||
| INTERNAL | INTERNAL |
|
||||
|
||||
## 의존성 버전 — strict locking
|
||||
|
||||
Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저장소엔 version catalog 도
|
||||
없다. 그래서 `io.grpc:grpc-bom` + `com.google.protobuf:protobuf-bom` 을 **이 모듈의**
|
||||
`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion`
|
||||
가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만
|
||||
가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다.
|
||||
@@ -0,0 +1,36 @@
|
||||
// Driving adapter: gRPC API (skeleton machinery, transport-only).
|
||||
//
|
||||
// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on
|
||||
// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles
|
||||
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection
|
||||
// come from grpc-services at runtime, and feature `.proto`/services live in the sample module.
|
||||
//
|
||||
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
|
||||
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
|
||||
// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to
|
||||
// this module (the shared root dependencyManagement block stays io.grpc-free).
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
|
||||
mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':domain-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
|
||||
implementation 'io.grpc:grpc-netty-shaded'
|
||||
implementation 'io.grpc:grpc-protobuf'
|
||||
implementation 'io.grpc:grpc-stub'
|
||||
implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection)
|
||||
|
||||
// grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity
|
||||
// with the feature module (the skeleton itself generates no stubs).
|
||||
compileOnly 'org.apache.tomcat:annotations-api:6.0.53'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=runtimeClasspath,spotbugs,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,testCompileClasspath
|
||||
com.google.guava:guava:33.2.1-jre=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat:annotations-api:6.0.53=compileClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
|
||||
/**
|
||||
* Adapter-level throwable a feature gRPC service throws after mapping a domain exception to a
|
||||
* stable {@link ApiErrorCode} (typically a feature code such as {@code
|
||||
* PortfolioErrorCode.WORKLOG_NOT_FOUND} or a skeleton {@code OperationalError}). It implements the
|
||||
* shared-contract {@link ApiErrorCarrier} hook so the {@link GrpcExceptionHandlingInterceptor}
|
||||
* translates it to the matching gRPC {@code Status} plus {@code code} / {@code category} trailers
|
||||
* through the same single carrier branch that handles the shared-contract infra exceptions.
|
||||
*
|
||||
* <p>This is the gRPC sibling of "the web adapter service just throws and one handler owns the wire
|
||||
* mapping": a feature service does the domain-exception → {@code ApiErrorCode} mapping (its own
|
||||
* concern) and throws this; the transport mapping stays in the interceptor. The supplied message is
|
||||
* server-log-only detail — only {@link #errorCode()} reaches the client.
|
||||
*/
|
||||
public class ApiErrorException extends RuntimeException implements ApiErrorCarrier {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ApiErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* @param errorCode the classified, client-facing code surfaced on the gRPC status trailers
|
||||
* @param message server-log-only diagnostic detail — never surfaced to the client
|
||||
*/
|
||||
public ApiErrorException(ApiErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Centralises the gRPC error contract: a feature service just throws, and this {@link
|
||||
* ServerInterceptor} translates a synchronous {@link RuntimeException} from the handler into a
|
||||
* {@code ServerCall#close(Status, Metadata)} carrying the mapped {@link Status} plus {@code code} /
|
||||
* {@code category} trailers — the gRPC sibling of the web adapter's {@code GlobalExceptionHandler}.
|
||||
*
|
||||
* <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link
|
||||
* ApiErrorCarrier} hook — implemented by a feature throwable (the gRPC {@link ApiErrorException}
|
||||
* carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} /
|
||||
* {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers
|
||||
* them all. An unrecognised {@link RuntimeException} maps to {@link Status#INTERNAL} with {@link
|
||||
* OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the status
|
||||
* description and trailers) — a raw exception message, which may carry a SQLState or upstream
|
||||
* detail, is never surfaced.
|
||||
*/
|
||||
public class GrpcExceptionHandlingInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcStatusMapper statusMapper;
|
||||
|
||||
public GrpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
this.statusMapper = statusMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall(
|
||||
ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) {
|
||||
AtomicBoolean closed = new AtomicBoolean(false);
|
||||
ServerCall.Listener<REQT> delegate;
|
||||
try {
|
||||
delegate = next.startCall(call, headers);
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(call, closed, e);
|
||||
return new ServerCall.Listener<>() {};
|
||||
}
|
||||
return new SimpleForwardingServerCallListener<>(delegate) {
|
||||
@Override
|
||||
public void onMessage(REQT message) {
|
||||
runGuarded(() -> super.onMessage(message));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
runGuarded(super::onHalfClose);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady() {
|
||||
runGuarded(super::onReady);
|
||||
}
|
||||
|
||||
private void runGuarded(Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
closeWithError(call, closed, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void closeWithError(
|
||||
ServerCall<?, ?> call, AtomicBoolean closed, RuntimeException exception) {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return; // the call was already closed once — never double-close.
|
||||
}
|
||||
ApiErrorCode code = errorCodeOf(exception);
|
||||
Status status;
|
||||
if (code != null) {
|
||||
status = statusMapper.toStatus(code.category()).withDescription(code.code());
|
||||
} else {
|
||||
code = OperationalError.INTERNAL_ERROR;
|
||||
status = Status.INTERNAL.withDescription(code.code());
|
||||
}
|
||||
call.close(status, statusMapper.trailersFor(code));
|
||||
}
|
||||
|
||||
private static ApiErrorCode errorCodeOf(Throwable throwable) {
|
||||
if (throwable instanceof ApiErrorCarrier carrier) {
|
||||
return carrier.errorCode();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Wires the gRPC transport machinery, only when {@code ca-skeleton.grpc.enabled=true} (default).
|
||||
* All collaborators are plain objects composed here, mirroring the clean DI style used across the
|
||||
* skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and
|
||||
* registered generically by {@link GrpcServerRunner}. See README.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.grpc",
|
||||
name = "enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
@EnableConfigurationProperties(GrpcServerProperties.class)
|
||||
public class GrpcServerConfig {
|
||||
|
||||
@Bean
|
||||
GrpcStatusMapper grpcStatusMapper() {
|
||||
return new GrpcStatusMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcExceptionHandlingInterceptor grpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) {
|
||||
return new GrpcExceptionHandlingInterceptor(statusMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HealthStatusManager grpcHealthStatusManager() {
|
||||
return new HealthStatusManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GrpcServerRunner grpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
return new GrpcServerRunner(services, properties, exceptionInterceptor, healthStatusManager);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no
|
||||
* per-module {@code yml}); values live in the composition-root {@code application.yml}, matching
|
||||
* the ca-skeleton config convention. See README for the self-managed-Netty rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.grpc")
|
||||
public class GrpcServerProperties {
|
||||
|
||||
/** Whether to start the gRPC server at all (feature composition can keep it off by property). */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */
|
||||
private int port = 9090;
|
||||
|
||||
/** Expose server reflection (handy for grpcurl / Postman; disable in production). */
|
||||
private boolean reflectionEnabled = true;
|
||||
|
||||
/** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */
|
||||
private int shutdownGraceSeconds = 5;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public void setPort(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public boolean isReflectionEnabled() {
|
||||
return reflectionEnabled;
|
||||
}
|
||||
|
||||
public void setReflectionEnabled(boolean reflectionEnabled) {
|
||||
this.reflectionEnabled = reflectionEnabled;
|
||||
}
|
||||
|
||||
public int getShutdownGraceSeconds() {
|
||||
return shutdownGraceSeconds;
|
||||
}
|
||||
|
||||
public void setShutdownGraceSeconds(int shutdownGraceSeconds) {
|
||||
this.shutdownGraceSeconds = shutdownGraceSeconds;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import io.grpc.BindableService;
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.InsecureServerCredentials;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.ServerInterceptors;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.protobuf.services.HealthStatusManager;
|
||||
import io.grpc.protobuf.services.ProtoReflectionServiceV1;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* Owns the io.grpc Netty {@link Server} lifecycle as a Spring {@link SmartLifecycle} bean — start
|
||||
* on context refresh, graceful shutdown on close. Deliberately avoids any third-party
|
||||
* grpc-spring-boot starter so the skeleton has no Spring Boot version coupling.
|
||||
*
|
||||
* <p>Feature services are discovered generically: every {@link BindableService} bean is registered
|
||||
* behind the {@link GrpcExceptionHandlingInterceptor}, so a feature (e.g. the sample WorkLog
|
||||
* service) auto-registers without the skeleton naming it. The skeleton also registers the standard
|
||||
* {@code grpc.health.v1} health service and, when enabled, the v1 server reflection service, so it
|
||||
* boots with a working surface and ZERO {@code .proto}. See README.
|
||||
*/
|
||||
public class GrpcServerRunner implements SmartLifecycle {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class);
|
||||
|
||||
private final ObjectProvider<BindableService> services;
|
||||
private final GrpcServerProperties properties;
|
||||
private final GrpcExceptionHandlingInterceptor exceptionInterceptor;
|
||||
private final HealthStatusManager healthStatusManager;
|
||||
private volatile Server server;
|
||||
|
||||
public GrpcServerRunner(
|
||||
ObjectProvider<BindableService> services,
|
||||
GrpcServerProperties properties,
|
||||
GrpcExceptionHandlingInterceptor exceptionInterceptor,
|
||||
HealthStatusManager healthStatusManager) {
|
||||
this.services = services;
|
||||
this.properties = properties;
|
||||
this.exceptionInterceptor = exceptionInterceptor;
|
||||
this.healthStatusManager = healthStatusManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (isRunning()) {
|
||||
return;
|
||||
}
|
||||
var builder =
|
||||
Grpc.newServerBuilderForPort(properties.getPort(), InsecureServerCredentials.create());
|
||||
|
||||
int registered = 0;
|
||||
for (BindableService service : services) {
|
||||
builder.addService(ServerInterceptors.intercept(service, exceptionInterceptor));
|
||||
registered++;
|
||||
}
|
||||
|
||||
healthStatusManager.setStatus(
|
||||
HealthStatusManager.SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
|
||||
builder.addService(healthStatusManager.getHealthService());
|
||||
if (properties.isReflectionEnabled()) {
|
||||
builder.addService(ProtoReflectionServiceV1.newInstance());
|
||||
}
|
||||
|
||||
try {
|
||||
server = builder.build().start();
|
||||
log.info(
|
||||
"gRPC server started on port {} ({} feature service(s), reflection={})",
|
||||
server.getPort(),
|
||||
registered,
|
||||
properties.isReflectionEnabled());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(
|
||||
"failed to start gRPC server on port " + properties.getPort(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
Server current = this.server;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
healthStatusManager.enterTerminalState();
|
||||
try {
|
||||
current.shutdown();
|
||||
if (!current.awaitTermination(properties.getShutdownGraceSeconds(), TimeUnit.SECONDS)) {
|
||||
current.shutdownNow();
|
||||
}
|
||||
log.info("gRPC server stopped");
|
||||
} catch (InterruptedException e) {
|
||||
current.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
Server current = this.server;
|
||||
return current != null && !current.isShutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual bound port — useful when configured with port 0 for tests; {@code -1} when not started.
|
||||
*/
|
||||
public int getListeningPort() {
|
||||
Server current = this.server;
|
||||
return current != null ? current.getPort() : -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
// Start after the web server is up, stop before it during shutdown.
|
||||
return Integer.MAX_VALUE - 1;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
|
||||
/**
|
||||
* Pure translation of the 10-value operational {@link Category} SSOT to an {@link io.grpc.Status},
|
||||
* plus helpers to carry the machine-readable {@code code} / {@code category} on the response
|
||||
* trailer {@link Metadata}. This is the gRPC sibling of the web adapter's error contract: the wire
|
||||
* status (like an HTTP status) is coarse, while the exact {@link ApiErrorCode#code()} and the
|
||||
* category name ride in the trailers for the client to switch on.
|
||||
*
|
||||
* <p>The classification table is fixed by the design spec's Error Mapping section; see README.
|
||||
*/
|
||||
public class GrpcStatusMapper {
|
||||
|
||||
/**
|
||||
* Trailer key carrying the stable {@link ApiErrorCode#code()} (e.g. {@code WORKLOG_NOT_FOUND}).
|
||||
*/
|
||||
static final Metadata.Key<String> CODE_KEY =
|
||||
Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/** Trailer key carrying the {@link Category} enum name (e.g. {@code NOT_FOUND}). */
|
||||
static final Metadata.Key<String> CATEGORY_KEY =
|
||||
Metadata.Key.of("error-category", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
/**
|
||||
* Maps an operational {@link Category} to its gRPC {@link Status} (design Error-Mapping table).
|
||||
*/
|
||||
public Status toStatus(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION -> Status.INVALID_ARGUMENT;
|
||||
case AUTH -> Status.UNAUTHENTICATED;
|
||||
case AUTHZ -> Status.PERMISSION_DENIED;
|
||||
case NOT_FOUND -> Status.NOT_FOUND;
|
||||
case CONFLICT -> Status.ABORTED;
|
||||
case RATE_LIMIT -> Status.RESOURCE_EXHAUSTED;
|
||||
case TRANSIENT_DEPENDENCY -> Status.UNAVAILABLE;
|
||||
case PERMANENT_DEPENDENCY -> Status.INTERNAL;
|
||||
case DATA_INTEGRITY -> Status.INTERNAL;
|
||||
case INTERNAL -> Status.INTERNAL;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the {@code code} and {@code category} of {@code errorCode} onto a fresh trailer {@link
|
||||
* Metadata}, returned for {@code ServerCall#close(Status, Metadata)}.
|
||||
*/
|
||||
public Metadata trailersFor(ApiErrorCode errorCode) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(CODE_KEY, errorCode.code());
|
||||
trailers.put(CATEGORY_KEY, errorCode.category().name());
|
||||
return trailers;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies {@link ApiErrorException} carries its {@link dev.caskeleton.shared.error.ApiErrorCode}
|
||||
* through the shared-contract {@link ApiErrorCarrier} hook unchanged, and keeps the diagnostic
|
||||
* message off the carrier surface.
|
||||
*/
|
||||
class ApiErrorExceptionTest {
|
||||
|
||||
@Test
|
||||
void errorCodeRoundTripsThroughTheCarrierHook() {
|
||||
ApiErrorException exception =
|
||||
new ApiErrorException(OperationalError.BAD_PARAMETER, "server-log-only detail");
|
||||
|
||||
assertThat(exception).isInstanceOf(ApiErrorCarrier.class);
|
||||
assertThat(exception.errorCode()).isEqualTo(OperationalError.BAD_PARAMETER);
|
||||
assertThat(exception.getMessage()).isEqualTo("server-log-only detail");
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.MethodDescriptor;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.Status;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies the interceptor translates a synchronous handler exception into a mapped {@code
|
||||
* close(status, trailers)} — recognising the {@link ApiErrorCarrier} feature hook and the
|
||||
* shared-contract {@link PersistenceFailureException}, and falling back to {@link Status#INTERNAL}
|
||||
* for an unrecognised {@link RuntimeException}. Driven with a capturing fake {@link ServerCall}, so
|
||||
* no channel/server is needed.
|
||||
*/
|
||||
class GrpcExceptionHandlingInterceptorTest {
|
||||
|
||||
private final GrpcExceptionHandlingInterceptor interceptor =
|
||||
new GrpcExceptionHandlingInterceptor(new GrpcStatusMapper());
|
||||
|
||||
@Test
|
||||
void mapsApiErrorCarrierToItsCategoryStatusWithTrailers() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(new CarrierException(OperationalError.BAD_PARAMETER));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("BAD_PARAMETER");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("VALIDATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsApiErrorExceptionByItsCarriedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new ApiErrorException(OperationalError.ROUTE_NOT_FOUND, "server-log-only detail"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.NOT_FOUND);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("ROUTE_NOT_FOUND");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsSharedPersistenceFailureByItsClassifiedCode() {
|
||||
CapturingServerCall call =
|
||||
closeAfterThrowing(
|
||||
new PersistenceFailureException(OperationalError.DB_UNAVAILABLE, "08006", null));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.UNAVAILABLE);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("DB_UNAVAILABLE");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("TRANSIENT_DEPENDENCY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsUnknownRuntimeExceptionToInternal() {
|
||||
CapturingServerCall call = closeAfterThrowing(new IllegalStateException("boom"));
|
||||
|
||||
assertThat(call.status.getCode()).isEqualTo(Status.Code.INTERNAL);
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("INTERNAL_ERROR");
|
||||
assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("INTERNAL");
|
||||
}
|
||||
|
||||
private CapturingServerCall closeAfterThrowing(RuntimeException thrown) {
|
||||
CapturingServerCall call = new CapturingServerCall();
|
||||
ServerCallHandler<String, String> handler =
|
||||
(serverCall, headers) ->
|
||||
new ServerCall.Listener<>() {
|
||||
@Override
|
||||
public void onHalfClose() {
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
ServerCall.Listener<String> listener = interceptor.interceptCall(call, new Metadata(), handler);
|
||||
listener.onHalfClose();
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}.
|
||||
*/
|
||||
private static final class CarrierException extends RuntimeException implements ApiErrorCarrier {
|
||||
private final ApiErrorCode errorCode;
|
||||
|
||||
CarrierException(ApiErrorCode errorCode) {
|
||||
super(errorCode.code());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal {@link ServerCall} that records the {@code close(status, trailers)} arguments. */
|
||||
private static final class CapturingServerCall extends ServerCall<String, String> {
|
||||
private Status status;
|
||||
private Metadata trailers;
|
||||
|
||||
@Override
|
||||
public void request(int numMessages) {}
|
||||
|
||||
@Override
|
||||
public void sendHeaders(Metadata headers) {}
|
||||
|
||||
@Override
|
||||
public void sendMessage(String message) {}
|
||||
|
||||
@Override
|
||||
public void close(Status status, Metadata trailers) {
|
||||
this.status = status;
|
||||
this.trailers = trailers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MethodDescriptor<String, String> getMethodDescriptor() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.health.v1.HealthCheckRequest;
|
||||
import io.grpc.health.v1.HealthCheckResponse;
|
||||
import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
|
||||
import io.grpc.health.v1.HealthGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionGrpc;
|
||||
import io.grpc.reflection.v1.ServerReflectionRequest;
|
||||
import io.grpc.reflection.v1.ServerReflectionResponse;
|
||||
import io.grpc.reflection.v1.ServiceResponse;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Boots the skeleton gRPC machinery in a real Spring context on an ephemeral port ({@code
|
||||
* ca-skeleton.grpc.port=0}) with ZERO feature services and proves it stands up a working surface:
|
||||
* the {@link GrpcServerRunner} SmartLifecycle starts, the standard {@code grpc.health.v1} health
|
||||
* service reports SERVING, and v1 server reflection lists the built-in services. A real Netty
|
||||
* channel exercises the wire, so this is a genuine transport smoke test, not a wiring mock.
|
||||
*/
|
||||
class GrpcServerRunnerBootTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner =
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(GrpcServerConfig.class)
|
||||
.withPropertyValues("ca-skeleton.grpc.port=0");
|
||||
|
||||
@Test
|
||||
void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() {
|
||||
contextRunner.run(
|
||||
context -> {
|
||||
GrpcServerRunner runner = context.getBean(GrpcServerRunner.class);
|
||||
assertThat(runner.isRunning()).isTrue();
|
||||
|
||||
int port = runner.getListeningPort();
|
||||
assertThat(port).isGreaterThan(0);
|
||||
|
||||
ManagedChannel channel =
|
||||
ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
|
||||
try {
|
||||
HealthCheckResponse health =
|
||||
HealthGrpc.newBlockingStub(channel).check(HealthCheckRequest.newBuilder().build());
|
||||
assertThat(health.getStatus()).isEqualTo(ServingStatus.SERVING);
|
||||
|
||||
assertThat(listServicesViaReflection(channel))
|
||||
.contains("grpc.health.v1.Health", "grpc.reflection.v1.ServerReflection");
|
||||
} finally {
|
||||
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> listServicesViaReflection(ManagedChannel channel)
|
||||
throws InterruptedException {
|
||||
List<String> services = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
|
||||
StreamObserver<ServerReflectionRequest> requests =
|
||||
ServerReflectionGrpc.newStub(channel)
|
||||
.serverReflectionInfo(
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ServerReflectionResponse response) {
|
||||
for (ServiceResponse service :
|
||||
response.getListServicesResponse().getServiceList()) {
|
||||
services.add(service.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
error.set(t);
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build());
|
||||
requests.onCompleted();
|
||||
|
||||
assertThat(done.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(error.get()).isNull();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.inbound.grpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import java.util.EnumSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/**
|
||||
* Pins the 10-value {@link Category} → {@link Status} classification table (design Error-Mapping)
|
||||
* and the {@code code} / {@code category} trailer helper. One assertion per Category value guards
|
||||
* against a silent remap on a Spring/grpc upgrade.
|
||||
*/
|
||||
class GrpcStatusMapperTest {
|
||||
|
||||
private final GrpcStatusMapper mapper = new GrpcStatusMapper();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"VALIDATION,INVALID_ARGUMENT",
|
||||
"AUTH,UNAUTHENTICATED",
|
||||
"AUTHZ,PERMISSION_DENIED",
|
||||
"NOT_FOUND,NOT_FOUND",
|
||||
"CONFLICT,ABORTED",
|
||||
"RATE_LIMIT,RESOURCE_EXHAUSTED",
|
||||
"TRANSIENT_DEPENDENCY,UNAVAILABLE",
|
||||
"PERMANENT_DEPENDENCY,INTERNAL",
|
||||
"DATA_INTEGRITY,INTERNAL",
|
||||
"INTERNAL,INTERNAL",
|
||||
})
|
||||
void mapsEachCategoryToItsGrpcStatusCode(Category category, Status.Code expected) {
|
||||
assertThat(mapper.toStatus(category).getCode()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void coversEveryCategoryValue() {
|
||||
// Fails the moment a new Category is added without a mapping decision (switch is exhaustive).
|
||||
for (Category category : EnumSet.allOf(Category.class)) {
|
||||
assertThat(mapper.toStatus(category)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void trailersCarryStableCodeAndCategoryName() {
|
||||
Metadata trailers = mapper.trailersFor(OperationalError.RATE_LIMIT_EXCEEDED);
|
||||
|
||||
assertThat(trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("RATE_LIMIT_EXCEEDED");
|
||||
assertThat(trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("RATE_LIMIT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# adapter:inbound:web — inbound HTTP adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-inbound-web`
|
||||
- Gradle path: `:adapter:inbound:web`
|
||||
- Focused test: `./gradlew :adapter:inbound:web:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.inbound.web`.
|
||||
|
||||
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- HTTP controllers.
|
||||
- Request/response DTOs.
|
||||
- Request DTO to application command mapping.
|
||||
- Authentication, validation, error mapping, filters, and web/security settings.
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`
|
||||
- `:domain-core`
|
||||
- `:shared-contract`
|
||||
- Spring Web/Security/Validation dependencies.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Direct dependency on `adapter-persistence` or `adapter-outbound`.
|
||||
- Direct repository or JPA entity access from controllers.
|
||||
- Core business rules in controller, filter, config, mapper, or settings code.
|
||||
- DTO leakage into application or domain.
|
||||
|
||||
## Boundary validation & mapper contract
|
||||
|
||||
`feature-boundary-validation-mapping-contract` (LLM Wiki branch note) fixes the
|
||||
behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit +
|
||||
Jackson config + handler) only catch the static violations — the contract below
|
||||
also drives the runtime patterns reference implementations must follow.
|
||||
|
||||
- **B1 — Jackson policy at the request boundary.** `spring.jackson.deserialization`
|
||||
pins `fail-on-unknown-properties`, `fail-on-null-for-primitives`,
|
||||
`fail-on-ignored-properties` to `true` and `read-unknown-enum-values-as-null`
|
||||
to `false`. Do NOT undo this per-DTO with class-level
|
||||
`@JsonIgnoreProperties(ignoreUnknown = true)` — ArchUnit rule
|
||||
`request_dtos_do_not_silence_unknown_fields` blocks it. Use wrapper types
|
||||
(`Integer`, `Long`, `Boolean`, `Optional<T>`) in request records so JSON
|
||||
`null` cannot become primitive `0`.
|
||||
- **B2 — PATCH semantics.** Do not adopt RFC 7396 `application/merge-patch+json`
|
||||
(`null = deletion`). PATCH endpoints must distinguish *absent* (no change),
|
||||
*explicit null* (clear field), and *value* (replace). Use
|
||||
`org.openapitools:jackson-databind-nullable` (`JsonNullable<T>`) or
|
||||
`Optional<T>` wrappers on request records.
|
||||
- **B3 — Mapper-internal failures.** Map record canonical-constructor
|
||||
`IllegalArgumentException`, MapStruct generated NPE, ACL normalization
|
||||
failures, etc. by throwing `MappingException` (sample implementation in
|
||||
`sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400),
|
||||
never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException`
|
||||
remains `BAD_PARAMETER` for non-mapper callers.
|
||||
- **B4 — Validation layering.** Class-level Bean Validation constraints belong
|
||||
to the *syntax* layer (request DTO). Domain invariants belong to
|
||||
`application-core` / `domain-core`. Use `@GroupSequence(...)` to short-circuit
|
||||
invariant evaluation when syntax fails. Keep `@Valid` cascade depth ≤ 3.
|
||||
- **B5 — Polymorphic deserialization.** Calling
|
||||
`ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping()` or
|
||||
referencing `LaissezFaireSubTypeValidator` is the CVE-2019-14379 RCE entry
|
||||
point and is blocked by ArchUnit (`no_jackson_laissez_faire_subtype_validator`,
|
||||
`no_jackson_enable_default_typing_call`). Sealed `Command` types must use
|
||||
`@JsonTypeInfo(use = NAME)` + `@JsonSubTypes`, or a
|
||||
`BasicPolymorphicTypeValidator` allowlist.
|
||||
- **B6 — Virtual thread context propagation.** With
|
||||
`spring.threads.virtual.enabled=true`, do not use `InheritableThreadLocal`
|
||||
(ArchUnit rule `no_inheritable_thread_local`). Filters and interceptors must
|
||||
propagate `requestId` / `traceId` via SLF4J 2.0+ MDC or
|
||||
`RequestContextHolder`.
|
||||
- **B7 — Outbound ACL mapper scope.** Outbound HTTP / messaging adapter
|
||||
responses must pass through an ACL mapper (normalization, masking, public
|
||||
field selection) before reaching `application-core` or `domain-core` — the
|
||||
same boundary contract as inbound. Raw external response types must not leak
|
||||
into `domain-core`.
|
||||
- **B8 — Bulk endpoint partial success.** Envelope `success = true` only when
|
||||
every item succeeded. Partial failure responds with `success = false` +
|
||||
`error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` (per-item array) —
|
||||
a different shape from the single-item endpoint. Document the shape divergence
|
||||
in the OpenAPI spec.
|
||||
|
||||
Domain `@RestControllerAdvice` in a consuming module must be annotated
|
||||
`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this
|
||||
module's base `GlobalExceptionHandler`), because the base handler's catch-all
|
||||
`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions
|
||||
to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the
|
||||
pattern.
|
||||
|
||||
The base operational handler (`error/GlobalExceptionHandler`), the error-code
|
||||
contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the
|
||||
`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in
|
||||
production modules (`adapter:inbound:web` / `shared-contract`), so the running application
|
||||
provides them without depending on `sample-portfolio`. Domain-specific exception
|
||||
handlers and error codes live in the consuming module (see sample's
|
||||
`DomainExceptionHandler` / `PortfolioErrorCode`).
|
||||
|
||||
## Schema / serialization contract
|
||||
|
||||
`feature-schema-serialization-contract` (LLM Wiki branch note) fixes the
|
||||
*response producer* side of the wire contract — the sibling of the B1 *request
|
||||
consumer* policy above. The deserialization switches (B1) and the
|
||||
null/empty/missing 3-state (`Patch<T>` + `JsonNullable`, B2) already cover the
|
||||
inbound side; the rules below cover the outbound side. The Jackson properties
|
||||
live in `app-bootstrap` (`application.yml` `spring.jackson.serialization.*` /
|
||||
`spring.jackson.generator.*`); ArchUnit + effective-config tests live in
|
||||
`app-bootstrap` (`JacksonSerializationPolicyTest`, `no_bigdecimal_double_constructor`).
|
||||
|
||||
- **S1 — Date / time / timezone (D2).** `WRITE_DATES_AS_TIMESTAMPS=false` is
|
||||
pinned, so `java.time` values serialize as ISO-8601 strings via `JavaTimeModule`
|
||||
(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`), never a numeric
|
||||
epoch or `[y,m,d,...]` array. Server timezone is **UTC**: emit instants as
|
||||
`OffsetDateTime`/`Instant` with a `Z` offset. Use `LocalDate` only for
|
||||
date-only calendar fields. Do **not** put timezone-less `LocalDateTime` on a
|
||||
response DTO — it serializes without an offset and breaks the contract.
|
||||
- **S2 — Money / BigDecimal (D3).** Default scale 2, rounding `HALF_UP` unless
|
||||
the domain documents otherwise (KRW/JPY = scale 0 with a schema note).
|
||||
`WRITE_BIGDECIMAL_AS_PLAIN=true` is pinned so values never serialize in
|
||||
scientific notation. Pick **one** JSON representation per API and state it in
|
||||
the OpenAPI schema: **string** (`@JsonSerialize(using = ToStringSerializer.class)`)
|
||||
for public / financial endpoints (client parses, no precision loss), or
|
||||
**number + plain** for internal service-to-service endpoints. Never rely on
|
||||
the default — decide at endpoint design time.
|
||||
- **S3 — `new BigDecimal(double)` is banned.** The `double`/`float` constructors
|
||||
capture binary floating-point error (`new BigDecimal(0.1)` ≠ `0.1`). Build from
|
||||
a `String` (`new BigDecimal("0.1")`) or `BigDecimal.valueOf(double)`. Enforced
|
||||
by the `no_bigdecimal_double_constructor` ArchUnit rule (D3 / SBMS-C3).
|
||||
- **S4 — Enum / null·empty·missing.** Request-side unknown enum →
|
||||
`VALIDATION_FAILED` (B1 `read-unknown-enum-values-as-null=false`); legacy values
|
||||
map through an explicit adapter, never a silent fallback. The
|
||||
absent / explicit-null / value distinction is owned by the inbound web mapper
|
||||
(Controller DTO → Command), expressed with `Patch<T>` (B2); `domain-core` and
|
||||
`application-core` receive the already-resolved 3-state, never a wire type.
|
||||
- **S5 — Out of this branch's scope.** OpenAPI drift enforcement (D5) is owned by
|
||||
the verification suite / api-contract-baseline; removed-field-reuse ban tooling
|
||||
(D6, `x-removed-fields` vs markdown catalog) is `needs-confirmation`; Avro
|
||||
Schema Registry for outbox/event (D7) and response field rename/versioning
|
||||
(`feature-api-compatibility-deprecation-contract`) are separate branches.
|
||||
|
||||
## Business rule validation contract
|
||||
|
||||
`feature-business-rule-validation-contract` (LLM Wiki branch note) fixes **which
|
||||
rule is validated at which boundary**, so "validation" does not collapse into the
|
||||
controller DTO or a DB constraint. It sits on top of the boundary/mapping contract
|
||||
above and is enforced by ArchUnit + contract tests (not new runtime mechanism).
|
||||
|
||||
| Layer | Owner | Validates | `error.category` | Enforced by |
|
||||
|---|---|---|---|---|
|
||||
| syntax / shape | `adapter:inbound:web` request DTO (`@Valid` / `jakarta.validation`) | request shape, types, required fields | `VALIDATION` | `validation_constraints_stay_at_web_boundary` ArchUnit rule |
|
||||
| use case policy | `application-core` | authorization, cross-aggregate policy, state preconditions | `AUTHZ` / `CONFLICT` | `BusinessRuleValidationContractTest` |
|
||||
| domain invariant | `domain-core` model / value object **constructor** | business invariants (e.g. end ≥ start) | `CONFLICT` / `VALIDATION` | domain unit tests (e.g. `PeriodTest`) — constructor is the sole, immutable construction path |
|
||||
| persistence integrity | `adapter-persistence` (translator owned by `feature-persistence-failure-baseline`) | unique / FK / check / serialization | `DATA_INTEGRITY` / `CONFLICT` | `BusinessRuleValidationContractTest` + leak test |
|
||||
|
||||
- **C1 — Validation annotations stay at the web boundary.** `jakarta.validation`
|
||||
(`@NotNull`, `@Valid`, …) must appear only in `adapter:inbound:web`. `domain-core` and
|
||||
`application-core` express invariants and policy as plain Java. The
|
||||
`validation_constraints_stay_at_web_boundary` ArchUnit rule fails the build if a
|
||||
Bean Validation annotation leaks into `..domain..` or `..application..`.
|
||||
- **C2 — Business invariants live in the domain, un-bypassable.** Enforce invariants
|
||||
in the value-object / entity **constructor** (the sole construction path) and keep
|
||||
the type immutable, so no application-service or persistence path can hand out an
|
||||
invariant-violating instance. A DB constraint is a backstop, never the only check
|
||||
(Forbidden: "DB constraint as only invariant").
|
||||
- **C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free.** A
|
||||
unique/FK/check/serialization failure maps to `DATA_INTEGRITY` / `CONFLICT` with a
|
||||
**client-safe message only**. The raw SQL, constraint/index name, SQLState code,
|
||||
exception class, and stack frame must never reach `error.message` or
|
||||
`error.details`. The base `GlobalExceptionHandler` catch-all already replaces the
|
||||
message with `"Internal server error"` and emits `null` details; the
|
||||
category-correct mapping (23505 → `CONFLICT/DB_UNIQUE_VIOLATION`, …) is owned by
|
||||
`feature-persistence-failure-baseline`'s persistence-adapter translator.
|
||||
- **C8 — Duplicate validation needs a canonical owner.** The same rule MAY be
|
||||
pre-checked at another layer for UX / performance (e.g. an application pre-check
|
||||
mirroring a DB unique constraint), but the **canonical owner** of the rule must be
|
||||
named in a code comment or the relevant `CLAUDE.md`. A duplicate validator with no
|
||||
documented owner is a review failure (silent contradiction risk). This is a process
|
||||
gate (PR review), not an automated rule — `needs-confirmation` until an owner-marker
|
||||
annotation is justified.
|
||||
|
||||
Out of this branch's scope (cross-referenced, not re-implemented here): the
|
||||
SQLState→code 9-row matrix and the `DataAccessException` translator
|
||||
(`feature-persistence-failure-baseline`); the Jackson B1/B2 request-boundary switches
|
||||
and mapper sentinel (`feature-boundary-validation-mapping-contract`); the envelope,
|
||||
`Category` enum, and `OperationalError` codes
|
||||
(`feature-operational-error-observability-foundation`).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:web:test --console=plain
|
||||
```
|
||||
@@ -0,0 +1,414 @@
|
||||
# adapter-web — 설계 결정 참조
|
||||
|
||||
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
|
||||
|
||||
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
|
||||
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
|
||||
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
|
||||
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
|
||||
풀어 둔다.
|
||||
|
||||
---
|
||||
|
||||
## auth — 인증 (OIDC resource server)
|
||||
|
||||
### SecurityConfig
|
||||
- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이
|
||||
HTTP cache 헤더 정책을 소유한다(`CacheControlFilter` 가 `Cache-Control: no-store` + `Vary` 방출).
|
||||
헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
|
||||
- **AuthN/AuthZ 분류기를 `exceptionHandling` 과 `oauth2ResourceServer` 양쪽에 설정.** entry point 는
|
||||
missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied
|
||||
handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한
|
||||
Envelope writer 로 귀결된다.
|
||||
|
||||
### JwtDecoderConfig
|
||||
- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다.
|
||||
- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring
|
||||
업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다.
|
||||
- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`),
|
||||
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
|
||||
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
|
||||
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
|
||||
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
|
||||
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
|
||||
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
|
||||
- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier` 의
|
||||
"aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다.
|
||||
|
||||
### JwtToAuthenticatedPrincipalConverter
|
||||
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
|
||||
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
|
||||
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
|
||||
|
||||
### SecurityErrorClassifier
|
||||
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
|
||||
선언한 세분화 코드를 방출한다.
|
||||
- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로,
|
||||
classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑:
|
||||
- missing token → `InsufficientAuthenticationException` → `AUTH_TOKEN_MISSING`
|
||||
- claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` /
|
||||
`AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH`
|
||||
- decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) →
|
||||
`AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN`
|
||||
- JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient)
|
||||
- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한
|
||||
`AUTH_TOKEN_MALFORMED`(401)로 폴백한다.
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION`
|
||||
만 방출한다.
|
||||
|
||||
### AuthErrorResponseWriter
|
||||
- **토큰/PII 리댁션.** 응답 본문엔 해당
|
||||
코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는
|
||||
절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지
|
||||
않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다.
|
||||
- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description`
|
||||
으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다.
|
||||
|
||||
### EnvelopeAuthenticationEntryPoint
|
||||
- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류).
|
||||
- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서
|
||||
처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler` 가
|
||||
아니라 반드시 이 entry point 에 위치해야 한다.
|
||||
|
||||
### EnvelopeAccessDeniedHandler
|
||||
- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403).
|
||||
- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라
|
||||
일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## authz — 인가 (`@RequiresPermission` 강제)
|
||||
|
||||
### MethodSecurityConfig
|
||||
- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다.
|
||||
- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/
|
||||
`@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래
|
||||
커스텀 advisor 가 되게 하기 위함.
|
||||
- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는
|
||||
프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급.
|
||||
- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어
|
||||
애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
|
||||
|
||||
### RequiresPermissionAuthorizationManager
|
||||
- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`).
|
||||
- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적
|
||||
`AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서
|
||||
application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다.
|
||||
- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된
|
||||
`AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException` →
|
||||
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는
|
||||
메서드는 영향받지 않는다.
|
||||
- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로
|
||||
해석되어 거부된다.
|
||||
|
||||
### AuthorizationAdapter
|
||||
- application `AuthorizationPort` 의 web-adapter 구현체.
|
||||
- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로
|
||||
거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403.
|
||||
|
||||
### RolePermissionRegistry
|
||||
- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다.
|
||||
- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로.
|
||||
- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의
|
||||
`worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B).
|
||||
- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한.
|
||||
|
||||
### RolePermissionPolicy
|
||||
- app-side role→permission 매핑 소스.
|
||||
- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role
|
||||
집합엔 없으므로 붙이지 않는다.
|
||||
- startup-bound static config 라 staleness 가 없다.
|
||||
- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터
|
||||
디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서
|
||||
의도적으로 out-of-scope 인 대안이다.
|
||||
|
||||
---
|
||||
|
||||
## error — 에러 → Envelope 변환
|
||||
|
||||
### GlobalExceptionHandler
|
||||
스켈레톤 공통 기반 에러 → `Envelope` 변환기.
|
||||
|
||||
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
|
||||
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
|
||||
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
|
||||
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
|
||||
제공하도록.
|
||||
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
|
||||
라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired`
|
||||
생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트
|
||||
모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만
|
||||
등록하면 no-op 을 오버라이드한다.
|
||||
|
||||
**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거):
|
||||
|
||||
| 예외 | 코드 | 상태 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
|
||||
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
|
||||
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
|
||||
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | field/message violation 리스트를 details 로 |
|
||||
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
|
||||
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
|
||||
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
|
||||
| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 |
|
||||
| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) |
|
||||
| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 |
|
||||
| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" |
|
||||
| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 |
|
||||
| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 |
|
||||
| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 |
|
||||
| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) |
|
||||
| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor` 로 `Retry-After` 부착 |
|
||||
| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 |
|
||||
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
|
||||
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
|
||||
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
|
||||
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | field/rejectedValue/message 리스트를 details 로 |
|
||||
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
|
||||
| `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | |
|
||||
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
|
||||
|
||||
### ErrorResponseFactory
|
||||
- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게
|
||||
하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출).
|
||||
|
||||
---
|
||||
|
||||
## envelope / filter
|
||||
|
||||
### EnvelopeBodyAdvice
|
||||
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
|
||||
`{success, data | error, traceId}` 로 보장한다.
|
||||
- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지
|
||||
않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다.
|
||||
|
||||
### CacheControlFilter
|
||||
- 스켈레톤 기본 HTTP 캐시 정책.
|
||||
- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization` 로
|
||||
공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다.
|
||||
- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예:
|
||||
`private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록.
|
||||
- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다.
|
||||
- 단일 소유권: Spring Security 기본 `Cache-Control` 은 `SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가
|
||||
헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer.
|
||||
|
||||
### RequestLoggingFilter
|
||||
- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용.
|
||||
- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거)
|
||||
및 길이 제한. 부재/공백은 서버 생성.
|
||||
- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`,
|
||||
`spanId`→`span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId,
|
||||
sampled=false)하여 MDC `trace_id` 가 **항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성
|
||||
상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정.
|
||||
- `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다.
|
||||
- `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트
|
||||
전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을
|
||||
non-zero 로 유지해 W3C non-all-zero 규칙 충족.
|
||||
- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전
|
||||
`UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다.
|
||||
- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE`
|
||||
는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능.
|
||||
- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer
|
||||
스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory` 가
|
||||
이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를
|
||||
발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는
|
||||
필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId` 가
|
||||
실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다.
|
||||
실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing
|
||||
observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지
|
||||
못한다 — 무-tracer 메커니즘만 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## ratelimit
|
||||
|
||||
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
|
||||
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
|
||||
- **OCP(개방-폐쇄)**: `RateLimitInterceptor` 는 `RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
|
||||
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
|
||||
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
|
||||
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
|
||||
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
|
||||
- `limit` — 설정 quota
|
||||
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
|
||||
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
|
||||
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
|
||||
- `allowed` — quota 소진 시 false (→ 429)
|
||||
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
|
||||
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
|
||||
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
|
||||
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
|
||||
|
||||
### RateLimitKeyResolver
|
||||
- 키 형태: service-to-service
|
||||
`apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화).
|
||||
- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은
|
||||
이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다.
|
||||
- tenant prefixing 은 아직 구현하지 않은 확장 지점이다.
|
||||
- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다.
|
||||
- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet
|
||||
filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다.
|
||||
|
||||
### RateLimitClientIpMode
|
||||
- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum.
|
||||
- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시).
|
||||
- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용.
|
||||
|
||||
### RateLimitWebConfig
|
||||
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
|
||||
resolve 되기 때문(RateLimitKeyResolver 참조).
|
||||
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
|
||||
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
|
||||
슬라이스에선 `Clock#systemUTC()` 로 fallback.
|
||||
|
||||
### RateLimitInterceptor
|
||||
- fixed-window rate limit 을 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
|
||||
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
|
||||
의존성 장애로 오분류하는 것을 막는다.
|
||||
|
||||
---
|
||||
|
||||
## settings / config / http
|
||||
|
||||
### CorsSettings
|
||||
- **3계층 검증 전략.**
|
||||
1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로
|
||||
기동 실패(`maxAgeSeconds`).
|
||||
2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값
|
||||
폴백 금지).
|
||||
3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
|
||||
- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록
|
||||
관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다.
|
||||
- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *`
|
||||
는 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
|
||||
시점에 fail-fast 거부.
|
||||
|
||||
### RateLimitSettings
|
||||
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
|
||||
- `enabled` 는 `APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
|
||||
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
|
||||
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
|
||||
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
|
||||
|
||||
### SecuritySettings
|
||||
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
|
||||
기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백.
|
||||
|
||||
### PresentationSettings
|
||||
- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가
|
||||
(/api 없이) 계속 접근 가능하게 유지.
|
||||
|
||||
### JacksonNullableConfig
|
||||
- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를
|
||||
Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다.
|
||||
- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치.
|
||||
|
||||
### ApiHeaders
|
||||
- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`의
|
||||
코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리
|
||||
일관성 테스트가 단일 출처를 참조하게 한다.
|
||||
- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은
|
||||
application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/
|
||||
LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은
|
||||
rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조.
|
||||
|
||||
---
|
||||
|
||||
## observability
|
||||
|
||||
### MdcKeys
|
||||
- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다.
|
||||
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
|
||||
변환 단일 지점은 `ResponseMetaFactory`.
|
||||
|
||||
### HeaderSanitizer
|
||||
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
|
||||
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
|
||||
제거 후 길이 제한.
|
||||
- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는
|
||||
원칙만 규정하고 정규식/한계는 규정하지 않는다.
|
||||
|
||||
### ResponseMetaFactory
|
||||
- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web
|
||||
에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
|
||||
|
||||
### RetryAfterAdvisor
|
||||
- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고,
|
||||
per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다.
|
||||
이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가
|
||||
재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다.
|
||||
- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나,
|
||||
Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적
|
||||
미구현.
|
||||
- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러.
|
||||
이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다.
|
||||
`DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환.
|
||||
|
||||
---
|
||||
|
||||
## pagination
|
||||
|
||||
### PageParams
|
||||
- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable`
|
||||
parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE`
|
||||
는 2000, SPRING-PAGE-C4).
|
||||
- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부
|
||||
트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다.
|
||||
|
||||
### SortParam
|
||||
- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거:
|
||||
JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을
|
||||
깨뜨리므로 금지.
|
||||
|
||||
### PageValidationException
|
||||
- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생.
|
||||
|
||||
---
|
||||
|
||||
## cursor
|
||||
|
||||
### CursorCodec
|
||||
- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5).
|
||||
- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이
|
||||
저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리
|
||||
제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현.
|
||||
- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정).
|
||||
|
||||
### CursorException
|
||||
- 불투명 페이지네이션 커서 검증 실패 시 발생(D22).
|
||||
|
||||
---
|
||||
|
||||
## conditional
|
||||
|
||||
### ETags
|
||||
- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17).
|
||||
`weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다.
|
||||
- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을
|
||||
leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다.
|
||||
strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능.
|
||||
|
||||
### PreconditionFailedException
|
||||
- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과
|
||||
구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현.
|
||||
|
||||
---
|
||||
|
||||
## idempotency
|
||||
|
||||
### IdempotencyKeySupport
|
||||
- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper.
|
||||
- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일
|
||||
표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of`
|
||||
가 `IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지.
|
||||
- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로
|
||||
계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는
|
||||
여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다.
|
||||
- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다.
|
||||
|
||||
### JsonIdempotentResponseCodec
|
||||
- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을
|
||||
소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED`
|
||||
400 으로 라우팅.
|
||||
@@ -0,0 +1,16 @@
|
||||
// HTTP / web adapters. Depends on application, domain, and shared operational contracts.
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
||||
// feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the
|
||||
// running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated —
|
||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||
// owned by feature-contract-verification-test-suite (planned).
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6'
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Writes a classified security failure to the servlet response as the skeleton-wide {@link
|
||||
* Envelope} (same shape as every other error), and logs it safely. The response body and log line
|
||||
* carry only redacted, client-safe metadata. See README for the design rationale.
|
||||
*/
|
||||
class AuthErrorResponseWriter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthErrorResponseWriter.class);
|
||||
|
||||
/** Client-safe messages, aligned with the registry {@code client_safe_message} column. */
|
||||
private static final Map<OperationalError, String> CLIENT_MESSAGES =
|
||||
Map.of(
|
||||
OperationalError.AUTH_TOKEN_MISSING, "Authentication required",
|
||||
OperationalError.AUTH_TOKEN_EXPIRED, "Authentication expired",
|
||||
OperationalError.AUTH_KID_UNKNOWN, "Authentication failed, please retry",
|
||||
OperationalError.AUTH_JWKS_UNAVAILABLE, "Authentication service temporarily unavailable",
|
||||
OperationalError.AUTHZ_INSUFFICIENT_PERMISSION, "Permission denied",
|
||||
OperationalError.AUTHZ_TENANT_MISMATCH, "Permission denied");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
AuthErrorResponseWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
void write(HttpServletRequest request, HttpServletResponse response, OperationalError code)
|
||||
throws IOException {
|
||||
// Log only safe metadata — never the token or the raw failure message.
|
||||
log.warn(
|
||||
"security failure: code={} category={} method={} path={}",
|
||||
code.code(),
|
||||
code.category(),
|
||||
request.getMethod(),
|
||||
request.getRequestURI());
|
||||
|
||||
response.setStatus(code.httpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
// A 401 response carries a minimal WWW-Authenticate header (no issuer / token detail).
|
||||
if (code.httpStatus() == 401) {
|
||||
response.setHeader(
|
||||
HttpHeaders.WWW_AUTHENTICATE,
|
||||
code == OperationalError.AUTH_TOKEN_MISSING
|
||||
? "Bearer"
|
||||
: "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(
|
||||
seconds -> response.setHeader(HttpHeaders.RETRY_AFTER, Integer.toString(seconds)));
|
||||
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, clientMessage(code), null);
|
||||
objectMapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
|
||||
private String clientMessage(OperationalError code) {
|
||||
return CLIENT_MESSAGES.getOrDefault(code, "Authentication failed");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Principal exposed to controllers via {@code @AuthenticationPrincipal}. Carries the IdP-side
|
||||
* identifier ({@code idpUserId}, JWT {@code sub} claim) plus any claims a controller is likely to
|
||||
* want without reaching into the raw Jwt.
|
||||
*/
|
||||
public record AuthenticatedPrincipal(String idpUserId, String email, Set<String> roles) {
|
||||
|
||||
public AuthenticatedPrincipal {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
}
|
||||
|
||||
public boolean hasRole(String role) {
|
||||
return roles.contains(role);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AccessDeniedHandler} that maps an authorization failure (valid token,
|
||||
* insufficient permission) to {@code AUTHZ_INSUFFICIENT_PERMISSION} (403) and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAccessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAccessDenied(accessDeniedException));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Resource-server {@link AuthenticationEntryPoint} that classifies an authentication failure into a
|
||||
* fine-grained {@link dev.caskeleton.shared.error.OperationalError} and writes it as the
|
||||
* skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class EnvelopeAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final SecurityErrorClassifier classifier;
|
||||
private final AuthErrorResponseWriter writer;
|
||||
|
||||
public EnvelopeAuthenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
this.classifier = classifier;
|
||||
this.writer = new AuthErrorResponseWriter(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
writer.write(request, response, classifier.classifyAuthentication(authException));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
|
||||
|
||||
/**
|
||||
* Custom {@link JwtDecoder} for the resource server with an explicit validator chain: timestamp
|
||||
* (60s clock skew) + issuer, plus an optional audience check when configured. JWKS discovery is
|
||||
* deferred via {@link SupplierJwtDecoder} so startup does not require the IdP to be reachable. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
public class JwtDecoderConfig {
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(SecuritySettings settings) {
|
||||
// Lazy: JWKS discovery happens on first decode, not at startup.
|
||||
return new SupplierJwtDecoder(
|
||||
() -> {
|
||||
NimbusJwtDecoder decoder =
|
||||
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
|
||||
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
|
||||
return decoder;
|
||||
});
|
||||
}
|
||||
|
||||
/** The explicit validator chain: timestamp (60s skew) + issuer + optional audience. */
|
||||
static OAuth2TokenValidator<Jwt> jwtValidator(String issuerUri, String audience) {
|
||||
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
|
||||
validators.add(new JwtTimestampValidator(Duration.ofSeconds(60)));
|
||||
validators.add(new JwtIssuerValidator(issuerUri));
|
||||
if (audience != null && !audience.isBlank()) {
|
||||
validators.add(audienceValidator(audience));
|
||||
}
|
||||
return new DelegatingOAuth2TokenValidator<>(validators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> audienceValidator(String audience) {
|
||||
return jwt -> {
|
||||
if (jwt.getAudience() != null && jwt.getAudience().contains(audience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
OAuth2Error error = new OAuth2Error("invalid_token", "The aud claim is not valid", null);
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
};
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Maps an OIDC JWT to a JwtAuthenticationToken whose principal is our {@link
|
||||
* AuthenticatedPrincipal}. We pull {@code sub} as the IdP user id and union Keycloak-style {@code
|
||||
* realm_access.roles} with {@code resource_access[*].roles} into a single role set. Roles also
|
||||
* become Spring authorities (ROLE_*).
|
||||
*/
|
||||
@Component
|
||||
public class JwtToAuthenticatedPrincipalConverter
|
||||
implements Converter<Jwt, AbstractAuthenticationToken> {
|
||||
|
||||
@Override
|
||||
public AbstractAuthenticationToken convert(Jwt jwt) {
|
||||
Set<String> roles = extractRoles(jwt);
|
||||
String email = jwt.getClaimAsString("email");
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal(jwt.getSubject(), email, roles);
|
||||
Collection<GrantedAuthority> authorities =
|
||||
roles.stream()
|
||||
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase()))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
return new AuthenticatedJwtToken(jwt, authorities, principal);
|
||||
}
|
||||
|
||||
private Set<String> extractRoles(Jwt jwt) {
|
||||
Set<String> roles = new HashSet<>();
|
||||
|
||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||
if (realmAccess != null) {
|
||||
Object r = realmAccess.get("roles");
|
||||
if (r instanceof Collection<?> col) {
|
||||
col.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
for (Object client : resourceAccess.values()) {
|
||||
if (client instanceof Map<?, ?> clientMap
|
||||
&& clientMap.get("roles") instanceof Collection<?> rolesCol) {
|
||||
rolesCol.forEach(x -> roles.add(String.valueOf(x)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic OIDC "roles" claim as a fallback
|
||||
List<String> flat = jwt.getClaimAsStringList("roles");
|
||||
if (flat != null) {
|
||||
roles.addAll(flat);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* JwtAuthenticationToken whose {@link #getPrincipal()} is our domain-oriented record instead of
|
||||
* the raw Jwt. Both are kept available — controllers usually want the record; filters/loggers can
|
||||
* still pull the Jwt via {@link #getToken()}.
|
||||
*/
|
||||
public static final class AuthenticatedJwtToken extends JwtAuthenticationToken {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
// transient: the principal is reconstructed by the converter on each authentication,
|
||||
// never round-tripped through Java serialization. See README for the design rationale.
|
||||
private final transient AuthenticatedPrincipal principal;
|
||||
|
||||
AuthenticatedJwtToken(
|
||||
Jwt jwt,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
AuthenticatedPrincipal principal) {
|
||||
super(jwt, authorities);
|
||||
this.principal = principal;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final SecuritySettings securitySettings;
|
||||
private final CorsSettings corsSettings;
|
||||
private final JwtToAuthenticatedPrincipalConverter jwtConverter;
|
||||
|
||||
public SecurityConfig(
|
||||
SecuritySettings securitySettings,
|
||||
CorsSettings corsSettings,
|
||||
JwtToAuthenticatedPrincipalConverter jwtConverter) {
|
||||
this.securitySettings = securitySettings;
|
||||
this.corsSettings = corsSettings;
|
||||
this.jwtConverter = jwtConverter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityErrorClassifier securityErrorClassifier() {
|
||||
return new SecurityErrorClassifier();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationEntryPoint authenticationEntryPoint(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAuthenticationEntryPoint(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AccessDeniedHandler accessDeniedHandler(
|
||||
SecurityErrorClassifier classifier, ObjectMapper objectMapper) {
|
||||
return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(
|
||||
HttpSecurity http,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
AccessDeniedHandler accessDeniedHandler)
|
||||
throws Exception {
|
||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
|
||||
// owns the cache header policy. See README for the design rationale.
|
||||
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
auth -> {
|
||||
if (publicPaths.length > 0) {
|
||||
auth.requestMatchers(publicPaths).permitAll();
|
||||
}
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
// The entry point and access-denied handler are set on both exceptionHandling and
|
||||
// oauth2ResourceServer so every filter resolves to the same Envelope writer.
|
||||
// See README for the design rationale.
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler))
|
||||
.oauth2ResourceServer(
|
||||
oauth ->
|
||||
oauth
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
if (!corsSettings.enabled()) {
|
||||
return source; // no patterns registered -> Spring uses null config -> CORS inactive
|
||||
}
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(corsSettings.allowedOrigins());
|
||||
cfg.setAllowedMethods(corsSettings.allowedMethods());
|
||||
cfg.setAllowedHeaders(corsSettings.allowedHeaders());
|
||||
cfg.setAllowCredentials(corsSettings.allowCredentials());
|
||||
cfg.setMaxAge(corsSettings.maxAgeSeconds());
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.Locale;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
|
||||
/**
|
||||
* Classifies a resource-server security failure into a fine-grained {@link OperationalError} by
|
||||
* inspecting the exception graph and validator/Nimbus message text. Unmapped failures fall back to
|
||||
* the generic, safe {@code AUTH_TOKEN_MALFORMED} (401) rather than a 500. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class SecurityErrorClassifier {
|
||||
|
||||
/** Classifies an authentication (401-family) failure reaching the AuthenticationEntryPoint. */
|
||||
public OperationalError classifyAuthentication(AuthenticationException ex) {
|
||||
if (ex instanceof OAuth2AuthenticationException oauth) {
|
||||
OperationalError byCause = classifyByCause(oauth.getCause());
|
||||
if (byCause != null) {
|
||||
return byCause;
|
||||
}
|
||||
OperationalError byError = classifyByText(describe(oauth.getError()));
|
||||
return byError != null ? byError : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
if (ex instanceof InsufficientAuthenticationException) {
|
||||
return OperationalError.AUTH_TOKEN_MISSING;
|
||||
}
|
||||
// Unmapped authentication failure: a generic, safe 401 — never an unclassified 500.
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
|
||||
/** Classifies an authorization (403-family) failure reaching the AccessDeniedHandler. */
|
||||
public OperationalError classifyAccessDenied(AccessDeniedException ex) {
|
||||
return OperationalError.AUTHZ_INSUFFICIENT_PERMISSION;
|
||||
}
|
||||
|
||||
private OperationalError classifyByCause(Throwable cause) {
|
||||
if (cause == null) {
|
||||
return null;
|
||||
}
|
||||
if (cause instanceof JwtValidationException validation) {
|
||||
// A JWKS retrieval failure can surface wrapped in validation errors too.
|
||||
OperationalError fromText = null;
|
||||
for (OAuth2Error error : validation.getErrors()) {
|
||||
OperationalError mapped = classifyByText(describe(error));
|
||||
fromText = higherPriority(fromText, mapped);
|
||||
}
|
||||
return fromText != null ? fromText : OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
// BadJwtException extends JwtException; both carry the decode/signature/kid/JWKS message.
|
||||
return classifyByText(cause.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single validator/decoder message to a code. Ordered, narrow heuristics; returns {@code
|
||||
* null} when nothing matches so callers can fall back.
|
||||
*/
|
||||
private OperationalError classifyByText(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String m = raw.toLowerCase(Locale.ROOT);
|
||||
// JWKS endpoint outage is a transient dependency failure (check before generic decode text).
|
||||
if (m.contains("jwk set") || m.contains("jwk source") || m.contains("jwkset")) {
|
||||
return OperationalError.AUTH_JWKS_UNAVAILABLE;
|
||||
}
|
||||
if (m.contains("expired") || m.contains("jwt expired")) {
|
||||
return OperationalError.AUTH_TOKEN_EXPIRED;
|
||||
}
|
||||
if (m.contains("iss claim") || m.contains("issuer")) {
|
||||
return OperationalError.AUTH_ISSUER_MISMATCH;
|
||||
}
|
||||
if (m.contains("aud claim") || m.contains("audience")) {
|
||||
return OperationalError.AUTH_AUDIENCE_MISMATCH;
|
||||
}
|
||||
if (m.contains("signature") || m.contains("signed jwt rejected")) {
|
||||
return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE;
|
||||
}
|
||||
if (m.contains("kid") || m.contains("matching key") || m.contains("key id")) {
|
||||
return OperationalError.AUTH_KID_UNKNOWN;
|
||||
}
|
||||
if (m.contains("malformed")
|
||||
|| m.contains("invalid jwt")
|
||||
|| m.contains("invalid compact")
|
||||
|| m.contains("decode")) {
|
||||
return OperationalError.AUTH_TOKEN_MALFORMED;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence among multiple simultaneous validation failures. Expiry is the most common
|
||||
* operational case and is reported first; then issuer, then audience, then anything else.
|
||||
*/
|
||||
private OperationalError higherPriority(OperationalError current, OperationalError candidate) {
|
||||
if (candidate == null) {
|
||||
return current;
|
||||
}
|
||||
if (current == null) {
|
||||
return candidate;
|
||||
}
|
||||
return rank(candidate) < rank(current) ? candidate : current;
|
||||
}
|
||||
|
||||
private int rank(OperationalError e) {
|
||||
return switch (e) {
|
||||
case AUTH_TOKEN_EXPIRED -> 0;
|
||||
case AUTH_ISSUER_MISMATCH -> 1;
|
||||
case AUTH_AUDIENCE_MISMATCH -> 2;
|
||||
default -> 3;
|
||||
};
|
||||
}
|
||||
|
||||
private String describe(OAuth2Error error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String description = error.getDescription();
|
||||
return description != null ? description : error.getErrorCode();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Web-adapter implementation of the application {@link AuthorizationPort}.
|
||||
*
|
||||
* <p>Resolves the caller's raw roles to an effective permission set via {@link
|
||||
* RolePermissionRegistry} and denies (with {@link AuthorizationDeniedException}) when the required
|
||||
* permission is absent. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationAdapter implements AuthorizationPort {
|
||||
|
||||
private final RolePermissionRegistry registry;
|
||||
|
||||
public AuthorizationAdapter(RolePermissionRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requirePermission(AuthorizationPrincipal principal, Permission required) {
|
||||
Set<Permission> effective = registry.effectivePermissions(principal.roles());
|
||||
if (!effective.contains(required)) {
|
||||
throw new AuthorizationDeniedException(principal.subject(), required);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.Pointcut;
|
||||
import org.springframework.aop.support.Pointcuts;
|
||||
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Role;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
|
||||
/**
|
||||
* Wires the {@link RequiresPermission} enforcement point into Spring method security.
|
||||
*
|
||||
* <p>{@code @EnableMethodSecurity(prePostEnabled = false)} enables the method-security
|
||||
* infrastructure without the {@code @PreAuthorize}/{@code @PostAuthorize} interceptors, leaving the
|
||||
* custom advisor below as the only authorization advice. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableMethodSecurity(prePostEnabled = false)
|
||||
public class MethodSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
|
||||
AuthorizationManager<MethodInvocation> manager =
|
||||
new RequiresPermissionAuthorizationManager(authorizationPort);
|
||||
|
||||
Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
|
||||
Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class);
|
||||
Pointcut pointcut = Pointcuts.union(onMethod, onClass);
|
||||
|
||||
return new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
/**
|
||||
* Spring-aware enforcement mechanism for {@link RequiresPermission}.
|
||||
*
|
||||
* <p>Reads the {@link RequiresPermission} annotation off the intercepted method (or its declaring
|
||||
* type), maps the current {@link Authentication} to the framework-free {@link
|
||||
* AuthorizationPrincipal}, and delegates the decision to the application {@link AuthorizationPort}.
|
||||
* A denial from the port becomes a denied {@link AuthorizationDecision}; an absent annotation
|
||||
* returns {@code null} to abstain. See README for the design rationale.
|
||||
*/
|
||||
public final class RequiresPermissionAuthorizationManager
|
||||
implements AuthorizationManager<MethodInvocation> {
|
||||
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) {
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthorizationResult authorize(
|
||||
Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||
RequiresPermission annotation = findAnnotation(invocation);
|
||||
if (annotation == null) {
|
||||
return null; // not guarded by this manager — abstain
|
||||
}
|
||||
Permission required = Permission.parse(annotation.value());
|
||||
|
||||
Authentication auth = authentication.get();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
try {
|
||||
authorizationPort.requirePermission(toPrincipal(auth), required);
|
||||
return new AuthorizationDecision(true);
|
||||
} catch (AuthorizationDeniedException denied) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
}
|
||||
|
||||
private RequiresPermission findAnnotation(MethodInvocation invocation) {
|
||||
Method method = invocation.getMethod();
|
||||
RequiresPermission onMethod = AnnotationUtils.findAnnotation(method, RequiresPermission.class);
|
||||
if (onMethod != null) {
|
||||
return onMethod;
|
||||
}
|
||||
Class<?> targetClass =
|
||||
invocation.getThis() != null
|
||||
? AopUtils.getTargetClass(invocation.getThis())
|
||||
: method.getDeclaringClass();
|
||||
return AnnotationUtils.findAnnotation(targetClass, RequiresPermission.class);
|
||||
}
|
||||
|
||||
private AuthorizationPrincipal toPrincipal(Authentication auth) {
|
||||
if (auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return new AuthorizationPrincipal(user.idpUserId(), user.roles());
|
||||
}
|
||||
// Any other principal type carries no resolvable roles → fail-closed.
|
||||
return new AuthorizationPrincipal(auth.getName(), Set.of());
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* App-side role→permission mapping source.
|
||||
*
|
||||
* <p>Bound from {@code ca-skeleton.authz.role-permissions.<role> = [resource:action, ...]}. Keys
|
||||
* are <em>raw</em> IdP role names (no {@code ROLE_} prefix), e.g.:
|
||||
*
|
||||
* <pre>
|
||||
* ca-skeleton:
|
||||
* authz:
|
||||
* role-permissions:
|
||||
* user: [worklog:read, worklog:write]
|
||||
* admin: [worklog:read, worklog:write, worklog:close]
|
||||
* </pre>
|
||||
*
|
||||
* <p>See README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.authz")
|
||||
public record RolePermissionPolicy(Map<String, List<String>> rolePermissions) {
|
||||
|
||||
public RolePermissionPolicy {
|
||||
rolePermissions =
|
||||
rolePermissions == null
|
||||
? Map.of()
|
||||
: rolePermissions.entrySet().stream()
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey, entry -> List.copyOf(entry.getValue())));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Resolves a caller's raw roles to an effective {@link Permission} set.
|
||||
*
|
||||
* <p>Built once from {@link RolePermissionPolicy} at startup. Role keys are normalized to lower
|
||||
* case so a lookup is case-insensitive. Permissions are the explicitly enumerated set per role;
|
||||
* wildcards are unsupported. An unknown role, an empty role set, or an empty registry all resolve
|
||||
* to zero permissions. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
public class RolePermissionRegistry {
|
||||
|
||||
private final Map<String, Set<Permission>> permissionsByRole;
|
||||
|
||||
public RolePermissionRegistry(RolePermissionPolicy properties) {
|
||||
Map<String, Set<Permission>> resolved = new HashMap<>();
|
||||
properties
|
||||
.rolePermissions()
|
||||
.forEach(
|
||||
(role, tokens) -> {
|
||||
Set<Permission> permissions =
|
||||
tokens.stream().map(Permission::parse).collect(Collectors.toUnmodifiableSet());
|
||||
resolved.put(normalize(role), permissions);
|
||||
});
|
||||
this.permissionsByRole = Map.copyOf(resolved);
|
||||
}
|
||||
|
||||
/** Union of the permissions granted by each of {@code roles}; empty if none/unknown. */
|
||||
public Set<Permission> effectivePermissions(Set<String> roles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return roles.stream()
|
||||
.filter(role -> role != null && !role.isBlank())
|
||||
.map(role -> permissionsByRole.getOrDefault(normalize(role), Set.of()))
|
||||
.flatMap(Set::stream)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
return role.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Weak-ETag derivation and conditional-request matching for HTTP-layer optimistic concurrency /
|
||||
* cache validation. See README for the design rationale.
|
||||
*
|
||||
* <p>An entity's optimistic-lock version is the ETag source: {@link #weakFromVersion} yields {@code
|
||||
* W/"<version>"}. Reads emit it as the {@code ETag} header; a write carrying {@code If-Match} is
|
||||
* accepted only when {@link #matches} is {@code true}, otherwise the controller raises {@link
|
||||
* PreconditionFailedException} (→ 412); a read carrying {@code If-None-Match} that {@link #matches}
|
||||
* returns 304 (no body).
|
||||
*/
|
||||
public final class ETags {
|
||||
|
||||
private static final String WILDCARD = "*";
|
||||
|
||||
private ETags() {}
|
||||
|
||||
/** {@code W/"<version>"} weak validator from an optimistic-lock version. */
|
||||
public static String weakFromVersion(long version) {
|
||||
return "W/\"" + version + "\"";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient conditional match: {@code true} when {@code header} is {@code *} or any comma-separated
|
||||
* candidate's opaque value equals {@code etag}'s opaque value. Null/blank header → {@code false}
|
||||
* (no precondition supplied).
|
||||
*/
|
||||
public static boolean matches(
|
||||
String header, String etag) { // e.g. If-None-Match: W/"3" on reads, If-Match: W/"3" on writes
|
||||
if (header == null || header.isBlank() || etag == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = header.trim();
|
||||
if (WILDCARD.equals(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
String target = opaque(etag);
|
||||
for (String candidate : trimmed.split(",")) {
|
||||
if (opaque(candidate).equals(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Strips the {@code W/} weak marker and surrounding double quotes. */
|
||||
private static String opaque(String raw) {
|
||||
String v = raw.trim();
|
||||
if (v.startsWith("W/")) {
|
||||
v = v.substring(2).trim();
|
||||
}
|
||||
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
/**
|
||||
* Raised when a write request's {@code If-Match} validator does not match the current resource
|
||||
* ETag. The global handler maps it to {@code OperationalError.PRECONDITION_FAILED} (HTTP 412). See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class PreconditionFailedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PreconditionFailedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import org.openapitools.jackson.nullable.JsonNullable;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.databind.BeanProperty;
|
||||
import tools.jackson.databind.DeserializationContext;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.ValueDeserializer;
|
||||
import tools.jackson.databind.ValueSerializer;
|
||||
import tools.jackson.databind.module.SimpleModule;
|
||||
|
||||
/**
|
||||
* Registers Jackson 3 handlers for {@link JsonNullable}. The upstream jackson-databind-nullable
|
||||
* module is still Jackson 2 based, so the template keeps a narrow local adapter for PATCH request
|
||||
* DTOs.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonNullableConfig {
|
||||
|
||||
@Bean
|
||||
public SimpleModule jsonNullableModule() {
|
||||
SimpleModule module = new SimpleModule("JsonNullableJackson3Module");
|
||||
module.addDeserializer(JsonNullable.class, new JsonNullableValueDeserializer());
|
||||
addJsonNullableSerializer(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static void addJsonNullableSerializer(SimpleModule module) {
|
||||
module.addSerializer((Class) JsonNullable.class, new JsonNullableValueSerializer());
|
||||
}
|
||||
|
||||
static final class JsonNullableValueDeserializer extends ValueDeserializer<JsonNullable<Object>> {
|
||||
|
||||
private final JavaType valueType;
|
||||
private final ValueDeserializer<Object> valueDeserializer;
|
||||
|
||||
JsonNullableValueDeserializer() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
private JsonNullableValueDeserializer(
|
||||
JavaType valueType, ValueDeserializer<Object> valueDeserializer) {
|
||||
this.valueType = valueType;
|
||||
this.valueDeserializer = valueDeserializer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValueDeserializer<?> createContextual(
|
||||
DeserializationContext ctxt, BeanProperty property) {
|
||||
JavaType contextualType =
|
||||
property == null ? ctxt.constructType(Object.class) : property.getType();
|
||||
JavaType referencedType =
|
||||
contextualType.containedTypeCount() == 0
|
||||
? ctxt.constructType(Object.class)
|
||||
: contextualType.containedTypeOrUnknown(0);
|
||||
return new JsonNullableValueDeserializer(
|
||||
referencedType, ctxt.findContextualValueDeserializer(referencedType, property));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNullable<Object> deserialize(JsonParser parser, DeserializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (parser.currentToken() == JsonToken.VALUE_NULL) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
Object value =
|
||||
valueDeserializer == null
|
||||
? ctxt.readValue(
|
||||
parser, valueType == null ? ctxt.constructType(Object.class) : valueType)
|
||||
: valueDeserializer.deserialize(parser, ctxt);
|
||||
return JsonNullable.of(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNullValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.of(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getAbsentValue(DeserializationContext ctxt) {
|
||||
return JsonNullable.undefined();
|
||||
}
|
||||
}
|
||||
|
||||
static final class JsonNullableValueSerializer extends ValueSerializer<JsonNullable<Object>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonNullable<Object> value, JsonGenerator generator, SerializationContext ctxt)
|
||||
throws JacksonException {
|
||||
if (value == null || !value.isPresent()) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
ctxt.writeValue(generator, value.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.web.config;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class PresentationWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final PresentationSettings settings;
|
||||
|
||||
public PresentationWebConfig(PresentationSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
String prefix = settings.apiBasePath();
|
||||
if (prefix == null || prefix.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
configurer.addPathPrefix(prefix, c -> true);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.controller;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/healthcheck")
|
||||
public class HealthcheckController {
|
||||
|
||||
@GetMapping
|
||||
public Envelope<Map<String, String>> healthcheck() {
|
||||
return Envelope.ok(Map.of("status", "UP"), ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Opaque, signed, time-bounded pagination cursor codec. A cursor is {@code base64url(iat + ":" +
|
||||
* payload)} plus an HMAC-SHA256 signature, so it is URL-safe, tamper-evident, and expires after a
|
||||
* fixed TTL (24h). Clients MUST treat the token as opaque. See README for the design rationale.
|
||||
*/
|
||||
public final class CursorCodec {
|
||||
|
||||
/** Default cursor TTL (24h). */
|
||||
public static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
||||
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final char SEP = '.';
|
||||
private static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DEC = Base64.getUrlDecoder();
|
||||
|
||||
private final byte[] key;
|
||||
private final Duration ttl;
|
||||
|
||||
public CursorCodec(byte[] key, Duration ttl) {
|
||||
if (key == null || key.length < 16) {
|
||||
throw new IllegalArgumentException("cursor HMAC key must be at least 16 bytes");
|
||||
}
|
||||
this.key = key.clone();
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
/** Dev / test factory — NOT for production. */
|
||||
public static CursorCodec withDevKey() {
|
||||
return new CursorCodec(
|
||||
"ca-skeleton-dev-cursor-key-0001".getBytes(StandardCharsets.UTF_8), DEFAULT_TTL);
|
||||
}
|
||||
|
||||
/** Encodes an opaque payload string + issue instant into a signed URL-safe token. */
|
||||
public String encode(String payload, Instant issuedAt) {
|
||||
String body = issuedAt.getEpochSecond() + ":" + payload;
|
||||
String b64Body = ENC.encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return b64Body + SEP + ENC.encodeToString(sign(b64Body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies signature + TTL and returns the original payload, or throws {@link CursorException}.
|
||||
*/
|
||||
public String decode(String token, Instant now) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new CursorException("cursor token is missing");
|
||||
}
|
||||
int dot = token.lastIndexOf(SEP);
|
||||
if (dot <= 0 || dot == token.length() - 1) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
String b64Body = token.substring(0, dot);
|
||||
byte[] presented;
|
||||
byte[] expected;
|
||||
String body;
|
||||
try {
|
||||
presented = DEC.decode(token.substring(dot + 1));
|
||||
expected = sign(b64Body);
|
||||
body = new String(DEC.decode(b64Body), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new CursorException("cursor token is malformed");
|
||||
}
|
||||
if (!MessageDigest.isEqual(expected, presented)) {
|
||||
throw new CursorException("cursor token signature is invalid");
|
||||
}
|
||||
int colon = body.indexOf(':');
|
||||
if (colon < 0) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
long issuedAtEpoch;
|
||||
try {
|
||||
issuedAtEpoch = Long.parseLong(body.substring(0, colon));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new CursorException("cursor token payload is malformed");
|
||||
}
|
||||
if (now.getEpochSecond() - issuedAtEpoch > ttl.toSeconds()) {
|
||||
throw new CursorException("cursor token has expired");
|
||||
}
|
||||
return body.substring(colon + 1);
|
||||
}
|
||||
|
||||
private byte[] sign(String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, HMAC_ALGORITHM));
|
||||
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HMAC computation failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
/**
|
||||
* Raised when an opaque pagination cursor fails verification — tampered HMAC signature, malformed
|
||||
* encoding, or expired TTL. Controllers map it to 400 VALIDATION_FAILED and advise re-requesting
|
||||
* the first page.
|
||||
*/
|
||||
public class CursorException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CursorException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.response.BulkEnvelope;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
/**
|
||||
* Wraps every JSON controller response in {@link Envelope} unless it is already an envelope
|
||||
* variant, so the wire shape is always {@code {success, data | error, traceId}}. See README for the
|
||||
* design rationale.
|
||||
*
|
||||
* <p>Skipped: already-{@link Envelope}/{@link BulkEnvelope} bodies, null/void (DELETE 204),
|
||||
* non-JSON content types.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class EnvelopeBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Override
|
||||
public boolean supports(
|
||||
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
if (body instanceof Envelope<?> || body instanceof BulkEnvelope<?>) {
|
||||
return body;
|
||||
}
|
||||
if (selectedContentType != null && !MediaType.APPLICATION_JSON.includes(selectedContentType)) {
|
||||
return body;
|
||||
}
|
||||
return Envelope.ok(body, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
|
||||
final class ClientSafeErrorMessages {
|
||||
|
||||
private ClientSafeErrorMessages() {}
|
||||
|
||||
static String forPersistence(Category category) {
|
||||
return switch (category) {
|
||||
case TRANSIENT_DEPENDENCY -> "Service temporarily unavailable, please retry later";
|
||||
case CONFLICT -> "Request conflicted with the current state, please retry";
|
||||
case DATA_INTEGRITY -> "Request violates a data constraint";
|
||||
default -> "Internal server error";
|
||||
};
|
||||
}
|
||||
|
||||
static String forDependency(ApiErrorCode code) {
|
||||
return switch (code.code()) {
|
||||
case "DEPENDENCY_TIMEOUT" -> "Upstream service did not respond in time, please retry";
|
||||
case "DEPENDENCY_CONNECT_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_DNS_FAILED" -> "Upstream service unreachable, please retry";
|
||||
case "DEPENDENCY_4XX_CLIENT" -> "Upstream service rejected the request";
|
||||
case "DEPENDENCY_5XX_SERVER" -> "Upstream service error, please retry";
|
||||
case "DEPENDENCY_CIRCUIT_OPEN" ->
|
||||
"Upstream service temporarily unavailable, please retry later";
|
||||
default -> forPersistence(code.category());
|
||||
};
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.response.ApiError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
/**
|
||||
* Builds a failure {@link Envelope} response from any {@link ApiErrorCode}, mapping the
|
||||
* framework-neutral {@code int httpStatus()} to Spring {@link HttpStatus}, carrying {@code
|
||||
* error.category}, and lifting the {@code meta} object (request/trace/correlation ids) from MDC via
|
||||
* {@link ResponseMetaFactory}.
|
||||
*/
|
||||
public final class ErrorResponseFactory {
|
||||
|
||||
private ErrorResponseFactory() {}
|
||||
|
||||
public static ResponseEntity<Envelope<Void>> envelope(
|
||||
ApiErrorCode code, String message, Object details) {
|
||||
return ResponseEntity.status(HttpStatus.valueOf(code.httpStatus()))
|
||||
.body(body(code, message, details));
|
||||
}
|
||||
|
||||
/** Body-only variant for ResponseEntityExceptionHandler hooks that set status separately. */
|
||||
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
|
||||
ApiError err =
|
||||
details == null
|
||||
? ApiError.of(code.code(), code.category().name(), message, code.retryable())
|
||||
: ApiError.withDetails(
|
||||
code.code(), code.category().name(), message, code.retryable(), details);
|
||||
return Envelope.failure(err, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
package dev.caskeleton.adapter.inbound.web.error;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.SecurityErrorClassifier;
|
||||
import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException;
|
||||
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyInFlightException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.error.PersistenceFailureException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
/**
|
||||
* Skeleton-wide base error → {@link Envelope} converter. Handles operational, transport, and
|
||||
* security exceptions only; domain exceptions are handled by a separate
|
||||
* {@code @RestControllerAdvice} in the consuming module. See README for the design rationale.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** Stateless classifier shared with the filter-layer access-denied handler. */
|
||||
private static final SecurityErrorClassifier ACCESS_DENIED_CLASSIFIER =
|
||||
new SecurityErrorClassifier();
|
||||
|
||||
/** Records span errors via a tracer-neutral seam (default {@link SpanErrorRecorder#NOOP}). */
|
||||
private final SpanErrorRecorder spanErrorRecorder;
|
||||
|
||||
/**
|
||||
* Spring entry point. Self-defaults to {@link SpanErrorRecorder#NOOP} when no {@code
|
||||
* SpanErrorRecorder} bean is present. See README for the design rationale.
|
||||
*/
|
||||
@Autowired
|
||||
public GlobalExceptionHandler(ObjectProvider<SpanErrorRecorder> spanErrorRecorderProvider) {
|
||||
this(spanErrorRecorderProvider.getIfAvailable(() -> SpanErrorRecorder.NOOP));
|
||||
}
|
||||
|
||||
/** Direct constructor for tests and explicit wiring (e.g. a capturing recorder). */
|
||||
public GlobalExceptionHandler(SpanErrorRecorder spanErrorRecorder) {
|
||||
this.spanErrorRecorder = spanErrorRecorder;
|
||||
}
|
||||
|
||||
@ExceptionHandler(MappingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fail-fast for a disabled optional adapter that was invoked → 500 {@link
|
||||
* OperationalError#ADAPTER_DISABLED}. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AdapterDisabledException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleAdapterDisabled(AdapterDisabledException ex) {
|
||||
log.error(
|
||||
"disabled optional adapter invoked at runtime: adapter={} (Layer 3 fail-fast)",
|
||||
ex.adapterName(),
|
||||
ex);
|
||||
return ErrorResponseFactory.envelope(OperationalError.ADAPTER_DISABLED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.BAD_PARAMETER, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
List<Map<String, Object>> violations =
|
||||
ex.getConstraintViolations().stream()
|
||||
.map(
|
||||
v ->
|
||||
Map.<String, Object>of(
|
||||
"field", v.getPropertyPath().toString(),
|
||||
"message", v.getMessage()))
|
||||
.toList();
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, "Request validation failed", violations);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
Map<String, Object> details =
|
||||
ex.getRequiredType() == null
|
||||
? null
|
||||
: Map.of("expectedType", ex.getRequiredType().getSimpleName());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.BAD_PARAMETER,
|
||||
"Parameter '" + ex.getName() + "' has invalid value '" + ex.getValue() + "'",
|
||||
details);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidBearerTokenException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.INVALID_TOKEN, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.UNAUTHENTICATED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a method-security {@link AccessDeniedException} that escaped the controller, delegating
|
||||
* to {@link SecurityErrorClassifier} for the fine-grained authorization code. See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleForbidden(AccessDeniedException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex), ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/** Handles a failed {@code If-Match} precondition → 412 PRECONDITION_FAILED. */
|
||||
@ExceptionHandler(PreconditionFailedException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePreconditionFailed(PreconditionFailedException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.PRECONDITION_FAILED, ex.getMessage(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an out-of-bounds pagination / sort / filter parameter → 400 VALIDATION_FAILED carrying
|
||||
* the offending field + reason code.
|
||||
*/
|
||||
@ExceptionHandler(PageValidationException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePageValidation(PageValidationException ex) {
|
||||
Map<String, Object> details = Map.of("field", ex.field(), "code", ex.reasonCode());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED, ex.getMessage(), details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a tampered / expired / malformed opaque cursor → 400 VALIDATION_FAILED advising the
|
||||
* client to re-request the first page.
|
||||
*/
|
||||
@ExceptionHandler(CursorException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleCursor(CursorException ex) {
|
||||
Map<String, Object> details = Map.of("field", "cursor", "code", "CURSOR_INVALID");
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
ex.getMessage() + "; re-request the first page",
|
||||
details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a duplicate request whose original is still in flight → 409 IDEMPOTENT_IN_FLIGHT with a
|
||||
* fixed client-safe message. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyInFlightException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentInFlight(IdempotencyInFlightException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_IN_FLIGHT,
|
||||
"A previous identical request is still being processed, please poll for result",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an {@code Idempotency-Key} reused with a different body → 422
|
||||
* IDEMPOTENT_REQUEST_MISMATCH with a client-safe message only.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyRequestMismatchException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotentMismatch(
|
||||
IdempotencyRequestMismatchException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.IDEMPOTENT_REQUEST_MISMATCH,
|
||||
"Idempotency key reused with different request body",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an idempotency key applied without a resolvable scope → 400 VALIDATION_FAILED. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(IdempotencyScopeMissingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleIdempotencyScopeMissing(
|
||||
IdempotencyScopeMissingException ex) {
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.VALIDATION_FAILED,
|
||||
"Idempotency key cannot be applied to this request",
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link PersistenceFailureException}: its {@link
|
||||
* PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* category-derived safe string and the raw cause is logged server-side. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@ExceptionHandler(PersistenceFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"persistence failure classified as {} (category={}, retryable={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable(),
|
||||
ex);
|
||||
spanErrorRecorder.recordException(ex, code.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pre-classified {@link DependencyFailureException}: its {@link
|
||||
* DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a
|
||||
* per-code safe string and a {@code Retry-After} header is attached via {@link RetryAfterAdvisor}
|
||||
* when applicable. See README for the design rationale.
|
||||
*/
|
||||
@ExceptionHandler(DependencyFailureException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleDependencyFailure(DependencyFailureException ex) {
|
||||
ApiErrorCode code = ex.errorCode();
|
||||
log.error(
|
||||
"dependency failure classified as {} (category={}, retryable={}, dependency={})",
|
||||
code.code(),
|
||||
code.category(),
|
||||
code.retryable(),
|
||||
ex.dependencyName(),
|
||||
ex);
|
||||
spanErrorRecorder.recordException(ex, code.code());
|
||||
String message = ClientSafeErrorMessages.forDependency(code);
|
||||
Envelope<Void> body = ErrorResponseFactory.body(code, message, null);
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.status(code.httpStatus());
|
||||
RetryAfterAdvisor.retryAfterSeconds(code)
|
||||
.ifPresent(seconds -> builder.header(ApiHeaders.RETRY_AFTER, String.valueOf(seconds)));
|
||||
return builder.body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Envelope<Void>> handleUnknown(Exception ex, WebRequest req) {
|
||||
log.error("unhandled exception on {}", req.getDescription(false), ex);
|
||||
spanErrorRecorder.recordException(ex, OperationalError.INTERNAL_ERROR.code());
|
||||
return ErrorResponseFactory.envelope(
|
||||
OperationalError.INTERNAL_ERROR, "Internal server error", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
List<Map<String, Object>> fields =
|
||||
ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(
|
||||
fe -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("field", fe.getField());
|
||||
m.put("rejectedValue", String.valueOf(fe.getRejectedValue()));
|
||||
m.put("message", fe.getDefaultMessage());
|
||||
return m;
|
||||
})
|
||||
.toList();
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body failed validation", fields),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMessageNotReadable(
|
||||
HttpMessageNotReadableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
Map<String, Object> details =
|
||||
ex.getCause() != null ? Map.of("cause", ex.getCause().getClass().getSimpleName()) : null;
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.VALIDATION_FAILED, "Request body is malformed or unparsable", details),
|
||||
HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
|
||||
HttpRequestMethodNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 405 carries the `Allow` header listing the supported methods.
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
if (ex.getSupportedHttpMethods() != null) {
|
||||
responseHeaders.setAllow(new LinkedHashSet<>(ex.getSupportedHttpMethods()));
|
||||
}
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedHttpMethods() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMethods",
|
||||
ex.getSupportedHttpMethods().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.METHOD_NOT_ALLOWED,
|
||||
"HTTP method " + ex.getMethod() + " not allowed for this route",
|
||||
details),
|
||||
responseHeaders,
|
||||
HttpStatusCode.valueOf(OperationalError.METHOD_NOT_ALLOWED.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 415: request body format unsupported.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.UNSUPPORTED_MEDIA_TYPE,
|
||||
"Content-Type " + ex.getContentType() + " is not supported",
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.UNSUPPORTED_MEDIA_TYPE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMaxUploadSizeExceededException(
|
||||
MaxUploadSizeExceededException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// An oversized request body classifies as 413 inside the envelope.
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.PAYLOAD_TOO_LARGE,
|
||||
"Request payload exceeds the maximum allowed size",
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.PAYLOAD_TOO_LARGE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleHttpMediaTypeNotAcceptable(
|
||||
HttpMediaTypeNotAcceptableException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
// 406: no representation matches the Accept header.
|
||||
Map<String, Object> details =
|
||||
ex.getSupportedMediaTypes() == null
|
||||
? null
|
||||
: Map.of(
|
||||
"supportedMediaTypes",
|
||||
ex.getSupportedMediaTypes().stream().map(Object::toString).toList());
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.NOT_ACCEPTABLE,
|
||||
"No acceptable representation for the requested Accept header",
|
||||
details),
|
||||
HttpStatusCode.valueOf(OperationalError.NOT_ACCEPTABLE.httpStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleNoHandlerFoundException(
|
||||
NoHandlerFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
|
||||
return new ResponseEntity<>(
|
||||
ErrorResponseFactory.body(
|
||||
OperationalError.ROUTE_NOT_FOUND,
|
||||
"No handler for " + ex.getHttpMethod() + " " + ex.getRequestURL(),
|
||||
null),
|
||||
HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus()));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Applies the skeleton's default HTTP cache policy: every response gets {@code Cache-Control:
|
||||
* no-store} and a {@code Vary: Accept, Accept-Encoding, Authorization} header. Defaults are set
|
||||
* before the chain so a cacheable endpoint can opt in by returning a {@code ResponseEntity} whose
|
||||
* {@code Cache-Control} header overwrites the default. See README for the design rationale.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
||||
public class CacheControlFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String DEFAULT_CACHE_CONTROL = "no-store";
|
||||
static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
|
||||
response.setHeader(ApiHeaders.VARY, DEFAULT_VARY);
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package dev.caskeleton.adapter.inbound.web.filter;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.HeaderSanitizer;
|
||||
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
||||
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
|
||||
import dev.caskeleton.shared.tracing.TraceParent;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
/**
|
||||
* Logs one structured line per HTTP request and threads the correlation ids onto MDC using the
|
||||
* snake_case keys of {@link MdcKeys}. Inbound {@code X-Request-Id} / {@code X-Correlation-Id} are
|
||||
* sanitized and length-capped before use; absent/blank values are server-generated. A valid inbound
|
||||
* W3C {@code traceparent} is adopted onto MDC ({@code trace_id} / {@code span_id}), otherwise a
|
||||
* fresh ROOT traceparent is generated; the resolved value is set on the response header. {@code
|
||||
* user_principal} is pseudonymized via {@link UserPrincipalPseudonymizerPort} before being placed
|
||||
* on MDC.
|
||||
*
|
||||
* <p>See README for the design rationale, including the fork landmine around the generated {@code
|
||||
* trace_id} when a real tracer is later wired in.
|
||||
*/
|
||||
@Component
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
|
||||
private static final String HEADER_REQUEST_ID = "X-Request-Id";
|
||||
private static final String HEADER_CORRELATION_ID = "X-Correlation-Id";
|
||||
private static final String HEADER_TRACEPARENT = "traceparent";
|
||||
private static final int MAX_ID_LENGTH = 200;
|
||||
|
||||
private final UserPrincipalPseudonymizerPort pseudonymizer;
|
||||
|
||||
public RequestLoggingFilter(UserPrincipalPseudonymizerPort pseudonymizer) {
|
||||
this.pseudonymizer = pseudonymizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String requestId = resolveOrGenerate(req.getHeader(HEADER_REQUEST_ID));
|
||||
String correlationId = resolveOrGenerate(req.getHeader(HEADER_CORRELATION_ID));
|
||||
res.setHeader(HEADER_REQUEST_ID, requestId);
|
||||
res.setHeader(HEADER_CORRELATION_ID, correlationId);
|
||||
|
||||
MDC.put(MdcKeys.REQUEST_ID, requestId);
|
||||
MDC.put(MdcKeys.CORRELATION_ID, correlationId);
|
||||
|
||||
// W3C traceparent: adopt inbound if valid, otherwise generate a fresh ROOT.
|
||||
TraceParent traceParent = resolveOrGenerateTraceParent(req.getHeader(HEADER_TRACEPARENT));
|
||||
MDC.put(MdcKeys.TRACE_ID, traceParent.traceId());
|
||||
MDC.put(MdcKeys.SPAN_ID, traceParent.spanId());
|
||||
res.setHeader(HEADER_TRACEPARENT, traceParent.toHeader());
|
||||
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
putUserPrincipalIfAvailable();
|
||||
long durationMs = (System.nanoTime() - start) / 1_000_000L;
|
||||
log.info(
|
||||
"http_request method={} uri_template={} status={} duration_ms={}",
|
||||
req.getMethod(),
|
||||
resolveUriTemplate(req),
|
||||
res.getStatus(),
|
||||
durationMs);
|
||||
MDC.remove(MdcKeys.REQUEST_ID);
|
||||
MDC.remove(MdcKeys.CORRELATION_ID);
|
||||
MDC.remove(MdcKeys.TRACE_ID);
|
||||
MDC.remove(MdcKeys.SPAN_ID);
|
||||
MDC.remove(MdcKeys.USER_PRINCIPAL);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanitize an inbound id header; generate a fresh one when absent/blank. */
|
||||
private static String resolveOrGenerate(String inbound) {
|
||||
String clean = HeaderSanitizer.sanitize(inbound, MAX_ID_LENGTH);
|
||||
return (clean == null || clean.isBlank()) ? UUID.randomUUID().toString() : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a W3C {@code traceparent} for this request: adopts a present, valid inbound header
|
||||
* unchanged, otherwise generates a fresh ROOT traceparent (32-hex {@code traceId}, 16-hex {@code
|
||||
* spanId}, {@code sampled=false}).
|
||||
*/
|
||||
private static TraceParent resolveOrGenerateTraceParent(String header) {
|
||||
return TraceParent.parse(header)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
String traceId = freshHex32();
|
||||
String spanId = freshHex16();
|
||||
return TraceParent.of(traceId, spanId, false);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns a 32-char lowercase hex string from a random UUID (dashes stripped). */
|
||||
private static String freshHex32() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 16-char lowercase hex span id (64 random bits), derived from a fresh UUID's
|
||||
* least-significant bits and zero-padded. See README for the design rationale.
|
||||
*/
|
||||
private static String freshHex16() {
|
||||
return String.format("%016x", UUID.randomUUID().getLeastSignificantBits());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the low-cardinality matched route template ({@code BEST_MATCHING_PATTERN_ATTRIBUTE}),
|
||||
* falling back to the raw request URI for unmatched requests (e.g. 404s).
|
||||
*/
|
||||
private static String resolveUriTemplate(HttpServletRequest req) {
|
||||
Object pattern = req.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
return pattern instanceof String s ? s : req.getRequestURI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pseudonymizes the authenticated user's raw principal via {@link UserPrincipalPseudonymizerPort}
|
||||
* before placing the value on the {@code user_principal} MDC key. The raw {@code idpUserId()} is
|
||||
* never written to MDC or logs.
|
||||
*/
|
||||
private void putUserPrincipalIfAvailable() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
String pseudo = pseudonymizer.pseudonymize(user.idpUserId());
|
||||
if (pseudo != null) {
|
||||
MDC.put(MdcKeys.USER_PRINCIPAL, pseudo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.inbound.web.http;
|
||||
|
||||
/**
|
||||
* HTTP header-name constants used across the inbound web adapter. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public final class ApiHeaders {
|
||||
|
||||
// versioning + idempotency name
|
||||
public static final String X_API_VERSION = "X-Api-Version";
|
||||
public static final String IDEMPOTENCY_KEY = "Idempotency-Key";
|
||||
|
||||
// conditional requests
|
||||
public static final String ETAG = "ETag";
|
||||
public static final String IF_MATCH = "If-Match";
|
||||
public static final String IF_NONE_MATCH = "If-None-Match";
|
||||
|
||||
// cache policy
|
||||
public static final String CACHE_CONTROL = "Cache-Control";
|
||||
public static final String VARY = "Vary";
|
||||
|
||||
// method / negotiation / LRO
|
||||
public static final String ALLOW = "Allow";
|
||||
public static final String LOCATION = "Location";
|
||||
public static final String RETRY_AFTER = "Retry-After";
|
||||
|
||||
// rate-limit signaling
|
||||
public static final String X_RATELIMIT_LIMIT = "X-RateLimit-Limit";
|
||||
public static final String X_RATELIMIT_REMAINING = "X-RateLimit-Remaining";
|
||||
public static final String X_RATELIMIT_RESET = "X-RateLimit-Reset";
|
||||
|
||||
// deep-offset deprecation marker
|
||||
public static final String DEPRECATION = "Deprecation";
|
||||
|
||||
// always-emitted
|
||||
public static final String DATE = "Date";
|
||||
|
||||
private ApiHeaders() {}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.adapter.inbound.web.idempotency;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyScope;
|
||||
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
|
||||
import dev.caskeleton.application.idempotency.RequestFingerprint;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Web-side helper that assembles the inputs the application {@code IdempotencyExecutor} needs from
|
||||
* an HTTP request: the {@code Idempotency-Key} header, the scope (principal + use case name, tenant
|
||||
* seam), the request body fingerprint, and a JSON response codec. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Component
|
||||
public class IdempotencyKeySupport {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public IdempotencyKeySupport(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** The trimmed {@code Idempotency-Key} header value, or empty when absent/blank. */
|
||||
public Optional<String> idempotencyKey(HttpServletRequest request) {
|
||||
String value = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
|
||||
return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value.trim());
|
||||
}
|
||||
|
||||
/** Build the scope for the current authenticated caller (tenant left null). */
|
||||
public IdempotencyScope scope(String idempotencyKey, String useCaseName) {
|
||||
return IdempotencyScope.of(currentPrincipal(), idempotencyKey, useCaseName);
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprint of the serialized request payload. */
|
||||
public RequestFingerprint fingerprint(Object requestPayload) {
|
||||
try {
|
||||
return RequestFingerprint.ofSha256(objectMapper.writeValueAsBytes(requestPayload));
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to fingerprint idempotent request payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** A JSON codec for replaying a use case result of type {@code R}. */
|
||||
public <R> IdempotentResponseCodec<R> codec(Class<R> responseType) {
|
||||
return new JsonIdempotentResponseCodec<>(objectMapper, responseType);
|
||||
}
|
||||
|
||||
private static String currentPrincipal() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null
|
||||
&& auth.isAuthenticated()
|
||||
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return user.idpUserId();
|
||||
}
|
||||
return null; // IdempotencyScope.of → IdempotencyScopeMissingException (400)
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.adapter.inbound.web.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
|
||||
import dev.caskeleton.shared.error.MappingException;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Jackson-backed {@link IdempotentResponseCodec}: the web adapter owns the JSON wire format the
|
||||
* application executor stores and replays. A (de)serialization failure surfaces as {@link
|
||||
* MappingException}, which the base handler routes to {@code MAPPING_FAILED} 400. See README for
|
||||
* the design rationale.
|
||||
*
|
||||
* @param <R> the use case result type made idempotent
|
||||
*/
|
||||
public final class JsonIdempotentResponseCodec<R> implements IdempotentResponseCodec<R> {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JavaType type;
|
||||
|
||||
public JsonIdempotentResponseCodec(ObjectMapper objectMapper, Class<R> type) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.type = objectMapper.getTypeFactory().constructType(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String serialize(R result) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(result);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to serialize idempotent response for replay", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public R deserialize(String payload) {
|
||||
try {
|
||||
return objectMapper.readValue(payload, type);
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to deserialize stored idempotent response", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
/**
|
||||
* Neutralizes inbound header values before they reach MDC / logs by stripping every {@code \r},
|
||||
* {@code \n} and ASCII control char ({@code < 0x20}) and length-capping the result. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public final class HeaderSanitizer {
|
||||
|
||||
private HeaderSanitizer() {}
|
||||
|
||||
public static String sanitize(String raw, int maxLength) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(Math.min(raw.length(), maxLength));
|
||||
for (int i = 0; i < raw.length() && sb.length() < maxLength; i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (c >= 0x20) {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
/** snake_case MDC key names. See README for the design rationale. */
|
||||
public final class MdcKeys {
|
||||
|
||||
public static final String REQUEST_ID = "request_id";
|
||||
public static final String TRACE_ID = "trace_id";
|
||||
public static final String SPAN_ID = "span_id";
|
||||
public static final String CORRELATION_ID = "correlation_id";
|
||||
public static final String USER_PRINCIPAL = "user_principal";
|
||||
|
||||
private MdcKeys() {}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import dev.caskeleton.shared.response.ResponseMeta;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* Projects the snake_case MDC diagnostic keys onto the camelCase {@link ResponseMeta} wire object.
|
||||
* See README for the design rationale.
|
||||
*/
|
||||
public final class ResponseMetaFactory {
|
||||
|
||||
private ResponseMetaFactory() {}
|
||||
|
||||
public static ResponseMeta fromMdc() {
|
||||
return new ResponseMeta(
|
||||
MDC.get(MdcKeys.REQUEST_ID), MDC.get(MdcKeys.TRACE_ID), MDC.get(MdcKeys.CORRELATION_ID));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.inbound.web.observability;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
/**
|
||||
* Answers whether a retryable error code warrants a {@code Retry-After} header and supplies its
|
||||
* per-code value (seconds). See README for the design rationale.
|
||||
*/
|
||||
public final class RetryAfterAdvisor {
|
||||
|
||||
/** Per-code {@code Retry-After} values (seconds). */
|
||||
private static final Map<String, Integer> RETRY_AFTER_SECONDS =
|
||||
Map.of(
|
||||
"RATE_LIMIT_EXCEEDED", 1,
|
||||
"AUTH_KID_UNKNOWN", 5,
|
||||
"AUTH_JWKS_UNAVAILABLE", 30,
|
||||
"DEPENDENCY_TIMEOUT", 2,
|
||||
"DEPENDENCY_CONNECT_FAILED", 2,
|
||||
"DEPENDENCY_DNS_FAILED", 5,
|
||||
"DEPENDENCY_5XX_SERVER", 2,
|
||||
"DEPENDENCY_CIRCUIT_OPEN", 10);
|
||||
|
||||
private RetryAfterAdvisor() {}
|
||||
|
||||
/** True when a code's response should carry a {@code Retry-After} header. */
|
||||
public static boolean shouldAdvise(ApiErrorCode code) {
|
||||
return code.retryable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@code Retry-After} value (seconds) for a code, or empty when the code is
|
||||
* non-retryable or has no registry value.
|
||||
*/
|
||||
public static OptionalInt retryAfterSeconds(ApiErrorCode code) {
|
||||
if (!shouldAdvise(code)) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
Integer seconds = RETRY_AFTER_SECONDS.get(code.code());
|
||||
return seconds == null ? OptionalInt.empty() : OptionalInt.of(seconds);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
import dev.caskeleton.shared.response.PageMeta;
|
||||
|
||||
/**
|
||||
* Validated offset-pagination parameters. See README for the design rationale.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code page} 0-indexed; {@code page < 0} → {@link PageValidationException}.
|
||||
* <li>{@code size} default 20, min 1, max 100; out of range → {@link PageValidationException}
|
||||
* (mapped to 400 VALIDATION_FAILED).
|
||||
* <li>{@link #isDeepOffset()} flags {@code page > 10000} so the controller can emit a {@code
|
||||
* Deprecation} header and recommend cursor pagination.
|
||||
* </ul>
|
||||
*/
|
||||
public record PageParams(int page, int size) {
|
||||
|
||||
public static final int DEFAULT_SIZE = 20;
|
||||
public static final int MIN_SIZE = 1;
|
||||
public static final int MAX_SIZE = 100;
|
||||
public static final int DEEP_OFFSET_THRESHOLD = 10000;
|
||||
|
||||
public static PageParams of(Integer page, Integer size) {
|
||||
int p = page == null ? 0 : page;
|
||||
int s = size == null ? DEFAULT_SIZE : size;
|
||||
if (p < 0) {
|
||||
throw new PageValidationException("page", "PAGE_NEGATIVE", "page must be >= 0");
|
||||
}
|
||||
if (s < MIN_SIZE) {
|
||||
throw new PageValidationException("size", "SIZE_BELOW_MIN", "size must be >= " + MIN_SIZE);
|
||||
}
|
||||
if (s > MAX_SIZE) {
|
||||
throw new PageValidationException("size", "SIZE_EXCEEDS_MAX", "size must be <= " + MAX_SIZE);
|
||||
}
|
||||
return new PageParams(p, s);
|
||||
}
|
||||
|
||||
public boolean isDeepOffset() {
|
||||
return page > DEEP_OFFSET_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Builds the response {@code meta.page} object for the given total + applied sort. */
|
||||
public PageMeta toPageMeta(long total, String sort) {
|
||||
return new PageMeta(page, size, total, sort);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
/**
|
||||
* Raised when a pagination / sort request parameter violates its bounds (e.g. {@code size > 100},
|
||||
* {@code page < 0}, non-native sort syntax). The global handler maps it to {@code
|
||||
* OperationalError.VALIDATION_FAILED} (HTTP 400) and surfaces {@code error.details.{field,code}} so
|
||||
* a client can correct the exact parameter.
|
||||
*/
|
||||
public class PageValidationException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String field;
|
||||
private final String reasonCode;
|
||||
|
||||
public PageValidationException(String field, String reasonCode, String message) {
|
||||
super(message);
|
||||
this.field = field;
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public String field() {
|
||||
return field;
|
||||
}
|
||||
|
||||
public String reasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.inbound.web.pagination;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A single sort term in Spring {@code Pageable} native syntax {@code field,direction} (e.g. {@code
|
||||
* createdAt,desc}). Direction is optional and defaults to {@code asc}. Non-native syntaxes are
|
||||
* rejected with {@link PageValidationException} (→ 400 VALIDATION_FAILED); multi-sort is expressed
|
||||
* by repeating the {@code sort} query parameter, parsed term-by-term. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public record SortParam(String field, boolean ascending) {
|
||||
|
||||
public static SortParam parse(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new PageValidationException("sort", "SORT_EMPTY", "sort term must not be blank");
|
||||
}
|
||||
String term = raw.trim();
|
||||
if (term.contains(" ") || term.contains(":") || term.startsWith("-") || term.startsWith("+")) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_SYNTAX_INVALID",
|
||||
"sort must be 'field,direction' (Spring native); '" + raw + "' is not allowed");
|
||||
}
|
||||
String[] parts = term.split(",", -1);
|
||||
if (parts.length > 2 || parts[0].isBlank()) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_SYNTAX_INVALID",
|
||||
"sort must be 'field' or 'field,direction'; got '" + raw + "'");
|
||||
}
|
||||
String field = parts[0].trim();
|
||||
if (!field.matches("[a-zA-Z][a-zA-Z0-9]*")) {
|
||||
throw new PageValidationException(
|
||||
"sort", "SORT_FIELD_INVALID", "sort field '" + field + "' is not a valid identifier");
|
||||
}
|
||||
boolean ascending = true;
|
||||
if (parts.length == 2) {
|
||||
String dir = parts[1].trim().toLowerCase(Locale.ROOT);
|
||||
if (dir.equals("desc")) {
|
||||
ascending = false;
|
||||
} else if (!dir.equals("asc")) {
|
||||
throw new PageValidationException(
|
||||
"sort",
|
||||
"SORT_DIRECTION_INVALID",
|
||||
"sort direction must be 'asc' or 'desc'; got '" + parts[1] + "'");
|
||||
}
|
||||
}
|
||||
return new SortParam(field, ascending);
|
||||
}
|
||||
|
||||
/** Re-renders this term in canonical Spring native form for {@code meta.page.sort}. */
|
||||
public String canonical() {
|
||||
return field + "," + (ascending ? "asc" : "desc");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
interface ClientIpResolver {
|
||||
|
||||
String resolve(HttpServletRequest request);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
final class ClientIpResolverFactory {
|
||||
|
||||
private ClientIpResolverFactory() {}
|
||||
|
||||
static ClientIpResolver create(RateLimitClientIpMode mode) {
|
||||
return switch (mode) {
|
||||
case REMOTE_ADDR_ONLY -> new RemoteAddrClientIpResolver();
|
||||
case FORWARDED_HEADERS_TRUSTED -> new ForwardedHeaderClientIpResolver();
|
||||
};
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window
|
||||
* {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
public final class FixedWindowRateLimiter implements RateLimiter {
|
||||
|
||||
private final int limit;
|
||||
private final long windowSeconds;
|
||||
private final Clock clock;
|
||||
private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>();
|
||||
|
||||
public FixedWindowRateLimiter(int limit, Duration window, Clock clock) {
|
||||
this.limit = Math.max(1, limit);
|
||||
this.windowSeconds = Math.max(1L, window.toSeconds());
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RateLimitDecision decide(String key) {
|
||||
long nowSecond = clock.instant().getEpochSecond();
|
||||
long windowId = nowSecond / windowSeconds;
|
||||
Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds);
|
||||
|
||||
Window window =
|
||||
windows.compute(
|
||||
key,
|
||||
(k, current) ->
|
||||
(current == null || current.id != windowId) ? new Window(windowId) : current);
|
||||
|
||||
int count = window.count.incrementAndGet();
|
||||
boolean allowed = count <= limit;
|
||||
int remaining = Math.max(0, limit - count);
|
||||
return new RateLimitDecision(allowed, limit, remaining, resetAt);
|
||||
}
|
||||
|
||||
private static final class Window {
|
||||
private final long id;
|
||||
private final AtomicInteger count = new AtomicInteger();
|
||||
|
||||
private Window(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
final class ForwardedHeaderClientIpResolver implements ClientIpResolver {
|
||||
|
||||
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
|
||||
|
||||
@Override
|
||||
public String resolve(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader(X_FORWARDED_FOR);
|
||||
if (forwarded == null || forwarded.isBlank()) {
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
String firstHop = forwarded.split(",", 2)[0].trim();
|
||||
return firstHop.isEmpty() ? request.getRemoteAddr() : firstHop;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README
|
||||
* for the design rationale.
|
||||
*/
|
||||
public enum RateLimitAlgorithm {
|
||||
|
||||
/** Fixed-window counter — the default single-node implementation. */
|
||||
FIXED_WINDOW
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Selects the client-IP source for unauthenticated rate-limit keys. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public enum RateLimitClientIpMode {
|
||||
/** Uses {@code remoteAddr} only; ignores forwarded headers. */
|
||||
REMOTE_ADDR_ONLY,
|
||||
|
||||
/** Trusts forwarded headers. */
|
||||
FORWARDED_HEADERS_TRUSTED
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*}
|
||||
* signaling headers. See README for the design rationale.
|
||||
*
|
||||
* @param allowed false when the caller has exceeded the limit this window (→ 429)
|
||||
* @param limit the window quota ({@code X-RateLimit-Limit})
|
||||
* @param remaining requests left in the current window, floored at 0 ({@code
|
||||
* X-RateLimit-Remaining})
|
||||
* @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339)
|
||||
*/
|
||||
public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Applies the rate limit before a mapped handler runs. Every response carries the {@code
|
||||
* X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429
|
||||
* {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers.
|
||||
* See README for the design rationale.
|
||||
*/
|
||||
public final class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String CLIENT_SAFE_MESSAGE =
|
||||
"Too many requests, please retry after the indicated interval";
|
||||
|
||||
private final boolean enabled;
|
||||
private final RateLimiter limiter;
|
||||
private final RateLimitKeyResolver keyResolver;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final int retryAfterSeconds;
|
||||
|
||||
public RateLimitInterceptor(
|
||||
boolean enabled,
|
||||
RateLimiter limiter,
|
||||
RateLimitKeyResolver keyResolver,
|
||||
ObjectMapper objectMapper,
|
||||
int retryAfterSeconds) {
|
||||
this.enabled = enabled;
|
||||
this.limiter = limiter;
|
||||
this.keyResolver = keyResolver;
|
||||
this.objectMapper = objectMapper;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
RateLimitDecision decision = limiter.decide(keyResolver.resolve(request));
|
||||
applySignalingHeaders(response, decision);
|
||||
if (decision.allowed()) {
|
||||
return true;
|
||||
}
|
||||
rejectWith429(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) {
|
||||
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit()));
|
||||
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining()));
|
||||
response.setHeader(
|
||||
ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt()));
|
||||
}
|
||||
|
||||
private void rejectWith429(HttpServletResponse response) throws Exception {
|
||||
response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus());
|
||||
response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds));
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
Envelope<Void> body =
|
||||
ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null);
|
||||
objectMapper.writeValue(response.getWriter(), body);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
/**
|
||||
* Derives the rate-limit key from a request:
|
||||
*
|
||||
* <ul>
|
||||
* <li>authenticated user → {@code user:<principal>}
|
||||
* <li>service-to-service (a {@code service}-role principal) → {@code apikey:<id>}
|
||||
* <li>unauthenticated → {@code ip:<source-ip>:<METHOD route-template>}
|
||||
* </ul>
|
||||
*
|
||||
* <p>See README for the design rationale.
|
||||
*/
|
||||
public final class RateLimitKeyResolver {
|
||||
|
||||
private static final String SERVICE_ROLE = "service";
|
||||
|
||||
private final ClientIpResolver clientIpResolver;
|
||||
|
||||
public RateLimitKeyResolver(ClientIpResolver clientIpResolver) {
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
}
|
||||
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null
|
||||
&& auth.isAuthenticated()
|
||||
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId();
|
||||
}
|
||||
return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request);
|
||||
}
|
||||
|
||||
private static String routeTemplate(HttpServletRequest request) {
|
||||
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
String route = pattern instanceof String s ? s : request.getRequestURI();
|
||||
return request.getMethod() + " " + route;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.time.Clock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken
|
||||
* from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With
|
||||
* no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a
|
||||
* pass-through. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RateLimitSettings.class)
|
||||
public class RateLimitWebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final RateLimitInterceptor rateLimitInterceptor;
|
||||
|
||||
public RateLimitWebConfig(
|
||||
RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) {
|
||||
RateLimiter limiter =
|
||||
RateLimiterFactory.create(
|
||||
properties.algorithm(),
|
||||
properties.limit(),
|
||||
properties.window(),
|
||||
clock.getIfAvailable(Clock::systemUTC));
|
||||
int retryAfter =
|
||||
RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1);
|
||||
ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode());
|
||||
this.rateLimitInterceptor =
|
||||
new RateLimitInterceptor(
|
||||
properties.enabled(),
|
||||
limiter,
|
||||
new RateLimitKeyResolver(clientIpResolver),
|
||||
objectMapper,
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(rateLimitInterceptor);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
/**
|
||||
* Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code
|
||||
* X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design
|
||||
* rationale and the algorithm-neutral output contract.
|
||||
*/
|
||||
public interface RateLimiter {
|
||||
|
||||
/** Register one request for {@code key} and report whether it is within the limit. */
|
||||
RateLimitDecision decide(String key);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */
|
||||
public final class RateLimiterFactory {
|
||||
|
||||
private RateLimiterFactory() {}
|
||||
|
||||
public static RateLimiter create(
|
||||
RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) {
|
||||
return switch (algorithm) {
|
||||
case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock);
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.inbound.web.ratelimit;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
final class RemoteAddrClientIpResolver implements ClientIpResolver {
|
||||
|
||||
@Override
|
||||
public String resolve(HttpServletRequest request) {
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* CORS settings bound from {@code ca-skeleton.cors.*}. See README for the validation-policy
|
||||
* rationale.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.cors")
|
||||
public record CorsSettings(
|
||||
boolean enabled,
|
||||
List<String> allowedOrigins,
|
||||
List<String> allowedMethods,
|
||||
List<String> allowedHeaders,
|
||||
boolean allowCredentials,
|
||||
@PositiveOrZero(
|
||||
message = "APP_SECURITY_CORS_MAX_AGE (ca-skeleton.cors.max-age-seconds) must be >= 0")
|
||||
long maxAgeSeconds) {
|
||||
|
||||
public CorsSettings {
|
||||
// When CORS is enabled at least one allowed origin is required.
|
||||
if (enabled && (allowedOrigins == null || allowedOrigins.isEmpty())) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_CORS_ENABLED=true requires a non-empty APP_SECURITY_CORS_ORIGINS "
|
||||
+ "(ca-skeleton.cors.allowed-origins)");
|
||||
}
|
||||
// A wildcard origin must not be combined with credentials.
|
||||
if (allowCredentials && allowedOrigins != null && allowedOrigins.contains("*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_CORS_ALLOW_CREDENTIALS=true must not be combined with a wildcard "
|
||||
+ "\"*\" in APP_SECURITY_CORS_ORIGINS (ca-skeleton.cors.allowed-origins); "
|
||||
+ "list explicit origins instead");
|
||||
}
|
||||
if (allowedOrigins == null) {
|
||||
allowedOrigins = List.of();
|
||||
} else {
|
||||
allowedOrigins = List.copyOf(allowedOrigins);
|
||||
}
|
||||
if (allowedMethods == null || allowedMethods.isEmpty()) {
|
||||
allowedMethods = List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS");
|
||||
} else {
|
||||
allowedMethods = List.copyOf(allowedMethods);
|
||||
}
|
||||
if (allowedHeaders == null || allowedHeaders.isEmpty()) {
|
||||
allowedHeaders = List.of("*");
|
||||
} else {
|
||||
allowedHeaders = List.copyOf(allowedHeaders);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Bound from {@code ca-skeleton.presentation.*}. See README for the validation-policy rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.presentation")
|
||||
public record PresentationSettings(String apiBasePath) {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PresentationSettings.class);
|
||||
private static final String DEFAULT_API_BASE_PATH = "";
|
||||
|
||||
public PresentationSettings {
|
||||
if (apiBasePath == null) {
|
||||
log.warn("PRESENTATION_API_BASE_PATH is missing; using '{}'", DEFAULT_API_BASE_PATH);
|
||||
apiBasePath = DEFAULT_API_BASE_PATH;
|
||||
} else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) {
|
||||
String fixed = "/" + apiBasePath;
|
||||
log.warn(
|
||||
"PRESENTATION_API_BASE_PATH '{}' must start with '/'; using '{}'", apiBasePath, fixed);
|
||||
apiBasePath = fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
|
||||
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design
|
||||
* rationale.
|
||||
*
|
||||
* @param enabled whether the rate-limit interceptor enforces limits
|
||||
* @param limit max requests allowed per key within one window
|
||||
* @param window the fixed time window over which {@code limit} is counted
|
||||
* @param algorithm the rate-limit strategy to use
|
||||
* @param clientIpMode client-IP source for unauthenticated rate-limit keys
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.rate-limit")
|
||||
public record RateLimitSettings(
|
||||
boolean enabled,
|
||||
Integer limit,
|
||||
Duration window,
|
||||
RateLimitAlgorithm algorithm,
|
||||
RateLimitClientIpMode clientIpMode) {
|
||||
|
||||
public RateLimitSettings {
|
||||
if (limit == null || limit < 1) {
|
||||
limit = 100;
|
||||
}
|
||||
if (window == null || window.isZero() || window.isNegative()) {
|
||||
window = Duration.ofSeconds(1);
|
||||
}
|
||||
if (algorithm == null) {
|
||||
algorithm = RateLimitAlgorithm.FIXED_WINDOW;
|
||||
}
|
||||
if (clientIpMode == null) {
|
||||
clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.inbound.web.settings;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.security")
|
||||
public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class);
|
||||
|
||||
public SecuritySettings {
|
||||
if (issuerUri == null || issuerUri.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required");
|
||||
}
|
||||
if (audience == null) {
|
||||
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
|
||||
audience = "";
|
||||
}
|
||||
if (publicPaths == null) {
|
||||
publicPaths = List.of();
|
||||
} else {
|
||||
publicPaths = List.copyOf(publicPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AuthenticatedPrincipalTest {
|
||||
|
||||
@Test
|
||||
void rolesAreSnapshotAndImmutable() {
|
||||
Set<String> roles = new HashSet<>();
|
||||
roles.add("user");
|
||||
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", roles);
|
||||
roles.add("admin");
|
||||
|
||||
assertThat(principal.roles()).containsExactly("user");
|
||||
assertThat(principal.hasRole("admin")).isFalse();
|
||||
assertThatThrownBy(() -> principal.roles().add("admin"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullRolesDefaultToEmptySet() {
|
||||
AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", null);
|
||||
|
||||
assertThat(principal.roles()).isEmpty();
|
||||
assertThat(principal.hasRole("user")).isFalse();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class EnvelopeAccessDeniedHandlerTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final EnvelopeAccessDeniedHandler handler =
|
||||
new EnvelopeAccessDeniedHandler(new SecurityErrorClassifier(), mapper);
|
||||
|
||||
@Test
|
||||
void accessDeniedWrites403InsufficientPermissionEnvelope() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.handle(
|
||||
new MockHttpServletRequest("POST", "/v1/things"),
|
||||
response,
|
||||
new AccessDeniedException("Access is denied"));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull();
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull();
|
||||
JsonNode body = mapper.readTree(response.getContentAsString());
|
||||
assertThat(body.get("success").asBoolean()).isFalse();
|
||||
assertThat(body.path("error").path("code").asString())
|
||||
.isEqualTo("AUTHZ_INSUFFICIENT_PERMISSION");
|
||||
assertThat(body.path("error").path("category").asString()).isEqualTo("AUTHZ");
|
||||
assertThat(body.path("error").path("message").asString()).isEqualTo("Permission denied");
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class EnvelopeAuthenticationEntryPointTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final EnvelopeAuthenticationEntryPoint entryPoint =
|
||||
new EnvelopeAuthenticationEntryPoint(new SecurityErrorClassifier(), mapper);
|
||||
|
||||
private JsonNode bodyOf(MockHttpServletResponse response) throws Exception {
|
||||
return mapper.readTree(response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenWrites401EnvelopeWithWwwAuthenticate() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InsufficientAuthenticationException("Full authentication is required"));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isEqualTo("Bearer");
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull();
|
||||
JsonNode body = bodyOf(response);
|
||||
assertThat(body.get("success").asBoolean()).isFalse();
|
||||
assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_TOKEN_MISSING");
|
||||
assertThat(body.path("error").path("category").asString()).isEqualTo("AUTH");
|
||||
assertThat(body.path("error").path("message").asString()).isEqualTo("Authentication required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenWrites401WithInvalidTokenChallenge() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null);
|
||||
JwtValidationException cause = new JwtValidationException("expired", List.of(expired));
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException("invalid", cause));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE))
|
||||
.isEqualTo("Bearer error=\"invalid_token\"");
|
||||
assertThat(bodyOf(response).path("error").path("code").asString())
|
||||
.isEqualTo("AUTH_TOKEN_EXPIRED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKidCarriesRetryAfter5() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException(
|
||||
"invalid", new BadJwtException("Unable to find a matching key with kid 'abc'")));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("5");
|
||||
JsonNode body = bodyOf(response);
|
||||
assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_KID_UNKNOWN");
|
||||
assertThat(body.path("error").path("retryable").asBoolean()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwksOutageIs503WithRetryAfter30AndNoChallenge() throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
entryPoint.commence(
|
||||
new MockHttpServletRequest("GET", "/v1/things"),
|
||||
response,
|
||||
new InvalidBearerTokenException(
|
||||
"invalid", new JwtException("Couldn't retrieve remote JWK set: connect timed out")));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(503);
|
||||
assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("30");
|
||||
assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull();
|
||||
assertThat(bodyOf(response).path("error").path("code").asString())
|
||||
.isEqualTo("AUTH_JWKS_UNAVAILABLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenValueNeverLeaksIntoResponseBodyOrLogs(CapturedOutput output) throws Exception {
|
||||
// §테스트 계약: a JWT value must never appear in the response or the log output.
|
||||
String token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJsZWFrIn0.SECRETSIGNATURE";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/things");
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
// The decode failure message embeds the raw token, as Nimbus messages sometimes do.
|
||||
InvalidBearerTokenException ex =
|
||||
new InvalidBearerTokenException(
|
||||
"An error occurred while attempting to decode the Jwt: " + token);
|
||||
|
||||
entryPoint.commence(request, response, ex);
|
||||
|
||||
assertThat(response.getContentAsString())
|
||||
.as("response body must not echo the bearer token")
|
||||
.doesNotContain(token)
|
||||
.doesNotContain("eyJ");
|
||||
assertThat(output.getOut())
|
||||
.as("log output must not contain the bearer token")
|
||||
.doesNotContain(token)
|
||||
.doesNotContain("eyJ");
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* feature-security-operational-baseline §구현가이드 2 — the explicit JWT validator chain (60s clock skew
|
||||
* + issuer + audience). Unit-tested directly (no IdP / network), which also answers the branch-note
|
||||
* Claim "61s expired token is rejected (auto-config path)".
|
||||
*/
|
||||
class JwtDecoderConfigTest {
|
||||
|
||||
private static final String ISSUER = "https://issuer.example/realms/ca-skeleton";
|
||||
private static final String AUDIENCE = "ca-skeleton-api";
|
||||
|
||||
private final OAuth2TokenValidator<Jwt> validator =
|
||||
JwtDecoderConfig.jwtValidator(ISSUER, AUDIENCE);
|
||||
|
||||
private Jwt.Builder validJwt() {
|
||||
Instant now = Instant.now();
|
||||
return Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("user-1")
|
||||
.issuer(ISSUER)
|
||||
.audience(List.of(AUDIENCE))
|
||||
.issuedAt(now.minus(5, ChronoUnit.MINUTES))
|
||||
.expiresAt(now.plus(5, ChronoUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFullyValidTokenPasses() {
|
||||
OAuth2TokenValidatorResult result = validator.validate(validJwt().build());
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredWithin60sSkewIsAccepted() {
|
||||
Jwt jwt = validJwt().expiresAt(Instant.now().minus(30, ChronoUnit.SECONDS)).build();
|
||||
assertThat(validator.validate(jwt).hasErrors())
|
||||
.as("a token expired 30s ago is within the 60s clock-skew tolerance")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredBeyond60sSkewIsRejected() {
|
||||
Jwt jwt = validJwt().expiresAt(Instant.now().minus(90, ChronoUnit.SECONDS)).build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).as("a token expired 90s ago exceeds the 60s skew").isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(
|
||||
e ->
|
||||
e.getDescription() != null && e.getDescription().toLowerCase().contains("expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerMismatchIsRejected() {
|
||||
Jwt jwt = validJwt().issuer("https://evil.example/realms/other").build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(
|
||||
e -> e.getDescription() != null && e.getDescription().toLowerCase().contains("iss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void audienceMismatchIsRejectedWithAudClaimDescription() {
|
||||
Jwt jwt = validJwt().audience(List.of("some-other-api")).build();
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.anyMatch(e -> "The aud claim is not valid".equals(e.getDescription()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAudienceSkipsTheAudienceCheck() {
|
||||
OAuth2TokenValidator<Jwt> noAud = JwtDecoderConfig.jwtValidator(ISSUER, "");
|
||||
Jwt jwt = validJwt().audience(List.of("anything")).build();
|
||||
assertThat(noAud.validate(jwt).hasErrors())
|
||||
.as("a blank configured audience disables audience validation")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package dev.caskeleton.adapter.inbound.web.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.InsufficientAuthenticationException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.jwt.BadJwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidationException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
|
||||
/**
|
||||
* feature-security-operational-baseline §구현가이드 3 — the resource-server exception → fine-grained
|
||||
* {@link OperationalError} classifier that resolves the CODE_GRANULARITY_DRIFT.
|
||||
*
|
||||
* <p>The classification is best-effort over Spring Security's exception shapes: a missing token
|
||||
* surfaces as {@link InsufficientAuthenticationException}; a present-but-invalid token surfaces as
|
||||
* an {@link org.springframework.security.oauth2.core.OAuth2AuthenticationException} whose cause is
|
||||
* a {@link JwtValidationException} (claim validators: exp / iss / aud) or a {@link BadJwtException}
|
||||
* (decode / signature / unknown kid). The heuristics key off the validator/Nimbus message text the
|
||||
* branch note documents — see the per-case comments.
|
||||
*/
|
||||
class SecurityErrorClassifierTest {
|
||||
|
||||
private final SecurityErrorClassifier classifier = new SecurityErrorClassifier();
|
||||
|
||||
private OperationalError classify(AuthenticationException ex) {
|
||||
return classifier.classifyAuthentication(ex);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenIsTokenMissing() {
|
||||
// ExceptionTranslationFilter raises InsufficientAuthenticationException when an
|
||||
// unauthenticated request hits an authenticated() endpoint (no bearer token present).
|
||||
assertThat(classify(new InsufficientAuthenticationException("Full authentication is required")))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MISSING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsTokenExpired() {
|
||||
// JwtTimestampValidator description: "Jwt expired at 2024-..."
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(
|
||||
OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at 2024-01-01T00:00:00Z", null);
|
||||
JwtValidationException cause = new JwtValidationException("token expired", List.of(expired));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerMismatchIsIssuerMismatch() {
|
||||
// JwtIssuerValidator description: "The iss claim is not valid"
|
||||
OAuth2Error iss =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("bad iss", List.of(iss));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_ISSUER_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void audienceMismatchIsAudienceMismatch() {
|
||||
OAuth2Error aud =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("bad aud", List.of(aud));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_AUDIENCE_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTakesPrecedenceOverAudienceWhenBothFail() {
|
||||
OAuth2Error expired =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null);
|
||||
OAuth2Error aud =
|
||||
new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null);
|
||||
JwtValidationException cause = new JwtValidationException("multi", List.of(aud, expired));
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSignatureIsInvalidSignature() {
|
||||
BadJwtException cause = new BadJwtException("Signed JWT rejected: Invalid signature");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_INVALID_SIGNATURE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unparsableTokenIsMalformed() {
|
||||
BadJwtException cause =
|
||||
new BadJwtException(
|
||||
"An error occurred while attempting to decode the Jwt: Malformed token");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKidIsKidUnknown() {
|
||||
// Nimbus: no JWK matches the token's kid header after a key rotation.
|
||||
BadJwtException cause =
|
||||
new BadJwtException(
|
||||
"An error occurred while attempting to decode the Jwt: "
|
||||
+ "Unable to find a matching key with kid 'abc123'");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_KID_UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwksEndpointOutageIsJwksUnavailable() {
|
||||
JwtException cause =
|
||||
new JwtException(
|
||||
"An error occurred while attempting to decode the Jwt: Couldn't retrieve remote JWK set: connect timed out");
|
||||
assertThat(classify(new InvalidBearerTokenException("invalid", cause)))
|
||||
.isEqualTo(OperationalError.AUTH_JWKS_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownAuthenticationFailureFallsBackToMalformed() {
|
||||
// A novel/unmapped AuthenticationException must never leak as a 500; the safe default
|
||||
// is a generic 401 AUTH classification rather than an unclassified error.
|
||||
assertThat(classify(new AuthenticationException("weird") {}))
|
||||
.isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDeniedIsInsufficientPermission() {
|
||||
assertThat(classifier.classifyAccessDenied(new AccessDeniedException("denied")))
|
||||
.isEqualTo(OperationalError.AUTHZ_INSUFFICIENT_PERMISSION);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AuthorizationAdapterTest {
|
||||
|
||||
private final AuthorizationAdapter adapter =
|
||||
new AuthorizationAdapter(
|
||||
new RolePermissionRegistry(
|
||||
new RolePermissionPolicy(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
|
||||
|
||||
@Test
|
||||
void grantsWhenAnEffectivePermissionCoversTheRequirement() {
|
||||
AuthorizationPrincipal admin = new AuthorizationPrincipal("sub-admin", Set.of("admin"));
|
||||
|
||||
assertThatCode(() -> adapter.requirePermission(admin, Permission.parse("worklog:close")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenRoleLacksTheRequiredPermission() {
|
||||
AuthorizationPrincipal user = new AuthorizationPrincipal("sub-user", Set.of("user"));
|
||||
|
||||
assertThatThrownBy(() -> adapter.requirePermission(user, Permission.parse("worklog:close")))
|
||||
.isInstanceOf(AuthorizationDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesAPrincipalWithNoRolesFailClosed() {
|
||||
AuthorizationPrincipal anon = new AuthorizationPrincipal("sub-anon", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> adapter.requirePermission(anon, Permission.parse("worklog:read")))
|
||||
.isInstanceOf(AuthorizationDeniedException.class);
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||
import dev.caskeleton.application.security.AuthorizationDeniedException;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.application.security.AuthorizationPrincipal;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
|
||||
class RequiresPermissionAuthorizationManagerTest {
|
||||
|
||||
private final AuthorizationPort port =
|
||||
new AuthorizationAdapter(
|
||||
new RolePermissionRegistry(
|
||||
new RolePermissionPolicy(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
|
||||
|
||||
private final RequiresPermissionAuthorizationManager manager =
|
||||
new RequiresPermissionAuthorizationManager(port);
|
||||
|
||||
// --- fixtures: a guarded and an unguarded method ----------------------------------
|
||||
|
||||
static class Guarded {
|
||||
@RequiresPermission("worklog:close")
|
||||
public void close() {}
|
||||
|
||||
public void unguarded() {}
|
||||
}
|
||||
|
||||
private MethodInvocation invocationOf(String methodName) throws Exception {
|
||||
Method method = Guarded.class.getMethod(methodName);
|
||||
Guarded target = new Guarded();
|
||||
MethodInvocation mi = mock(MethodInvocation.class);
|
||||
when(mi.getMethod()).thenReturn(method);
|
||||
when(mi.getThis()).thenReturn(target);
|
||||
return mi;
|
||||
}
|
||||
|
||||
private static Supplier<Authentication> principalWithRoles(String... roles) {
|
||||
AuthenticatedPrincipal user =
|
||||
new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles));
|
||||
TestingAuthenticationToken auth =
|
||||
new TestingAuthenticationToken(user, "n/a", AuthorityUtils.NO_AUTHORITIES);
|
||||
auth.setAuthenticated(true);
|
||||
return () -> auth;
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsWhenPrincipalHoldsRequiredPermission() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("admin"), invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenPrincipalLacksRequiredPermission() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("user"), invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void abstainsOnUnguardedMethod() throws Exception {
|
||||
AuthorizationResult decision =
|
||||
manager.authorize(principalWithRoles("admin"), invocationOf("unguarded"));
|
||||
|
||||
assertThat(decision).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesUnauthenticatedRequestFailClosed() throws Exception {
|
||||
Supplier<Authentication> anonymous =
|
||||
() -> {
|
||||
AnonymousAuthenticationToken token =
|
||||
new AnonymousAuthenticationToken(
|
||||
"key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
token.setAuthenticated(false);
|
||||
return token;
|
||||
};
|
||||
|
||||
AuthorizationResult decision = manager.authorize(anonymous, invocationOf("close"));
|
||||
|
||||
assertThat(decision).isNotNull();
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesWhenPrincipalIsNotOurAuthenticatedUser() throws Exception {
|
||||
Supplier<Authentication> foreignPrincipal =
|
||||
() -> {
|
||||
TestingAuthenticationToken auth =
|
||||
new TestingAuthenticationToken("just-a-string", "n/a", AuthorityUtils.NO_AUTHORITIES);
|
||||
auth.setAuthenticated(true);
|
||||
return auth;
|
||||
};
|
||||
|
||||
AuthorizationResult decision = manager.authorize(foreignPrincipal, invocationOf("close"));
|
||||
|
||||
assertThat(decision.isGranted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void portDenialCarriesRequiredPermission() {
|
||||
AuthorizationPrincipal user = new AuthorizationPrincipal("sub-1", Set.of("user"));
|
||||
|
||||
assertThatThrownBy(() -> port.requirePermission(user, Permission.parse("worklog:close")))
|
||||
.isInstanceOfSatisfying(
|
||||
AuthorizationDeniedException.class,
|
||||
denied ->
|
||||
assertThat(denied.requiredPermission())
|
||||
.isEqualTo(Permission.parse("worklog:close")));
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Binding test for {@link RolePermissionPolicy} (AGENTS.md §설정과 런타임: every new
|
||||
* {@code @ConfigurationProperties} class gets a binding test). Verifies the {@code
|
||||
* ca-skeleton.authz.role-permissions.<role>=<comma list>} YAML/env shape binds to the {@code
|
||||
* Map<String, List<String>>} the registry consumes, and that an absent block is null-safe.
|
||||
*/
|
||||
class RolePermissionPolicyTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner().withUserConfiguration(EnableProperties.class);
|
||||
|
||||
@Test
|
||||
void bindsRoleToPermissionLists() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.authz.role-permissions.user=worklog:read,worklog:write",
|
||||
"ca-skeleton.authz.role-permissions.admin=worklog:read,worklog:write,worklog:close")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
RolePermissionPolicy props = context.getBean(RolePermissionPolicy.class);
|
||||
assertThat(props.rolePermissions()).containsKeys("user", "admin");
|
||||
assertThat(props.rolePermissions().get("admin"))
|
||||
.containsExactly("worklog:read", "worklog:write", "worklog:close");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentBlockBindsToEmptyMap() {
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBean(RolePermissionPolicy.class).rolePermissions()).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolePermissionListsAreDeepCopied() {
|
||||
List<String> permissions = new ArrayList<>(List.of("worklog:read"));
|
||||
Map<String, List<String>> rolePermissions = new HashMap<>();
|
||||
rolePermissions.put("user", permissions);
|
||||
|
||||
RolePermissionPolicy policy = new RolePermissionPolicy(rolePermissions);
|
||||
permissions.add("worklog:write");
|
||||
rolePermissions.put("admin", List.of("worklog:close"));
|
||||
|
||||
assertThat(policy.rolePermissions()).containsOnlyKeys("user");
|
||||
assertThat(policy.rolePermissions().get("user")).containsExactly("worklog:read");
|
||||
assertThat(policy.rolePermissions()).isUnmodifiable();
|
||||
assertThat(policy.rolePermissions().get("user")).isUnmodifiable();
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RolePermissionPolicy.class)
|
||||
static class EnableProperties {}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.inbound.web.authz;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RolePermissionRegistryTest {
|
||||
|
||||
private static RolePermissionRegistry registry(Map<String, List<String>> roles) {
|
||||
return new RolePermissionRegistry(new RolePermissionPolicy(roles));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesASingleRoleToItsDeclaredPermissions() {
|
||||
RolePermissionRegistry reg = registry(Map.of("user", List.of("worklog:read", "worklog:write")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("user")))
|
||||
.containsExactlyInAnyOrder(
|
||||
Permission.parse("worklog:read"), Permission.parse("worklog:write"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unionsPermissionsAcrossMultipleRoles() {
|
||||
RolePermissionRegistry reg =
|
||||
registry(
|
||||
Map.of(
|
||||
"user", List.of("worklog:read", "worklog:write"),
|
||||
"admin", List.of("worklog:read", "worklog:write", "worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("user", "admin")))
|
||||
.containsExactlyInAnyOrder(
|
||||
Permission.parse("worklog:read"),
|
||||
Permission.parse("worklog:write"),
|
||||
Permission.parse("worklog:close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleLookupIsCaseInsensitive() {
|
||||
// D3: Keycloak does not guarantee role casing; registry lookup normalises so a
|
||||
// raw role of "ADMIN" still resolves to the "admin" bundle.
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("ADMIN")))
|
||||
.containsExactly(Permission.parse("worklog:close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRoleResolvesToZeroPermissionsFailClosed() {
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of("ghost"))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrNullRoleSetResolvesToZeroPermissions() {
|
||||
RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close")));
|
||||
|
||||
assertThat(reg.effectivePermissions(Set.of())).isEmpty();
|
||||
assertThat(reg.effectivePermissions(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyRegistryGrantsNothing() {
|
||||
assertThat(registry(Map.of()).effectivePermissions(Set.of("admin"))).isEmpty();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.conditional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ETagsTest {
|
||||
|
||||
@Test
|
||||
void weakEtagFromVersionUsesTheBranchNoteForm() {
|
||||
assertThat(ETags.weakFromVersion(7)).isEqualTo("W/\"7\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesSameVersionLenientlyAcrossWeakMarker() {
|
||||
String etag = ETags.weakFromVersion(3);
|
||||
assertThat(ETags.matches("W/\"3\"", etag)).isTrue();
|
||||
assertThat(ETags.matches("\"3\"", etag)).as("strong form of same value still matches").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wildcardMatchesAnyExistingEtag() {
|
||||
assertThat(ETags.matches("*", ETags.weakFromVersion(99))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleCandidatesMatchWhenAnyMatches() {
|
||||
assertThat(ETags.matches("W/\"1\", W/\"2\", W/\"3\"", ETags.weakFromVersion(2))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersionDoesNotMatch() {
|
||||
assertThat(ETags.matches("W/\"1\"", ETags.weakFromVersion(2))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOrBlankHeaderDoesNotMatch() {
|
||||
assertThat(ETags.matches(null, ETags.weakFromVersion(1))).isFalse();
|
||||
assertThat(ETags.matches(" ", ETags.weakFromVersion(1))).isFalse();
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.inbound.web.cursor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CursorCodecTest {
|
||||
|
||||
private final CursorCodec codec = CursorCodec.withDevKey();
|
||||
private static final Instant T0 = Instant.parse("2026-06-02T00:00:00Z");
|
||||
|
||||
@Test
|
||||
void roundTripsThePayload() {
|
||||
String token = codec.encode("01HZX9-after", T0);
|
||||
assertThat(codec.decode(token, T0.plusSeconds(60))).isEqualTo("01HZX9-after");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIsUrlSafeAndNotPlainlyThePayload() {
|
||||
String token = codec.encode("offset=40", T0);
|
||||
assertThat(token).doesNotContain("offset=40"); // opacity: not the raw payload
|
||||
assertThat(token).matches("[A-Za-z0-9_.\\-]+"); // URL-safe (base64url + '.')
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedTokenIsRejected() {
|
||||
String token = codec.encode("01HZX9", T0);
|
||||
String tampered = token.substring(0, token.length() - 1) + (token.endsWith("A") ? "B" : "A");
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode(tampered, T0.plusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsRejectedAfterTtl() {
|
||||
String token = codec.encode("01HZX9", T0);
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode(token, T0.plus(Duration.ofHours(24)).plusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedTokenIsRejected() {
|
||||
assertThatExceptionOfType(CursorException.class)
|
||||
.isThrownBy(() -> codec.decode("not-a-valid-token", T0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortKeyIsRefused() {
|
||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
||||
.isThrownBy(() -> new CursorCodec("short".getBytes(), CursorCodec.DEFAULT_TTL));
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.caskeleton.shared.response.BulkEnvelope;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import dev.caskeleton.shared.response.ResponseMeta;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@WebMvcTest(
|
||||
controllers = EnvelopeBodyAdviceTest.Probe.class,
|
||||
excludeAutoConfiguration = SecurityAutoConfiguration.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@Import({EnvelopeBodyAdviceTest.Probe.class, EnvelopeBodyAdvice.class})
|
||||
class EnvelopeBodyAdviceTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@RestController
|
||||
static class Probe {
|
||||
@GetMapping("/probe/raw")
|
||||
public Map<String, String> raw() {
|
||||
return Map.of("k", "v");
|
||||
}
|
||||
|
||||
@GetMapping("/probe/already-enveloped")
|
||||
public Envelope<Map<String, String>> enveloped() {
|
||||
return Envelope.ok(Map.of("k", "v"), new ResponseMeta("r", "t", "c"));
|
||||
}
|
||||
|
||||
@GetMapping("/probe/bulk")
|
||||
public BulkEnvelope<Map<String, String>> bulk() {
|
||||
return BulkEnvelope.allOk(List.of(Map.of("k", "v")), new ResponseMeta("r", "t", "c"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawBodyIsWrappedInEnvelope() throws Exception {
|
||||
mvc.perform(get("/probe/raw"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.k").value("v"))
|
||||
.andExpect(jsonPath("$.error").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedBodyCarriesAMetaObject() throws Exception {
|
||||
mvc.perform(get("/probe/raw"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.meta").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyEnvelopedBodyIsNotDoubleWrapped() throws Exception {
|
||||
mvc.perform(get("/probe/already-enveloped"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.k").value("v"))
|
||||
.andExpect(jsonPath("$.data.data").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkEnvelopeBodyIsNotDoubleWrapped() throws Exception {
|
||||
mvc.perform(get("/probe/bulk"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.results[0].k").value("v"))
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
static class TestBootstrap {}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.inbound.web.envelope;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
||||
import dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilter;
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* End-to-end pin of the foundation envelope contract across the adapter-web chain: {@link
|
||||
* RequestLoggingFilter} sets the SNAKE_CASE MDC ids, {@link EnvelopeBodyAdvice} (success) and
|
||||
* {@link GlobalExceptionHandler} (5xx) project them onto a camelCase {@code meta} object.
|
||||
* Standalone MockMvc — no Spring context, so it neither needs the production {@code
|
||||
* application.yml} placeholders nor a security filter chain.
|
||||
*/
|
||||
class EnvelopeMetaIntegrationTest {
|
||||
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc =
|
||||
MockMvcBuilders.standaloneSetup(new Probe())
|
||||
.addFilter(new RequestLoggingFilter(rawPrincipal -> rawPrincipal))
|
||||
.setControllerAdvice(
|
||||
new EnvelopeBodyAdvice(), new GlobalExceptionHandler(SpanErrorRecorder.NOOP))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successResponseCarriesNonEmptyMetaRequestAndTraceId() throws Exception {
|
||||
mvc.perform(get("/__meta/ok"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.probe").value("ok"))
|
||||
.andExpect(jsonPath("$.meta.requestId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thrown5xxCarriesEnvelopeWithMetaAndCategoryAndNoStacktrace() throws Exception {
|
||||
mvc.perform(get("/__meta/boom"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.error.category").value("INTERNAL"))
|
||||
.andExpect(jsonPath("$.error.message").value("Internal server error"))
|
||||
.andExpect(jsonPath("$.error.retryable").value(true))
|
||||
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Probe {
|
||||
|
||||
@GetMapping("/__meta/ok")
|
||||
Map<String, String> ok() {
|
||||
return Map.of("probe", "ok");
|
||||
}
|
||||
|
||||
@GetMapping("/__meta/boom")
|
||||
Map<String, String> boom() {
|
||||
throw new RuntimeException("kaboom");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user