diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 2c02269..280e8c0 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -7,7 +7,7 @@ body: id: version attributes: label: Codes version - placeholder: 0.3.0 + placeholder: 0.4.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index efd5f60..68c4f79 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,5 +1,5 @@ name: Change request -description: Propose a core semantic, mapping, or integration change. +description: Propose a problem type or Spring integration change. title: "request: " labels: [enhancement] body: @@ -13,7 +13,7 @@ body: - type: textarea id: current attributes: - label: Current integration + label: Current approach description: Show how the problem is handled without the proposed change. validations: required: true @@ -28,5 +28,7 @@ body: attributes: label: Contract impact options: - - label: This changes the standard outcome catalog, state semantics, or a built-in mapping. + - label: This changes ProblemType validation or semantics. - label: This changes the public Java API. + - label: This changes Spring ProblemDetail conversion behavior. + - label: This changes compatibility, dependency, or publication behavior. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f04f71..4f5c4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,46 @@ concurrency: cancel-in-progress: true jobs: + matrix: + name: CI matrix policy + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runtime_jdks: ${{ steps.matrix.outputs.runtime_jdks }} + kotlin: ${{ steps.matrix.outputs.kotlin }} + os: ${{ steps.matrix.outputs.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Verify compatibility policy + run: python scripts/verify-compatibility-matrix.py + + - id: matrix + name: Export required check matrices + shell: bash + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import json + from pathlib import Path + + matrix = json.loads(Path("compatibility/matrix.json").read_text(encoding="utf-8")) + compact = lambda value: json.dumps(value, separators=(",", ":")) + + print("runtime_jdks=" + compact({ + "java": [version for version in matrix["java"] if version != "17"] + })) + print("kotlin=" + compact({ + "kotlin": matrix["kotlin"]["versions"] + })) + print("os=" + compact({ + "os": matrix["os"] + })) + PY + verify: + needs: [matrix] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -21,19 +60,16 @@ jobs: with: persist-credentials: false - - name: Verify release promotion policy - run: python scripts/test-release-promotion-policy.py - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 with: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - - name: Verify core, adapters, and references + - name: Verify library and reference application run: ./gradlew clean verifyAll --stacktrace - name: Publish clean compatibility artifacts @@ -60,11 +96,7 @@ jobs: run: | set -euo pipefail version="$(sh ./scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify - name: Verify Maven Kotlin consumer env: @@ -78,17 +110,16 @@ jobs: path: | build/reports/jacoco codes-spring/build/reports/jacoco - codes-grpc-java/build/reports/jacoco if-no-files-found: warn retention-days: 14 kotlin-consumers: + needs: [matrix] runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: - kotlin: ["1.9.24", "2.0.21", "2.1.21", "2.2.20", "2.4.10"] + matrix: ${{ fromJSON(needs.matrix.outputs.kotlin) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -99,42 +130,42 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - - name: Publish all artifacts locally + - name: Publish clean compatibility artifacts run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven - name: Verify Kotlin consumer env: MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: sh ./scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" + run: sh scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" kotlin-consumers-gate: name: Kotlin consumers gate if: always() - needs: [kotlin-consumers] + needs: [matrix, kotlin-consumers] runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Verify Kotlin consumer matrix - env: - RESULT: ${{ needs.kotlin-consumers.result }} + - name: Require Kotlin consumer matrix shell: bash + env: + MATRIX_RESULT: ${{ needs.matrix.result }} + KOTLIN_RESULT: ${{ needs.kotlin-consumers.result }} run: | - if [ "$RESULT" != "success" ]; then - echo "Kotlin consumer matrix result: $RESULT" - exit 1 - fi + set -euo pipefail + test "$MATRIX_RESULT" = "success" + test "$KOTLIN_RESULT" = "success" runtime-jdks: + needs: [matrix] runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false - matrix: - java: ["21", "25"] + matrix: ${{ fromJSON(needs.matrix.outputs.runtime_jdks) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -145,20 +176,29 @@ jobs: distribution: temurin java-version: ${{ matrix.java }} - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - - name: Test core on runtime JDK - run: ./gradlew :test --stacktrace + - name: Publish clean compatibility artifacts + run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + + - name: Verify Java consumer on runtime JDK + run: >- + ./gradlew + -p smoke-test-java + -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven + -PconsumerJavaVersion=${{ matrix.java }} + clean check + --stacktrace operating-systems: + needs: [matrix] runs-on: ${{ matrix.os }} - timeout-minutes: 25 + timeout-minutes: 30 strategy: fail-fast: false - matrix: - os: [windows-latest, macos-latest] + matrix: ${{ fromJSON(needs.matrix.outputs.os) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -169,9 +209,33 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - - name: Test core and adapters - run: ./gradlew :test :codes-spring:test :codes-grpc-java:test --stacktrace + - name: Verify published consumers on Unix + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + + ./gradlew -p smoke-test-java "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" clean check --stacktrace + + ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" clean check --stacktrace + + - name: Verify published consumers on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" + + .\gradlew.bat "-Dmaven.repo.local=$repo" :publishToMavenLocal :codes-spring:publishToMavenLocal --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + .\gradlew.bat -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + .\gradlew.bat -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bbdff24..ce2533c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,7 +29,7 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic @@ -43,9 +43,7 @@ jobs: ./gradlew clean :testClasses :codes-spring:testClasses - :codes-grpc-java:testClasses :reference-spring-orders:testClasses - :reference-grpc-orders:testClasses --stacktrace - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 6f5fc4d..5c20fdd 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -18,9 +18,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - adapter: ${{ steps.matrix.outputs.adapter }} + java_spring: ${{ steps.matrix.outputs.java_spring }} kotlin: ${{ steps.matrix.outputs.kotlin }} - os: ${{ steps.matrix.outputs.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -37,33 +36,31 @@ jobs: import json from pathlib import Path - matrix = json.loads(Path("compatibility/matrix.json").read_text()) + matrix = json.loads(Path("compatibility/matrix.json").read_text(encoding="utf-8")) compact = lambda value: json.dumps(value, separators=(",", ":")) - print("adapter=" + compact({ + print("java_spring=" + compact({ "java": matrix["java"], "spring": matrix["spring"], - "grpc": matrix["grpc"], })) + kotlin = matrix["kotlin"] print("kotlin=" + compact({ "include": [ { "kotlin": version, "spring": kotlin["spring"], - "grpc": kotlin["grpc"], } for version in kotlin["versions"] ] })) - print("os=" + compact({"os": matrix["os"]})) PY contracts: name: Artifact contracts and coverage - runs-on: ubuntu-latest - timeout-minutes: 35 needs: [matrix] + runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -78,39 +75,26 @@ jobs: with: cache-provider: basic - - name: Verify API, wire, POM, and coverage contracts + - name: Verify contracts run: ./gradlew clean verifyAll --stacktrace - name: Build clean publication repository run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven - - name: Upload clean publication repository - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: compatibility-publications path: build/compatibility-maven if-no-files-found: error retention-days: 1 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: always() - with: - name: compatibility-coverage - path: | - build/reports/jacoco - codes-spring/build/reports/jacoco - codes-grpc-java/build/reports/jacoco - if-no-files-found: error - retention-days: 14 - - adapter-versions: - name: JDK ${{ matrix.java }} / Spring ${{ matrix.spring }} / gRPC ${{ matrix.grpc }} + java-spring: needs: [matrix, contracts] runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.adapter) }} + matrix: ${{ fromJSON(needs.matrix.outputs.java_spring) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -125,12 +109,18 @@ jobs: with: cache-provider: basic - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: compatibility-publications path: build/compatibility-maven + - name: Test Spring bridge against selected Spring version + run: >- + ./gradlew + :codes-spring:test + -PspringFrameworkOverride=${{ matrix.spring }} + --stacktrace + - name: Verify Gradle Java consumer run: >- ./gradlew @@ -138,25 +128,17 @@ jobs: -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven -PconsumerJavaVersion=${{ matrix.java }} -PspringFrameworkOverride=${{ matrix.spring }} - -PgrpcVersionOverride=${{ matrix.grpc }} clean check --stacktrace - name: Verify Maven Java consumer shell: bash run: | + set -euo pipefail version="$(sh scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - "-Dmaven.compiler.release=${{ matrix.java }}" \ - "-Dspring.framework.version=${{ matrix.spring }}" \ - "-Dgrpc.version=${{ matrix.grpc }}" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" "-Dmaven.compiler.release=${{ matrix.java }}" "-Dspring.framework.version=${{ matrix.spring }}" clean verify - kotlin-nullability: - name: Kotlin ${{ matrix.kotlin }} / Spring ${{ matrix.spring }} / gRPC ${{ matrix.grpc }} JSpecify + kotlin: needs: [matrix, contracts] runs-on: ubuntu-latest timeout-minutes: 25 @@ -173,135 +155,36 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: compatibility-publications path: build/compatibility-maven - - name: Verify Kotlin JSpecify consumer + - name: Verify Kotlin consumer env: MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven run: >- sh scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" "${{ matrix.spring }}" - "${{ matrix.grpc }}" - - operating-systems: - name: Published consumers / ${{ matrix.os }} - needs: [matrix, contracts] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.os) }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 - with: - name: compatibility-publications - path: build/compatibility-maven - - - name: Verify Gradle Java and Kotlin consumers (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - repo="${GITHUB_WORKSPACE}/build/compatibility-maven" - ./gradlew -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace - ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace - - - name: Verify Gradle Java and Kotlin consumers (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" - - .\gradlew.bat -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - .\gradlew.bat -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify Maven Java consumer (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - repo="${GITHUB_WORKSPACE}/build/compatibility-maven" - version="$(sh scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=$repo" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify - - - name: Verify Maven Java consumer (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" - $version = (Get-Content .\gradle.properties | - Where-Object { $_ -like "VERSION_NAME=*" } | - Select-Object -First 1).Substring("VERSION_NAME=".Length) - mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=$repo" ` - -f smoke-test-maven-java/pom.xml ` - "-Dcodes.version=$version" ` - clean verify - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } compatibility-gate: name: Compatibility gate if: always() - needs: - - matrix - - contracts - - adapter-versions - - kotlin-nullability - - operating-systems + needs: [matrix, contracts, java-spring, kotlin] runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Require complete compatibility matrix - env: - MATRIX_RESULT: ${{ needs.matrix.result }} - CONTRACTS_RESULT: ${{ needs.contracts.result }} - ADAPTER_RESULT: ${{ needs.adapter-versions.result }} - KOTLIN_RESULT: ${{ needs.kotlin-nullability.result }} - OS_RESULT: ${{ needs.operating-systems.result }} + - name: Require all compatibility checks shell: bash + env: + MATRIX: ${{ needs.matrix.result }} + CONTRACTS: ${{ needs.contracts.result }} + JAVA_SPRING: ${{ needs.java-spring.result }} + KOTLIN: ${{ needs.kotlin.result }} run: | set -euo pipefail - - for entry in \ - "matrix:$MATRIX_RESULT" \ - "contracts:$CONTRACTS_RESULT" \ - "adapter-versions:$ADAPTER_RESULT" \ - "kotlin-nullability:$KOTLIN_RESULT" \ - "operating-systems:$OS_RESULT" - do - name="${entry%%:*}" - result="${entry#*:}" - if [ "$result" != "success" ]; then - echo "$name result: $result" - exit 1 - fi - done - - echo "Complete Codes compatibility matrix passed from clean publication artifacts." + test "$MATRIX" = "success" + test "$CONTRACTS" = "success" + test "$JAVA_SPRING" = "success" + test "$KOTLIN" = "success" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml deleted file mode 100644 index 8e8d678..0000000 --- a/.github/workflows/release-candidate.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Release Candidate - -on: - push: - tags: - - "v*.*.*-RC*" - -permissions: - contents: read - -concurrency: - group: maven-central-release - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - timeout-minutes: 90 - outputs: - version: ${{ steps.version.outputs.value }} - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 0 - persist-credentials: false - - - id: version - name: Verify release-candidate tag - shell: bash - run: | - set -euo pipefail - - version="$(sh ./scripts/version.sh)" - - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-RC[1-9][0-9]*$ ]] || { - echo "Invalid release-candidate version: $version" - exit 1 - } - - test "${GITHUB_REF_NAME}" = "v${version}" - - git fetch --no-tags origin main - - test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \ - "$(git rev-parse "origin/main^{commit}")" - - echo "value=$version" >> "$GITHUB_OUTPUT" - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Verify release-candidate tree - run: | - python scripts/verify-compatibility-matrix.py - ./gradlew clean verifyAll --stacktrace - - - name: Publish clean release-candidate artifacts - run: bash scripts/prepare-compatibility-repository.sh build/compatibility-maven - - - name: Verify published-artifact consumers before Central - shell: bash - env: - MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: | - set -euo pipefail - version="$(sh ./scripts/version.sh)" - - ./gradlew \ - -p smoke-test-java \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - clean check \ - --stacktrace - - ./gradlew \ - -p smoke-test-kotlin \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - clean check \ - --stacktrace - - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify - - bash ./scripts/verify-kotlin-nullability.sh 2.4.10 - - - name: Publish all Codes artifacts to Maven Central - run: >- - ./gradlew - :publishToMavenCentral - :codes-spring:publishToMavenCentral - :codes-grpc-java:publishToMavenCentral - --no-configuration-cache - --stacktrace - env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} - ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} - - external-integrations: - needs: publish - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "21" - - - name: Verify external integrations from Maven Central - run: >- - bash scripts/verify-rc-integrations.sh - "${{ needs.publish.outputs.version }}" - build/reports/rc-integrations.md - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: always() - with: - name: rc-external-integrations - path: | - build/reports/rc-integrations.md - build/rc-integrations/*.log - if-no-files-found: warn - retention-days: 30 - - github-prerelease: - needs: [publish, external-integrations] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - name: Create GitHub prerelease - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - version="${GITHUB_REF_NAME#v}" - - awk -v version="$version" ' - $0 == "## " version { - capture = 1 - next - } - capture && /^## / { - exit - } - capture { - print - } - ' CHANGELOG.md > release-notes.md - - test -s release-notes.md - - gh release create "$GITHUB_REF_NAME" \ - --verify-tag \ - --prerelease \ - --title "Codes $version" \ - --notes-file release-notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6be7545..d11c949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,9 @@ on: push: tags: - "v*.*.*" - - "!v*.*.*-RC*" + - "!v*.*.*-*" permissions: - actions: read contents: read concurrency: @@ -31,79 +30,56 @@ jobs: set -euo pipefail version="$(sh ./scripts/version.sh)" - - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { - echo "Invalid release version: $version" - exit 1 - } - + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] test "${GITHUB_REF_NAME}" = "v${version}" git fetch --no-tags origin main - - test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \ - "$(git rev-parse "origin/main^{commit}")" - - - name: Verify release-candidate promotion - env: - GITHUB_TOKEN: ${{ github.token }} - run: python scripts/verify-release-promotion.py + test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = "$(git rev-parse "origin/main^{commit}")" - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 with: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - name: Verify release tree run: | + set -euo pipefail python scripts/verify-compatibility-matrix.py ./gradlew clean verifyAll --stacktrace - - name: Publish clean release artifacts - run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + - name: Build clean release artifacts + run: sh scripts/prepare-compatibility-repository.sh build/release-maven - - name: Verify Gradle Java consumer - run: >- - ./gradlew - -p smoke-test-java - -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven - clean check - --stacktrace + - name: Verify Gradle consumers + shell: bash + run: | + set -euo pipefail - - name: Verify Gradle Kotlin consumer - run: >- - ./gradlew - -p smoke-test-kotlin - -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven - clean check - --stacktrace + ./gradlew -p smoke-test-java "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace + + ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace - name: Verify Maven consumers shell: bash env: - MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven + MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/release-maven run: | set -euo pipefail version="$(sh ./scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify sh ./scripts/verify-kotlin-nullability.sh 2.4.10 - - name: Publish all Codes artifacts to Maven Central + - name: Publish to Maven Central run: >- ./gradlew :publishToMavenCentral :codes-spring:publishToMavenCentral - :codes-grpc-java:publishToMavenCentral --no-configuration-cache --stacktrace env: @@ -148,7 +124,4 @@ jobs: test -s release-notes.md - gh release create "$GITHUB_REF_NAME" \ - --verify-tag \ - --title "Codes $version" \ - --notes-file release-notes.md + gh release create "$GITHUB_REF_NAME" --verify-tag --title "Codes $version" --notes-file release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ae6ca..3b19d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # Changelog -## Unreleased +## 0.4.0 + +### Added + +* Added `ProblemType` as the reusable RFC 9457 problem type definition. +* Added `ProblemDetails` as the minimal Spring bridge for creating native `ProblemDetail` instances. +* Added MVC and WebFlux wire-contract verification for rendered problem responses. + +### Changed + +* Refocused Codes on reusable RFC 9457 problem types. +* Made `ProblemType` an immutable value with one validated creation path. +* Required absolute problem type URIs and rejected `about:blank`, which remains a native HTTP status-only problem. +* Kept the core artifact dependency-free. +* Made Spring Framework an application-provided integration dependency instead of a transitive dependency of `codes-spring`. +* Reduced the verified compatibility matrix to the Java, Spring, Kotlin, Gradle, and Maven combinations relevant to the new API. + +### Removed + +* Removed the generic outcome model, standard outcome catalog, registries, validation aggregation, exception helpers, and protocol-neutral status mappings. +* Removed the gRPC artifact and integration. +* Removed release-candidate promotion requirements tied to the superseded 0.4.0-RC1 API. + +### Breaking + +`0.4.0` is intentionally source and binary incompatible with the 0.4.0-RC1 outcome-oriented API. Applications using pre-0.4 outcome APIs should migrate only reusable HTTP problem definitions to `ProblemType`; other domain concerns remain application-owned. ## 0.4.0-RC1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51eccdc..9a375dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,25 +2,26 @@ Keep changes focused, tested, and justified. -## Before changing contracts +## Contracts -Codes has public contracts across three artifacts: +Codes publishes two artifacts: - `codes` - `codes-spring` -- `codes-grpc-java` -Changes to standard outcome membership, standard outcome state, or built-in HTTP/gRPC mappings change the semantic contract. +The core contract is the `ProblemType` value: an absolute RFC 9457 type URI, HTTP status, and non-blank title. -Public API changes require updating the matching snapshot under `api/`. +The Spring contract is the explicit conversion from a `ProblemType` to Spring's native `ProblemDetail`. -Spring or gRPC wire changes require updating the matching snapshot under `compatibility/`. +Public Java API changes require updating the matching snapshot under `api/`. -Do not update a snapshot just because a test failed. Understand the change and explain why the contract should move. +Changes that affect rendered Spring problem responses require updating the MVC and WebFlux contract tests in the Spring reference application. -The core artifact must remain free of runtime dependencies. +The core artifact must remain free of runtime dependencies and publish no Maven dependencies. -Adapters must stay thin and explicit. Do not introduce auto-configuration, serialization frameworks, registries, hidden mapping rules, or unrelated abstractions as part of an adapter change. +`codes-spring` must remain a thin bridge. Its published dependency surface is limited to `codes`; applications provide Spring Web. + +Do not introduce auto-configuration, exception mapping, registries, serialization frameworks, or unrelated abstractions as part of a Spring integration change. ## Build @@ -38,7 +39,7 @@ Linux/macOS: ./gradlew clean verifyAll --stacktrace ``` -For publication or dependency changes, also verify all three artifacts from a clean isolated Maven repository. +For publication or dependency changes, also verify the artifacts from a clean isolated Maven repository. Windows: @@ -53,24 +54,15 @@ Linux/macOS: bash scripts/prepare-compatibility-repository.sh build/compatibility-maven ``` -The CI compatibility workflow covers the supported Java, Kotlin, Spring, gRPC, Gradle/Maven consumer, and operating-system matrix. +CI verifies the supported Java, Kotlin, Spring, Gradle/Maven consumer, and operating-system matrix. ## Pull requests -Keep pull requests small enough to review. - -Include tests for behavior changes. - -Explain any: - -- public API change -- semantic or wire change -- dependency change -- compatibility change +Keep changes small enough to review and include tests for behavior changes. -Do not mix unrelated cleanup with a behavioral change. +Do not mix unrelated cleanup with behavioral changes. -Do not weaken a compatibility, coverage, dependency, or publication check just to make CI green. +Do not weaken a compatibility, coverage, dependency, publication, or security check just to make CI green. ## AI-assisted contributions diff --git a/README.md b/README.md index 4f6766c..35a4831 100644 --- a/README.md +++ b/README.md @@ -4,167 +4,123 @@ [![CI](https://github.com/aalsanie/codes/actions/workflows/ci.yml/badge.svg)](https://github.com/aalsanie/codes/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -Codes provides stable application outcome identities and explicit boundary mappings for JVM applications. Applications keep their existing domain result or error model; Codes gives the system one machine identity for an outcome while HTTP and gRPC remain boundary decisions. - -```text -com.example.orders:ORDER_NOT_FOUND - -> HTTP 404 - -> gRPC NOT_FOUND - -> metric label - -> log identity - -> test assertion +Codes is a lightweight Java library for reusable RFC 9457 problem type definitions. + +Spring provides `ProblemDetail` for an individual error occurrence. Codes provides the stable definition that can be reused across controllers, exception handlers, tests, and documentation. + +```java +ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" +); ``` -The same `OutcomeCode` can be used wherever application code, observability, and protocol boundaries need to agree on the outcome. +With Spring: -## Install +```java +ProblemDetail problem = ProblemDetails.forType(ORDER_NOT_FOUND); +``` -`0.4.0-RC1` is the current pre-release on Maven Central. +or with occurrence-specific detail: -Core: +```java +ProblemDetail problem = ProblemDetails.forTypeAndDetail( + ORDER_NOT_FOUND, + "Order o-123 was not found." +); +``` -```kotlin -dependencies { - implementation("io.github.aalsanie:codes:0.4.0-RC1") +The resulting problem keeps the reusable definition stable: + +```json +{ + "type": "https://api.example.com/problems/order-not-found", + "title": "Order not found", + "status": 404, + "detail": "Order o-123 was not found." } ``` -Spring: +## Install + +Core: ```kotlin dependencies { - implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") + implementation("io.github.aalsanie:codes:0.4.0") } ``` -gRPC Java: +Spring: ```kotlin dependencies { - implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") + implementation("io.github.aalsanie:codes-spring:0.4.0") } ``` -All artifacts require Java 17+. The core artifact has zero runtime dependencies. The Spring and gRPC artifacts depend only on the boundary libraries they adapt. +All artifacts require Java 17+. -For a boundary-first walkthrough: +The core artifact has no runtime dependencies and publishes no Maven dependencies. `codes-spring` depends on the core artifact but does not impose a Spring Framework version; the application supplies Spring Web. -* [Spring integration](docs/integration-spring.md) -* [gRPC integration](docs/integration-grpc.md) +## Define a problem catalog -## Custom outcomes +A problem type is an immutable value. Define reusable application problem types as constants: ```java -OutcomeDefinition paymentDeclined = OutcomeDefinition.custom( - "com.example.payments", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." -); - -Outcome outcome = Outcome.of(paymentDeclined); - -HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(paymentDeclined, HttpStatusCode.of(422)); - -GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping(paymentDeclined, GrpcStatusCode.FAILED_PRECONDITION); -``` - -`OutcomeCode` is the stable machine identity. Protocol mappings do not change that identity. - -## Standard outcomes - -```text -OK - -INVALID_ARGUMENT -UNAUTHENTICATED -PERMISSION_DENIED -NOT_FOUND -ALREADY_EXISTS -FAILED_PRECONDITION -OUT_OF_RANGE -RATE_LIMITED -CANCELLED -DEADLINE_EXCEEDED -ABORTED -UNIMPLEMENTED -UNAVAILABLE -INTERNAL -DATA_LOSS -RESOURCE_EXHAUSTED -``` - -`OK` is the standard successful outcome. Applications define domain-specific success, pending, and failure outcomes when the standard catalog does not match the operation. - -## Runtime occurrences - -```java -Outcome outcome = Outcome.of( - StandardOutcomes.NOT_FOUND, - "customerId=123" -); - -System.out.println(outcome.getCode()); -System.out.println(outcome.getMessage()); -System.out.println(outcome.getDetail()); -``` - -`message` comes from the reusable definition. `detail` belongs to one occurrence. - -## Structured issues - -```java -ValidationResult validation = ValidationResult.invalid( - Issue.at("email", "Invalid email address.") -); - -Outcome outcome = validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT); -``` - -`ValidationResult` is a small convenience for aggregating issues and converting them into an outcome. - -## HTTP - -```java -HttpStatusCode status = HttpOutcomeMapper.standard() - .map(StandardOutcomes.NOT_FOUND) - .orNull(); - -assert status == HttpStatusCode.NOT_FOUND; +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + static final ProblemType ORDER_ALREADY_CANCELLED = ProblemType.of( + URI.create("https://api.example.com/problems/order-already-cancelled"), + 409, + "Order already cancelled" + ); + + private OrderProblems() { + } +} ``` -Some standard outcomes are intentionally left unmapped for HTTP when the correct status depends on the application. +Codes requires an absolute type URI, rejects `about:blank`, requires an HTTP status from 100 through 599, and requires a non-blank title. Use Spring's native `ProblemDetail.forStatus(...)` for status-only `about:blank` responses. -## gRPC +Different application exceptions can then reuse the same public problem type: ```java -GrpcStatusCode status = GrpcOutcomeMapper.standard() - .map(StandardOutcomes.NOT_FOUND) - .orNull(); +@ExceptionHandler(OrderNotFoundException.class) +ProblemDetail handleOrderNotFound(OrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + ex.orderId() + " was not found." + ); +} -assert status == GrpcStatusCode.NOT_FOUND; +@ExceptionHandler(ArchivedOrderNotFoundException.class) +ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + ex.orderId() + " was not found." + ); +} ``` -The standard gRPC mapper covers all standard outcomes. - -## Kotlin +The library does not own exception handling, controller advice, localization, extension properties, or request-specific data. Those remain application and Spring concerns. -Java getters and static factories are directly usable as Kotlin properties and calls: +## When to use Codes -```kotlin -val outcome = Outcome.of(StandardOutcomes.NOT_FOUND, "customerId=123") -val status = HttpOutcomeMapper.standard().map(outcome).orNull() +If an application only creates one or two `ProblemDetail` instances directly, a local helper may be simpler. -check(outcome.code == StandardOutcomes.NOT_FOUND.code) -check(status?.value == 404) -``` +Codes is useful when problem types are part of the API contract and need to be defined once and reused consistently across multiple handlers, modules, tests, or documentation. ## Reference +* [Spring integration](docs/integration-spring.md) * [Semantic contract](docs/semantic-contract.md) -* [HTTP and gRPC mappings](docs/protocol-mappings.md) * [Compatibility policy](docs/compatibility-policy.md) ## License diff --git a/SECURITY.md b/SECURITY.md index 38b9d92..7d068c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,10 +13,10 @@ Do not use a public issue as a fallback. Please include, when applicable: - affected Codes version -- affected artifact: `codes`, `codes-spring`, or `codes-grpc-java` -- affected surface: core API, Spring boundary, gRPC boundary, publication, or build tooling +- affected artifact: `codes` or `codes-spring` +- affected surface: core API, Spring bridge, publication, or build tooling - Java and Kotlin versions -- Spring or gRPC version +- Spring version when `codes-spring` is involved - Gradle or Maven version - operating system - minimal reproduction steps @@ -32,16 +32,16 @@ Please avoid public disclosure until a fix or coordinated disclosure date is ava Security fixes target the latest stable release line. -Pre-release versions may receive a fix through a newer release candidate or the final release rather than a patch to the affected pre-release. +Older release lines and pre-release versions are not guaranteed to receive security fixes. -Older release lines are not guaranteed to receive security fixes. +## Application-controlled problem data -## Boundary data +Codes does not sanitize or redact application-provided problem data. -Codes can carry application-controlled occurrence `detail` and structured `Issue` content. +`ProblemType` stores the type URI, status, and title supplied by the application. `ProblemDetails.forTypeAndDetail(...)` copies the supplied detail into Spring's `ProblemDetail`. -The Spring and gRPC adapters use conservative defaults and do not expose protected occurrence detail unless the application explicitly opts in. +Treat titles and occurrence details as client-visible data. Do not expose exception messages, credentials, tokens, internal identifiers, stack traces, or other sensitive implementation details unless they are explicitly safe for the public API. -A case where safe defaults expose protected data is security-relevant and should be reported privately. +A case where Codes changes, corrupts, or unexpectedly exposes data beyond the values supplied through its public API is security-relevant and should be reported privately. -Suspected compromise of a published artifact, signature, or release process should also be reported privately. +Suspected compromise of a published artifact, signature, dependency metadata, or release process should also be reported privately. diff --git a/api/codes-grpc-java.api b/api/codes-grpc-java.api deleted file mode 100644 index c2b5402..0000000 --- a/api/codes-grpc-java.api +++ /dev/null @@ -1,24 +0,0 @@ -public final class io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper - ctor public (io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper, io.github.aalsanie.codes.grpc.GrpcOutcomeExposure) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper safeDefaults() - -public final class io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions - method public static io.github.aalsanie.codes.MappingResult toStatusRuntimeException(io.github.aalsanie.codes.Outcome, io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper) - -public final class io.github.aalsanie.codes.grpc.GrpcOutcomeExposure extends java.lang.Record - ctor public (boolean, boolean, boolean) - method public boolean exposeDetail() - method public boolean exposeIssues() - method public boolean exposeMessage() - method public final boolean equals(java.lang.Object) - method public final int hashCode() - method public final java.lang.String toString() - method public static io.github.aalsanie.codes.grpc.GrpcOutcomeExposure publicErrors() - method public static io.github.aalsanie.codes.grpc.GrpcOutcomeExposure safeDefaults() - -public final class io.github.aalsanie.codes.grpc.GrpcStatusMapper - ctor public (io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.grpc.GrpcStatusMapper standard() diff --git a/api/codes-spring.api b/api/codes-spring.api index 7867066..d636dc1 100644 --- a/api/codes-spring.api +++ b/api/codes-spring.api @@ -1,33 +1,3 @@ -public final class io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper - ctor public (io.github.aalsanie.codes.spring.SpringHttpStatusMapper, io.github.aalsanie.codes.spring.SpringOutcomeExposure) - ctor public (io.github.aalsanie.codes.spring.SpringHttpStatusMapper, io.github.aalsanie.codes.spring.SpringOutcomeExposure, io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper) - field public static final java.lang.String CODE_PROPERTY - field public static final java.lang.String ISSUES_PROPERTY - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper safeDefaults() - -public final class io.github.aalsanie.codes.spring.SpringHttpStatusMapper - ctor public (io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.spring.SpringHttpStatusMapper standard() - -public final class io.github.aalsanie.codes.spring.SpringOutcomeExceptions - method public static io.github.aalsanie.codes.MappingResult toErrorResponseException(io.github.aalsanie.codes.Outcome, io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper) - -public final class io.github.aalsanie.codes.spring.SpringOutcomeExposure extends java.lang.Record - ctor public (boolean, boolean, boolean) - method public boolean exposeDetail() - method public boolean exposeIssues() - method public boolean exposeMessage() - method public final boolean equals(java.lang.Object) - method public final int hashCode() - method public final java.lang.String toString() - method public static io.github.aalsanie.codes.spring.SpringOutcomeExposure publicErrors() - method public static io.github.aalsanie.codes.spring.SpringOutcomeExposure safeDefaults() - -public final class io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, java.net.URI) - method public static io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper empty() +public final class io.github.aalsanie.codes.spring.ProblemDetails + method public static org.springframework.http.ProblemDetail forType(io.github.aalsanie.codes.ProblemType) + method public static org.springframework.http.ProblemDetail forTypeAndDetail(io.github.aalsanie.codes.ProblemType, java.lang.String) diff --git a/api/codes.api b/api/codes.api index 33bfce1..e96ee95 100644 --- a/api/codes.api +++ b/api/codes.api @@ -1,223 +1,8 @@ -public final class io.github.aalsanie.codes.Issue +public final class io.github.aalsanie.codes.ProblemType method public boolean equals(java.lang.Object) + method public int getStatus() method public int hashCode() - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public java.lang.String getMessage() - method public java.lang.String getPath() + method public java.lang.String getTitle() method public java.lang.String toString() - method public static io.github.aalsanie.codes.Issue at(java.lang.String, io.github.aalsanie.codes.OutcomeCode, java.lang.String) - method public static io.github.aalsanie.codes.Issue at(java.lang.String, java.lang.String) - method public static io.github.aalsanie.codes.Issue coded(io.github.aalsanie.codes.OutcomeCode, java.lang.String) - method public static io.github.aalsanie.codes.Issue of(java.lang.String) - -public abstract class io.github.aalsanie.codes.MappingResult permits io.github.aalsanie.codes.MappingResult$Mapped, io.github.aalsanie.codes.MappingResult$Unmapped - method public abstract T orNull() - method public abstract boolean isMapped() - method public final R fold(java.util.function.Function, java.util.function.Supplier) - method public final boolean isUnmapped() - method public static io.github.aalsanie.codes.MappingResult mapped(T) - method public static io.github.aalsanie.codes.MappingResult unmapped() - -public static final class io.github.aalsanie.codes.MappingResult$Mapped extends io.github.aalsanie.codes.MappingResult - method public T getValue() - method public T orNull() - method public boolean equals(java.lang.Object) - method public boolean isMapped() - method public int hashCode() - method public java.lang.String toString() - -public static final class io.github.aalsanie.codes.MappingResult$Unmapped extends io.github.aalsanie.codes.MappingResult - method public T orNull() - method public boolean equals(java.lang.Object) - method public boolean isMapped() - method public int hashCode() - method public java.lang.String toString() - -public final class io.github.aalsanie.codes.Outcome - method public boolean isFailed() - method public boolean isPending() - method public boolean isSuccessful() - method public boolean isTerminal() - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public io.github.aalsanie.codes.OutcomeDefinition getDefinition() - method public io.github.aalsanie.codes.OutcomeState getState() - method public java.lang.String getDefaultMessage() - method public java.lang.String getDetail() - method public java.lang.String getMessage() - method public java.lang.String toString() - method public java.util.List getIssues() - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String) - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String, java.util.List) - -public final class io.github.aalsanie.codes.OutcomeCode implements java.lang.Comparable - method public boolean equals(java.lang.Object) - method public int compareTo(io.github.aalsanie.codes.OutcomeCode) - method public int hashCode() - method public java.lang.String getName() - method public java.lang.String getNamespace() - method public java.lang.String getValue() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.OutcomeCode of(java.lang.String, java.lang.String) - method public static io.github.aalsanie.codes.OutcomeCode parse(java.lang.String) - method public static io.github.aalsanie.codes.OutcomeCode parseOrNull(java.lang.String) - -public final class io.github.aalsanie.codes.OutcomeDefinition - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public io.github.aalsanie.codes.OutcomeState getState() - method public java.lang.String getDefaultMessage() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.OutcomeDefinition custom(io.github.aalsanie.codes.OutcomeCode, io.github.aalsanie.codes.OutcomeState, java.lang.String) - method public static io.github.aalsanie.codes.OutcomeDefinition custom(java.lang.String, java.lang.String, io.github.aalsanie.codes.OutcomeState, java.lang.String) - -public final class io.github.aalsanie.codes.OutcomeException extends java.lang.RuntimeException - ctor public (io.github.aalsanie.codes.Outcome) - ctor public (io.github.aalsanie.codes.Outcome, java.lang.Throwable) - method public io.github.aalsanie.codes.Outcome getOutcome() - -public final class io.github.aalsanie.codes.OutcomeExceptions - method public static io.github.aalsanie.codes.OutcomeException toException(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.OutcomeException toException(io.github.aalsanie.codes.Outcome, java.lang.Throwable) - -public abstract interface io.github.aalsanie.codes.OutcomeMapper - method public abstract io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - -public final class io.github.aalsanie.codes.OutcomeRegistry - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.OutcomeDefinition find(io.github.aalsanie.codes.OutcomeCode) - method public io.github.aalsanie.codes.OutcomeDefinition find(java.lang.String) - method public io.github.aalsanie.codes.OutcomeDefinition require(io.github.aalsanie.codes.OutcomeCode) - method public io.github.aalsanie.codes.OutcomeRegistry with(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.OutcomeRegistry withAll(java.lang.Iterable) - method public java.util.List definitions() - method public static io.github.aalsanie.codes.OutcomeRegistry empty() - method public static io.github.aalsanie.codes.OutcomeRegistry of(io.github.aalsanie.codes.OutcomeDefinition[]) - method public static io.github.aalsanie.codes.OutcomeRegistry standard() - -public final enum io.github.aalsanie.codes.OutcomeState - field public static final io.github.aalsanie.codes.OutcomeState FAILED - field public static final io.github.aalsanie.codes.OutcomeState PENDING - field public static final io.github.aalsanie.codes.OutcomeState SUCCEEDED - method public static io.github.aalsanie.codes.OutcomeState valueOf(java.lang.String) - method public static io.github.aalsanie.codes.OutcomeState[] values() - -public final class io.github.aalsanie.codes.StandardOutcomes - field public static final io.github.aalsanie.codes.OutcomeDefinition ABORTED - field public static final io.github.aalsanie.codes.OutcomeDefinition ALREADY_EXISTS - field public static final io.github.aalsanie.codes.OutcomeDefinition CANCELLED - field public static final io.github.aalsanie.codes.OutcomeDefinition DATA_LOSS - field public static final io.github.aalsanie.codes.OutcomeDefinition DEADLINE_EXCEEDED - field public static final io.github.aalsanie.codes.OutcomeDefinition FAILED_PRECONDITION - field public static final io.github.aalsanie.codes.OutcomeDefinition INTERNAL - field public static final io.github.aalsanie.codes.OutcomeDefinition INVALID_ARGUMENT - field public static final io.github.aalsanie.codes.OutcomeDefinition NOT_FOUND - field public static final io.github.aalsanie.codes.OutcomeDefinition OK - field public static final io.github.aalsanie.codes.OutcomeDefinition OUT_OF_RANGE - field public static final io.github.aalsanie.codes.OutcomeDefinition PERMISSION_DENIED - field public static final io.github.aalsanie.codes.OutcomeDefinition RATE_LIMITED - field public static final io.github.aalsanie.codes.OutcomeDefinition RESOURCE_EXHAUSTED - field public static final io.github.aalsanie.codes.OutcomeDefinition UNAUTHENTICATED - field public static final io.github.aalsanie.codes.OutcomeDefinition UNAVAILABLE - field public static final io.github.aalsanie.codes.OutcomeDefinition UNIMPLEMENTED - field public static final java.lang.String NAMESPACE - field public static final java.util.List all - -public abstract class io.github.aalsanie.codes.ValidationResult permits io.github.aalsanie.codes.ValidationResult$Invalid, io.github.aalsanie.codes.ValidationResult$Valid - method public abstract java.util.List issues() - method public final boolean isInvalid() - method public final boolean isValid() - method public final io.github.aalsanie.codes.Outcome toOutcome(io.github.aalsanie.codes.OutcomeDefinition) - method public final io.github.aalsanie.codes.Outcome toOutcome(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String) - method public static io.github.aalsanie.codes.ValidationResult combine(io.github.aalsanie.codes.ValidationResult[]) - method public static io.github.aalsanie.codes.ValidationResult combine(java.util.List) - method public static io.github.aalsanie.codes.ValidationResult invalid(io.github.aalsanie.codes.Issue) - method public static io.github.aalsanie.codes.ValidationResult invalid(java.util.List) - method public static io.github.aalsanie.codes.ValidationResult valid() - -public static final class io.github.aalsanie.codes.ValidationResult$Invalid extends io.github.aalsanie.codes.ValidationResult - method public boolean equals(java.lang.Object) - method public int hashCode() - method public java.lang.String toString() - method public java.util.List getIssues() - method public java.util.List issues() - -public static final class io.github.aalsanie.codes.ValidationResult$Valid extends io.github.aalsanie.codes.ValidationResult - method public boolean equals(java.lang.Object) - method public int hashCode() - method public java.lang.String toString() - method public java.util.List issues() - -public final class io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper implements io.github.aalsanie.codes.OutcomeMapper - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode) - method public io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper withOverride(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode) - method public java.util.Map mappings() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper empty() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper standard() - -public final enum io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode ABORTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode ALREADY_EXISTS - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode CANCELLED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode DATA_LOSS - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode DEADLINE_EXCEEDED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode FAILED_PRECONDITION - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode INTERNAL - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode INVALID_ARGUMENT - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode NOT_FOUND - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode OK - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode OUT_OF_RANGE - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode PERMISSION_DENIED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode RESOURCE_EXHAUSTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNAUTHENTICATED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNAVAILABLE - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNIMPLEMENTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNKNOWN - method public int getValue() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode fromValue(int) - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode valueOf(java.lang.String) - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode[] values() - -public final class io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper implements io.github.aalsanie.codes.OutcomeMapper - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper withOverride(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public java.util.Map mappings() - method public static io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper empty() - method public static io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper standard() - -public final class io.github.aalsanie.codes.protocol.http.HttpStatusCode implements java.lang.Comparable - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode ACCEPTED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode BAD_REQUEST - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode CONFLICT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode CREATED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode FORBIDDEN - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode GATEWAY_TIMEOUT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode INTERNAL_SERVER_ERROR - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NOT_FOUND - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NOT_IMPLEMENTED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NO_CONTENT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode OK - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode PAYLOAD_TOO_LARGE - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode PRECONDITION_FAILED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode SERVICE_UNAVAILABLE - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode TOO_MANY_REQUESTS - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode UNAUTHORIZED - method public boolean equals(java.lang.Object) - method public boolean isClientError() - method public boolean isInformational() - method public boolean isRedirection() - method public boolean isServerError() - method public boolean isSuccessful() - method public int compareTo(io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public int getFamily() - method public int getValue() - method public int hashCode() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.protocol.http.HttpStatusCode of(int) \ No newline at end of file + method public java.net.URI getType() + method public static io.github.aalsanie.codes.ProblemType of(java.net.URI, int, java.lang.String) diff --git a/build.gradle.kts b/build.gradle.kts index 18085b5..76e8135 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,10 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +sourceSets.test { + java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) +} + tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) @@ -70,11 +74,8 @@ tasks.register("verifyCoreRuntimeDependencies") { doLast { val runtimeFiles = runtimeClasspath.get().files - check(runtimeFiles.isEmpty()) { - "Codes core must remain dependency-free at runtime: ${ - runtimeFiles.joinToString { it.name } - }" + "Codes core must remain dependency-free at runtime: ${runtimeFiles.joinToString { it.name }}" } } } @@ -95,7 +96,6 @@ tasks.register("verifyPublishedPomHasNoDependencies") { .parse(pomFile.get().asFile) val dependencies = document.getElementsByTagName("dependency") - check(dependencies.length == 0) { "Codes core Maven POM must not declare dependencies; found ${dependencies.length}." } @@ -121,13 +121,11 @@ tasks.jar { tasks.register("verifyAll") { group = "verification" - description = "Verifies the core, incubating adapters, and reference applications." + description = "Verifies Codes, the Spring bridge, and the reference application." dependsOn( tasks.check, ":codes-spring:check", - ":codes-grpc-java:check", ":reference-spring-orders:check", - ":reference-grpc-orders:check", ) } diff --git a/codes-grpc-java/build.gradle.kts b/codes-grpc-java/build.gradle.kts deleted file mode 100644 index 9f855b8..0000000 --- a/codes-grpc-java/build.gradle.kts +++ /dev/null @@ -1,184 +0,0 @@ -plugins { - `java-library` - jacoco - id("com.vanniktech.maven.publish") -} - -group = providers.gradleProperty("GROUP").get() -version = providers.gradleProperty("VERSION_NAME").get() - -val artifactId = "codes-grpc-java" -val pomName = "Codes gRPC Java Adapter" -val pomDescription = "gRPC Java and google.rpc boundary adapter for Codes application outcomes." -val expectedPomDependencies = listOf( - "${project.group}:codes:${project.version}:compile", - "com.google.api.grpc:proto-google-common-protos:${providers.gradleProperty("protoGoogleCommonProtosVersion").get()}:compile", - "io.grpc:grpc-api:${providers.gradleProperty("grpcVersion").get()}:compile", - "io.grpc:grpc-protobuf:${providers.gradleProperty("grpcVersion").get()}:runtime", -).sorted() - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} - -tasks.withType().configureEach { - options.release.set(17) - options.encoding = "UTF-8" - options.compilerArgs.addAll(listOf("-Xlint:all,-serial", "-Werror")) -} - -dependencies { - compileOnly("org.jspecify:jspecify:1.0.0") - - api(project(":")) - api("io.grpc:grpc-api:${providers.gradleProperty("grpcVersion").get()}") - api("com.google.api.grpc:proto-google-common-protos:${providers.gradleProperty("protoGoogleCommonProtosVersion").get()}") - implementation("io.grpc:grpc-protobuf:${providers.gradleProperty("grpcVersion").get()}") - - testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.test { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) - systemProperty( - "codes.apiSnapshot", - rootProject.file("api/codes-grpc-java.api").absolutePath, - ) - systemProperty( - "codes.grpcStatusSnapshot", - rootProject.file("compatibility/grpc-google-rpc-status.snapshot").absolutePath, - ) -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - html.required.set(true) - xml.required.set(true) - } -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { - rule { - limit { - counter = "LINE" - minimum = "0.90".toBigDecimal() - } - limit { - counter = "BRANCH" - minimum = "0.80".toBigDecimal() - } - } - } -} - -sourceSets.test { - java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) -} - -tasks.register("verifyPublishedPomContract") { - group = "verification" - description = "Verifies the Codes gRPC Java POM metadata and direct dependency budget." - - dependsOn("generatePomFileForMavenPublication") - - val artifactId = artifactId - val pomName = pomName - val pomDescription = pomDescription - val expectedPomDependencies = expectedPomDependencies - val pomFile = layout.buildDirectory.file("publications/maven/pom-default.xml") - inputs.file(pomFile) - - doLast { - val document = javax.xml.parsers.DocumentBuilderFactory - .newInstance() - .newDocumentBuilder() - .parse(pomFile.get().asFile) - val projectElement = document.documentElement - - fun directText(name: String): String = (0 until projectElement.childNodes.length) - .map(projectElement.childNodes::item) - .first { it.nodeName == name } - .textContent - - check(directText("name") == pomName) { - "Unexpected $artifactId POM name: ${directText("name")}" - } - check(directText("description") == pomDescription) { - "Unexpected $artifactId POM description: ${directText("description")}" - } - - val dependencies = document.getElementsByTagName("dependency") - val actual = (0 until dependencies.length).map { index -> - val dependency = dependencies.item(index) as org.w3c.dom.Element - fun value(name: String): String = dependency.getElementsByTagName(name).item(0).textContent - listOf("groupId", "artifactId", "version", "scope") - .joinToString(":") { value(it) } - }.sorted() - - check(actual == expectedPomDependencies) { - "$artifactId direct dependency budget changed. " + - "Expected $expectedPomDependencies, found $actual." - } - } -} - -tasks.check { - dependsOn(tasks.jacocoTestCoverageVerification) - dependsOn(tasks.named("verifyPublishedPomContract")) -} - -tasks.jar { - manifest { - attributes["Automatic-Module-Name"] = "io.github.aalsanie.codes.grpc" - } -} - -tasks.withType().configureEach { - isPreserveFileTimestamps = false - isReproducibleFileOrder = true -} - -val signingConfigured = providers.gradleProperty("signingInMemoryKey").isPresent - -mavenPublishing { - coordinates( - providers.gradleProperty("GROUP").get(), - artifactId, - providers.gradleProperty("VERSION_NAME").get(), - ) - - pom { - name.set(pomName) - description.set(pomDescription) - } - - publishToMavenCentral(automaticRelease = true) - - if (signingConfigured) { - signAllPublications() - } -} - -tasks.configureEach { - if (name.contains("MavenCentral", ignoreCase = true)) { - doFirst { - val required = listOf("mavenCentralUsername", "mavenCentralPassword", "signingInMemoryKey") - val missing = required.filterNot { providers.gradleProperty(it).isPresent } - require(missing.isEmpty()) { - "Maven Central publication requires Gradle properties: ${missing.joinToString()}" - } - } - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java deleted file mode 100644 index 348de95..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java +++ /dev/null @@ -1,181 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import com.google.protobuf.Any; -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import java.util.Objects; -import java.util.regex.Pattern; - -/** - * Maps failed Codes outcomes to {@code google.rpc.Status}. - * - *

The Codes identity is represented losslessly as {@link ErrorInfo#getDomain() domain} = - * {@link OutcomeCode#getNamespace() namespace} and {@link ErrorInfo#getReason() reason} = - * {@link OutcomeCode#getName() name}. Codes never truncates or normalizes identity to make it fit - * the Google RPC contract. A mapped outcome whose name cannot be represented as an - * {@code ErrorInfo.reason} is rejected. - * - *

Structured issues are exposed through {@link BadRequest} only for gRPC - * {@code INVALID_ARGUMENT} and {@code OUT_OF_RANGE}. Every exposed issue must have a path that the - * application intends as a request-field path. Pathless issues cannot be represented honestly as - * {@link BadRequest.FieldViolation} and are rejected instead of being emitted with an empty field. - * - *

When a coded {@link Issue} is exposed through {@link BadRequest.FieldViolation#getReason() - * reason}, its namespace must match the enclosing outcome namespace. This keeps the field-level - * reason scoped by the same {@code ErrorInfo.domain} and avoids silently changing issue identity. - */ -public final class GoogleRpcOutcomeMapper { - private static final int MAX_GOOGLE_REASON_LENGTH = 63; - private static final String GOOGLE_REASON_EXPRESSION = "[A-Z][A-Z0-9_]+[A-Z0-9]"; - private static final Pattern GOOGLE_REASON_PATTERN = - Pattern.compile(GOOGLE_REASON_EXPRESSION); - - private final GrpcOutcomeMapper mapper; - private final GrpcOutcomeExposure exposure; - - public GoogleRpcOutcomeMapper(GrpcOutcomeMapper mapper, GrpcOutcomeExposure exposure) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - this.exposure = Objects.requireNonNull(exposure, "exposure"); - } - - public static GoogleRpcOutcomeMapper safeDefaults() { - return new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard(), - GrpcOutcomeExposure.safeDefaults() - ); - } - - public MappingResult map(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException( - "google.rpc.Status error details are only produced for failed outcomes" - ); - } - - return mapper.map(outcome).fold( - status -> MappingResult.mapped(createStatus(outcome, status)), - MappingResult::unmapped - ); - } - - private com.google.rpc.Status createStatus(Outcome outcome, GrpcStatusCode statusCode) { - OutcomeCode outcomeCode = outcome.getCode(); - requireGoogleReason(outcomeCode, "outcome code", "google.rpc.ErrorInfo.reason"); - - com.google.rpc.Status.Builder status = com.google.rpc.Status.newBuilder() - .setCode(statusCode.getValue()) - .setMessage(exposure.exposeMessage() ? outcome.getMessage() : outcomeCode.getValue()) - .addDetails(Any.pack( - ErrorInfo.newBuilder() - .setDomain(outcomeCode.getNamespace()) - .setReason(outcomeCode.getName()) - .build() - )); - - if (exposure.exposeDetail() && outcome.getDetail() != null) { - status.addDetails(Any.pack( - DebugInfo.newBuilder() - .setDetail(outcome.getDetail()) - .build() - )); - } - - if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) { - status.addDetails(Any.pack(toBadRequest(outcome, statusCode))); - } - - return status.build(); - } - - private static BadRequest toBadRequest(Outcome outcome, GrpcStatusCode statusCode) { - requireBadRequestStatus(outcome, statusCode); - - OutcomeCode outcomeCode = outcome.getCode(); - BadRequest.Builder request = BadRequest.newBuilder(); - - for (Issue issue : outcome.getIssues()) { - String path = issue.getPath(); - if (path == null) { - throw new IllegalArgumentException( - "issue on outcome '" + outcomeCode.getValue() - + "' cannot be represented as google.rpc.BadRequest.FieldViolation" - + "; exposed gRPC issues must have a request-field path" - ); - } - - BadRequest.FieldViolation.Builder violation = BadRequest.FieldViolation.newBuilder() - .setField(path) - .setDescription(issue.getMessage()); - - OutcomeCode issueCode = issue.getCode(); - if (issueCode != null) { - requireFieldViolationIdentity(outcomeCode, issueCode); - violation.setReason(issueCode.getName()); - } - - request.addFieldViolations(violation); - } - - return request.build(); - } - - private static void requireBadRequestStatus(Outcome outcome, GrpcStatusCode statusCode) { - if (statusCode == GrpcStatusCode.INVALID_ARGUMENT - || statusCode == GrpcStatusCode.OUT_OF_RANGE) { - return; - } - - throw new IllegalArgumentException( - "issues on outcome '" + outcome.getCode().getValue() - + "' cannot be represented as google.rpc.BadRequest when mapped to gRPC " - + statusCode - + "; BadRequest issue exposure requires INVALID_ARGUMENT or OUT_OF_RANGE" - ); - } - - private static void requireFieldViolationIdentity( - OutcomeCode outcomeCode, - OutcomeCode issueCode - ) { - if (!outcomeCode.getNamespace().equals(issueCode.getNamespace())) { - throw new IllegalArgumentException( - "issue code '" + issueCode.getValue() - + "' cannot be represented losslessly as google.rpc.BadRequest.FieldViolation" - + ".reason under ErrorInfo.domain '" + outcomeCode.getNamespace() - + "'; coded issues must use the same namespace as the enclosing outcome" - ); - } - - requireGoogleReason( - issueCode, - "issue code", - "google.rpc.BadRequest.FieldViolation.reason" - ); - } - - private static void requireGoogleReason( - OutcomeCode code, - String label, - String target - ) { - String reason = code.getName(); - if (reason.length() <= MAX_GOOGLE_REASON_LENGTH - && GOOGLE_REASON_PATTERN.matcher(reason).matches()) { - return; - } - - throw new IllegalArgumentException( - label + " '" + code.getValue() + "' cannot be represented losslessly as " + target - + "; reason must be at most " + MAX_GOOGLE_REASON_LENGTH - + " characters and match " + GOOGLE_REASON_EXPRESSION - ); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java deleted file mode 100644 index 8db8eeb..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import java.util.Objects; - -public final class GrpcOutcomeExceptions { - private GrpcOutcomeExceptions() { - } - - public static MappingResult toStatusRuntimeException( - Outcome outcome, - GoogleRpcOutcomeMapper mapper - ) { - Objects.requireNonNull(outcome, "outcome"); - Objects.requireNonNull(mapper, "mapper"); - return mapper.map(outcome).fold( - status -> MappingResult.mapped(StatusProto.toStatusRuntimeException(status)), - MappingResult::unmapped - ); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java deleted file mode 100644 index 85925cf..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -public record GrpcOutcomeExposure( - boolean exposeMessage, - boolean exposeDetail, - boolean exposeIssues -) { - public static GrpcOutcomeExposure safeDefaults() { - return new GrpcOutcomeExposure(false, false, false); - } - - public static GrpcOutcomeExposure publicErrors() { - return new GrpcOutcomeExposure(true, false, true); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java deleted file mode 100644 index 1b86d2c..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.grpc.Status; -import java.util.Objects; - -public final class GrpcStatusMapper { - private final GrpcOutcomeMapper mapper; - - public GrpcStatusMapper(GrpcOutcomeMapper mapper) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - } - - public static GrpcStatusMapper standard() { - return new GrpcStatusMapper(GrpcOutcomeMapper.standard()); - } - - public MappingResult map(OutcomeDefinition definition) { - return mapper.map(Objects.requireNonNull(definition, "definition")) - .fold( - status -> MappingResult.mapped(Status.fromCodeValue(status.getValue())), - MappingResult::unmapped - ); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java deleted file mode 100644 index 87762a0..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.grpc; - -import org.jspecify.annotations.NullMarked; diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java deleted file mode 100644 index 1700531..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.github.aalsanie.codes.testing.PublicApiSnapshot; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; - -class ApiSnapshotTest { - @Test - void publicApiMatchesSnapshot() throws IOException { - String expected = Files.readString(Path.of(System.getProperty("codes.apiSnapshot"))) - .replace("\r\n", "\n") - .stripTrailing(); - - assertEquals( - expected, - PublicApiSnapshot.create(GoogleRpcOutcomeMapper.class, "io.github.aalsanie.codes.grpc") - ); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java deleted file mode 100644 index 1202815..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java +++ /dev/null @@ -1,294 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import java.util.List; -import org.junit.jupiter.api.Test; - -class GoogleRpcOutcomeMapperTest { - private static final String APP_NAMESPACE = "com.example.checkout"; - - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - APP_NAMESPACE, - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." - ); - - private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom( - APP_NAMESPACE, - "CHECKOUT_INVALID", - OutcomeState.FAILED, - "Checkout request is invalid." - ); - - private static final OutcomeDefinition RANGE_INVALID = OutcomeDefinition.custom( - APP_NAMESPACE, - "RANGE_INVALID", - OutcomeState.FAILED, - "Requested range is invalid." - ); - - private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of( - APP_NAMESPACE, - "PAYMENT_METHOD_INVALID" - ); - - private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard() - .withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION) - .withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT) - .withMapping(RANGE_INVALID, GrpcStatusCode.OUT_OF_RANGE); - - @Test - void safeDefaultsExposeOnlyMachineIdentity() throws Exception { - Outcome outcome = paymentDeclinedOutcome(); - GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.safeDefaults()); - - com.google.rpc.Status status = mapper.map(outcome).orNull(); - - assertNotNull(status); - assertEquals(9, status.getCode()); - assertEquals(outcome.getCode().getValue(), status.getMessage()); - assertEquals(1, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals(outcome.getCode().getNamespace(), info.getDomain()); - assertEquals(outcome.getCode().getName(), info.getReason()); - } - - @Test - void publicErrorsExposeRequestIssuesButNotOccurrenceDetail() throws Exception { - Outcome outcome = validationOutcome(); - GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.publicErrors()); - - com.google.rpc.Status status = mapper.map(outcome).orNull(); - - assertNotNull(status); - assertEquals(3, status.getCode()); - assertEquals(outcome.getMessage(), status.getMessage()); - assertEquals(2, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals(APP_NAMESPACE, info.getDomain()); - assertEquals("CHECKOUT_INVALID", info.getReason()); - - BadRequest request = status.getDetails(1).unpack(BadRequest.class); - assertEquals(2, request.getFieldViolationsCount()); - - BadRequest.FieldViolation coded = request.getFieldViolations(0); - assertEquals("paymentMethod", coded.getField()); - assertEquals("PAYMENT_METHOD_INVALID", coded.getReason()); - assertEquals("Payment method is invalid.", coded.getDescription()); - - BadRequest.FieldViolation uncoded = request.getFieldViolations(1); - assertEquals("amount", uncoded.getField()); - assertEquals("", uncoded.getReason()); - assertEquals("Amount must be positive.", uncoded.getDescription()); - } - - @Test - void explicitDetailExposureAddsDebugInfo() throws Exception { - Outcome outcome = validationOutcome(); - GoogleRpcOutcomeMapper mapper = mapper(new GrpcOutcomeExposure(true, true, true)); - - com.google.rpc.Status status = mapper.map(outcome).orNull(); - - assertNotNull(status); - assertEquals(3, status.getDetailsCount()); - DebugInfo debugInfo = status.getDetails(1).unpack(DebugInfo.class); - assertEquals("gateway_token=secret-123", debugInfo.getDetail()); - assertTrue(status.getDetails(2).is(BadRequest.class)); - } - - @Test - void outOfRangeCanExposeBadRequest() throws Exception { - Outcome outcome = Outcome.of( - RANGE_INVALID, - null, - List.of(Issue.at("offset", "Offset is outside the available range.")) - ); - - com.google.rpc.Status status = mapper(GrpcOutcomeExposure.publicErrors()) - .map(outcome) - .orNull(); - - assertNotNull(status); - assertEquals(11, status.getCode()); - BadRequest request = status.getDetails(1).unpack(BadRequest.class); - assertEquals("offset", request.getFieldViolations(0).getField()); - } - - @Test - void rejectsBadRequestIssuesForFailedPrecondition() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper(GrpcOutcomeExposure.publicErrors()).map(paymentDeclinedOutcome()) - ); - - assertTrue(exception.getMessage().contains("FAILED_PRECONDITION")); - assertTrue(exception.getMessage().contains("INVALID_ARGUMENT or OUT_OF_RANGE")); - } - - @Test - void rejectsPathlessIssueAsBadRequestFieldViolation() { - Outcome outcome = Outcome.of( - CHECKOUT_INVALID, - null, - List.of(Issue.of("The request is invalid.")) - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper(GrpcOutcomeExposure.publicErrors()).map(outcome) - ); - - assertTrue(exception.getMessage().contains("request-field path")); - assertTrue(exception.getMessage().contains(CHECKOUT_INVALID.getCode().getValue())); - } - - @Test - void rejectsOutcomeNamesLongerThanErrorInfoReasonAllows() { - OutcomeDefinition incompatible = OutcomeDefinition.custom( - "com.example.compatibility", - "A".repeat(64), - OutcomeState.FAILED, - "Incompatible reason." - ); - GoogleRpcOutcomeMapper mapper = mapperFor( - incompatible, - GrpcOutcomeExposure.safeDefaults() - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper.map(Outcome.of(incompatible)) - ); - - assertTrue(exception.getMessage().contains("at most 63")); - assertTrue(exception.getMessage().contains(incompatible.getCode().getValue())); - } - - @Test - void rejectsTrailingUnderscoreThatCannotBeErrorInfoReason() { - OutcomeDefinition incompatible = OutcomeDefinition.custom( - "com.example.compatibility", - "PAYMENT_DECLINED_", - OutcomeState.FAILED, - "Incompatible reason." - ); - GoogleRpcOutcomeMapper mapper = mapperFor( - incompatible, - GrpcOutcomeExposure.safeDefaults() - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper.map(Outcome.of(incompatible)) - ); - - assertTrue(exception.getMessage().contains("[A-Z][A-Z0-9_]+[A-Z0-9]")); - } - - @Test - void rejectsCodedIssueFromDifferentDomainInsteadOfChangingItsIdentity() { - OutcomeCode differentDomain = OutcomeCode.of( - "com.example.validation", - "PAYMENT_METHOD_INVALID" - ); - Outcome outcome = Outcome.of( - CHECKOUT_INVALID, - null, - List.of( - Issue.at( - "paymentMethod", - differentDomain, - "Payment method is invalid." - ) - ) - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper(GrpcOutcomeExposure.publicErrors()).map(outcome) - ); - - assertTrue(exception.getMessage().contains("same namespace")); - assertTrue(exception.getMessage().contains(differentDomain.getValue())); - } - - @Test - void richErrorMapperRejectsSuccessfulOutcomes() { - assertThrows( - IllegalArgumentException.class, - () -> GoogleRpcOutcomeMapper.safeDefaults().map( - Outcome.of(io.github.aalsanie.codes.StandardOutcomes.OK) - ) - ); - } - - @Test - void exposurePoliciesAreExplicit() { - assertEquals( - new GrpcOutcomeExposure(false, false, false), - GrpcOutcomeExposure.safeDefaults() - ); - assertEquals( - new GrpcOutcomeExposure(true, false, true), - GrpcOutcomeExposure.publicErrors() - ); - assertFalse(GrpcOutcomeExposure.safeDefaults().exposeIssues()); - assertTrue(GrpcOutcomeExposure.publicErrors().exposeIssues()); - } - - private static Outcome validationOutcome() { - return Outcome.of( - CHECKOUT_INVALID, - "gateway_token=secret-123", - List.of( - Issue.at( - "paymentMethod", - PAYMENT_METHOD_INVALID, - "Payment method is invalid." - ), - Issue.at("amount", "Amount must be positive.") - ) - ); - } - - private static Outcome paymentDeclinedOutcome() { - return Outcome.of( - PAYMENT_DECLINED, - "gateway_token=secret-123", - List.of(Issue.of("Payment cannot be completed.")) - ); - } - - private static GoogleRpcOutcomeMapper mapper(GrpcOutcomeExposure exposure) { - return new GoogleRpcOutcomeMapper(APP_MAPPER, exposure); - } - - private static GoogleRpcOutcomeMapper mapperFor( - OutcomeDefinition definition, - GrpcOutcomeExposure exposure - ) { - return new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard() - .withMapping(definition, GrpcStatusCode.INTERNAL), - exposure - ); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java deleted file mode 100644 index 13143a3..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.Status; -import org.junit.jupiter.api.Test; - -class GrpcStatusMapperTest { - @Test - void mapsCoreGrpcStatusToGrpcJava() { - assertEquals(Status.Code.NOT_FOUND, GrpcStatusMapper.standard().map(StandardOutcomes.NOT_FOUND).orNull().getCode()); - } - - @Test - void preservesApplicationMappingPolicy() { - OutcomeDefinition rejected = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - GrpcOutcomeMapper core = GrpcOutcomeMapper.standard().withMapping(rejected, GrpcStatusCode.FAILED_PRECONDITION); - assertEquals(Status.Code.FAILED_PRECONDITION, new GrpcStatusMapper(core).map(rejected).orNull().getCode()); - assertTrue(GrpcStatusMapper.standard().map(rejected).isUnmapped()); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java deleted file mode 100644 index 8e44c92..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.protobuf.Any; -import com.google.protobuf.InvalidProtocolBufferException; -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; - -class GrpcStatusRuntimeExceptionRoundTripTest { - private static final String APP_NAMESPACE = "com.example.checkout"; - private static final String PROTECTED_DETAIL = "gateway_token=secret-123"; - private static final String PROTECTED_ISSUE_MESSAGE = "Payment method is invalid."; - - private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom( - APP_NAMESPACE, - "CHECKOUT_INVALID", - OutcomeState.FAILED, - "Checkout request is invalid." - ); - - private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of( - APP_NAMESPACE, - "PAYMENT_METHOD_INVALID" - ); - - private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard() - .withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT); - - @Test - void decodedStatusRuntimeExceptionsMatchGoldenContract() throws Exception { - Outcome outcome = testOutcome(); - - com.google.rpc.Status safe = roundTrip( - outcome, - new GoogleRpcOutcomeMapper(APP_MAPPER, GrpcOutcomeExposure.safeDefaults()) - ); - com.google.rpc.Status publicErrors = roundTrip( - outcome, - new GoogleRpcOutcomeMapper(APP_MAPPER, GrpcOutcomeExposure.publicErrors()) - ); - com.google.rpc.Status explicit = roundTrip( - outcome, - new GoogleRpcOutcomeMapper( - APP_MAPPER, - new GrpcOutcomeExposure(true, true, true) - ) - ); - - assertIdentity(safe, outcome.getCode()); - assertIdentity(publicErrors, outcome.getCode()); - assertIdentity(explicit, outcome.getCode()); - - assertProtectedPayloadAbsent(safe); - assertProtectedDetailAbsent(publicErrors); - assertEquals(PROTECTED_DETAIL, debugInfo(explicit).orElseThrow().getDetail()); - - String actual = String.join( - "\n\n", - snapshot("safe", safe), - snapshot("public", publicErrors), - snapshot("explicit", explicit) - ); - - String expected = Files.readString( - Path.of(System.getProperty("codes.grpcStatusSnapshot")) - ).replace("\r\n", "\n").stripTrailing(); - - assertEquals(expected, actual); - } - - private static com.google.rpc.Status roundTrip( - Outcome outcome, - GoogleRpcOutcomeMapper mapper - ) { - StatusRuntimeException exception = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, mapper) - .orNull(); - - assertNotNull(exception); - assertEquals( - io.grpc.Status.Code.INVALID_ARGUMENT, - exception.getStatus().getCode() - ); - - com.google.rpc.Status decoded = StatusProto.fromThrowable(exception); - assertNotNull(decoded); - return decoded; - } - - private static void assertIdentity( - com.google.rpc.Status status, - OutcomeCode expected - ) throws InvalidProtocolBufferException { - ErrorInfo info = errorInfo(status); - OutcomeCode decoded = OutcomeCode.of(info.getDomain(), info.getReason()); - assertEquals(expected, decoded); - } - - private static void assertProtectedPayloadAbsent(com.google.rpc.Status status) { - assertEquals(1, status.getDetailsCount()); - assertTrue(debugInfo(status).isEmpty()); - assertTrue(badRequest(status).isEmpty()); - - String serialized = new String( - status.toByteArray(), - StandardCharsets.ISO_8859_1 - ); - assertFalse(serialized.contains(PROTECTED_DETAIL)); - assertFalse(serialized.contains(PROTECTED_ISSUE_MESSAGE)); - } - - private static void assertProtectedDetailAbsent(com.google.rpc.Status status) { - assertTrue(debugInfo(status).isEmpty()); - - String serialized = new String( - status.toByteArray(), - StandardCharsets.ISO_8859_1 - ); - assertFalse(serialized.contains(PROTECTED_DETAIL)); - } - - private static String snapshot( - String name, - com.google.rpc.Status status - ) throws InvalidProtocolBufferException { - ErrorInfo info = errorInfo(status); - Optional debugInfo = debugInfo(status); - Optional badRequest = badRequest(status); - - StringBuilder result = new StringBuilder(); - result.append('[').append(name).append("]\n") - .append("code=").append(status.getCode()).append('\n') - .append("message=").append(status.getMessage()).append('\n') - .append("details=").append(status.getDetailsCount()).append('\n') - .append("identity.domain=").append(info.getDomain()).append('\n') - .append("identity.reason=").append(info.getReason()).append('\n') - .append("debug=") - .append(debugInfo.map(DebugInfo::getDetail).orElse("")) - .append('\n'); - - if (badRequest.isEmpty()) { - result.append("issues=0"); - return result.toString(); - } - - BadRequest request = badRequest.orElseThrow(); - result.append("issues=").append(request.getFieldViolationsCount()); - - for (int index = 0; index < request.getFieldViolationsCount(); index++) { - BadRequest.FieldViolation violation = request.getFieldViolations(index); - result.append('\n') - .append("issue.").append(index).append(".field=") - .append(orAbsent(violation.getField())) - .append('\n') - .append("issue.").append(index).append(".reason=") - .append(orAbsent(violation.getReason())) - .append('\n') - .append("issue.").append(index).append(".description=") - .append(violation.getDescription()); - } - - return result.toString(); - } - - private static ErrorInfo errorInfo( - com.google.rpc.Status status - ) throws InvalidProtocolBufferException { - for (Any detail : status.getDetailsList()) { - if (detail.is(ErrorInfo.class)) { - return detail.unpack(ErrorInfo.class); - } - } - throw new IllegalStateException("decoded google.rpc.Status is missing ErrorInfo"); - } - - private static Optional debugInfo(com.google.rpc.Status status) { - for (Any detail : status.getDetailsList()) { - if (detail.is(DebugInfo.class)) { - try { - return Optional.of(detail.unpack(DebugInfo.class)); - } catch (InvalidProtocolBufferException exception) { - throw new IllegalStateException("invalid DebugInfo detail", exception); - } - } - } - return Optional.empty(); - } - - private static Optional badRequest(com.google.rpc.Status status) { - for (Any detail : status.getDetailsList()) { - if (detail.is(BadRequest.class)) { - try { - return Optional.of(detail.unpack(BadRequest.class)); - } catch (InvalidProtocolBufferException exception) { - throw new IllegalStateException("invalid BadRequest detail", exception); - } - } - } - return Optional.empty(); - } - - private static String orAbsent(String value) { - return value.isEmpty() ? "" : value; - } - - private static Outcome testOutcome() { - return Outcome.of( - CHECKOUT_INVALID, - PROTECTED_DETAIL, - List.of( - Issue.at( - "paymentMethod", - PAYMENT_METHOD_INVALID, - PROTECTED_ISSUE_MESSAGE - ), - Issue.at("amount", "Amount must be positive.") - ) - ); - } -} diff --git a/codes-spring/build.gradle.kts b/codes-spring/build.gradle.kts index e2603a3..76b888b 100644 --- a/codes-spring/build.gradle.kts +++ b/codes-spring/build.gradle.kts @@ -8,12 +8,13 @@ group = providers.gradleProperty("GROUP").get() version = providers.gradleProperty("VERSION_NAME").get() val artifactId = "codes-spring" -val pomName = "Codes Spring Adapter" -val pomDescription = "Spring Framework HTTP boundary adapter for Codes application outcomes." +val pomName = "Codes Spring" +val pomDescription = "Spring Framework bridge for Codes RFC 9457 problem types." +val springFrameworkVersion = providers.gradleProperty("springFrameworkOverride") + .orElse(providers.gradleProperty("springFrameworkVersion")) val expectedPomDependencies = listOf( "${project.group}:codes:${project.version}:compile", - "org.springframework:spring-web:${providers.gradleProperty("springFrameworkVersion").get()}:compile", -).sorted() +) java { toolchain { @@ -29,31 +30,24 @@ tasks.withType().configureEach { dependencies { compileOnly("org.jspecify:jspecify:1.0.0") - api(project(":")) - api("org.springframework:spring-web:${providers.gradleProperty("springFrameworkVersion").get()}") + compileOnly("org.springframework:spring-web:${springFrameworkVersion.get()}") testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.springframework:spring-webmvc:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("org.springframework:spring-webflux:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("org.springframework:spring-test:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("tools.jackson.core:jackson-databind:${providers.gradleProperty("jackson3Version").get()}") - testImplementation("jakarta.servlet:jakarta.servlet-api:6.1.0") + testImplementation("org.jspecify:jspecify:1.0.0") + testImplementation("org.springframework:spring-web:${springFrameworkVersion.get()}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +sourceSets.test { + java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) +} + tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) - systemProperty( - "codes.apiSnapshot", - rootProject.file("api/codes-spring.api").absolutePath, - ) - systemProperty( - "codes.springHttpSnapshot", - rootProject.file("compatibility/spring-http-problems.snapshot").absolutePath, - ) + systemProperty("codes.apiSnapshot", rootProject.file("api/codes-spring.api").absolutePath) } jacoco { @@ -74,23 +68,19 @@ tasks.jacocoTestCoverageVerification { rule { limit { counter = "LINE" - minimum = "0.90".toBigDecimal() + minimum = "0.95".toBigDecimal() } limit { counter = "BRANCH" - minimum = "0.80".toBigDecimal() + minimum = "0.90".toBigDecimal() } } } } -sourceSets.test { - java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) -} - tasks.register("verifyPublishedPomContract") { group = "verification" - description = "Verifies the Codes Spring POM metadata and direct dependency budget." + description = "Verifies the Codes Spring POM metadata and dependency budget." dependsOn("generatePomFileForMavenPublication") @@ -124,13 +114,12 @@ tasks.register("verifyPublishedPomContract") { val actual = (0 until dependencies.length).map { index -> val dependency = dependencies.item(index) as org.w3c.dom.Element fun value(name: String): String = dependency.getElementsByTagName(name).item(0).textContent - listOf("groupId", "artifactId", "version", "scope") - .joinToString(":") { value(it) } + listOf("groupId", "artifactId", "version", "scope").joinToString(":") { value(it) } }.sorted() - check(actual == expectedPomDependencies) { - "$artifactId direct dependency budget changed. " + - "Expected $expectedPomDependencies, found $actual." + check(actual == expectedPomDependencies.sorted()) { + "$artifactId dependency budget changed. " + + "Expected ${expectedPomDependencies.sorted()}, found $actual." } } } @@ -171,15 +160,3 @@ mavenPublishing { signAllPublications() } } - -tasks.configureEach { - if (name.contains("MavenCentral", ignoreCase = true)) { - doFirst { - val required = listOf("mavenCentralUsername", "mavenCentralPassword", "signingInMemoryKey") - val missing = required.filterNot { providers.gradleProperty(it).isPresent } - require(missing.isEmpty()) { - "Maven Central publication requires Gradle properties: ${missing.joinToString()}" - } - } - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java deleted file mode 100644 index 4d58621..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import java.net.URI; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.jspecify.annotations.Nullable; -import org.springframework.http.ProblemDetail; - -/** - * Maps failed Codes outcomes to Spring {@link ProblemDetail} instances. - * - *

The stable Codes identity is always exposed as {@code code}. An RFC 9457 problem - * {@code type} is set only when the application explicitly supplies an owned URI mapping. - * For a mapped problem type, the reusable outcome message may be exposed as the stable - * {@code title}. Occurrence-specific {@link Outcome#getDetail()} is exposed only as RFC - * {@code detail}; it is never moved to a custom extension field. - */ -public final class OutcomeProblemDetailMapper { - public static final String CODE_PROPERTY = "code"; - public static final String ISSUES_PROPERTY = "issues"; - - private final SpringHttpStatusMapper statusMapper; - private final SpringOutcomeExposure exposure; - private final SpringProblemTypeUriMapper problemTypeMapper; - - public OutcomeProblemDetailMapper( - SpringHttpStatusMapper statusMapper, - SpringOutcomeExposure exposure - ) { - this(statusMapper, exposure, SpringProblemTypeUriMapper.empty()); - } - - public OutcomeProblemDetailMapper( - SpringHttpStatusMapper statusMapper, - SpringOutcomeExposure exposure, - SpringProblemTypeUriMapper problemTypeMapper - ) { - this.statusMapper = Objects.requireNonNull(statusMapper, "statusMapper"); - this.exposure = Objects.requireNonNull(exposure, "exposure"); - this.problemTypeMapper = Objects.requireNonNull(problemTypeMapper, "problemTypeMapper"); - } - - public static OutcomeProblemDetailMapper safeDefaults() { - return new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.safeDefaults(), - SpringProblemTypeUriMapper.empty() - ); - } - - public MappingResult map(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException("ProblemDetail is only produced for failed outcomes"); - } - - return statusMapper.map(outcome).fold( - status -> MappingResult.mapped(createProblemDetail(outcome, status.value())), - MappingResult::unmapped - ); - } - - private ProblemDetail createProblemDetail(Outcome outcome, int status) { - URI problemType = problemTypeMapper.map(outcome).orNull(); - ProblemDetail problem = createProblemDetail(status, problemType); - - problem.setProperty(CODE_PROPERTY, outcome.getCode().getValue()); - - if (problemType != null) { - problem.setType(problemType); - if (exposure.exposeMessage()) { - problem.setTitle(outcome.getMessage()); - } - } - - if (exposure.exposeDetail() && outcome.getDetail() != null) { - problem.setDetail(outcome.getDetail()); - } - if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) { - problem.setProperty(ISSUES_PROPERTY, toIssuePayload(outcome.getIssues())); - } - return problem; - } - - private ProblemDetail createProblemDetail(int status, @Nullable URI problemType) { - if (problemType != null && !exposure.exposeMessage()) { - return new ProblemDetailWithoutDefaultTitle(status); - } - return ProblemDetail.forStatus(status); - } - - private static List> toIssuePayload(List issues) { - List> values = new ArrayList<>(issues.size()); - for (Issue issue : issues) { - LinkedHashMap value = new LinkedHashMap<>(); - if (issue.getCode() != null) { - value.put("code", issue.getCode().getValue()); - } - if (issue.getPath() != null) { - value.put("path", issue.getPath()); - } - value.put("message", issue.getMessage()); - values.add(Map.copyOf(value)); - } - return List.copyOf(values); - } - - /** - * Spring's default {@link ProblemDetail#getTitle()} synthesizes the HTTP reason phrase when - * no title was explicitly configured. That behavior is correct for implicit {@code about:blank} - * but not for an application-owned problem type whose stable title is intentionally hidden. - */ - private static final class ProblemDetailWithoutDefaultTitle extends ProblemDetail { - private @Nullable String explicitTitle; - - private ProblemDetailWithoutDefaultTitle(int status) { - super(status); - } - - @Override - public void setTitle(@Nullable String title) { - explicitTitle = title; - } - - @Override - public @Nullable String getTitle() { - return explicitTitle; - } - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java new file mode 100644 index 0000000..46bc7ff --- /dev/null +++ b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java @@ -0,0 +1,43 @@ +package io.github.aalsanie.codes.spring; + +import io.github.aalsanie.codes.ProblemType; +import java.util.Objects; +import org.springframework.http.ProblemDetail; + +/** + * Creates Spring {@link ProblemDetail} instances from reusable {@link ProblemType} definitions. + */ +public final class ProblemDetails { + private ProblemDetails() { + } + + /** + * Creates a problem occurrence from the stable type, status, and title. + * + * @param problemType reusable problem type definition + * @return a new Spring problem detail + * @throws NullPointerException if {@code problemType} is {@code null} + */ + public static ProblemDetail forType(ProblemType problemType) { + Objects.requireNonNull(problemType, "problemType"); + ProblemDetail problemDetail = ProblemDetail.forStatus(problemType.getStatus()); + problemDetail.setType(problemType.getType()); + problemDetail.setTitle(problemType.getTitle()); + return problemDetail; + } + + /** + * Creates a problem occurrence with occurrence-specific detail. + * + * @param problemType reusable problem type definition + * @param detail client-visible detail for this occurrence + * @return a new Spring problem detail + * @throws NullPointerException if either argument is {@code null} + */ + public static ProblemDetail forTypeAndDetail(ProblemType problemType, String detail) { + Objects.requireNonNull(detail, "detail"); + ProblemDetail problemDetail = forType(problemType); + problemDetail.setDetail(detail); + return problemDetail; + } +} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java deleted file mode 100644 index 7a6bfbb..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import java.util.Objects; -import org.springframework.http.HttpStatusCode; - -public final class SpringHttpStatusMapper { - private final HttpOutcomeMapper mapper; - - public SpringHttpStatusMapper(HttpOutcomeMapper mapper) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - } - - public static SpringHttpStatusMapper standard() { - return new SpringHttpStatusMapper(HttpOutcomeMapper.standard()); - } - - public MappingResult map(OutcomeDefinition definition) { - return mapper.map(Objects.requireNonNull(definition, "definition")) - .fold( - status -> MappingResult.mapped(HttpStatusCode.valueOf(status.getValue())), - MappingResult::unmapped - ); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java deleted file mode 100644 index af35a09..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.ErrorResponseException; - -/** - * Thin Spring exception bridge for failed Codes outcomes. - */ -public final class SpringOutcomeExceptions { - private SpringOutcomeExceptions() { - } - - public static MappingResult toErrorResponseException( - Outcome outcome, - OutcomeProblemDetailMapper mapper - ) { - Objects.requireNonNull(outcome, "outcome"); - Objects.requireNonNull(mapper, "mapper"); - - return mapper.map(outcome).fold( - problem -> MappingResult.mapped( - new ErrorResponseException( - HttpStatusCode.valueOf(problem.getStatus()), - problem, - null - ) - ), - MappingResult::unmapped - ); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java deleted file mode 100644 index 456f782..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.aalsanie.codes.spring; - -public record SpringOutcomeExposure( - boolean exposeMessage, - boolean exposeDetail, - boolean exposeIssues -) { - public static SpringOutcomeExposure safeDefaults() { - return new SpringOutcomeExposure(false, false, false); - } - - public static SpringOutcomeExposure publicErrors() { - return new SpringOutcomeExposure(true, false, true); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java deleted file mode 100644 index f5f415e..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import java.net.URI; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Immutable application-owned mapping from Codes outcome identity to RFC 9457 problem type URI. - * - *

Codes does not assign or invent problem type URIs. Applications opt in by registering the - * URIs whose ownership and lifecycle they control. The RFC-defined {@code about:blank} type is - * implicit and therefore cannot be registered as an application-owned problem type. - */ -public final class SpringProblemTypeUriMapper { - private static final URI ABOUT_BLANK = URI.create("about:blank"); - - private final Map mappings; - - private SpringProblemTypeUriMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static SpringProblemTypeUriMapper empty() { - return new SpringProblemTypeUriMapper(Map.of()); - } - - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - URI type = mappings.get(definition.getCode()); - return type == null ? MappingResult.unmapped() : MappingResult.mapped(type); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } - - public SpringProblemTypeUriMapper withMapping(OutcomeDefinition definition, URI type) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(type, "type"); - if (ABOUT_BLANK.equals(type)) { - throw new IllegalArgumentException( - "about:blank is the implicit RFC 9457 default and cannot be application-owned" - ); - } - - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("problem type mapping already exists for outcome code: " + code); - } - - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, type); - return new SpringProblemTypeUriMapper(next); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java index 810c460..56e6dbf 100644 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java +++ b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java @@ -17,7 +17,7 @@ void publicApiMatchesSnapshot() throws IOException { assertEquals( expected, - PublicApiSnapshot.create(OutcomeProblemDetailMapper.class, "io.github.aalsanie.codes.spring") + PublicApiSnapshot.create(ProblemDetails.class, "io.github.aalsanie.codes.spring") ); } } diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java deleted file mode 100644 index 4c5c3bc..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import java.net.URI; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.http.ProblemDetail; - -class OutcomeProblemDetailMapperTest { - private static final URI ABOUT_BLANK = URI.create("about:blank"); - - @Test - void safeDefaultsExposeOnlyRfcDefaultsAndMachineCode() { - Outcome outcome = Outcome.of( - StandardOutcomes.NOT_FOUND, - "database shard=7 customerId=42" - ); - - ProblemDetail problem = OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull(); - - assertEquals(404, problem.getStatus()); - assertAboutBlank(problem.getType()); - assertEquals("Not Found", problem.getTitle()); - assertNull(problem.getDetail()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - assertFalse(problem.getProperties().containsKey("outcomeDetail")); - assertFalse(problem.getProperties().containsKey("issues")); - } - - @Test - void applicationOwnedTypeUsesStableMessageAsTitleAndOccurrenceDetailAsRfcDetail() { - Outcome outcome = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "The email address cannot receive mail.", - List.of(Issue.at("email", "Invalid email address.")) - ); - URI type = URI.create("https://api.example.test/problems/invalid-argument"); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - new SpringOutcomeExposure(true, true, true), - SpringProblemTypeUriMapper.empty() - .withMapping(StandardOutcomes.INVALID_ARGUMENT, type) - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertEquals(type, problem.getType()); - assertEquals(outcome.getMessage(), problem.getTitle()); - assertEquals(outcome.getDetail(), problem.getDetail()); - assertFalse(problem.getProperties().containsKey("outcomeDetail")); - - List issues = (List) problem.getProperties().get("issues"); - assertEquals(1, issues.size()); - Map issue = (Map) issues.get(0); - assertEquals("email", issue.get("path")); - assertEquals("Invalid email address.", issue.get("message")); - } - - @Test - void applicationOwnedTypeDoesNotReuseHttpStatusTitleWhenMessageIsHidden() { - Outcome outcome = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "sensitive occurrence detail" - ); - URI type = URI.create("https://api.example.test/problems/invalid-argument"); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.safeDefaults(), - SpringProblemTypeUriMapper.empty() - .withMapping(StandardOutcomes.INVALID_ARGUMENT, type) - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertEquals(type, problem.getType()); - assertNull(problem.getTitle()); - assertNull(problem.getDetail()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - } - - @Test - void aboutBlankKeepsHttpTitleEvenWhenPublicMessageExposureIsEnabled() { - Outcome outcome = Outcome.of(StandardOutcomes.INVALID_ARGUMENT); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.publicErrors() - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertAboutBlank(problem.getType()); - assertEquals("Bad Request", problem.getTitle()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - } - - @Test - void preservesExplicitUnmappedResultAndRejectsSuccessProblems() { - assertTrue( - OutcomeProblemDetailMapper.safeDefaults() - .map(Outcome.of(StandardOutcomes.FAILED_PRECONDITION)) - .isUnmapped() - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeProblemDetailMapper.safeDefaults().map(Outcome.of(StandardOutcomes.OK)) - ); - } - - @Test - void namedExposurePoliciesRemainConservative() { - assertEquals( - new SpringOutcomeExposure(false, false, false), - SpringOutcomeExposure.safeDefaults() - ); - assertEquals( - new SpringOutcomeExposure(true, false, true), - SpringOutcomeExposure.publicErrors() - ); - } - - private static void assertAboutBlank(URI type) { - assertTrue(type == null || ABOUT_BLANK.equals(type)); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java new file mode 100644 index 0000000..553747c --- /dev/null +++ b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java @@ -0,0 +1,64 @@ +package io.github.aalsanie.codes.spring; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.aalsanie.codes.ProblemType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.springframework.http.ProblemDetail; + +class ProblemDetailsTest { + private static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + @Test + void createsProblemDetailFromReusableType() { + ProblemDetail problem = ProblemDetails.forType(ORDER_NOT_FOUND); + + assertEquals(ORDER_NOT_FOUND.getType(), problem.getType()); + assertEquals(ORDER_NOT_FOUND.getStatus(), problem.getStatus()); + assertEquals(ORDER_NOT_FOUND.getTitle(), problem.getTitle()); + assertNull(problem.getDetail()); + assertNull(problem.getInstance()); + assertNull(problem.getProperties()); + } + + @Test + void createsProblemDetailWithOccurrenceDetail() { + ProblemDetail problem = ProblemDetails.forTypeAndDetail( + ORDER_NOT_FOUND, + "Order o-123 was not found." + ); + + assertEquals(ORDER_NOT_FOUND.getType(), problem.getType()); + assertEquals(ORDER_NOT_FOUND.getStatus(), problem.getStatus()); + assertEquals(ORDER_NOT_FOUND.getTitle(), problem.getTitle()); + assertEquals("Order o-123 was not found.", problem.getDetail()); + } + + @Test + void rejectsNullArguments() { + assertThrows(NullPointerException.class, () -> ProblemDetails.forType(null)); + assertThrows( + NullPointerException.class, + () -> ProblemDetails.forTypeAndDetail(ORDER_NOT_FOUND, null) + ); + } + + @Test + void cannotBeConstructedThroughThePublicApi() throws Exception { + Constructor constructor = ProblemDetails.class.getDeclaredConstructor(); + + assertTrue(Modifier.isPrivate(constructor.getModifiers())); + constructor.setAccessible(true); + constructor.newInstance(); + } +} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java deleted file mode 100644 index 0b2b914..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java +++ /dev/null @@ -1,412 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.test.web.reactive.server.EntityExchangeResult; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.test.web.servlet.client.MockMvcWebTestClient; -import org.springframework.web.ErrorResponseException; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RestController; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.json.JsonMapper; - -class SpringHttpGoldenResponseTest { - private static final String ABSENT = ""; - private static final String ABOUT_BLANK = "about:blank"; - private static final JsonMapper JSON = JsonMapper.builder().build(); - - private static final List GOLDEN_CASES = List.of( - "safe", - "public", - "custom", - "validation", - "unmapped", - "sensitive-detail", - "custom-safe" - ); - - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." - ); - - private static final SpringHttpStatusMapper STATUS_MAPPER = new SpringHttpStatusMapper( - HttpOutcomeMapper.standard() - .withMapping(PAYMENT_DECLINED, HttpStatusCode.of(422)) - ); - - private static final SpringProblemTypeUriMapper TYPE_MAPPER = - SpringProblemTypeUriMapper.empty() - .withMapping( - StandardOutcomes.INVALID_ARGUMENT, - URI.create("https://api.example.test/problems/invalid-argument") - ) - .withMapping( - PAYMENT_DECLINED, - URI.create("https://api.example.test/problems/payment-declined") - ); - - private static final OutcomeProblemDetailMapper PUBLIC_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - SpringOutcomeExposure.publicErrors(), - TYPE_MAPPER - ); - - private static final OutcomeProblemDetailMapper DETAIL_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - new SpringOutcomeExposure(true, true, false), - TYPE_MAPPER - ); - - private static final OutcomeProblemDetailMapper CUSTOM_SAFE_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - SpringOutcomeExposure.safeDefaults(), - TYPE_MAPPER - ); - - @Test - void mvcRenderedHttpResponsesMatchGoldenContract() throws Exception { - WebTestClient client = MockMvcWebTestClient - .bindToController(new WireController()) - .controllerAdvice(new MvcProblemAdvice()) - .build(); - - assertGoldenResponses("mvc", client); - } - - @Test - void webFluxRenderedHttpResponsesMatchGoldenContract() throws Exception { - WebTestClient client = WebTestClient - .bindToController(new WireController()) - .controllerAdvice(new WebFluxProblemAdvice()) - .build(); - - assertGoldenResponses("webflux", client); - } - - private static void assertGoldenResponses(String stack, WebTestClient client) throws Exception { - Map> expected = readGoldenContract(); - assertEquals(GOLDEN_CASES, List.copyOf(expected.keySet()), stack + " golden case order"); - - for (String caseName : GOLDEN_CASES) { - assertResponse(stack, caseName, expected.get(caseName), client); - } - } - - private static void assertResponse( - String stack, - String caseName, - Map expected, - WebTestClient client - ) throws Exception { - String context = stack + "/" + caseName; - String path = "/" + caseName; - - EntityExchangeResult response = client.get() - .uri(path) - .accept(MediaType.APPLICATION_PROBLEM_JSON) - .exchange() - .expectBody() - .returnResult(); - - int expectedStatus = Integer.parseInt(required(expected, "status")); - assertEquals(expectedStatus, response.getStatus().value(), context + " HTTP status"); - - MediaType contentType = response.getResponseHeaders().getContentType(); - assertNotNull(contentType, context + " content type"); - MediaType expectedContentType = MediaType.parseMediaType(required(expected, "contentType")); - assertTrue( - expectedContentType.isCompatibleWith(contentType), - () -> context + " content type expected " + expectedContentType + " but was " + contentType - ); - - byte[] responseBody = Objects.requireNonNull( - response.getResponseBody(), - context + " response body" - ); - String rawBody = new String(responseBody, StandardCharsets.UTF_8); - JsonNode body = Objects.requireNonNull(JSON.readTree(responseBody), context + " JSON body"); - - assertEquals(expectedStatus, body.path("status").asInt(), context + " problem status"); - assertProblemType(body, required(expected, "type"), context); - assertTextField(body, "title", required(expected, "title"), context); - assertTextField(body, "detail", required(expected, "detail"), context); - assertTextField(body, "instance", required(expected, "instance"), context); - assertTextField(body, "code", required(expected, "code"), context); - - assertFalse(body.has("properties"), context + " must flatten ProblemDetail extensions"); - assertFalse(body.has("outcomeDetail"), context + " must use RFC detail"); - - assertIssues(body, expected, context); - assertSensitiveValuesAreNotRendered(caseName, rawBody, context); - } - - private static void assertProblemType(JsonNode body, String expected, String context) { - JsonNode type = body.get("type"); - - if (ABOUT_BLANK.equals(expected)) { - if (type != null) { - assertFalse(type.isNull(), context + " type must be absent or about:blank"); - assertEquals(ABOUT_BLANK, type.stringValue(), context + " problem type"); - } - return; - } - - assertNotNull(type, context + " problem type"); - assertFalse(type.isNull(), context + " problem type"); - assertEquals(expected, type.stringValue(), context + " problem type"); - } - - private static void assertTextField( - JsonNode body, - String field, - String expected, - String context - ) { - JsonNode value = body.get(field); - - if (ABSENT.equals(expected)) { - assertTrue( - value == null, - () -> context + " expected absent '" + field + "' but body contained " + value - ); - return; - } - - assertNotNull(value, context + " missing '" + field + "'"); - assertFalse(value.isNull(), context + " null '" + field + "'"); - assertEquals(expected, value.stringValue(), context + " '" + field + "'"); - } - - private static void assertIssues( - JsonNode body, - Map expected, - String context - ) { - int expectedCount = Integer.parseInt(required(expected, "issues")); - JsonNode issues = body.get("issues"); - - if (expectedCount == 0) { - assertTrue(issues == null, context + " issues must be absent"); - return; - } - - assertNotNull(issues, context + " missing issues"); - assertTrue(issues.isArray(), context + " issues must be a JSON array"); - assertEquals(expectedCount, issues.size(), context + " issue count"); - - for (int index = 0; index < expectedCount; index++) { - JsonNode issue = issues.get(index); - assertNotNull(issue, context + " missing issue " + index); - assertTextField( - issue, - "code", - required(expected, "issue." + index + ".code"), - context + " issue " + index - ); - assertTextField( - issue, - "path", - required(expected, "issue." + index + ".path"), - context + " issue " + index - ); - assertTextField( - issue, - "message", - required(expected, "issue." + index + ".message"), - context + " issue " + index - ); - } - } - - private static void assertSensitiveValuesAreNotRendered( - String caseName, - String rawBody, - String context - ) { - String sensitiveValue = switch (caseName) { - case "safe" -> "database shard=7 customerId=42"; - case "validation" -> "raw request body contained account metadata"; - case "sensitive-detail" -> "gateway_token=secret-123"; - case "custom-safe" -> "gateway_token=custom-safe-secret"; - default -> null; - }; - - if (sensitiveValue != null) { - assertFalse( - rawBody.contains(sensitiveValue), - context + " leaked sensitive occurrence detail" - ); - } - } - - private static Map> readGoldenContract() throws IOException { - List lines = Files.readAllLines( - Path.of(System.getProperty("codes.springHttpSnapshot")) - ); - LinkedHashMap> result = new LinkedHashMap<>(); - Map current = null; - - for (String rawLine : lines) { - String line = rawLine.strip(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - String name = line.substring(1, line.length() - 1); - if (name.isBlank() || result.containsKey(name)) { - throw new IllegalStateException("Invalid duplicate or blank golden case: " + line); - } - current = new LinkedHashMap<>(); - result.put(name, current); - continue; - } - - if (current == null) { - throw new IllegalStateException("Golden property appears before a case: " + line); - } - - int separator = line.indexOf('='); - if (separator <= 0) { - throw new IllegalStateException("Invalid golden property: " + line); - } - - String key = line.substring(0, separator); - String value = line.substring(separator + 1); - if (current.put(key, value) != null) { - throw new IllegalStateException("Duplicate golden property: " + key); - } - } - - return result; - } - - private static String required(Map values, String key) { - String value = values.get(key); - if (value == null) { - throw new IllegalStateException("Missing golden property: " + key); - } - return value; - } - - @RestController - static final class WireController { - @GetMapping("/{caseName}") - Object render(@PathVariable("caseName") String caseName) { - return switch (caseName) { - case "safe" -> throw mappedException( - Outcome.of( - StandardOutcomes.NOT_FOUND, - "database shard=7 customerId=42" - ), - OutcomeProblemDetailMapper.safeDefaults() - ); - case "public" -> throw mappedException( - Outcome.of(StandardOutcomes.INVALID_ARGUMENT), - PUBLIC_MAPPER - ); - case "custom" -> throw mappedException( - Outcome.of( - PAYMENT_DECLINED, - "Ask the customer to use another payment method." - ), - DETAIL_MAPPER - ); - case "validation" -> throw mappedException( - Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "raw request body contained account metadata", - List.of( - Issue.at("email", "Invalid email address."), - Issue.at("quantity", "Must be greater than zero.") - ) - ), - PUBLIC_MAPPER - ); - case "unmapped" -> unmappedResponse( - Outcome.of(StandardOutcomes.FAILED_PRECONDITION) - ); - case "sensitive-detail" -> throw mappedException( - Outcome.of(PAYMENT_DECLINED, "gateway_token=secret-123"), - PUBLIC_MAPPER - ); - case "custom-safe" -> throw mappedException( - Outcome.of(PAYMENT_DECLINED, "gateway_token=custom-safe-secret"), - CUSTOM_SAFE_MAPPER - ); - default -> throw new IllegalArgumentException("Unknown golden case: " + caseName); - }; - } - - private static ErrorResponseException mappedException( - Outcome outcome, - OutcomeProblemDetailMapper mapper - ) { - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException(outcome, mapper); - ErrorResponseException exception = result.orNull(); - if (exception == null) { - throw new IllegalStateException("Expected mapped Spring error response for " + outcome.getCode()); - } - return exception; - } - - private static ResponseEntity unmappedResponse(Outcome outcome) { - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException(outcome, PUBLIC_MAPPER); - if (result.isMapped()) { - throw new IllegalStateException("Expected unmapped Spring error response for " + outcome.getCode()); - } - - ProblemDetail fallback = ProblemDetail.forStatus(500); - fallback.setProperty( - OutcomeProblemDetailMapper.CODE_PROPERTY, - outcome.getCode().getValue() - ); - return ResponseEntity.internalServerError().body(fallback); - } - } - - @ControllerAdvice - static final class MvcProblemAdvice - extends org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler { - } - - @ControllerAdvice - static final class WebFluxProblemAdvice - extends org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler { - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java deleted file mode 100644 index 022adc9..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import org.junit.jupiter.api.Test; - -class SpringHttpStatusMapperTest { - @Test - void mapsCoreHttpStatusToSpringStatus() { - SpringHttpStatusMapper mapper = SpringHttpStatusMapper.standard(); - assertEquals(404, mapper.map(StandardOutcomes.NOT_FOUND).orNull().value()); - assertTrue(mapper.map(StandardOutcomes.FAILED_PRECONDITION).isUnmapped()); - } - - @Test - void acceptsApplicationHttpPolicy() { - OutcomeDefinition rejected = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - HttpOutcomeMapper core = HttpOutcomeMapper.standard().withMapping(rejected, HttpStatusCode.of(422)); - assertEquals(422, new SpringHttpStatusMapper(core).map(rejected).orNull().value()); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java deleted file mode 100644 index 6d68610..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; -import org.springframework.web.ErrorResponseException; - -class SpringOutcomeExceptionsTest { - @Test - void convertsMappedFailureWithoutChangingProblemBody() { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND); - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException( - outcome, - OutcomeProblemDetailMapper.safeDefaults() - ); - - ErrorResponseException exception = result.orNull(); - - assertEquals(404, exception.getStatusCode().value()); - assertEquals(404, exception.getBody().getStatus()); - assertEquals( - outcome.getCode().getValue(), - exception.getBody().getProperties().get(OutcomeProblemDetailMapper.CODE_PROPERTY) - ); - } - - @Test - void preservesUnmappedOutcome() { - assertTrue( - SpringOutcomeExceptions.toErrorResponseException( - Outcome.of(StandardOutcomes.FAILED_PRECONDITION), - OutcomeProblemDetailMapper.safeDefaults() - ).isUnmapped() - ); - } - - @Test - void rejectsSuccessfulOutcome() { - assertThrows( - IllegalArgumentException.class, - () -> SpringOutcomeExceptions.toErrorResponseException( - Outcome.of(StandardOutcomes.OK), - OutcomeProblemDetailMapper.safeDefaults() - ) - ); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java deleted file mode 100644 index d308ad5..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import java.net.URI; -import org.junit.jupiter.api.Test; - -class SpringProblemTypeUriMapperTest { - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." - ); - - @Test - void emptyMapperIsExplicitlyUnmapped() { - assertTrue(SpringProblemTypeUriMapper.empty().map(PAYMENT_DECLINED).isUnmapped()); - } - - @Test - void mapsDefinitionsAndOccurrencesByStableOutcomeCode() { - URI type = URI.create("https://api.example.test/problems/payment-declined"); - SpringProblemTypeUriMapper mapper = SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, type); - OutcomeDefinition sameIdentity = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "A different human-readable message." - ); - - assertEquals(type, mapper.map(sameIdentity).orNull()); - assertEquals(type, mapper.map(Outcome.of(sameIdentity)).orNull()); - } - - @Test - void aboutBlankCannotBeRegisteredAsApplicationOwnedProblemType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, URI.create("about:blank")) - ); - - assertTrue(exception.getMessage().contains("about:blank")); - } - - @Test - void duplicateApplicationMappingIsRejected() { - URI type = URI.create("https://api.example.test/problems/payment-declined"); - SpringProblemTypeUriMapper mapper = SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, type); - - assertThrows( - IllegalArgumentException.class, - () -> mapper.withMapping(PAYMENT_DECLINED, type) - ); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java deleted file mode 100644 index 83e37c9..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.ErrorResponseException; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.server.ServerWebExchange; - -class SpringStackCompatibilityContractTest { - @Test - void mvcResponseEntityExceptionHandlerAcceptsTheAdapterBridgeType() throws Exception { - assertErrorResponseHook( - org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.class, - WebRequest.class - ); - } - - @Test - void webFluxResponseEntityExceptionHandlerAcceptsTheAdapterBridgeType() throws Exception { - assertErrorResponseHook( - org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler.class, - ServerWebExchange.class - ); - } - - private static void assertErrorResponseHook( - Class handlerType, - Class requestType - ) throws NoSuchMethodException { - Method method = handlerType.getDeclaredMethod( - "handleErrorResponseException", - ErrorResponseException.class, - HttpHeaders.class, - HttpStatusCode.class, - requestType - ); - - assertTrue(Modifier.isProtected(method.getModifiers())); - } -} diff --git a/compatibility/README.md b/compatibility/README.md index b7085f8..a39c7c3 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -1,57 +1,18 @@ -# Compatibility contracts +# Compatibility -These fixtures and matrix definitions protect Codes' semantic, boundary, publication, and consumer contracts independently from the Java API snapshots under `api/`. +Compatibility verification uses clean locally published Maven artifacts rather than Gradle project dependencies. -They cover: +The matrix covers: -* standard outcome codes and states; -* built-in HTTP mappings; -* built-in gRPC mappings; -* rendered Spring RFC 9457 HTTP problem responses; -* decoded `google.rpc.Status` payloads after `StatusRuntimeException` trailer round trips; -* published-artifact compatibility across supported framework, JDK, language, build-tool, and operating-system combinations. +* Java 17, 21, and 25; +* Spring Framework 6.0.0 and 7.0.9; +* Kotlin 1.9.24 and 2.4.10; +* Gradle and Maven consumers; +* Windows and macOS published-consumer checks. -Core semantic snapshots do not freeze human-readable messages, occurrence details, or issue paths. +The checks also verify that: -`spring-http-problems.snapshot` is intentionally a boundary golden fixture. The same contract is rendered through Spring MVC and Spring WebFlux and verifies HTTP status, `application/problem+json`, problem type semantics, title, occurrence detail, request `instance`, top-level `code` and `issues`, application fallback for unmapped outcomes, and sensitive-detail non-disclosure. JSON member ordering is intentionally not frozen. - -A change to that fixture is therefore a reviewed Spring wire-contract change. - -`grpc-google-rpc-status.snapshot` is the gRPC adapter wire fixture. It is captured after a `google.rpc.Status` is encoded into a `StatusRuntimeException` and decoded from its trailers again. It verifies exact application identity in `ErrorInfo.domain` and `ErrorInfo.reason`, safe message behavior, explicit detail exposure through `DebugInfo`, and request-field issues through `BadRequest` for a status whose standard detail is `BadRequest`. - -When gRPC issue exposure is enabled, the adapter emits `BadRequest` only for `INVALID_ARGUMENT` and `OUT_OF_RANGE`. Every exposed issue must have a request-field path. Incompatible statuses and pathless issues are rejected instead of being encoded with misleading wire semantics. - -A change to that fixture is therefore a reviewed gRPC wire-contract change. - -A snapshot change represents a contract change and must be reviewed together with the code that requires it. - -## Production compatibility matrix - -`compatibility/matrix.json` is the executable support policy for `0.4.x`. - -Current matrix: - -* Java: 17, 21, 25. -* Spring Framework: 6.0.0 and 7.0.9. -* gRPC Java: 1.75.0 and 1.83.1. -* Kotlin compiler consumers: 1.9.24, 2.0.21, 2.1.21, 2.2.20, 2.4.10. -* Kotlin/JSpecify compiler matrix framework baseline: Spring 6.0.0 and gRPC 1.75.0. -* Build tools: Gradle and Maven. -* Consumer languages: Java and Kotlin. -* Operating systems: Linux, Windows, macOS. - -Spring Framework 6.0.0 is the Codes `0.4.x` binary compatibility floor for the Spring 6 generation. Spring 7.0.9 is the current Spring 7 baseline used to build the adapter. This compatibility floor is not a statement about upstream maintenance or security support for old Spring releases. - -Codes explicitly supports gRPC Java 1.75.0 as its `0.4.x` compatibility floor and 1.83.1 as the current build baseline. The minimum is a Codes support policy, not an upstream gRPC LTS declaration. - -The Kotlin/JSpecify compiler matrix intentionally runs all supported Kotlin compiler versions against the minimum supported Spring and gRPC framework versions. This isolates Codes' published JSpecify metadata contract from the Kotlin metadata version used internally by the current Spring release. Current-framework Kotlin consumption remains covered separately by the Gradle Kotlin smoke consumer on the current Spring/gRPC baselines. Do not use Kotlin's metadata-version bypass flags to make an older compiler consume a newer framework's incompatible Kotlin metadata. - -Framework/JDK compatibility is exercised as a full Cartesian product: - -```text -3 Java versions × 2 Spring versions × 2 gRPC versions = 12 published-artifact jobs -``` - -Every compatibility job starts with an empty Maven repository, publishes `codes`, `codes-spring`, and `codes-grpc-java` into that repository, verifies the published POMs, and then runs an external consumer. Gradle consumer repositories exclude `io.github.aalsanie` from Maven Central so Codes artifacts cannot be accidentally resolved remotely. - -For HTTP, absence from `http-mappings.snapshot` means `HttpOutcomeMapper.standard()` returns `Unmapped`. +* the core artifact publishes no dependencies; +* `codes-spring` publishes only its dependency on `codes`; +* Java and Kotlin consumers can create a `ProblemType`; +* Spring consumers can convert it to a native `ProblemDetail`. diff --git a/compatibility/external/integration-reliability-platform.patch b/compatibility/external/integration-reliability-platform.patch deleted file mode 100644 index 505165b..0000000 --- a/compatibility/external/integration-reliability-platform.patch +++ /dev/null @@ -1,159 +0,0 @@ ---- a/build.gradle.kts -+++ b/build.gradle.kts -@@ -6,6 +6,8 @@ - - group = "io.github.aalsanie" - version = "0.0.1-SNAPSHOT" -+ -+val codesVersion = providers.gradleProperty("codesVersion").orElse("0.4.0-RC1") - - java { - toolchain { -@@ -18,6 +20,7 @@ - } - - dependencies { -+ implementation("io.github.aalsanie:codes-spring:${codesVersion.get()}") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-validation") ---- a/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java -+++ b/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java -@@ -1,5 +1,8 @@ - package io.github.aalsanie.irp.common.api; - -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; - import io.github.aalsanie.irp.connections.DuplicateConnectionException; - import io.github.aalsanie.irp.events.DuplicateInboundEventException; - import io.github.aalsanie.irp.events.EventNotFoundException; -@@ -7,6 +10,7 @@ - import io.github.aalsanie.irp.events.InvalidEventProcessingStatus; - import jakarta.servlet.http.HttpServletRequest; - import org.springframework.http.HttpStatus; -+import org.springframework.http.ProblemDetail; - import org.springframework.http.ResponseEntity; - import org.springframework.web.bind.annotation.ExceptionHandler; - import org.springframework.web.bind.annotation.RestControllerAdvice; -@@ -15,6 +19,8 @@ - - @RestControllerAdvice - public class GlobalExceptionHandler { -+ private static final OutcomeProblemDetailMapper CODES_PROBLEMS = -+ OutcomeProblemDetailMapper.safeDefaults(); - - @ExceptionHandler(value = {DuplicateConnectionException.class}) - public ResponseEntity handleException(DuplicateConnectionException exception, -@@ -53,14 +59,16 @@ - } - - @ExceptionHandler(value = {DuplicateInboundEventException.class}) -- public ResponseEntity handleException(DuplicateInboundEventException exception, HttpServletRequest request) { -- HttpStatus status = HttpStatus.CONFLICT; -- ApiErrorResponse response = new ApiErrorResponse(Instant.now(), -- status.value(), -- exception.getMessage(), -- status.getReasonPhrase(), -- request.getRequestURI()); -- return ResponseEntity.status(HttpStatus.CONFLICT).body(response); -+ public ResponseEntity handleException(DuplicateInboundEventException exception) { -+ ProblemDetail problem = CODES_PROBLEMS -+ .map(Outcome.of(StandardOutcomes.ALREADY_EXISTS)) -+ .orNull(); -+ -+ if (problem == null) { -+ throw new IllegalStateException("ALREADY_EXISTS has no HTTP mapping", exception); -+ } -+ -+ return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(value = {InvalidEventProcessingStatus.class}) ---- /dev/null -+++ b/src/test/java/io/github/aalsanie/irp/CodesSpringIntegrationTest.java -@@ -0,0 +1,84 @@ -+package io.github.aalsanie.irp; -+ -+import io.github.aalsanie.irp.connections.ConnectionStatus; -+import io.github.aalsanie.irp.connections.IntegrationConnection; -+import io.github.aalsanie.irp.connections.IntegrationConnectionRepository; -+import io.github.aalsanie.irp.events.EventRepository; -+import org.junit.jupiter.api.BeforeEach; -+import org.junit.jupiter.api.Test; -+import org.springframework.beans.factory.annotation.Autowired; -+import org.springframework.boot.test.context.SpringBootTest; -+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -+import org.springframework.context.annotation.Import; -+import org.springframework.http.MediaType; -+import org.springframework.test.web.servlet.MockMvc; -+ -+import java.time.Instant; -+import java.util.UUID; -+ -+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -+ -+@SpringBootTest -+@AutoConfigureMockMvc -+@Import(TestcontainersConfiguration.class) -+class CodesSpringIntegrationTest { -+ -+ @Autowired -+ private MockMvc mockMvc; -+ -+ @Autowired -+ private EventRepository eventRepository; -+ -+ @Autowired -+ private IntegrationConnectionRepository connectionRepository; -+ -+ @BeforeEach -+ void setUp() { -+ eventRepository.deleteAll(); -+ connectionRepository.deleteAll(); -+ } -+ -+ @Test -+ void duplicateInboundEventKeepsStableCodesIdentityWithoutOccurrenceDetail() -+ throws Exception { -+ IntegrationConnection connection = connectionRepository.saveAndFlush( -+ new IntegrationConnection( -+ UUID.randomUUID(), -+ "integration-test", -+ "stripe", -+ ConnectionStatus.ACTIVE, -+ Instant.now() -+ ) -+ ); -+ -+ String request = """ -+ { -+ "externalEventId": "evt_codes", -+ "eventType": "payment.succeeded", -+ "payload": { -+ "paymentId": "pay_codes" -+ } -+ } -+ """; -+ -+ String endpoint = "/api/v1/connections/" + connection.getId() + "/events"; -+ -+ mockMvc.perform(post(endpoint) -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(request)) -+ .andExpect(status().isCreated()); -+ -+ mockMvc.perform(post(endpoint) -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(request)) -+ .andExpect(status().isConflict()) -+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) -+ .andExpect(jsonPath("$.status").value(409)) -+ .andExpect(jsonPath("$.code") -+ .value("io.github.aalsanie.codes.standard:ALREADY_EXISTS")) -+ .andExpect(jsonPath("$.detail").doesNotExist()); -+ } -+} diff --git a/compatibility/external/manifest.json b/compatibility/external/manifest.json deleted file mode 100644 index d05a599..0000000 --- a/compatibility/external/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "integrations": [ - { - "id": "integration-reliability-platform", - "repository": "https://github.com/aalsanie/integration-reliability-platform.git", - "commit": "1f99ad8ec4b0b7da2a20ab20101a6adb677cb735", - "patch": "compatibility/external/integration-reliability-platform.patch", - "boundary": "Spring MVC", - "scope": "replace one handwritten duplicate-event HTTP mapping with codes-spring", - "dependency_notes": "The application uses Spring Boot 4.1.0 and owns Spring dependency management.", - "expected_identity": "io.github.aalsanie.codes.standard:ALREADY_EXISTS" - }, - { - "id": "patient-mgmt-microservices", - "repository": "https://github.com/pratham2402/patient-mgmt-microservices.git", - "commit": "26645990986a4f17b755ec17ed390c9e90112d36", - "patch": "compatibility/external/patient-mgmt-microservices.patch", - "boundary": "Spring MVC + gRPC Java", - "scope": "exercise INVALID_ARGUMENT through Spring MVC and gRPC Java", - "dependency_notes": "The repository pins gRPC 1.69.0; the integration patch updates that line to the Codes 1.75.0 compatibility floor.", - "expected_identity": "io.github.aalsanie.codes.standard:INVALID_ARGUMENT" - } - ] -} diff --git a/compatibility/external/patient-mgmt-microservices.patch b/compatibility/external/patient-mgmt-microservices.patch deleted file mode 100644 index 7f78791..0000000 --- a/compatibility/external/patient-mgmt-microservices.patch +++ /dev/null @@ -1,365 +0,0 @@ ---- a/patient-service/pom.xml -+++ b/patient-service/pom.xml -@@ -28,8 +28,14 @@ - - - 21 -+ 0.4.0-RC1 - - -+ -+ io.github.aalsanie -+ codes-spring -+ ${codes.version} -+ - - org.springframework.boot - spring-boot-starter-data-jpa ---- a/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java -+++ b/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java -@@ -1,31 +1,57 @@ - package com.pm.patientservice.exception; - --import jakarta.validation.constraints.Email; -+import io.github.aalsanie.codes.Issue; -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -+import io.github.aalsanie.codes.spring.SpringHttpStatusMapper; -+import io.github.aalsanie.codes.spring.SpringOutcomeExposure; -+import io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper; - import org.slf4j.Logger; - import org.slf4j.LoggerFactory; -+import org.springframework.http.ProblemDetail; - import org.springframework.http.ResponseEntity; - import org.springframework.web.bind.MethodArgumentNotValidException; - import org.springframework.web.bind.annotation.ControllerAdvice; - import org.springframework.web.bind.annotation.ExceptionHandler; - -+import java.util.ArrayList; - import java.util.HashMap; -+import java.util.List; - import java.util.Map; - - @ControllerAdvice - public class GlobalExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); -+ private static final OutcomeProblemDetailMapper VALIDATION_PROBLEMS = -+ new OutcomeProblemDetailMapper( -+ SpringHttpStatusMapper.standard(), -+ SpringOutcomeExposure.publicErrors(), -+ SpringProblemTypeUriMapper.empty() -+ ); - - @ExceptionHandler(MethodArgumentNotValidException.class) -- public ResponseEntity> handleValidationException( -+ public ResponseEntity handleValidationException( - MethodArgumentNotValidException ex) { - -- Map errors = new HashMap<>(); -+ List issues = new ArrayList<>(); -+ ex.getBindingResult().getFieldErrors().forEach(error -> { -+ String message = error.getDefaultMessage() == null -+ ? "Invalid value." -+ : error.getDefaultMessage(); -+ issues.add(Issue.at(error.getField(), message)); -+ }); - -- ex.getBindingResult().getFieldErrors().forEach( -- error -> errors.put(error.getField(), error.getDefaultMessage())); -+ ProblemDetail problem = VALIDATION_PROBLEMS -+ .map(Outcome.of(StandardOutcomes.INVALID_ARGUMENT, null, issues)) -+ .orNull(); - -- return ResponseEntity.badRequest().body(errors); -+ if (problem == null) { -+ throw new IllegalStateException("INVALID_ARGUMENT has no HTTP mapping"); -+ } -+ -+ return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(EmailAlreadyExistsException.class) ---- /dev/null -+++ b/patient-service/src/test/java/com/pm/patientservice/exception/CodesValidationBoundaryTest.java -@@ -0,0 +1,55 @@ -+package com.pm.patientservice.exception; -+ -+import jakarta.validation.Valid; -+import jakarta.validation.constraints.NotBlank; -+import org.junit.jupiter.api.Test; -+import org.springframework.beans.factory.annotation.Autowired; -+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -+import org.springframework.context.annotation.Import; -+import org.springframework.http.MediaType; -+import org.springframework.web.bind.annotation.PostMapping; -+import org.springframework.web.bind.annotation.RequestBody; -+import org.springframework.web.bind.annotation.RestController; -+import org.springframework.test.web.servlet.MockMvc; -+ -+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -+ -+@WebMvcTest(controllers = CodesValidationBoundaryTest.TestController.class) -+@Import(GlobalExceptionHandler.class) -+class CodesValidationBoundaryTest { -+ -+ @Autowired -+ private MockMvc mockMvc; -+ -+ @Test -+ void validationKeepsCodesIdentityAndStructuredIssuesAtHttpBoundary() -+ throws Exception { -+ mockMvc.perform(post("/codes-test/validate") -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(""" -+ { -+ "email": "" -+ } -+ """)) -+ .andExpect(status().isBadRequest()) -+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) -+ .andExpect(jsonPath("$.status").value(400)) -+ .andExpect(jsonPath("$.code") -+ .value("io.github.aalsanie.codes.standard:INVALID_ARGUMENT")) -+ .andExpect(jsonPath("$.issues[0].path").value("email")) -+ .andExpect(jsonPath("$.detail").doesNotExist()); -+ } -+ -+ @RestController -+ static class TestController { -+ @PostMapping("/codes-test/validate") -+ void validate(@Valid @RequestBody TestRequest request) { -+ } -+ } -+ -+ record TestRequest(@NotBlank String email) { -+ } -+} ---- a/billing-service/pom.xml -+++ b/billing-service/pom.xml -@@ -28,8 +28,26 @@ - - - 21 -+ 0.4.0-RC1 -+ 1.75.0 - -+ -+ -+ -+ io.grpc -+ grpc-bom -+ ${grpc.version} -+ pom -+ import -+ -+ -+ - -+ -+ io.github.aalsanie -+ codes-grpc-java -+ ${codes.version} -+ - - org.springframework.boot - spring-boot-starter-web -@@ -45,17 +63,17 @@ - - io.grpc - grpc-netty-shaded -- 1.69.0 -+ ${grpc.version} - - - io.grpc - grpc-protobuf -- 1.69.0 -+ ${grpc.version} - - - io.grpc - grpc-stub -- 1.69.0 -+ ${grpc.version} - - - org.apache.tomcat -@@ -103,7 +121,7 @@ - com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier} - grpc-java - -- io.grpc:protoc-gen-grpc-java:1.68.1:exe:${os.detected.classifier} -+ io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} - - - ---- a/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java -+++ b/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java -@@ -1,21 +1,58 @@ - package com.pm.billingservice.grpc; - -+import billing.BillingRequest; - import billing.BillingResponse; - import billing.BillingServiceGrpc.BillingServiceImplBase; -+import io.github.aalsanie.codes.Issue; -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -+import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -+import io.github.aalsanie.codes.grpc.GrpcOutcomeExposure; -+import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -+import io.grpc.StatusRuntimeException; - import io.grpc.stub.StreamObserver; - import net.devh.boot.grpc.server.service.GrpcService; - import org.slf4j.Logger; - import org.slf4j.LoggerFactory; - -+import java.util.ArrayList; -+import java.util.List; -+ - @GrpcService - public class BillingGrpcService extends BillingServiceImplBase { - private static final Logger log = LoggerFactory.getLogger(BillingGrpcService.class); -+ private static final GoogleRpcOutcomeMapper ERRORS = -+ new GoogleRpcOutcomeMapper( -+ GrpcOutcomeMapper.standard(), -+ GrpcOutcomeExposure.publicErrors() -+ ); - - @Override -- public void createBillingAccount(billing.BillingRequest billingRequest, -+ public void createBillingAccount(BillingRequest billingRequest, - StreamObserver responseObserver) { - - log.info("createBillingAccount request received {}", billingRequest.toString()); -+ -+ List issues = validationIssues(billingRequest); -+ if (!issues.isEmpty()) { -+ Outcome outcome = Outcome.of( -+ StandardOutcomes.INVALID_ARGUMENT, -+ null, -+ issues -+ ); -+ -+ StatusRuntimeException error = GrpcOutcomeExceptions -+ .toStatusRuntimeException(outcome, ERRORS) -+ .orNull(); -+ -+ if (error == null) { -+ throw new IllegalStateException("INVALID_ARGUMENT has no gRPC mapping"); -+ } -+ -+ responseObserver.onError(error); -+ return; -+ } - - // Business logic - e.g. save to database, perform calculations, etc - -@@ -27,4 +64,20 @@ - responseObserver.onNext(response); - responseObserver.onCompleted(); - } -+ -+ private static List validationIssues(BillingRequest request) { -+ List issues = new ArrayList<>(); -+ -+ if (request.getPatientId().isBlank()) { -+ issues.add(Issue.at("patientId", "Patient id is required.")); -+ } -+ if (request.getName().isBlank()) { -+ issues.add(Issue.at("name", "Name is required.")); -+ } -+ if (request.getEmail().isBlank()) { -+ issues.add(Issue.at("email", "Email is required.")); -+ } -+ -+ return issues; -+ } - } ---- /dev/null -+++ b/billing-service/src/test/java/com/pm/billingservice/grpc/BillingGrpcServiceCodesTest.java -@@ -0,0 +1,79 @@ -+package com.pm.billingservice.grpc; -+ -+import billing.BillingRequest; -+import billing.BillingResponse; -+import com.google.protobuf.Any; -+import com.google.rpc.BadRequest; -+import com.google.rpc.ErrorInfo; -+import io.grpc.StatusRuntimeException; -+import io.grpc.protobuf.StatusProto; -+import io.grpc.stub.StreamObserver; -+import org.junit.jupiter.api.Test; -+ -+import java.util.concurrent.atomic.AtomicReference; -+ -+import static org.junit.jupiter.api.Assertions.assertEquals; -+import static org.junit.jupiter.api.Assertions.assertInstanceOf; -+import static org.junit.jupiter.api.Assertions.assertNotNull; -+import static org.junit.jupiter.api.Assertions.assertNull; -+ -+class BillingGrpcServiceCodesTest { -+ -+ @Test -+ void invalidRequestKeepsCodesIdentityAndStructuredIssuesInTrailers() -+ throws Exception { -+ AtomicReference failure = new AtomicReference<>(); -+ AtomicReference response = new AtomicReference<>(); -+ -+ StreamObserver observer = new StreamObserver<>() { -+ @Override -+ public void onNext(BillingResponse value) { -+ response.set(value); -+ } -+ -+ @Override -+ public void onError(Throwable throwable) { -+ failure.set(throwable); -+ } -+ -+ @Override -+ public void onCompleted() { -+ } -+ }; -+ -+ new BillingGrpcService().createBillingAccount( -+ BillingRequest.getDefaultInstance(), -+ observer -+ ); -+ -+ assertNull(response.get()); -+ -+ StatusRuntimeException exception = -+ assertInstanceOf(StatusRuntimeException.class, failure.get()); -+ -+ com.google.rpc.Status status = StatusProto.fromThrowable(exception); -+ assertNotNull(status); -+ assertEquals(3, status.getCode()); -+ -+ ErrorInfo identity = null; -+ BadRequest badRequest = null; -+ -+ for (Any detail : status.getDetailsList()) { -+ if (detail.is(ErrorInfo.class)) { -+ identity = detail.unpack(ErrorInfo.class); -+ } else if (detail.is(BadRequest.class)) { -+ badRequest = detail.unpack(BadRequest.class); -+ } -+ } -+ -+ assertNotNull(identity); -+ assertEquals("io.github.aalsanie.codes.standard", identity.getDomain()); -+ assertEquals("INVALID_ARGUMENT", identity.getReason()); -+ -+ assertNotNull(badRequest); -+ assertEquals(3, badRequest.getFieldViolationsCount()); -+ assertEquals("patientId", badRequest.getFieldViolations(0).getField()); -+ assertEquals("name", badRequest.getFieldViolations(1).getField()); -+ assertEquals("email", badRequest.getFieldViolations(2).getField()); -+ } -+} diff --git a/compatibility/grpc-google-rpc-status.snapshot b/compatibility/grpc-google-rpc-status.snapshot deleted file mode 100644 index 37a000e..0000000 --- a/compatibility/grpc-google-rpc-status.snapshot +++ /dev/null @@ -1,38 +0,0 @@ -[safe] -code=3 -message=com.example.checkout:CHECKOUT_INVALID -details=1 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug= -issues=0 - -[public] -code=3 -message=Checkout request is invalid. -details=2 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug= -issues=2 -issue.0.field=paymentMethod -issue.0.reason=PAYMENT_METHOD_INVALID -issue.0.description=Payment method is invalid. -issue.1.field=amount -issue.1.reason= -issue.1.description=Amount must be positive. - -[explicit] -code=3 -message=Checkout request is invalid. -details=3 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug=gateway_token=secret-123 -issues=2 -issue.0.field=paymentMethod -issue.0.reason=PAYMENT_METHOD_INVALID -issue.0.description=Payment method is invalid. -issue.1.field=amount -issue.1.reason= -issue.1.description=Amount must be positive. diff --git a/compatibility/grpc-mappings.snapshot b/compatibility/grpc-mappings.snapshot deleted file mode 100644 index d650fc4..0000000 --- a/compatibility/grpc-mappings.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -io.github.aalsanie.codes.standard:ABORTED|ABORTED -io.github.aalsanie.codes.standard:ALREADY_EXISTS|ALREADY_EXISTS -io.github.aalsanie.codes.standard:CANCELLED|CANCELLED -io.github.aalsanie.codes.standard:DATA_LOSS|DATA_LOSS -io.github.aalsanie.codes.standard:DEADLINE_EXCEEDED|DEADLINE_EXCEEDED -io.github.aalsanie.codes.standard:FAILED_PRECONDITION|FAILED_PRECONDITION -io.github.aalsanie.codes.standard:INTERNAL|INTERNAL -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|INVALID_ARGUMENT -io.github.aalsanie.codes.standard:NOT_FOUND|NOT_FOUND -io.github.aalsanie.codes.standard:OK|OK -io.github.aalsanie.codes.standard:OUT_OF_RANGE|OUT_OF_RANGE -io.github.aalsanie.codes.standard:PERMISSION_DENIED|PERMISSION_DENIED -io.github.aalsanie.codes.standard:RATE_LIMITED|RESOURCE_EXHAUSTED -io.github.aalsanie.codes.standard:RESOURCE_EXHAUSTED|RESOURCE_EXHAUSTED -io.github.aalsanie.codes.standard:UNAUTHENTICATED|UNAUTHENTICATED -io.github.aalsanie.codes.standard:UNAVAILABLE|UNAVAILABLE -io.github.aalsanie.codes.standard:UNIMPLEMENTED|UNIMPLEMENTED \ No newline at end of file diff --git a/compatibility/grpc-status-codes.snapshot b/compatibility/grpc-status-codes.snapshot deleted file mode 100644 index 0ec68b1..0000000 --- a/compatibility/grpc-status-codes.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -OK|0 -CANCELLED|1 -UNKNOWN|2 -INVALID_ARGUMENT|3 -DEADLINE_EXCEEDED|4 -NOT_FOUND|5 -ALREADY_EXISTS|6 -PERMISSION_DENIED|7 -RESOURCE_EXHAUSTED|8 -FAILED_PRECONDITION|9 -ABORTED|10 -OUT_OF_RANGE|11 -UNIMPLEMENTED|12 -INTERNAL|13 -UNAVAILABLE|14 -DATA_LOSS|15 -UNAUTHENTICATED|16 diff --git a/compatibility/http-mappings.snapshot b/compatibility/http-mappings.snapshot deleted file mode 100644 index 3056870..0000000 --- a/compatibility/http-mappings.snapshot +++ /dev/null @@ -1,12 +0,0 @@ -io.github.aalsanie.codes.standard:ALREADY_EXISTS|409 -io.github.aalsanie.codes.standard:DATA_LOSS|500 -io.github.aalsanie.codes.standard:INTERNAL|500 -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|400 -io.github.aalsanie.codes.standard:NOT_FOUND|404 -io.github.aalsanie.codes.standard:OK|200 -io.github.aalsanie.codes.standard:OUT_OF_RANGE|400 -io.github.aalsanie.codes.standard:PERMISSION_DENIED|403 -io.github.aalsanie.codes.standard:RATE_LIMITED|429 -io.github.aalsanie.codes.standard:UNAUTHENTICATED|401 -io.github.aalsanie.codes.standard:UNAVAILABLE|503 -io.github.aalsanie.codes.standard:UNIMPLEMENTED|501 \ No newline at end of file diff --git a/compatibility/http-status-codes.snapshot b/compatibility/http-status-codes.snapshot deleted file mode 100644 index 808ec01..0000000 --- a/compatibility/http-status-codes.snapshot +++ /dev/null @@ -1,16 +0,0 @@ -ACCEPTED|202 -BAD_REQUEST|400 -CONFLICT|409 -CREATED|201 -FORBIDDEN|403 -GATEWAY_TIMEOUT|504 -INTERNAL_SERVER_ERROR|500 -NOT_FOUND|404 -NOT_IMPLEMENTED|501 -NO_CONTENT|204 -OK|200 -PAYLOAD_TOO_LARGE|413 -PRECONDITION_FAILED|412 -SERVICE_UNAVAILABLE|503 -TOO_MANY_REQUESTS|429 -UNAUTHORIZED|401 diff --git a/compatibility/matrix.json b/compatibility/matrix.json index 5d97255..548094a 100644 --- a/compatibility/matrix.json +++ b/compatibility/matrix.json @@ -8,23 +8,14 @@ "6.0.0", "7.0.9" ], - "grpc": [ - "1.75.0", - "1.83.1" - ], "kotlin": { "versions": [ "1.9.24", - "2.0.21", - "2.1.21", - "2.2.20", "2.4.10" ], - "spring": "6.0.0", - "grpc": "1.75.0" + "spring": "6.0.0" }, "os": [ - "ubuntu-latest", "windows-latest", "macos-latest" ] diff --git a/compatibility/spring-http-problems.snapshot b/compatibility/spring-http-problems.snapshot deleted file mode 100644 index be29da1..0000000 --- a/compatibility/spring-http-problems.snapshot +++ /dev/null @@ -1,79 +0,0 @@ -# Rendered Spring HTTP problem contract. -# The same cases are exercised through MVC and WebFlux. -# JSON member ordering is intentionally not part of the contract. - -[safe] -status=404 -contentType=application/problem+json -type=about:blank -title=Not Found -detail= -instance=/safe -code=io.github.aalsanie.codes.standard:NOT_FOUND -issues=0 - -[public] -status=400 -contentType=application/problem+json -type=https://api.example.test/problems/invalid-argument -title=An argument supplied to the operation is invalid. -detail= -instance=/public -code=io.github.aalsanie.codes.standard:INVALID_ARGUMENT -issues=0 - -[custom] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title=The payment was declined. -detail=Ask the customer to use another payment method. -instance=/custom -code=com.example.checkout:PAYMENT_DECLINED -issues=0 - -[validation] -status=400 -contentType=application/problem+json -type=https://api.example.test/problems/invalid-argument -title=An argument supplied to the operation is invalid. -detail= -instance=/validation -code=io.github.aalsanie.codes.standard:INVALID_ARGUMENT -issues=2 -issue.0.code= -issue.0.path=email -issue.0.message=Invalid email address. -issue.1.code= -issue.1.path=quantity -issue.1.message=Must be greater than zero. - -[unmapped] -status=500 -contentType=application/problem+json -type=about:blank -title=Internal Server Error -detail= -instance=/unmapped -code=io.github.aalsanie.codes.standard:FAILED_PRECONDITION -issues=0 - -[sensitive-detail] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title=The payment was declined. -detail= -instance=/sensitive-detail -code=com.example.checkout:PAYMENT_DECLINED -issues=0 - -[custom-safe] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title= -detail= -instance=/custom-safe -code=com.example.checkout:PAYMENT_DECLINED -issues=0 diff --git a/compatibility/standard-namespace.snapshot b/compatibility/standard-namespace.snapshot deleted file mode 100644 index f724b15..0000000 --- a/compatibility/standard-namespace.snapshot +++ /dev/null @@ -1 +0,0 @@ -io.github.aalsanie.codes.standard diff --git a/compatibility/standard-outcomes.snapshot b/compatibility/standard-outcomes.snapshot deleted file mode 100644 index b79e985..0000000 --- a/compatibility/standard-outcomes.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -io.github.aalsanie.codes.standard:ABORTED|FAILED -io.github.aalsanie.codes.standard:ALREADY_EXISTS|FAILED -io.github.aalsanie.codes.standard:CANCELLED|FAILED -io.github.aalsanie.codes.standard:DATA_LOSS|FAILED -io.github.aalsanie.codes.standard:DEADLINE_EXCEEDED|FAILED -io.github.aalsanie.codes.standard:FAILED_PRECONDITION|FAILED -io.github.aalsanie.codes.standard:INTERNAL|FAILED -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|FAILED -io.github.aalsanie.codes.standard:NOT_FOUND|FAILED -io.github.aalsanie.codes.standard:OK|SUCCEEDED -io.github.aalsanie.codes.standard:OUT_OF_RANGE|FAILED -io.github.aalsanie.codes.standard:PERMISSION_DENIED|FAILED -io.github.aalsanie.codes.standard:RATE_LIMITED|FAILED -io.github.aalsanie.codes.standard:RESOURCE_EXHAUSTED|FAILED -io.github.aalsanie.codes.standard:UNAUTHENTICATED|FAILED -io.github.aalsanie.codes.standard:UNAVAILABLE|FAILED -io.github.aalsanie.codes.standard:UNIMPLEMENTED|FAILED \ No newline at end of file diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md index 3251359..52b848d 100644 --- a/docs/compatibility-policy.md +++ b/docs/compatibility-policy.md @@ -2,9 +2,11 @@ Codes is pre-1.0. Minor releases may contain source or binary breaking changes. Breaking changes are documented in `CHANGELOG.md`. -All published Codes artifacts target Java 17 and support Java and Kotlin consumers. +All published artifacts target Java 17 and support Java and Kotlin consumers. -The `codes` core artifact has zero runtime dependencies. `codes-spring` and `codes-grpc-java` depend on the boundary libraries they adapt. +The `codes` core artifact has no runtime dependencies and publishes no Maven dependencies. + +`codes-spring` depends on `codes` and compiles against Spring Web, but it does not publish Spring Framework as a transitive dependency. Applications supply their own supported Spring version. ## 0.4.x compatibility @@ -12,27 +14,18 @@ The `0.4.x` line is verified with: * Java 17, 21, and 25; * Spring Framework 6.0.0 and 7.0.9; -* gRPC Java 1.75.0 and 1.83.1; -* Kotlin 1.9.24, 2.0.21, 2.1.21, 2.2.20, and 2.4.10; +* Kotlin 1.9.24 and 2.4.10; * Gradle and Maven consumers; -* Linux, Windows, and macOS. - -Spring Framework 6.0.0 is the Codes compatibility floor for the Spring 6 generation. Spring 7.0.9 is the current Spring 7 verification baseline. +* published consumers on Windows and macOS in addition to the primary Linux CI environment. -gRPC Java 1.75.0 is the Codes compatibility floor. gRPC Java 1.83.1 is the current verification baseline. +Spring Framework 6.0.0 is the compatibility floor. Spring Framework 7.0.9 is the current verification baseline. These are Codes compatibility statements, not upstream maintenance or security-support declarations. ## Public contract -The machine-readable contract includes: - -* `OutcomeCode` identity; -* standard outcome membership; -* standard `OutcomeState` assignments; -* built-in HTTP mappings; -* built-in gRPC mappings. +The machine-readable identity of a `ProblemType` is its RFC 9457 type URI. -The Spring adapter preserves Codes identity in the RFC 9457 `code` extension. The gRPC adapter preserves identity in `google.rpc.ErrorInfo.domain` and `ErrorInfo.reason`. +The stable definition also contains the associated HTTP status and title. -Human-readable messages are not machine identity and may change without changing `OutcomeCode`. +Occurrence-specific detail, instance, and extension properties are not part of the Codes core contract. diff --git a/docs/integration-grpc.md b/docs/integration-grpc.md deleted file mode 100644 index df8e981..0000000 --- a/docs/integration-grpc.md +++ /dev/null @@ -1,108 +0,0 @@ -# gRPC Integration - -This example adds Codes at a gRPC server boundary while the service keeps its existing domain model. - -## 1. Add the adapter - -```kotlin -dependencies { - implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") -} -``` - -Codes `0.4.x` declares gRPC Java 1.75.0 as its compatibility floor. - -## 2. Define the stable application outcome - -```java -final class CheckoutOutcomes { - static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom( - "com.example.checkout", - "CHECKOUT_INVALID", - OutcomeState.FAILED, - "Checkout request is invalid." - ); - - private CheckoutOutcomes() { - } -} -``` - -For `ErrorInfo.reason`, the outcome name must satisfy the Google RPC reason contract: at most 63 characters and UPPER_SNAKE_CASE without a trailing underscore. Codes rejects a lossy mapping rather than changing the identity. - -## 3. Configure the gRPC mapping - -```java -GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping( - CheckoutOutcomes.CHECKOUT_INVALID, - GrpcStatusCode.INVALID_ARGUMENT - ); - -GoogleRpcOutcomeMapper errors = new GoogleRpcOutcomeMapper( - grpc, - GrpcOutcomeExposure.publicErrors() -); -``` - -`publicErrors()` exposes the reusable message and request-field issues. It does not expose occurrence `detail`. - -## 4. Send the error through the existing service - -```java -Outcome outcome = Outcome.of( - CheckoutOutcomes.CHECKOUT_INVALID, - null, - List.of(Issue.at("paymentMethod", "Payment method is invalid.")) -); - -StatusRuntimeException error = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, errors) - .orNull(); - -if (error == null) { - throw new IllegalStateException("CHECKOUT_INVALID has no gRPC mapping"); -} - -responseObserver.onError(error); -``` - -The client receives a real `google.rpc.Status` in the gRPC trailers. - -## Decode it - -```java -com.google.rpc.Status status = StatusProto.fromThrowable(error); - -ErrorInfo identity = status.getDetailsList().stream() - .filter(any -> any.is(ErrorInfo.class)) - .findFirst() - .orElseThrow() - .unpack(ErrorInfo.class); - -assert identity.getDomain().equals("com.example.checkout"); -assert identity.getReason().equals("CHECKOUT_INVALID"); - -BadRequest request = status.getDetailsList().stream() - .filter(any -> any.is(BadRequest.class)) - .findFirst() - .orElseThrow() - .unpack(BadRequest.class); - -assert request.getFieldViolations(0).getField().equals("paymentMethod"); -``` - -Structured issues are carried in `google.rpc.BadRequest` only when the mapped gRPC status is `INVALID_ARGUMENT` or `OUT_OF_RANGE`. Every exposed issue must have a path that the application intends as a request-field path. Codes rejects incompatible or pathless issue exposure instead of changing its meaning. - -Occurrence detail is carried only when `exposeDetail` is explicitly enabled. - -The application identity therefore survives: - -```text -com.example.checkout:CHECKOUT_INVALID - -> gRPC INVALID_ARGUMENT - -> ErrorInfo.domain = com.example.checkout - -> ErrorInfo.reason = CHECKOUT_INVALID -``` - -For outcomes such as `FAILED_PRECONDITION`, Codes does not coerce generic `Issue` values into `PreconditionFailure`; that type has different semantics and requires information the core `Issue` model does not claim to contain. diff --git a/docs/integration-spring.md b/docs/integration-spring.md index e386aa6..2a91fed 100644 --- a/docs/integration-spring.md +++ b/docs/integration-spring.md @@ -1,110 +1,75 @@ # Spring Integration -This example adds Codes at the HTTP boundary while the application keeps its existing exception and domain model. +`codes-spring` converts a reusable Codes `ProblemType` into Spring Framework's native `ProblemDetail`. -## 1. Add the adapter +## Dependency ```kotlin dependencies { - implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") + implementation("io.github.aalsanie:codes-spring:0.4.0") } ``` -The adapter supports the declared Spring 6 and Spring 7 compatibility lines. +The application supplies Spring Web. `codes-spring` does not impose a Spring Framework version transitively. -## 2. Define the stable application outcome +## Define problem types ```java -final class PaymentOutcomes { - static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.payments", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" ); - private PaymentOutcomes() { + private OrderProblems() { } } ``` -`com.example.payments:PAYMENT_DECLINED` is the identity. HTTP 422 is a boundary decision. - -## 3. Configure the HTTP boundary explicitly +## Create a problem occurrence ```java -HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(PaymentOutcomes.PAYMENT_DECLINED, HttpStatusCode.of(422)); - -OutcomeProblemDetailMapper problems = new OutcomeProblemDetailMapper( - new SpringHttpStatusMapper(http), - SpringOutcomeExposure.publicErrors(), - SpringProblemTypeUriMapper.empty().withMapping( - PaymentOutcomes.PAYMENT_DECLINED, - URI.create("https://api.example.com/problems/payment-declined") - ) +ProblemDetail problem = ProblemDetails.forType( + OrderProblems.ORDER_NOT_FOUND ); ``` -The URI above belongs to the example application. Codes does not invent or own problem-type URIs. - -`publicErrors()` exposes the reusable outcome message and structured issues but not occurrence `detail`. Use `safeDefaults()` when even those fields should remain hidden. - -## 4. Use it from the application's existing exception boundary +For an occurrence-specific detail: ```java -@RestControllerAdvice -final class PaymentExceptionHandler { - private final OutcomeProblemDetailMapper problems; - - PaymentExceptionHandler(OutcomeProblemDetailMapper problems) { - this.problems = problems; - } - - @ExceptionHandler(PaymentDeclinedException.class) - ResponseEntity paymentDeclined() { - Outcome outcome = Outcome.of(PaymentOutcomes.PAYMENT_DECLINED); - ProblemDetail problem = problems.map(outcome).orNull(); - - if (problem == null) { - throw new IllegalStateException("PAYMENT_DECLINED has no HTTP mapping"); - } - - return ResponseEntity.status(problem.getStatus()).body(problem); - } -} +ProblemDetail problem = ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order o-123 was not found." +); ``` -The application decides which exception maps to which outcome. +The bridge sets only `type`, `status`, `title`, and the explicitly supplied `detail`. -## Result +Do not populate `detail` from `exception.getMessage()` unless that message was explicitly designed for public API consumption. Internal exception text can expose implementation details, identifiers, or other sensitive information. -A rendered response is an RFC 9457 problem document with the stable Codes identity: +The bridge does not set the request-specific `instance`, add extension properties, map exceptions, or register controller advice. -```json -{ - "type": "https://api.example.com/problems/payment-declined", - "title": "The payment was declined.", - "status": 422, - "code": "com.example.payments:PAYMENT_DECLINED" -} -``` - -Occurrence detail is absent unless the application explicitly opts into it. +## Exception handlers -## Validation issues +Different internal exceptions can intentionally expose the same public problem type: ```java -Outcome invalid = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - null, - List.of( - Issue.at("email", "Invalid email address."), - Issue.at("quantity", "Must be greater than zero.") - ) -); -``` +@ExceptionHandler(OrderNotFoundException.class) +ProblemDetail handleOrderNotFound(OrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + ex.orderId() + " was not found." + ); +} -With `SpringOutcomeExposure.publicErrors()`, those issues are emitted as structured `issues` while `code` remains `io.github.aalsanie.codes.standard:INVALID_ARGUMENT`. +@ExceptionHandler(ArchivedOrderNotFoundException.class) +ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + ex.orderId() + " was not found." + ); +} +``` -That is the complete integration: stable outcome definition, explicit protocol mapping, explicit exposure policy, existing application boundary. +Spring remains responsible for rendering the `ProblemDetail` through MVC or WebFlux. diff --git a/docs/protocol-mappings.md b/docs/protocol-mappings.md deleted file mode 100644 index a3ff566..0000000 --- a/docs/protocol-mappings.md +++ /dev/null @@ -1,61 +0,0 @@ -# Protocol mappings - -Standard mappings provided by `HttpOutcomeMapper.standard()` and `GrpcOutcomeMapper.standard()`. - -Mappings convert application outcomes to protocol status. They do not change outcome identity or lifecycle state. - -| Outcome | HTTP | gRPC | -|---|---|---| -| `OK` | `200 OK` | `OK` | -| `INVALID_ARGUMENT` | `400 Bad Request` | `INVALID_ARGUMENT` | -| `UNAUTHENTICATED` | `401 Unauthorized` | `UNAUTHENTICATED` | -| `PERMISSION_DENIED` | `403 Forbidden` | `PERMISSION_DENIED` | -| `NOT_FOUND` | `404 Not Found` | `NOT_FOUND` | -| `ALREADY_EXISTS` | `409 Conflict` | `ALREADY_EXISTS` | -| `FAILED_PRECONDITION` | unmapped | `FAILED_PRECONDITION` | -| `OUT_OF_RANGE` | `400 Bad Request` | `OUT_OF_RANGE` | -| `RATE_LIMITED` | `429 Too Many Requests` | `RESOURCE_EXHAUSTED` | -| `CANCELLED` | unmapped | `CANCELLED` | -| `DEADLINE_EXCEEDED` | unmapped | `DEADLINE_EXCEEDED` | -| `ABORTED` | unmapped | `ABORTED` | -| `UNIMPLEMENTED` | `501 Not Implemented` | `UNIMPLEMENTED` | -| `UNAVAILABLE` | `503 Service Unavailable` | `UNAVAILABLE` | -| `INTERNAL` | `500 Internal Server Error` | `INTERNAL` | -| `DATA_LOSS` | `500 Internal Server Error` | `DATA_LOSS` | -| `RESOURCE_EXHAUSTED` | unmapped | `RESOURCE_EXHAUSTED` | - -## Why some HTTP mappings are absent - -Codes supplies a standard HTTP mapping only when the status is broadly applicable. - -`FAILED_PRECONDITION` is not universally HTTP `412 Precondition Failed`; HTTP 412 has conditional-request semantics. `DEADLINE_EXCEEDED` is not universally `504 Gateway Timeout`; HTTP 504 describes a gateway or proxy timing out while waiting for an upstream server. - -Applications map such outcomes according to their own HTTP contract. - -## Custom mappings - -```java -HttpOutcomeMapper mapper = HttpOutcomeMapper.standard() - .withMapping(paymentDeclined, HttpStatusCode.of(422)); -``` - -Override an existing mapping: - -```java -HttpOutcomeMapper mapper = HttpOutcomeMapper.standard() - .withOverride(StandardOutcomes.NOT_FOUND, HttpStatusCode.of(410)); -``` - -`withMapping` rejects duplicate mappings. `withOverride` rejects outcomes that are not already mapped. - -HTTP status constants such as `CREATED`, `ACCEPTED`, `NO_CONTENT`, and `PAYLOAD_TOO_LARGE` remain available for explicit application mappings even though those names are not standard application outcomes. - -## gRPC structured issues - -`GoogleRpcOutcomeMapper` always preserves the stable Codes identity in `ErrorInfo.domain` and `ErrorInfo.reason` for mapped failures. - -When issue exposure is enabled, Codes uses `google.rpc.BadRequest` only for outcomes mapped to gRPC `INVALID_ARGUMENT` or `OUT_OF_RANGE`, matching the standard Google RPC error-detail semantics. Every exposed issue must have a path that the application intends as a request-field path. - -If an outcome with issues is mapped to another gRPC status while issue exposure is enabled, the adapter rejects the mapping instead of emitting a misleading `BadRequest`. Pathless issues are rejected for the same reason. - -A coded issue can populate `BadRequest.FieldViolation.reason` only when its namespace matches the enclosing outcome namespace, because the reason is scoped by the enclosing `ErrorInfo.domain`. diff --git a/docs/semantic-contract.md b/docs/semantic-contract.md index d315017..5eb3786 100644 --- a/docs/semantic-contract.md +++ b/docs/semantic-contract.md @@ -1,164 +1,50 @@ # Semantic contract -This document defines the semantics that Codes treats as part of its public contract. +Codes models reusable RFC 9457 problem type definitions. -## Core model +A `ProblemType` contains: -Codes provides a common vocabulary for application outcomes: +* an absolute type URI; +* an HTTP status between 100 and 599; +* a non-blank human-readable title. -* `OutcomeCode`: machine-readable identity -* `OutcomeState`: lifecycle state -* `OutcomeDefinition`: reusable outcome definition -* `Outcome`: runtime occurrence -* `Issue`: structured occurrence-level issue -* `OutcomeMapper` and `MappingResult`: boundary mapping -* `OutcomeRegistry`: immutable definition lookup and composition -* the standard outcome catalog -* standard HTTP and gRPC mappings +`about:blank` is not a Codes problem type. It represents a status-only problem with no additional semantics beyond the HTTP status and is handled directly by HTTP frameworks such as Spring. -Applications keep their own domain error or result model and use Codes where a shared outcome identity is useful. - -## Identity - -`OutcomeCode` is the machine identity of an outcome. - -```text -com.example.payments:PAYMENT_DECLINED -``` - -The namespace and name form the identity used for registry lookup, mappings, structured logging, observability, and other machine-readable uses. - -Human-readable messages are not identifiers and must not be used for branching or matching. - -## Lifecycle state - -### `SUCCEEDED` - -The modeled operation reached a successful terminal outcome. - -Transport status, response shape, resource creation, and follow-up work are separate concerns. - -### `PENDING` - -The modeled operation has not reached a terminal outcome. - -Applications define pending outcomes when that distinction is meaningful to the operation. - -### `FAILED` - -The modeled operation ended without producing a successful result for the caller. - -`FAILED` is not a transactional guarantee. External state may already have changed before a timeout, cancellation, or other failure was observed. - -## Definition and occurrence - -`OutcomeDefinition` contains: - -```text -code -state -defaultMessage -``` - -`Outcome` contains: - -```text -definition -detail -issues -``` - -`detail` and `issues` belong to one occurrence. `Outcome.toString()` and `OutcomeException.getMessage()` do not include occurrence `detail`. - -## Standard outcome catalog - -An outcome belongs in the standard catalog when its meaning: - -* is independent of a specific protocol, framework, or serialization format; -* applies across unrelated application domains; -* provides a useful distinction to callers, mapping policy, or observability; -* represents a distinct application outcome rather than only a transport representation. - -| Outcome | State | Meaning | -|---|---|---| -| `OK` | `SUCCEEDED` | The operation completed successfully. | -| `INVALID_ARGUMENT` | `FAILED` | One or more arguments are invalid independent of current application state. | -| `UNAUTHENTICATED` | `FAILED` | The operation requires an authenticated identity and no valid identity is available. | -| `PERMISSION_DENIED` | `FAILED` | The caller is not permitted to perform the operation. | -| `NOT_FOUND` | `FAILED` | A required or requested application resource or entity cannot be found. | -| `ALREADY_EXISTS` | `FAILED` | The operation conflicts with a resource or entity that already exists. | -| `FAILED_PRECONDITION` | `FAILED` | Required application state or a precondition is not satisfied. | -| `OUT_OF_RANGE` | `FAILED` | A supplied value is outside the valid range for the operation. | -| `RATE_LIMITED` | `FAILED` | A caller or workload exceeded an allowed operation rate. | -| `CANCELLED` | `FAILED` | The operation was cancelled before a successful result was produced. | -| `DEADLINE_EXCEEDED` | `FAILED` | The operation did not produce a successful result before its deadline. | -| `ABORTED` | `FAILED` | The operation was aborted because of a conflict or coordination condition. | -| `UNIMPLEMENTED` | `FAILED` | The requested operation or capability is not implemented or supported. | -| `UNAVAILABLE` | `FAILED` | A required capability is temporarily unavailable. | -| `INTERNAL` | `FAILED` | An internal implementation or invariant failure prevented a successful result. | -| `DATA_LOSS` | `FAILED` | Unrecoverable data loss or corruption was detected. | -| `RESOURCE_EXHAUSTED` | `FAILED` | A required capacity, quota, or other resource limit was exhausted. | - -Applications define more specific outcomes where the distinction matters. - -## Structured issues - -`Issue` contains an optional code, optional application-defined path, and required message. Issue path syntax is owned by the application. - -## Validation - -`ValidationResult` aggregates independent issues and can convert them to an outcome. Conversion requires the failure definition explicitly. +The type URI is the stable machine-readable identity of the problem type. ```java -validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT); -``` - -A valid result produces `StandardOutcomes.OK`. An invalid result produces the supplied failed definition. The supplied definition is validated even for a valid result so invalid conversion policy fails immediately. - -## Protocol mappings - -`OutcomeMapper` maps an application outcome definition to a boundary representation. - -```text -NOT_FOUND -> HTTP 404 -NOT_FOUND -> gRPC NOT_FOUND +ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" +); ``` -Different outcomes can map to the same boundary value while keeping distinct application identities. - -`MappingResult.Unmapped` means the mapper has no policy for the outcome. Applications can add or override mappings explicitly. - -## Namespace contract - -Custom codes use `namespace:NAME`. +## Definition and occurrence -Namespaces: +`ProblemType` represents stable definition metadata. -* contain lowercase ASCII letters, digits, and hyphens; -* use dot-separated segments; -* start each segment with a lowercase ASCII letter; -* end each segment with a lowercase ASCII letter or digit. +It does not contain occurrence-specific information such as: -Names use uppercase ASCII letters, digits, and underscores. +* detail; +* instance; +* request data; +* exception data; +* trace information; +* extension properties. -`io.github.aalsanie.codes` and its child namespaces are reserved. Applications should use namespaces they control. Syntax validation does not verify namespace ownership. +Those values belong to the individual RFC 9457 problem occurrence. -## Compatibility +For Spring applications, `codes-spring` copies the stable definition into a native Spring `ProblemDetail`. Spring and the application remain responsible for the rest of the response lifecycle. -The machine-readable semantic contract includes: +## Identity -* `OutcomeCode` identity; -* standard outcome membership; -* standard `OutcomeState` assignments; -* built-in HTTP mappings; -* built-in gRPC mappings. +Changing a problem type URI changes the public machine identity of the problem. -Human-readable text may evolve without changing outcome identity: +Changing the Java exception or controller path that produces the problem does not require changing the problem type URI. -* `OutcomeDefinition.defaultMessage`; -* `Outcome.detail`; -* `Issue.message`. +The title is a stable human-readable summary. Applications may localize it at the occurrence layer when required. -Consumers must use `OutcomeCode`, not human-readable text, when behavior depends on identity. +## Scope -Runtime, framework, language, and build-tool support is documented in the [compatibility policy](compatibility-policy.md). +Codes does not provide exception mapping, validation aggregation, registries, workflow states, or protocol-neutral status abstractions. diff --git a/gradle.properties b/gradle.properties index 4fe73f5..d0ba0fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ GROUP=io.github.aalsanie POM_ARTIFACT_ID=codes -VERSION_NAME=0.4.0-RC1 +VERSION_NAME=0.4.0 POM_NAME=Codes -POM_DESCRIPTION=Stable application outcomes, structured issues, and explicit protocol mappings for Java and Kotlin. +POM_DESCRIPTION=Dependency-free Java model for reusable RFC 9457 problem type definitions. POM_INCEPTION_YEAR=2026 POM_URL=https://github.com/aalsanie/codes POM_LICENSE_NAME=The Apache License, Version 2.0 @@ -17,10 +17,6 @@ POM_DEVELOPER_URL=https://github.com/aalsanie junitVersion=6.1.3 springFrameworkVersion=7.0.9 springBootVersion=4.1.1 -jackson3Version=3.1.5 -grpcVersion=1.83.1 -protoGoogleCommonProtosVersion=2.75.0 -protobufVersion=4.33.6 org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true diff --git a/reference/grpc-orders/build.gradle.kts b/reference/grpc-orders/build.gradle.kts deleted file mode 100644 index 917221e..0000000 --- a/reference/grpc-orders/build.gradle.kts +++ /dev/null @@ -1,59 +0,0 @@ -plugins { - java - application - id("com.google.protobuf") version "0.10.0" -} - -version = "0.4.0-reference" - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} - -tasks.withType().configureEach { - options.release.set(17) - options.encoding = "UTF-8" - options.compilerArgs.add("-Xlint:all,-serial") -} - -val grpcVersion = providers.gradleProperty("grpcVersion").get() - -dependencies { - implementation(project(":codes-grpc-java")) - implementation("io.grpc:grpc-netty-shaded:$grpcVersion") - implementation("io.grpc:grpc-protobuf:$grpcVersion") - implementation("io.grpc:grpc-stub:$grpcVersion") - compileOnly("javax.annotation:javax.annotation-api:1.3.2") - - testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:${providers.gradleProperty("protobufVersion").get()}" - } - plugins { - create("grpc") { - artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" - } - } - generateProtoTasks { - all().configureEach { - plugins { - create("grpc") - } - } - } -} - -tasks.test { - useJUnitPlatform() -} - -application { - mainClass.set("com.example.orders.grpc.OrdersGrpcApplication") -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java deleted file mode 100644 index 7e293f2..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; - -final class OrderOutcomes { - static final OutcomeDefinition ORDER_CREATED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_CREATED", OutcomeState.SUCCEEDED, "The order was created." - ); - static final OutcomeDefinition ORDER_REJECTED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "The order was rejected." - ); - - private OrderOutcomes() { - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java deleted file mode 100644 index 43d7efb..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.orders.grpc; - -record OrderRecord(String orderId, String customerId, String product, String status) { -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java deleted file mode 100644 index 482c6e0..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; - -record OrderResult(OrderRecord value, Outcome outcome) { - OrderResult { - Objects.requireNonNull(outcome, "outcome"); - } - - static OrderResult failure(Outcome outcome) { - return new OrderResult(null, outcome); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java deleted file mode 100644 index 6295679..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -final class OrderService { - private final Map orders = new ConcurrentHashMap<>(); - - OrderResult create(String orderId, String customerId, String product) { - ValidationResult validation = ValidationResult.combine( - required("orderId", orderId), - required("customerId", customerId), - required("product", product) - ); - if (validation.isInvalid()) { - return OrderResult.failure(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT)); - } - if ("blocked".equalsIgnoreCase(product)) { - return OrderResult.failure(Outcome.of(OrderOutcomes.ORDER_REJECTED)); - } - OrderRecord order = new OrderRecord(orderId, customerId, product, "CREATED"); - if (orders.putIfAbsent(orderId, order) != null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.ALREADY_EXISTS)); - } - return new OrderResult(order, Outcome.of(OrderOutcomes.ORDER_CREATED)); - } - - OrderResult find(String orderId) { - OrderRecord order = orders.get(orderId); - return order == null - ? OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)) - : new OrderResult(order, Outcome.of(StandardOutcomes.OK)); - } - - private static ValidationResult required(String path, String value) { - return value == null || value.isBlank() - ? ValidationResult.invalid(Issue.at(path, "The value is required.")) - : ValidationResult.valid(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java deleted file mode 100644 index 83d5be9..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.orders.grpc; - -import io.grpc.Server; -import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; -import java.io.IOException; - -public final class OrdersGrpcApplication { - private OrdersGrpcApplication() { - } - - public static void main(String[] args) throws IOException, InterruptedException { - Server server = NettyServerBuilder.forPort(9090) - .addService(new OrdersGrpcService(new OrderService(), new OutcomeGrpcBoundary())) - .build() - .start(); - Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown)); - server.awaitTermination(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java deleted file mode 100644 index 004da58..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.orders.grpc; - -import io.grpc.stub.StreamObserver; - -final class OrdersGrpcService extends OrdersGrpc.OrdersImplBase { - private final OrderService service; - private final OutcomeGrpcBoundary boundary; - - OrdersGrpcService(OrderService service, OutcomeGrpcBoundary boundary) { - this.service = service; - this.boundary = boundary; - } - - @Override - public void createOrder(CreateOrderRequest request, StreamObserver responseObserver) { - OrderResult result = service.create(request.getOrderId(), request.getCustomerId(), request.getProduct()); - if (result.outcome().isFailed()) { - responseObserver.onError(boundary.toException(result.outcome())); - return; - } - responseObserver.onNext(toReply(result.value())); - responseObserver.onCompleted(); - } - - @Override - public void getOrder(GetOrderRequest request, StreamObserver responseObserver) { - OrderResult result = service.find(request.getOrderId()); - if (result.outcome().isFailed()) { - responseObserver.onError(boundary.toException(result.outcome())); - return; - } - responseObserver.onNext(toReply(result.value())); - responseObserver.onCompleted(); - } - - private static OrderReply toReply(OrderRecord order) { - return OrderReply.newBuilder() - .setOrderId(order.orderId()) - .setCustomerId(order.customerId()) - .setProduct(order.product()) - .setStatus(order.status()) - .build(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java deleted file mode 100644 index a7f16df..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExposure; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.StatusRuntimeException; - -final class OutcomeGrpcBoundary { - private final GoogleRpcOutcomeMapper mapper = new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard() - .withMapping( - OrderOutcomes.ORDER_REJECTED, - GrpcStatusCode.FAILED_PRECONDITION - ), - new GrpcOutcomeExposure(false, false, true) - ); - - StatusRuntimeException toException(Outcome outcome) { - StatusRuntimeException exception = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, mapper) - .orNull(); - - if (exception == null) { - throw new IllegalStateException( - "No gRPC mapping configured for outcome: " + outcome.getCode() - ); - } - - return exception; - } -} diff --git a/reference/grpc-orders/src/main/proto/orders.proto b/reference/grpc-orders/src/main/proto/orders.proto deleted file mode 100644 index 2c29f88..0000000 --- a/reference/grpc-orders/src/main/proto/orders.proto +++ /dev/null @@ -1,28 +0,0 @@ -syntax = "proto3"; - -package example.orders.v1; - -option java_multiple_files = true; -option java_package = "com.example.orders.grpc"; - -service Orders { - rpc CreateOrder(CreateOrderRequest) returns (OrderReply); - rpc GetOrder(GetOrderRequest) returns (OrderReply); -} - -message CreateOrderRequest { - string order_id = 1; - string customer_id = 2; - string product = 3; -} - -message GetOrderRequest { - string order_id = 1; -} - -message OrderReply { - string order_id = 1; - string customer_id = 2; - string product = 3; - string status = 4; -} diff --git a/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java b/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java deleted file mode 100644 index 0b73323..0000000 --- a/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.orders.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import com.google.rpc.BadRequest; -import com.google.rpc.ErrorInfo; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import org.junit.jupiter.api.Test; - -class GrpcReferenceTest { - private final OrderService service = new OrderService(); - private final OutcomeGrpcBoundary boundary = new OutcomeGrpcBoundary(); - - @Test - void validationUsesAdapterIdentityAndStructuredIssues() throws Exception { - OrderResult result = service.create("", "", "book"); - StatusRuntimeException exception = boundary.toException(result.outcome()); - - com.google.rpc.Status status = StatusProto.fromThrowable(exception); - assertNotNull(status); - assertEquals(3, status.getCode()); - assertEquals(result.outcome().getCode().getValue(), status.getMessage()); - assertEquals(2, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals(result.outcome().getCode().getNamespace(), info.getDomain()); - assertEquals(result.outcome().getCode().getName(), info.getReason()); - - BadRequest badRequest = status.getDetails(1).unpack(BadRequest.class); - assertEquals(2, badRequest.getFieldViolationsCount()); - assertEquals("orderId", badRequest.getFieldViolations(0).getField()); - assertEquals("customerId", badRequest.getFieldViolations(1).getField()); - } - - @Test - void customDomainFailureKeepsItsIdentityThroughAdapter() throws Exception { - OrderResult result = service.create("o-1", "c-1", "blocked"); - - com.google.rpc.Status status = StatusProto.fromThrowable( - boundary.toException(result.outcome()) - ); - - assertNotNull(status); - assertEquals(9, status.getCode()); - assertEquals(result.outcome().getCode().getValue(), status.getMessage()); - assertEquals(1, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals("com.example.orders", info.getDomain()); - assertEquals("ORDER_REJECTED", info.getReason()); - } -} diff --git a/reference/spring-orders/build.gradle.kts b/reference/spring-orders/build.gradle.kts index 4b439e4..248277c 100644 --- a/reference/spring-orders/build.gradle.kts +++ b/reference/spring-orders/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.springframework.boot:spring-boot-starter-test:${providers.gradleProperty("springBootVersion").get()}") + testImplementation("org.springframework.boot:spring-boot-starter-webflux:${providers.gradleProperty("springBootVersion").get()}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java b/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java new file mode 100644 index 0000000..b995ab8 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java @@ -0,0 +1,15 @@ +package com.example.orders; + +final class ArchivedOrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String orderId; + + ArchivedOrderNotFoundException(String orderId) { + this.orderId = orderId; + } + + String orderId() { + return orderId; + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java b/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java deleted file mode 100644 index d545ae5..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.orders; - -public record CreateOrderRequest(String orderId, String customerId, String product) { -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/Order.java b/reference/spring-orders/src/main/java/com/example/orders/Order.java deleted file mode 100644 index aad8334..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/Order.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.orders; - -public record Order(String orderId, String customerId, String product, String status) { - Order withStatus(String nextStatus) { - return new Order(orderId, customerId, product, nextStatus); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java index 44ece8c..fdf6210 100644 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java @@ -1,36 +1,21 @@ package com.example.orders; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/orders") class OrderController { - private final OrderService service; - private final OutcomeHttpBoundary boundary; - - OrderController(OrderService service, OutcomeHttpBoundary boundary) { - this.service = service; - this.boundary = boundary; - } - - @PostMapping - ResponseEntity create(@RequestBody CreateOrderRequest request) { - return boundary.toResponse(service.create(request)); - } @GetMapping("/{orderId}") - ResponseEntity find(@PathVariable String orderId) { - return boundary.toResponse(service.find(orderId)); + String find(@PathVariable("orderId") String orderId) { + throw new OrderNotFoundException(orderId); } - @PostMapping("/{orderId}/process") - ResponseEntity process(@PathVariable String orderId) { - return boundary.toResponse(service.process(orderId)); + @GetMapping("/archive/{orderId}") + String findArchived(@PathVariable("orderId") String orderId) { + throw new ArchivedOrderNotFoundException(orderId); } } diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java b/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java new file mode 100644 index 0000000..a6468b1 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java @@ -0,0 +1,15 @@ +package com.example.orders; + +final class OrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String orderId; + + OrderNotFoundException(String orderId) { + this.orderId = orderId; + } + + String orderId() { + return orderId; + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java b/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java deleted file mode 100644 index 2c556e1..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; - -final class OrderOutcomes { - static final OutcomeDefinition ORDER_CREATED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_CREATED", OutcomeState.SUCCEEDED, "The order was created." - ); - static final OutcomeDefinition ORDER_PROCESSING = OutcomeDefinition.custom( - "com.example.orders", "ORDER_PROCESSING", OutcomeState.PENDING, "The order is processing." - ); - static final OutcomeDefinition ORDER_REJECTED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "The order was rejected." - ); - - private OrderOutcomes() { - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java b/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java new file mode 100644 index 0000000..9885513 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java @@ -0,0 +1,26 @@ +package com.example.orders; + +import io.github.aalsanie.codes.spring.ProblemDetails; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +class OrderProblemHandler { + + @ExceptionHandler(OrderNotFoundException.class) + ProblemDetail handleOrderNotFound(OrderNotFoundException exception) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + exception.orderId() + " was not found." + ); + } + + @ExceptionHandler(ArchivedOrderNotFoundException.class) + ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException exception) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + exception.orderId() + " was not found." + ); + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java new file mode 100644 index 0000000..5eb6388 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java @@ -0,0 +1,27 @@ +package com.example.orders; + +import io.github.aalsanie.codes.ProblemType; +import java.net.URI; + +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + static final ProblemType ORDER_ALREADY_CANCELLED = ProblemType.of( + URI.create("https://api.example.com/problems/order-already-cancelled"), + 409, + "Order already cancelled" + ); + + static final ProblemType INVALID_ORDER_STATE = ProblemType.of( + URI.create("https://api.example.com/problems/invalid-order-state"), + 409, + "Invalid order state" + ); + + private OrderProblems() { + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java b/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java deleted file mode 100644 index db72606..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; - -record OrderResult(T value, Outcome outcome) { - OrderResult { - Objects.requireNonNull(outcome, "outcome"); - } - - static OrderResult of(T value, Outcome outcome) { - return new OrderResult<>(value, outcome); - } - - static OrderResult failure(Outcome outcome) { - return new OrderResult<>(null, outcome); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderService.java b/reference/spring-orders/src/main/java/com/example/orders/OrderService.java deleted file mode 100644 index 1a28f6e..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Service; - -@Service -class OrderService { - private final Map orders = new ConcurrentHashMap<>(); - - OrderResult create(CreateOrderRequest request) { - ValidationResult validation = ValidationResult.combine( - required("orderId", request.orderId()), - required("customerId", request.customerId()), - required("product", request.product()) - ); - if (validation.isInvalid()) { - return OrderResult.failure(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT)); - } - - Order order = new Order(request.orderId(), request.customerId(), request.product(), "CREATED"); - if (orders.putIfAbsent(order.orderId(), order) != null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.ALREADY_EXISTS)); - } - return OrderResult.of(order, Outcome.of(OrderOutcomes.ORDER_CREATED)); - } - - OrderResult find(String orderId) { - Order order = orders.get(orderId); - if (order == null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)); - } - return OrderResult.of(order, Outcome.of(StandardOutcomes.OK)); - } - - OrderResult process(String orderId) { - Order current = orders.get(orderId); - if (current == null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)); - } - if ("blocked".equalsIgnoreCase(current.product())) { - return OrderResult.failure(Outcome.of(OrderOutcomes.ORDER_REJECTED)); - } - Order processing = current.withStatus("PROCESSING"); - orders.put(orderId, processing); - return OrderResult.of(processing, Outcome.of(OrderOutcomes.ORDER_PROCESSING)); - } - - private static ValidationResult required(String path, String value) { - return value == null || value.isBlank() - ? ValidationResult.invalid(Issue.at(path, "The value is required.")) - : ValidationResult.valid(); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java b/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java deleted file mode 100644 index 187e10e..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -import io.github.aalsanie.codes.spring.SpringHttpStatusMapper; -import io.github.aalsanie.codes.spring.SpringOutcomeExposure; -import io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper; -import java.net.URI; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -class OutcomeHttpBoundary { - private final SpringHttpStatusMapper statusMapper; - private final OutcomeProblemDetailMapper problemMapper; - - OutcomeHttpBoundary() { - HttpOutcomeMapper httpMapper = HttpOutcomeMapper.standard() - .withMapping(OrderOutcomes.ORDER_CREATED, HttpStatusCode.CREATED) - .withMapping(OrderOutcomes.ORDER_PROCESSING, HttpStatusCode.ACCEPTED) - .withMapping(OrderOutcomes.ORDER_REJECTED, HttpStatusCode.of(422)) - .withMapping(StandardOutcomes.FAILED_PRECONDITION, HttpStatusCode.CONFLICT); - - statusMapper = new SpringHttpStatusMapper(httpMapper); - problemMapper = new OutcomeProblemDetailMapper( - statusMapper, - SpringOutcomeExposure.publicErrors(), - SpringProblemTypeUriMapper.empty() - .withMapping( - StandardOutcomes.INVALID_ARGUMENT, - URI.create("https://api.example.com/problems/invalid-argument") - ) - .withMapping( - OrderOutcomes.ORDER_REJECTED, - URI.create("https://api.example.com/problems/order-rejected") - ) - ); - } - - ResponseEntity toResponse(OrderResult result) { - Outcome outcome = result.outcome(); - org.springframework.http.HttpStatusCode status = statusMapper.map(outcome).orNull(); - if (status == null) { - return unmapped(outcome); - } - if (!outcome.isFailed()) { - return ResponseEntity.status(status).body(result.value()); - } - - ProblemDetail problem = problemMapper.map(outcome).orNull(); - if (problem == null) { - throw new IllegalStateException("mapped HTTP status produced an unmapped problem detail"); - } - return ResponseEntity.status(status).body(problem); - } - - private static ResponseEntity unmapped(Outcome outcome) { - ProblemDetail problem = ProblemDetail.forStatus(500); - problem.setProperty(OutcomeProblemDetailMapper.CODE_PROPERTY, outcome.getCode().getValue()); - return ResponseEntity.internalServerError().body(problem); - } -} diff --git a/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java b/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java new file mode 100644 index 0000000..66dc48c --- /dev/null +++ b/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java @@ -0,0 +1,49 @@ +package com.example.orders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class OrderHttpContractTest { + + @Test + void mvcRendersProblemDetailContract() throws Exception { + MockMvc mvc = MockMvcBuilders.standaloneSetup(new OrderController()) + .setControllerAdvice(new OrderProblemHandler()) + .build(); + + mvc.perform(get("/orders/o-123").accept(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("https://api.example.com/problems/order-not-found")) + .andExpect(jsonPath("$.title").value("Order not found")) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail").value("Order o-123 was not found.")); + } + + @Test + void webFluxRendersProblemDetailContract() { + WebTestClient client = WebTestClient.bindToController(new OrderController()) + .controllerAdvice(new OrderProblemHandler()) + .build(); + + client.get() + .uri("/orders/o-123") + .accept(MediaType.APPLICATION_PROBLEM_JSON) + .exchange() + .expectStatus().isNotFound() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON) + .expectBody() + .jsonPath("$.type").isEqualTo("https://api.example.com/problems/order-not-found") + .jsonPath("$.title").isEqualTo("Order not found") + .jsonPath("$.status").isEqualTo(404) + .jsonPath("$.detail").isEqualTo("Order o-123 was not found."); + } +} diff --git a/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java b/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java index 4332730..af3b02c 100644 --- a/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java +++ b/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java @@ -1,68 +1,25 @@ package com.example.orders; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import io.github.aalsanie.codes.StandardOutcomes; -import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; class OrderReferenceTest { - private final OrderService service = new OrderService(); - private final OutcomeHttpBoundary boundary = new OutcomeHttpBoundary(); + private final OrderProblemHandler handler = new OrderProblemHandler(); @Test - void createUsesCustomSuccessOutcomeAndHttp201() { - ResponseEntity response = boundary.toResponse( - service.create(new CreateOrderRequest("o-1", "c-1", "book")) - ); - assertEquals(201, response.getStatusCode().value()); - assertTrue(response.getBody() instanceof Order); - } - - @Test - void validationUsesApplicationOwnedProblemTypeAndAdapterExposurePolicy() { - ResponseEntity response = boundary.toResponse( - service.create(new CreateOrderRequest("", "", "book")) - ); - - assertEquals(400, response.getStatusCode().value()); - ProblemDetail problem = (ProblemDetail) response.getBody(); - assertEquals( - URI.create("https://api.example.com/problems/invalid-argument"), - problem.getType() - ); - assertEquals(StandardOutcomes.INVALID_ARGUMENT.getDefaultMessage(), problem.getTitle()); - assertEquals( - StandardOutcomes.INVALID_ARGUMENT.getCode().getValue(), - problem.getProperties().get("code") - ); - assertEquals(2, ((java.util.List) problem.getProperties().get("issues")).size()); - assertNull(problem.getDetail()); - } - - @Test - void pendingAndDomainFailureKeepApplicationIdentity() { - boundary.toResponse(service.create(new CreateOrderRequest("o-2", "c-1", "book"))); - ResponseEntity processing = boundary.toResponse(service.process("o-2")); - assertEquals(202, processing.getStatusCode().value()); - - boundary.toResponse(service.create(new CreateOrderRequest("o-3", "c-1", "blocked"))); - ResponseEntity rejected = boundary.toResponse(service.process("o-3")); - assertEquals(422, rejected.getStatusCode().value()); - - ProblemDetail problem = (ProblemDetail) rejected.getBody(); - assertEquals( - URI.create("https://api.example.com/problems/order-rejected"), - problem.getType() - ); - assertEquals(OrderOutcomes.ORDER_REJECTED.getDefaultMessage(), problem.getTitle()); - assertEquals( - OrderOutcomes.ORDER_REJECTED.getCode().getValue(), - problem.getProperties().get("code") - ); + void differentApplicationExceptionsReuseTheSameProblemType() { + ProblemDetail current = handler.handleOrderNotFound(new OrderNotFoundException("o-1")); + ProblemDetail archived = handler.handleArchivedOrderNotFound(new ArchivedOrderNotFoundException("o-2")); + + assertEquals(OrderProblems.ORDER_NOT_FOUND.getType(), current.getType()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getType(), archived.getType()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getTitle(), current.getTitle()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getTitle(), archived.getTitle()); + assertEquals(404, current.getStatus()); + assertEquals(404, archived.getStatus()); + assertEquals("Order o-1 was not found.", current.getDetail()); + assertEquals("Archived order o-2 was not found.", archived.getDetail()); } } diff --git a/scripts/prepare-compatibility-repository.ps1 b/scripts/prepare-compatibility-repository.ps1 index 9d3965d..1364530 100644 --- a/scripts/prepare-compatibility-repository.ps1 +++ b/scripts/prepare-compatibility-repository.ps1 @@ -1,53 +1,32 @@ param( - [Parameter(Mandatory = $false)] - [string]$Repository = "build\compatibility-maven" + [Parameter(Mandatory = $true)] + [string]$Repository ) $ErrorActionPreference = "Stop" +$rootDir = Split-Path -Parent $PSScriptRoot -$Root = Split-Path -Parent $PSScriptRoot if (-not [System.IO.Path]::IsPathRooted($Repository)) { - $Repository = Join-Path $Root $Repository + $Repository = Join-Path $rootDir $Repository } -$Repository = [System.IO.Path]::GetFullPath($Repository) if (Test-Path $Repository) { Remove-Item -Recurse -Force $Repository } New-Item -ItemType Directory -Force -Path $Repository | Out-Null -& (Join-Path $Root "gradlew.bat") ` +& (Join-Path $rootDir "gradlew.bat") ` "-Dmaven.repo.local=$Repository" ` :publishToMavenLocal ` :codes-spring:publishToMavenLocal ` - :codes-grpc-java:publishToMavenLocal ` --stacktrace +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -if ($LASTEXITCODE -ne 0) { - throw "Compatibility publication failed with exit code $LASTEXITCODE" -} - -$VersionLine = Get-Content (Join-Path $Root "gradle.properties") | - Where-Object { $_ -like "VERSION_NAME=*" } | - Select-Object -First 1 -$Version = $VersionLine.Substring("VERSION_NAME=".Length) - -foreach ($Artifact in @("codes", "codes-spring", "codes-grpc-java")) { - $ArtifactDir = Join-Path $Repository "io\github\aalsanie\$Artifact\$Version" - $Jar = Join-Path $ArtifactDir "$Artifact-$Version.jar" - $Pom = Join-Path $ArtifactDir "$Artifact-$Version.pom" +$env:MAVEN_REPO_LOCAL = $Repository +sh (Join-Path $rootDir "scripts/verify-local-publications.sh") +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (-not (Test-Path $Jar) -or (Get-Item $Jar).Length -eq 0) { - throw "Missing compatibility publication JAR: $Jar" - } - if (-not (Test-Path $Pom) -or (Get-Item $Pom).Length -eq 0) { - throw "Missing compatibility publication POM: $Pom" - } -} - -python (Join-Path $Root "scripts\verify-published-pom-contracts.py") $Repository -if ($LASTEXITCODE -ne 0) { - throw "Published POM verification failed with exit code $LASTEXITCODE" -} +python (Join-Path $rootDir "scripts/verify-published-pom-contracts.py") $Repository +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Output $Repository diff --git a/scripts/prepare-compatibility-repository.sh b/scripts/prepare-compatibility-repository.sh old mode 100644 new mode 100755 index d3bb282..4ddf2db --- a/scripts/prepare-compatibility-repository.sh +++ b/scripts/prepare-compatibility-repository.sh @@ -16,7 +16,6 @@ mkdir -p "$repository" "-Dmaven.repo.local=$repository" \ :publishToMavenLocal \ :codes-spring:publishToMavenLocal \ - :codes-grpc-java:publishToMavenLocal \ --stacktrace MAVEN_REPO_LOCAL="$repository" sh "$root_dir/scripts/verify-local-publications.sh" diff --git a/scripts/test-release-promotion-policy.py b/scripts/test-release-promotion-policy.py deleted file mode 100644 index 9586324..0000000 --- a/scripts/test-release-promotion-policy.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import unittest -from pathlib import Path - -SCRIPT = Path(__file__).with_name("verify-release-promotion.py") -SPEC = importlib.util.spec_from_file_location("verify_release_promotion", SCRIPT) -if SPEC is None or SPEC.loader is None: - raise RuntimeError(f"Cannot load {SCRIPT}") -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class ReleasePromotionPolicyTest(unittest.TestCase): - def test_selects_highest_numeric_rc(self): - tag, version = MODULE.select_latest_rc( - "0.4.0", - ["v0.4.0-RC1", "v0.4.0-RC10", "v0.4.0-RC2", "v0.5.0-RC9"], - ) - self.assertEqual("v0.4.0-RC10", tag) - self.assertEqual("0.4.0-RC10", version) - - def test_requires_rc_for_same_final_version(self): - with self.assertRaisesRegex(MODULE.PromotionError, "No release candidate"): - MODULE.select_latest_rc("0.4.0", ["v0.3.1", "v0.5.0-RC1"]) - - def test_accepts_exact_version_only_promotion(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: f"before\nversion={rc_version}\nafter\n" - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_rejects_product_code_change(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: final_version - for path in MODULE.PROMOTION_FILES - } - - with self.assertRaisesRegex(MODULE.PromotionError, "unexpected changes"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES) | {"src/main/java/Changed.java"}, - rc_contents, - final_contents, - ) - - def test_rejects_missing_version_promotion_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: final_version - for path in MODULE.PROMOTION_FILES - } - changed = set(MODULE.PROMOTION_FILES) - changed.remove("README.md") - - with self.assertRaisesRegex(MODULE.PromotionError, "missing version-only changes"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - changed, - rc_contents, - final_contents, - ) - - def test_rejects_non_version_edit_in_allowed_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: f"version={rc_version}\n" - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - final_contents["README.md"] += "extra release edit\n" - - with self.assertRaisesRegex(MODULE.PromotionError, "changes other than replacing"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_requires_rc_version_token_in_every_promotion_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - rc_contents["README.md"] = "no version here" - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - - with self.assertRaisesRegex(MODULE.PromotionError, "policy is stale"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_rejects_structural_changes(self): - with self.assertRaisesRegex(MODULE.PromotionError, "must not create, delete, rename"): - MODULE.validate_no_structural_changes( - " mode change 100644 => 100755 README.md\n" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/verify-compatibility-matrix.py b/scripts/verify-compatibility-matrix.py old mode 100644 new mode 100755 index 1f3698b..7cf0357 --- a/scripts/verify-compatibility-matrix.py +++ b/scripts/verify-compatibility-matrix.py @@ -20,33 +20,21 @@ def properties(path: Path) -> dict[str, str]: matrix = json.loads((ROOT / "compatibility" / "matrix.json").read_text(encoding="utf-8")) gradle = properties(ROOT / "gradle.properties") -spring_floor = "6.0.0" -grpc_floor = "1.75.0" -kotlin_versions = ["1.9.24", "2.0.21", "2.1.21", "2.2.20", "2.4.10"] - -expected_axes = { +expected = { "java": ["17", "21", "25"], - "spring": [spring_floor, gradle["springFrameworkVersion"]], - "grpc": [grpc_floor, gradle["grpcVersion"]], + "spring": ["6.0.0", gradle["springFrameworkVersion"]], "kotlin": { - "versions": kotlin_versions, - "spring": spring_floor, - "grpc": grpc_floor, + "versions": ["1.9.24", "2.4.10"], + "spring": "6.0.0", }, - "os": ["ubuntu-latest", "windows-latest", "macos-latest"], + "os": ["windows-latest", "macos-latest"], } -if matrix != expected_axes: +if matrix != expected: raise SystemExit( - "compatibility/matrix.json drifted from the declared 0.4.x support policy.\n" - f"Expected: {expected_axes}\n" + "compatibility/matrix.json drifted from the declared support policy.\n" + f"Expected: {expected}\n" f"Actual: {matrix}" ) -if matrix["kotlin"]["spring"] != matrix["spring"][0]: - raise SystemExit("Kotlin/JSpecify Spring version must be the declared Spring compatibility floor.") - -if matrix["kotlin"]["grpc"] != matrix["grpc"][0]: - raise SystemExit("Kotlin/JSpecify gRPC version must be the declared gRPC compatibility floor.") - print("Compatibility matrix policy verified.") diff --git a/scripts/verify-kotlin-nullability.sh b/scripts/verify-kotlin-nullability.sh index b187f27..f5a1b34 100644 --- a/scripts/verify-kotlin-nullability.sh +++ b/scripts/verify-kotlin-nullability.sh @@ -3,139 +3,26 @@ set -eu root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" pom="$root_dir/smoke-test-maven-kotlin/pom.xml" -source_dir="$root_dir/smoke-test-maven-kotlin/src/main/kotlin" matrix_file="$root_dir/compatibility/matrix.json" -kotlin_version="${1:?Usage: verify-kotlin-nullability.sh [spring-version] [grpc-version]}" +kotlin_version="${1:?Usage: verify-kotlin-nullability.sh [spring-version]}" codes_version="$(sh "$root_dir/scripts/version.sh")" repository="${MAVEN_REPO_LOCAL:-$HOME/.m2/repository}" -configured_versions="$(python - "$matrix_file" "$kotlin_version" <<'PY' -import json -import sys -from pathlib import Path - -matrix = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -kotlin_version = sys.argv[2] -policy = matrix["kotlin"] - -if kotlin_version not in policy["versions"]: - raise SystemExit(f"Unsupported Kotlin compatibility version: {kotlin_version}") - -print(policy["spring"]) -print(policy["grpc"]) -PY -)" - -configured_spring="$(printf '%s\n' "$configured_versions" | sed -n '1p')" -configured_grpc="$(printf '%s\n' "$configured_versions" | sed -n '2p')" - +configured_spring="$(python -c 'import json,sys; m=json.load(open(sys.argv[1])); v=sys.argv[2]; p=m["kotlin"]; assert v in p["versions"], v; print(p["spring"])' "$matrix_file" "$kotlin_version")" spring_version="${2:-$configured_spring}" -grpc_version="${3:-$configured_grpc}" if [ "$spring_version" != "$configured_spring" ]; then echo "Kotlin/JSpecify Spring version must match compatibility policy: $configured_spring" >&2 exit 1 fi -if [ "$grpc_version" != "$configured_grpc" ]; then - echo "Kotlin/JSpecify gRPC version must match compatibility policy: $configured_grpc" >&2 - exit 1 -fi - -core_nullable_probe="$source_dir/CoreNullableReturnProbe.kt" -core_null_marked_probe="$source_dir/CoreNullMarkedProbe.kt" -spring_null_marked_probe="$source_dir/SpringNullMarkedProbe.kt" -grpc_null_marked_probe="$source_dir/GrpcNullMarkedProbe.kt" -output_file="$(mktemp)" - -cleanup() { - rm -f \ - "$core_nullable_probe" \ - "$core_null_marked_probe" \ - "$spring_null_marked_probe" \ - "$grpc_null_marked_probe" \ - "$output_file" -} -trap cleanup EXIT HUP INT TERM - mvn --batch-mode --no-transfer-progress \ "-Dmaven.repo.local=$repository" \ -f "$pom" \ "-Dcodes.version=$codes_version" \ "-Dkotlin.version=$kotlin_version" \ "-Dspring.framework.version=$spring_version" \ - "-Dgrpc.version=$grpc_version" \ clean verify -expect_compile_failure() { - probe_file="$1" - : > "$output_file" - - set +e - mvn --batch-mode --no-transfer-progress --offline \ - "-Dmaven.repo.local=$repository" \ - -f "$pom" \ - "-Dcodes.version=$codes_version" \ - "-Dkotlin.version=$kotlin_version" \ - "-Dspring.framework.version=$spring_version" \ - "-Dgrpc.version=$grpc_version" \ - clean compile >"$output_file" 2>&1 - status=$? - set -e - - cat "$output_file" - - if [ "$status" -eq 0 ]; then - echo "Expected Kotlin compilation to reject $(basename "$probe_file")." >&2 - exit 1 - fi - - if ! grep -Fq "$(basename "$probe_file")" "$output_file"; then - echo "Kotlin compilation failed for an unrelated reason while checking $(basename "$probe_file")." >&2 - exit 1 - fi - - rm -f "$probe_file" -} - -cat > "$core_nullable_probe" <<'EOF' -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.StandardOutcomes - -fun coreNullableReturnProbe() { - val outcome = Outcome.of(StandardOutcomes.NOT_FOUND) - val detail: String = outcome.detail - println(detail) -} -EOF -expect_compile_failure "$core_nullable_probe" - -cat > "$core_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.Outcome - -fun coreNullMarkedProbe() { - Outcome.of(null) -} -EOF -expect_compile_failure "$core_null_marked_probe" - -cat > "$spring_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper - -fun springNullMarkedProbe() { - OutcomeProblemDetailMapper.safeDefaults().map(null) -} -EOF -expect_compile_failure "$spring_null_marked_probe" - -cat > "$grpc_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper - -fun grpcNullMarkedProbe() { - GoogleRpcOutcomeMapper.safeDefaults().map(null) -} -EOF -expect_compile_failure "$grpc_null_marked_probe" - -echo "Kotlin $kotlin_version JSpecify contracts verified for core, Spring, and gRPC against Spring $spring_version and gRPC $grpc_version." +echo "Kotlin $kotlin_version consumer verified with strict JSpecify against Spring $spring_version." diff --git a/scripts/verify-local-publications.sh b/scripts/verify-local-publications.sh old mode 100644 new mode 100755 index 8b6e66d..4ddf7fb --- a/scripts/verify-local-publications.sh +++ b/scripts/verify-local-publications.sh @@ -6,19 +6,13 @@ version="$(sh "$root_dir/scripts/version.sh")" repository="${MAVEN_REPO_LOCAL:-$HOME/.m2/repository}" group_path="io/github/aalsanie" -for artifact in codes codes-spring codes-grpc-java; do +for artifact in codes codes-spring; do artifact_dir="$repository/$group_path/$artifact/$version" jar="$artifact_dir/$artifact-$version.jar" pom="$artifact_dir/$artifact-$version.pom" - test -s "$jar" || { - echo "Missing local publication JAR: $jar" >&2 - exit 1 - } - test -s "$pom" || { - echo "Missing local publication POM: $pom" >&2 - exit 1 - } + test -s "$jar" || { echo "Missing local publication JAR: $jar" >&2; exit 1; } + test -s "$pom" || { echo "Missing local publication POM: $pom" >&2; exit 1; } done -echo "Verified Maven Local publications for codes, codes-spring, and codes-grpc-java ($version)." +echo "Verified Maven Local publications for codes and codes-spring ($version)." diff --git a/scripts/verify-published-pom-contracts.py b/scripts/verify-published-pom-contracts.py index e425529..d10a19a 100644 --- a/scripts/verify-published-pom-contracts.py +++ b/scripts/verify-published-pom-contracts.py @@ -27,16 +27,9 @@ def properties(path: Path) -> dict[str, str]: expected = { "codes": [], - "codes-spring": sorted([ + "codes-spring": [ ("io.github.aalsanie", "codes", version, "compile"), - ("org.springframework", "spring-web", props["springFrameworkVersion"], "compile"), - ]), - "codes-grpc-java": sorted([ - ("io.github.aalsanie", "codes", version, "compile"), - ("com.google.api.grpc", "proto-google-common-protos", props["protoGoogleCommonProtosVersion"], "compile"), - ("io.grpc", "grpc-api", props["grpcVersion"], "compile"), - ("io.grpc", "grpc-protobuf", props["grpcVersion"], "runtime"), - ]), + ], } namespace = {"m": "http://maven.apache.org/POM/4.0.0"} @@ -65,6 +58,7 @@ def value(name: str) -> str: )) actual.sort() + expected_dependencies.sort() if actual != expected_dependencies: raise SystemExit( f"{artifact} published POM dependency budget changed.\n" diff --git a/scripts/verify-rc-integrations.sh b/scripts/verify-rc-integrations.sh deleted file mode 100644 index 5e2be7d..0000000 --- a/scripts/verify-rc-integrations.sh +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -manifest="$root_dir/compatibility/external/manifest.json" -version="${1:?usage: verify-rc-integrations.sh [report]}" -report="${2:-$root_dir/build/reports/rc-integrations.md}" -work_dir="${RC_INTEGRATION_WORK_DIR:-$root_dir/build/rc-integrations}" - -if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-RC[1-9][0-9]*$ ]]; then - echo "Invalid release-candidate version: $version" >&2 - exit 1 -fi - -central_base="https://repo.maven.apache.org/maven2/io/github/aalsanie" -for artifact in codes codes-spring codes-grpc-java; do - url="$central_base/$artifact/$version/$artifact-$version.pom" - found=false - - for attempt in $(seq 1 40); do - if curl --silent --show-error --fail --head "$url" >/dev/null 2>&1; then - found=true - break - fi - sleep 15 - done - - if [[ "$found" != true ]]; then - echo "Published artifact did not become visible on Maven Central: $url" >&2 - exit 1 - fi -done - -rm -rf "$work_dir" -mkdir -p "$work_dir" "$(dirname "$report")" - -{ - echo "# Codes $version external integration verification" - echo - echo "Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" - echo - echo "Each integration uses a pinned external repository, applies an application-only patch, resolves Codes from Maven Central, and runs the relevant build and tests." - echo -} > "$report" - -overall=0 - -integration_value() { - local integration_id="$1" - local field="$2" - python - "$manifest" "$integration_id" "$field" <<'PY' -import json -import sys -from pathlib import Path - -manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -integration = next(item for item in manifest["integrations"] if item["id"] == sys.argv[2]) -print(integration[sys.argv[3]]) -PY -} - -run_irp() { - local id="integration-reliability-platform" - local repo commit checkout patch_file start end elapsed status log - - repo="$(integration_value "$id" repository)" - commit="$(integration_value "$id" commit)" - patch_file="$root_dir/$(integration_value "$id" patch)" - checkout="$work_dir/$id" - log="$work_dir/$id.log" - - git clone --quiet --filter=blob:none --no-checkout "$repo" "$checkout" - git -C "$checkout" checkout --quiet --detach "$commit" - git -C "$checkout" apply --check "$patch_file" - git -C "$checkout" apply "$patch_file" - - start="$(date +%s)" - status="PASS" - - if ! ( - cd "$checkout" - bash ./gradlew \ - "-PcodesVersion=$version" \ - clean test \ - --no-build-cache \ - --stacktrace - - bash ./gradlew \ - "-PcodesVersion=$version" \ - dependencyInsight \ - --dependency codes-spring \ - --configuration runtimeClasspath \ - --no-build-cache - ) >"$log" 2>&1; then - status="FAIL" - overall=1 - fi - - end="$(date +%s)" - elapsed="$((end - start))" - - { - echo "## $id" - echo - echo "- Repository: \`$repo\`" - echo "- Commit: \`$commit\`" - echo "- Boundary: $(integration_value "$id" boundary)" - echo "- Scope: $(integration_value "$id" scope)" - echo "- Result: **$status**" - echo "- Clean build/test time: ${elapsed}s" - echo "- Dependency notes: $(integration_value "$id" dependency_notes)" - echo "- Expected identity: \`$(integration_value "$id" expected_identity)\`" - echo - echo "Patch statistics:" - echo - echo '```text' - git -C "$checkout" apply --stat "$patch_file" - echo '```' - echo - echo "Build log: \`$(basename "$log")\`" - echo - } >> "$report" -} - -run_patient() { - local id="patient-mgmt-microservices" - local repo commit checkout patch_file start end elapsed status log - - repo="$(integration_value "$id" repository)" - commit="$(integration_value "$id" commit)" - patch_file="$root_dir/$(integration_value "$id" patch)" - checkout="$work_dir/$id" - log="$work_dir/$id.log" - - git clone --quiet --filter=blob:none --no-checkout "$repo" "$checkout" - git -C "$checkout" checkout --quiet --detach "$commit" - git -C "$checkout" apply --check "$patch_file" - git -C "$checkout" apply "$patch_file" - - start="$(date +%s)" - status="PASS" - - if ! ( - cd "$checkout/patient-service" - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - clean test - - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - dependency:tree \ - "-Dincludes=io.github.aalsanie:*" - - cd "$checkout/billing-service" - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - clean test - - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - dependency:tree \ - "-Dincludes=io.github.aalsanie:*,io.grpc:*" - ) >"$log" 2>&1; then - status="FAIL" - overall=1 - fi - - end="$(date +%s)" - elapsed="$((end - start))" - - { - echo "## $id" - echo - echo "- Repository: \`$repo\`" - echo "- Commit: \`$commit\`" - echo "- Boundary: $(integration_value "$id" boundary)" - echo "- Scope: $(integration_value "$id" scope)" - echo "- Result: **$status**" - echo "- Clean build/test time: ${elapsed}s" - echo "- Dependency notes: $(integration_value "$id" dependency_notes)" - echo "- Expected identity: \`$(integration_value "$id" expected_identity)\`" - echo - echo "Patch statistics:" - echo - echo '```text' - git -C "$checkout" apply --stat "$patch_file" - echo '```' - echo - echo "Build log: \`$(basename "$log")\`" - echo - } >> "$report" -} - -run_irp -run_patient - -{ - echo "## Result" - echo - if [[ "$overall" -eq 0 ]]; then - echo "**PASS** — both external integrations built successfully and verified the expected boundary identity from Maven Central." - else - echo "**FAIL** — at least one external integration failed." - fi -} >> "$report" - -cat "$report" -exit "$overall" diff --git a/scripts/verify-release-promotion.py b/scripts/verify-release-promotion.py deleted file mode 100644 index 457dab0..0000000 --- a/scripts/verify-release-promotion.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -WORKFLOW_FILE = "release-candidate.yml" -PROMOTION_FILES = ( - "CHANGELOG.md", - "README.md", - "docs/integration-grpc.md", - "docs/integration-spring.md", - "gradle.properties", -) - - -class PromotionError(RuntimeError): - pass - - -def run_git(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", str(ROOT), *args], - check=check, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - -def git_line(*args: str) -> str: - return run_git(*args).stdout.strip() - - -def git_file(ref: str, path: str) -> str: - return run_git("show", f"{ref}:{path}").stdout - - -def property_value(text: str, key: str) -> str: - prefix = f"{key}=" - matches = [line[len(prefix):] for line in text.splitlines() if line.startswith(prefix)] - if len(matches) != 1 or not matches[0]: - raise PromotionError(f"Expected exactly one non-empty {key} property") - return matches[0] - - -def select_latest_rc(final_version: str, tags: list[str]) -> tuple[str, str]: - pattern = re.compile(rf"^v{re.escape(final_version)}-RC([1-9][0-9]*)$") - candidates: list[tuple[int, str]] = [] - for tag in tags: - match = pattern.fullmatch(tag) - if match: - candidates.append((int(match.group(1)), tag)) - - if not candidates: - raise PromotionError(f"No release candidate tag found for {final_version}") - - _, tag = max(candidates) - return tag, tag.removeprefix("v") - - -def validate_promotion_contents( - rc_version: str, - final_version: str, - changed_paths: set[str], - rc_contents: dict[str, str], - final_contents: dict[str, str], -) -> None: - expected_paths = set(PROMOTION_FILES) - if changed_paths != expected_paths: - unexpected = sorted(changed_paths - expected_paths) - missing = sorted(expected_paths - changed_paths) - details: list[str] = [] - if unexpected: - details.append("unexpected changes: " + ", ".join(unexpected)) - if missing: - details.append("missing version-only changes: " + ", ".join(missing)) - raise PromotionError("RC-to-final promotion is not version-only; " + "; ".join(details)) - - for path in PROMOTION_FILES: - rc_text = rc_contents[path] - final_text = final_contents[path] - if rc_version not in rc_text: - raise PromotionError( - f"Promotion policy is stale: {path} does not contain {rc_version} at the RC tag" - ) - expected = rc_text.replace(rc_version, final_version) - if final_text != expected: - raise PromotionError( - f"{path} contains changes other than replacing {rc_version} with {final_version}" - ) - - -def validate_no_structural_changes(summary: str) -> None: - if summary.strip(): - raise PromotionError( - "RC-to-final promotion must not create, delete, rename, or change file modes: " - + summary.strip().replace("\n", "; ") - ) - - -def github_json(url: str, token: str) -> object: - request = urllib.request.Request( - url, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "User-Agent": "codes-release-promotion", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return json.load(response) - except urllib.error.HTTPError as error: - raise PromotionError(f"GitHub API request failed with HTTP {error.code}: {url}") from error - except urllib.error.URLError as error: - raise PromotionError(f"GitHub API request failed: {url}: {error.reason}") from error - - -def verify_remote_rc(rc_tag: str, rc_sha: str) -> None: - repository = os.environ.get("GITHUB_REPOSITORY") - token = os.environ.get("GITHUB_TOKEN") - api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") - if not repository: - raise PromotionError("GITHUB_REPOSITORY is required") - if not token: - raise PromotionError("GITHUB_TOKEN is required") - - query = urllib.parse.urlencode( - { - "head_sha": rc_sha, - "per_page": "100", - } - ) - workflow = urllib.parse.quote(WORKFLOW_FILE, safe="") - runs_url = f"{api_url}/repos/{repository}/actions/workflows/{workflow}/runs?{query}" - runs_payload = github_json(runs_url, token) - if not isinstance(runs_payload, dict): - raise PromotionError("Unexpected GitHub workflow-runs response") - - runs = runs_payload.get("workflow_runs") - if not isinstance(runs, list): - raise PromotionError("GitHub workflow-runs response is missing workflow_runs") - - successful = [ - run for run in runs - if isinstance(run, dict) - and run.get("head_sha") == rc_sha - and run.get("event") == "push" - and run.get("conclusion") == "success" - ] - if not successful: - raise PromotionError( - f"No successful {WORKFLOW_FILE} push workflow exists for {rc_tag} ({rc_sha})" - ) - - encoded_tag = urllib.parse.quote(rc_tag, safe="") - release_url = f"{api_url}/repos/{repository}/releases/tags/{encoded_tag}" - release = github_json(release_url, token) - if not isinstance(release, dict): - raise PromotionError("Unexpected GitHub release response") - if release.get("tag_name") != rc_tag: - raise PromotionError(f"GitHub prerelease tag does not match {rc_tag}") - if release.get("draft") is not False or release.get("prerelease") is not True: - raise PromotionError(f"{rc_tag} must exist as a published GitHub prerelease") - - -def verify() -> None: - final_properties = (ROOT / "gradle.properties").read_text(encoding="utf-8") - final_version = property_value(final_properties, "VERSION_NAME") - if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", final_version): - raise PromotionError(f"Final version is not a release version: {final_version}") - - tags = [line for line in git_line("tag", "--list", f"v{final_version}-RC*").splitlines() if line] - rc_tag, rc_version = select_latest_rc(final_version, tags) - - ancestry = run_git("merge-base", "--is-ancestor", rc_tag, "HEAD", check=False) - if ancestry.returncode != 0: - raise PromotionError(f"Latest release candidate {rc_tag} is not an ancestor of HEAD") - - rc_sha = git_line("rev-parse", f"{rc_tag}^{{commit}}") - final_sha = git_line("rev-parse", "HEAD^{commit}") - if rc_sha == final_sha: - raise PromotionError("Final release must be a version-only promotion commit after the RC") - - validate_no_structural_changes(run_git("diff", "--summary", rc_tag, "HEAD").stdout) - - changed = { - line for line in git_line("diff", "--name-only", rc_tag, "HEAD").splitlines() if line - } - rc_contents = {path: git_file(rc_tag, path) for path in PROMOTION_FILES} - final_contents = { - path: (ROOT / path).read_text(encoding="utf-8") - for path in PROMOTION_FILES - } - validate_promotion_contents( - rc_version, - final_version, - changed, - rc_contents, - final_contents, - ) - - verify_remote_rc(rc_tag, rc_sha) - - print( - f"Verified {rc_tag} ({rc_sha}) -> v{final_version} ({final_sha}) " - "as a successful version-only RC promotion." - ) - - -def main() -> int: - try: - verify() - return 0 - except PromotionError as error: - print(f"Release promotion verification failed: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/settings.gradle.kts b/settings.gradle.kts index 1230986..ab21e4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,8 +15,5 @@ dependencyResolutionManagement { rootProject.name = "codes" include("codes-spring") -include("codes-grpc-java") include("reference-spring-orders") project(":reference-spring-orders").projectDir = file("reference/spring-orders") -include("reference-grpc-orders") -project(":reference-grpc-orders").projectDir = file("reference/grpc-orders") diff --git a/smoke-test-java/build.gradle.kts b/smoke-test-java/build.gradle.kts index ea49585..68d6a03 100644 --- a/smoke-test-java/build.gradle.kts +++ b/smoke-test-java/build.gradle.kts @@ -4,35 +4,20 @@ plugins { java } -val codesVersion = Properties().run { +val properties = Properties().apply { rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } - getProperty("VERSION_NAME") ?: error("VERSION_NAME is missing from gradle.properties") } - +val codesVersion = properties.getProperty("VERSION_NAME") +val defaultSpringVersion = properties.getProperty("springFrameworkVersion") +val springVersion = providers.gradleProperty("springFrameworkOverride").orElse(defaultSpringVersion) val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") .orElse("17") .map(String::toInt) -val springFrameworkOverride = providers.gradleProperty("springFrameworkOverride").orNull -val grpcVersionOverride = providers.gradleProperty("grpcVersionOverride").orNull - dependencies { implementation("io.github.aalsanie:codes:$codesVersion") implementation("io.github.aalsanie:codes-spring:$codesVersion") - implementation("io.github.aalsanie:codes-grpc-java:$codesVersion") -} - -configurations.configureEach { - resolutionStrategy.eachDependency { - if (springFrameworkOverride != null && requested.group == "org.springframework") { - useVersion(springFrameworkOverride) - because("Codes Spring compatibility matrix") - } - if (grpcVersionOverride != null && requested.group == "io.grpc") { - useVersion(grpcVersionOverride) - because("Codes gRPC compatibility matrix") - } - } + implementation("org.springframework:spring-web:${springVersion.get()}") } java { diff --git a/smoke-test-java/src/main/java/SmokeJava.java b/smoke-test-java/src/main/java/SmokeJava.java index c7cf68c..9bb4d8a 100644 --- a/smoke-test-java/src/main/java/SmokeJava.java +++ b/smoke-test-java/src/main/java/SmokeJava.java @@ -1,99 +1,32 @@ -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeRegistry; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -import io.github.aalsanie.codes.spring.SpringOutcomeExceptions; -import io.grpc.StatusRuntimeException; -import java.util.List; +import io.github.aalsanie.codes.ProblemType; +import io.github.aalsanie.codes.spring.ProblemDetails; +import java.net.URI; import org.springframework.http.ProblemDetail; -import org.springframework.web.ErrorResponseException; public final class SmokeJava { private SmokeJava() { } - public static void main(String[] args) throws Exception { - OutcomeCode code = OutcomeCode.of("com.example.orders", "ORDER_REJECTED"); - OutcomeDefinition rejected = OutcomeDefinition.custom( - code, - OutcomeState.FAILED, - "Order rejected." + public static void main(String[] args) { + ProblemType type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" ); - Outcome outcome = Outcome.of( - rejected, - "internal=42", - List.of(Issue.at("items[0]", "Unavailable.")) + ProblemDetail problem = ProblemDetails.forTypeAndDetail( + type, + "Order o-123 was not found." ); - HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(rejected, HttpStatusCode.of(422)); - GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping(rejected, GrpcStatusCode.FAILED_PRECONDITION); - require(http.map(outcome).orNull().getValue() == 422); - require(grpc.map(outcome).orNull() == GrpcStatusCode.FAILED_PRECONDITION); - - Outcome standardFailure = Outcome.of(StandardOutcomes.NOT_FOUND); - - ProblemDetail problem = OutcomeProblemDetailMapper.safeDefaults() - .map(standardFailure) - .orNull(); + require(problem.getType().equals(type.getType())); require(problem.getStatus() == 404); - - ErrorResponseException springException = SpringOutcomeExceptions - .toErrorResponseException( - standardFailure, - OutcomeProblemDetailMapper.safeDefaults() - ) - .orNull(); - require(springException.getStatusCode().value() == 404); - - com.google.rpc.Status rpcStatus = GoogleRpcOutcomeMapper.safeDefaults() - .map(standardFailure) - .orNull(); - require(rpcStatus.getCode() == io.grpc.Status.Code.NOT_FOUND.value()); - - ErrorInfo identity = rpcStatus.getDetails(0).unpack(ErrorInfo.class); - require(identity.getDomain().equals(standardFailure.getCode().getNamespace())); - require(identity.getReason().equals(standardFailure.getCode().getName())); - - StatusRuntimeException grpcException = GrpcOutcomeExceptions - .toStatusRuntimeException( - standardFailure, - GoogleRpcOutcomeMapper.safeDefaults() - ) - .orNull(); - require(grpcException.getStatus().getCode() == io.grpc.Status.Code.NOT_FOUND); - - ValidationResult validation = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")) - ); - require(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getIssues().size() == 1); - - OutcomeRegistry registry = OutcomeRegistry.standard().with(rejected); - require(registry.require(code) == rejected); - MappingResult mapped = MappingResult.mapped(42); - require(mapped.fold(value -> value, () -> -1) == 42); - - System.out.println("Pure Java consumer smoke test passed for all Codes artifacts."); + require(problem.getTitle().equals("Order not found")); + require(problem.getDetail().equals("Order o-123 was not found.")); } private static void require(boolean condition) { if (!condition) { - throw new AssertionError("Pure Java consumer assertion failed."); + throw new AssertionError("Java consumer assertion failed."); } } } diff --git a/smoke-test-kotlin/build.gradle.kts b/smoke-test-kotlin/build.gradle.kts index e274015..0e4dce0 100644 --- a/smoke-test-kotlin/build.gradle.kts +++ b/smoke-test-kotlin/build.gradle.kts @@ -5,11 +5,11 @@ plugins { kotlin("jvm") version "2.4.10" } -val codesVersion = Properties().run { +val properties = Properties().apply { rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } - getProperty("VERSION_NAME") ?: error("VERSION_NAME is missing from gradle.properties") } - +val codesVersion = properties.getProperty("VERSION_NAME") +val springVersion = properties.getProperty("springFrameworkVersion") val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") .orElse("17") .map(String::toInt) @@ -17,7 +17,7 @@ val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") dependencies { implementation("io.github.aalsanie:codes:$codesVersion") implementation("io.github.aalsanie:codes-spring:$codesVersion") - implementation("io.github.aalsanie:codes-grpc-java:$codesVersion") + implementation("org.springframework:spring-web:$springVersion") } kotlin { diff --git a/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt b/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt index 318323b..d0db167 100644 --- a/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt +++ b/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt @@ -1,57 +1,17 @@ -import com.google.rpc.ErrorInfo -import io.github.aalsanie.codes.Issue -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.OutcomeCode -import io.github.aalsanie.codes.OutcomeDefinition -import io.github.aalsanie.codes.OutcomeRegistry -import io.github.aalsanie.codes.OutcomeState -import io.github.aalsanie.codes.StandardOutcomes -import io.github.aalsanie.codes.ValidationResult -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpStatusCode -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper +import io.github.aalsanie.codes.ProblemType +import io.github.aalsanie.codes.spring.ProblemDetails +import java.net.URI fun main() { - val rejected = OutcomeDefinition.custom( - "com.example.orders", - "ORDER_REJECTED", - OutcomeState.FAILED, - "Order rejected.", + val type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found", ) - val outcome = Outcome.of( - rejected, - "internal=42", - listOf(Issue.at("items[0]", "Unavailable.")), - ) - val detail: String? = outcome.detail - val issueCode: OutcomeCode? = outcome.issues.first().code - val mapper = HttpOutcomeMapper.standard().withMapping(rejected, HttpStatusCode.of(422)) - val mappedStatus: HttpStatusCode? = mapper.map(outcome).orNull() - val missingDefinition: OutcomeDefinition? = - OutcomeRegistry.empty().find("com.example:MISSING") - check(mappedStatus?.value == 422) - check(detail == "internal=42") - check(issueCode == null) - check(missingDefinition == null) - check(outcome.code.value == "com.example.orders:ORDER_REJECTED") - - val standardFailure = Outcome.of(StandardOutcomes.NOT_FOUND) - val problem = OutcomeProblemDetailMapper.safeDefaults().map(standardFailure).orNull() - check(problem?.status == 404) - - val rpcStatus = GoogleRpcOutcomeMapper.safeDefaults().map(standardFailure).orNull() - check(rpcStatus?.code == io.grpc.Status.Code.NOT_FOUND.value()) - val identity = rpcStatus!!.detailsList.first().unpack(ErrorInfo::class.java) - check(identity.domain == standardFailure.code.namespace) - check(identity.reason == standardFailure.code.name) - - val validation = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")), - ) - check(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT).issues.size == 1) - check(OutcomeRegistry.standard().with(rejected).find(rejected.code) === rejected) + val problem = ProblemDetails.forTypeAndDetail(type, "Order o-123 was not found.") - println("Kotlin consumer smoke test passed for all Codes artifacts.") + check(problem.type == type.type) + check(problem.status == 404) + check(problem.title == "Order not found") + check(problem.detail == "Order o-123 was not found.") } diff --git a/smoke-test-maven-java/pom.xml b/smoke-test-maven-java/pom.xml index da35d16..9105fc8 100644 --- a/smoke-test-maven-java/pom.xml +++ b/smoke-test-maven-java/pom.xml @@ -1,20 +1,14 @@ - + 4.0.0 io.github.aalsanie.smoke codes-maven-java-smoke 1.0.0 - 17 - UTF-8 - 0.4.0-SNAPSHOT + 0.4.0 7.0.9 - 1.83.1 - @@ -24,16 +18,8 @@ pom import - - io.grpc - grpc-bom - ${grpc.version} - pom - import - - io.github.aalsanie @@ -46,26 +32,12 @@ ${codes.version} - io.github.aalsanie - codes-grpc-java - ${codes.version} + org.springframework + spring-web - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.1 - - ${maven.compiler.release} - - -Xlint:all,-serial - -Werror - - - org.codehaus.mojo exec-maven-plugin diff --git a/smoke-test-maven-java/src/main/java/SmokeMavenJava.java b/smoke-test-maven-java/src/main/java/SmokeMavenJava.java index 526f13a..847b426 100644 --- a/smoke-test-maven-java/src/main/java/SmokeMavenJava.java +++ b/smoke-test-maven-java/src/main/java/SmokeMavenJava.java @@ -1,25 +1,21 @@ -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; +import io.github.aalsanie.codes.ProblemType; +import io.github.aalsanie.codes.spring.ProblemDetails; +import java.net.URI; +import org.springframework.http.ProblemDetail; public final class SmokeMavenJava { private SmokeMavenJava() { } public static void main(String[] args) { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND); - if (HttpOutcomeMapper.standard().map(outcome).orNull().getValue() != 404) { + ProblemType type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + ProblemDetail problem = ProblemDetails.forType(type); + if (!type.getType().equals(problem.getType()) || problem.getStatus() != 404) { throw new AssertionError("Maven Java consumer mapping failed"); } - if (OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull().getStatus() != 404) { - throw new AssertionError("Maven Java Spring adapter mapping failed"); - } - if (GoogleRpcOutcomeMapper.safeDefaults().map(outcome).orNull().getCode() - != io.grpc.Status.Code.NOT_FOUND.value()) { - throw new AssertionError("Maven Java gRPC adapter mapping failed"); - } - System.out.println("Maven Java consumer smoke test passed for all Codes artifacts."); } } diff --git a/smoke-test-maven-kotlin/pom.xml b/smoke-test-maven-kotlin/pom.xml index a32afb7..887ed4f 100644 --- a/smoke-test-maven-kotlin/pom.xml +++ b/smoke-test-maven-kotlin/pom.xml @@ -1,20 +1,14 @@ - + 4.0.0 io.github.aalsanie.smoke codes-maven-kotlin-smoke 1.0.0 - - UTF-8 - 0.4.0-SNAPSHOT + 0.4.0 2.4.10 6.0.0 - 1.75.0 - @@ -24,16 +18,8 @@ pom import - - io.grpc - grpc-bom - ${grpc.version} - pom - import - - io.github.aalsanie @@ -46,9 +32,8 @@ ${codes.version} - io.github.aalsanie - codes-grpc-java - ${codes.version} + org.springframework + spring-web org.jetbrains.kotlin @@ -56,7 +41,6 @@ ${kotlin.version} - ${project.basedir}/src/main/kotlin diff --git a/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt b/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt index 610c8fb..8bbd553 100644 --- a/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt +++ b/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt @@ -1,22 +1,15 @@ -import com.google.rpc.ErrorInfo -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.StandardOutcomes -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper +import io.github.aalsanie.codes.ProblemType +import io.github.aalsanie.codes.spring.ProblemDetails +import java.net.URI fun main() { - val outcome = Outcome.of(StandardOutcomes.NOT_FOUND) - - check(HttpOutcomeMapper.standard().map(outcome).orNull()?.value == 404) - check(OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull()?.status == 404) - - val rpcStatus = GoogleRpcOutcomeMapper.safeDefaults().map(outcome).orNull() - check(rpcStatus?.code == io.grpc.Status.Code.NOT_FOUND.value()) - - val identity = rpcStatus!!.detailsList.first().unpack(ErrorInfo::class.java) - check(identity.domain == outcome.code.namespace) - check(identity.reason == outcome.code.name) - - println("Maven Kotlin consumer smoke test passed for all Codes artifacts.") + val type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found", + ) + val problem = ProblemDetails.forType(type) + + check(problem.type == type.type) + check(problem.status == 404) } diff --git a/src/main/java/io/github/aalsanie/codes/Constraints.java b/src/main/java/io/github/aalsanie/codes/Constraints.java deleted file mode 100644 index d5e2ba5..0000000 --- a/src/main/java/io/github/aalsanie/codes/Constraints.java +++ /dev/null @@ -1,113 +0,0 @@ -package io.github.aalsanie.codes; - -final class Constraints { - private static final int MAX_NAMESPACE_LENGTH = 128; - private static final int MAX_NAME_LENGTH = 64; - private static final int MAX_MESSAGE_LENGTH = 1024; - private static final int MAX_DETAIL_LENGTH = 4096; - private static final int MAX_PATH_LENGTH = 256; - - private Constraints() { - } - - static void requireNamespace(String namespace) { - if (namespace == null) { - throw new NullPointerException("namespace"); - } - if (namespace.isEmpty() || namespace.length() > MAX_NAMESPACE_LENGTH) { - throw new IllegalArgumentException( - "namespace length must be between 1 and " + MAX_NAMESPACE_LENGTH - ); - } - - String[] segments = namespace.split("\\.", -1); - for (String segment : segments) { - if (segment.isEmpty()) { - throw new IllegalArgumentException("namespace must not contain empty segments"); - } - if (segment.length() > 63) { - throw new IllegalArgumentException("namespace segment length must not exceed 63"); - } - if (!isLowerAsciiLetter(segment.charAt(0))) { - throw new IllegalArgumentException( - "namespace segments must start with a lowercase ASCII letter" - ); - } - char last = segment.charAt(segment.length() - 1); - if (!(isLowerAsciiLetter(last) || isAsciiDigit(last))) { - throw new IllegalArgumentException( - "namespace segments must end with a lowercase ASCII letter or digit" - ); - } - for (int i = 0; i < segment.length(); i++) { - char c = segment.charAt(i); - if (!(isLowerAsciiLetter(c) || isAsciiDigit(c) || c == '-')) { - throw new IllegalArgumentException( - "namespace segments may contain only lowercase ASCII letters, digits, and hyphens" - ); - } - } - } - } - - static void requireName(String name) { - if (name == null) { - throw new NullPointerException("name"); - } - if (name.isEmpty() || name.length() > MAX_NAME_LENGTH) { - throw new IllegalArgumentException( - "name length must be between 1 and " + MAX_NAME_LENGTH - ); - } - if (!isUpperAsciiLetter(name.charAt(0))) { - throw new IllegalArgumentException("name must start with an uppercase ASCII letter"); - } - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (!(isUpperAsciiLetter(c) || isAsciiDigit(c) || c == '_')) { - throw new IllegalArgumentException( - "name may contain only uppercase ASCII letters, digits, and underscores" - ); - } - } - } - - static void requireMessage(String message) { - requireText(message, "message", MAX_MESSAGE_LENGTH); - } - - static void requireDetail(String detail) { - requireText(detail, "detail", MAX_DETAIL_LENGTH); - } - - static void requirePath(String path) { - requireText(path, "path", MAX_PATH_LENGTH); - } - - private static void requireText(String value, String label, int maxLength) { - if (value == null) { - throw new NullPointerException(label); - } - if (value.isBlank()) { - throw new IllegalArgumentException(label + " must not be blank"); - } - if (value.length() > maxLength) { - throw new IllegalArgumentException(label + " length must not exceed " + maxLength); - } - if (value.indexOf('\0') >= 0) { - throw new IllegalArgumentException(label + " must not contain NUL"); - } - } - - private static boolean isLowerAsciiLetter(char c) { - return c >= 'a' && c <= 'z'; - } - - private static boolean isUpperAsciiLetter(char c) { - return c >= 'A' && c <= 'Z'; - } - - private static boolean isAsciiDigit(char c) { - return c >= '0' && c <= '9'; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/Issue.java b/src/main/java/io/github/aalsanie/codes/Issue.java deleted file mode 100644 index 9ccb187..0000000 --- a/src/main/java/io/github/aalsanie/codes/Issue.java +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -public final class Issue { - private final @Nullable OutcomeCode code; - private final @Nullable String path; - private final String message; - - private Issue(@Nullable OutcomeCode code, @Nullable String path, String message) { - this.code = code; - this.path = path; - this.message = message; - } - - public static Issue of(String message) { - return create(null, null, message); - } - - public static Issue coded(OutcomeCode code, String message) { - return create(Objects.requireNonNull(code, "code"), null, message); - } - - public static Issue at(String path, String message) { - return create(null, Objects.requireNonNull(path, "path"), message); - } - - public static Issue at(String path, OutcomeCode code, String message) { - return create( - Objects.requireNonNull(code, "code"), - Objects.requireNonNull(path, "path"), - message - ); - } - - public @Nullable OutcomeCode getCode() { - return code; - } - - public @Nullable String getPath() { - return path; - } - - public String getMessage() { - return message; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof Issue issue)) { - return false; - } - return Objects.equals(code, issue.code) - && Objects.equals(path, issue.path) - && message.equals(issue.message); - } - - @Override - public int hashCode() { - return Objects.hash(code, path, message); - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - if (code != null) { - builder.append(code).append(' '); - } - if (path != null) { - builder.append(path).append(": "); - } - return builder.append(message).toString(); - } - - private static Issue create( - @Nullable OutcomeCode code, - @Nullable String path, - String message - ) { - if (path != null) { - Constraints.requirePath(path); - } - Constraints.requireMessage(message); - return new Issue(code, path, message); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/MappingResult.java b/src/main/java/io/github/aalsanie/codes/MappingResult.java deleted file mode 100644 index c17525a..0000000 --- a/src/main/java/io/github/aalsanie/codes/MappingResult.java +++ /dev/null @@ -1,107 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; -import java.util.function.Function; -import java.util.function.Supplier; - -public abstract sealed class MappingResult permits MappingResult.Mapped, MappingResult.Unmapped { - private MappingResult() { - } - - public static MappingResult mapped(T value) { - return new Mapped<>(Objects.requireNonNull(value, "value")); - } - - @SuppressWarnings("unchecked") - public static MappingResult unmapped() { - return (MappingResult) Unmapped.INSTANCE; - } - - public abstract boolean isMapped(); - - public final boolean isUnmapped() { - return !isMapped(); - } - - public abstract @Nullable T orNull(); - - public final R fold(Function onMapped, Supplier onUnmapped) { - Objects.requireNonNull(onMapped, "onMapped"); - Objects.requireNonNull(onUnmapped, "onUnmapped"); - if (this instanceof Mapped mapped) { - return onMapped.apply(mapped.value); - } - return onUnmapped.get(); - } - - public static final class Mapped extends MappingResult { - private final T value; - - private Mapped(T value) { - this.value = value; - } - - public T getValue() { - return value; - } - - @Override - public boolean isMapped() { - return true; - } - - @Override - public @Nullable T orNull() { - return value; - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof Mapped mapped && value.equals(mapped.value)); - } - - @Override - public int hashCode() { - return value.hashCode(); - } - - @Override - public String toString() { - return "Mapped(value=" + value + ")"; - } - } - - public static final class Unmapped extends MappingResult { - private static final Unmapped INSTANCE = new Unmapped<>(); - - private Unmapped() { - } - - @Override - public boolean isMapped() { - return false; - } - - @Override - public @Nullable T orNull() { - return null; - } - - @Override - public boolean equals(Object other) { - return this == other; - } - - @Override - public int hashCode() { - return 0; - } - - @Override - public String toString() { - return "Unmapped"; - } - } -} diff --git a/src/main/java/io/github/aalsanie/codes/Outcome.java b/src/main/java/io/github/aalsanie/codes/Outcome.java deleted file mode 100644 index fc44040..0000000 --- a/src/main/java/io/github/aalsanie/codes/Outcome.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.List; -import java.util.Objects; - -public final class Outcome { - private final OutcomeDefinition definition; - private final @Nullable String detail; - private final List issues; - - private Outcome( - OutcomeDefinition definition, - @Nullable String detail, - List issues - ) { - this.definition = definition; - this.detail = detail; - this.issues = issues; - } - - public static Outcome of(OutcomeDefinition definition) { - return of(definition, null, List.of()); - } - - public static Outcome of( - OutcomeDefinition definition, - @Nullable String detail - ) { - return of(definition, detail, List.of()); - } - - public static Outcome of( - OutcomeDefinition definition, - @Nullable String detail, - List issues - ) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(issues, "issues"); - if (detail != null) { - Constraints.requireDetail(detail); - } - if (definition.getState() != OutcomeState.FAILED && !issues.isEmpty()) { - throw new IllegalArgumentException("issues are only allowed on failed outcomes"); - } - return new Outcome(definition, detail, List.copyOf(issues)); - } - - public OutcomeDefinition getDefinition() { - return definition; - } - - public OutcomeCode getCode() { - return definition.getCode(); - } - - public OutcomeState getState() { - return definition.getState(); - } - - public String getDefaultMessage() { - return definition.getDefaultMessage(); - } - - public String getMessage() { - return getDefaultMessage(); - } - - public @Nullable String getDetail() { - return detail; - } - - public List getIssues() { - return issues; - } - - public boolean isSuccessful() { - return getState() == OutcomeState.SUCCEEDED; - } - - public boolean isPending() { - return getState() == OutcomeState.PENDING; - } - - public boolean isFailed() { - return getState() == OutcomeState.FAILED; - } - - public boolean isTerminal() { - return getState() != OutcomeState.PENDING; - } - - @Override - public String toString() { - return "Outcome(code=" + getCode() - + ", state=" + getState() - + ", message=" + getMessage() - + ", issues=" + issues.size() - + ")"; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeCode.java b/src/main/java/io/github/aalsanie/codes/OutcomeCode.java deleted file mode 100644 index 613fec8..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeCode.java +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -public final class OutcomeCode implements Comparable { - private static final char DELIMITER = ':'; - - private final String namespace; - private final String name; - private final String value; - - private OutcomeCode(String namespace, String name) { - this.namespace = namespace; - this.name = name; - this.value = namespace + DELIMITER + name; - } - - public static OutcomeCode of(String namespace, String name) { - Constraints.requireNamespace(namespace); - Constraints.requireName(name); - return new OutcomeCode(namespace, name); - } - - public static OutcomeCode parse(String value) { - Objects.requireNonNull(value, "value"); - int delimiterIndex = value.indexOf(DELIMITER); - if (delimiterIndex <= 0 || delimiterIndex != value.lastIndexOf(DELIMITER)) { - throw new IllegalArgumentException("outcome code must use the format namespace:NAME"); - } - return of(value.substring(0, delimiterIndex), value.substring(delimiterIndex + 1)); - } - - public static @Nullable OutcomeCode parseOrNull(String value) { - Objects.requireNonNull(value, "value"); - try { - return parse(value); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - public String getNamespace() { - return namespace; - } - - public String getName() { - return name; - } - - public String getValue() { - return value; - } - - @Override - public int compareTo(OutcomeCode other) { - return value.compareTo(Objects.requireNonNull(other, "other").value); - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof OutcomeCode that)) { - return false; - } - return namespace.equals(that.namespace) && name.equals(that.name); - } - - @Override - public int hashCode() { - return 31 * namespace.hashCode() + name.hashCode(); - } - - @Override - public String toString() { - return value; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java b/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java deleted file mode 100644 index f9e0c7f..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java +++ /dev/null @@ -1,110 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class OutcomeDefinition { - static final String ROOT_NAMESPACE = "io.github.aalsanie.codes"; - static final String STANDARD_NAMESPACE = "io.github.aalsanie.codes.standard"; - - private static final Map STANDARD_DEFINITIONS = createStandardDefinitions(); - - private final OutcomeCode code; - private final OutcomeState state; - private final String defaultMessage; - - private OutcomeDefinition(OutcomeCode code, OutcomeState state, String defaultMessage) { - this.code = code; - this.state = state; - this.defaultMessage = defaultMessage; - } - - public static OutcomeDefinition custom( - String namespace, - String name, - OutcomeState state, - String defaultMessage - ) { - return custom(OutcomeCode.of(namespace, name), state, defaultMessage); - } - - public static OutcomeDefinition custom( - OutcomeCode code, - OutcomeState state, - String defaultMessage - ) { - Objects.requireNonNull(code, "code"); - Objects.requireNonNull(state, "state"); - if (isReservedNamespace(code.getNamespace())) { - throw new IllegalArgumentException("namespace '" + code.getNamespace() + "' is reserved"); - } - Constraints.requireMessage(defaultMessage); - return new OutcomeDefinition(code, state, defaultMessage); - } - - static OutcomeDefinition standard(String name) { - OutcomeDefinition definition = STANDARD_DEFINITIONS.get(name); - if (definition == null) { - throw new IllegalStateException("unknown standard outcome: " + name); - } - return definition; - } - - public OutcomeCode getCode() { - return code; - } - - public OutcomeState getState() { - return state; - } - - public String getDefaultMessage() { - return defaultMessage; - } - - @Override - public String toString() { - return "OutcomeDefinition(code=" + code + ", state=" + state + ", defaultMessage=" + defaultMessage + ")"; - } - - private static Map createStandardDefinitions() { - LinkedHashMap definitions = new LinkedHashMap<>(); - add(definitions, "OK", OutcomeState.SUCCEEDED, "The operation completed successfully."); - add(definitions, "INVALID_ARGUMENT", OutcomeState.FAILED, "An argument supplied to the operation is invalid."); - add(definitions, "UNAUTHENTICATED", OutcomeState.FAILED, "Authentication is required or invalid."); - add(definitions, "PERMISSION_DENIED", OutcomeState.FAILED, "The authenticated caller does not have permission."); - add(definitions, "NOT_FOUND", OutcomeState.FAILED, "The requested resource was not found."); - add(definitions, "ALREADY_EXISTS", OutcomeState.FAILED, "The resource already exists."); - add(definitions, "FAILED_PRECONDITION", OutcomeState.FAILED, "A required precondition was not satisfied."); - add(definitions, "OUT_OF_RANGE", OutcomeState.FAILED, "A value is outside the allowed range."); - add(definitions, "RATE_LIMITED", OutcomeState.FAILED, "The caller exceeded an allowed operation rate."); - add(definitions, "CANCELLED", OutcomeState.FAILED, "The operation was cancelled before completion."); - add(definitions, "DEADLINE_EXCEEDED", OutcomeState.FAILED, "The operation exceeded its deadline."); - add(definitions, "ABORTED", OutcomeState.FAILED, "The operation was aborted before completion."); - add(definitions, "UNIMPLEMENTED", OutcomeState.FAILED, "The requested operation is not implemented."); - add(definitions, "UNAVAILABLE", OutcomeState.FAILED, "The service is temporarily unavailable."); - add(definitions, "INTERNAL", OutcomeState.FAILED, "The service encountered an internal error."); - add(definitions, "DATA_LOSS", OutcomeState.FAILED, "Unrecoverable data loss or corruption was detected."); - add(definitions, "RESOURCE_EXHAUSTED", OutcomeState.FAILED, "A required resource limit was exhausted."); - return Collections.unmodifiableMap(definitions); - } - - private static void add( - Map definitions, - String name, - OutcomeState state, - String defaultMessage - ) { - Constraints.requireMessage(defaultMessage); - definitions.put( - name, - new OutcomeDefinition(OutcomeCode.of(STANDARD_NAMESPACE, name), state, defaultMessage) - ); - } - - private static boolean isReservedNamespace(String namespace) { - return namespace.equals(ROOT_NAMESPACE) || namespace.startsWith(ROOT_NAMESPACE + "."); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeException.java b/src/main/java/io/github/aalsanie/codes/OutcomeException.java deleted file mode 100644 index 0e29c91..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeException.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -public final class OutcomeException extends RuntimeException { - private final Outcome outcome; - - public OutcomeException(Outcome outcome) { - this(outcome, null); - } - - public OutcomeException(Outcome outcome, @Nullable Throwable cause) { - super(requireFailed(outcome).getMessage(), cause); - this.outcome = outcome; - } - - public Outcome getOutcome() { - return outcome; - } - - private static Outcome requireFailed(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException("only failed outcomes can be converted to OutcomeException"); - } - return outcome; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java b/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java deleted file mode 100644 index 40a62ae..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.aalsanie.codes; - -public final class OutcomeExceptions { - private OutcomeExceptions() { - } - - public static OutcomeException toException(Outcome outcome) { - return new OutcomeException(outcome); - } - - public static OutcomeException toException(Outcome outcome, Throwable cause) { - return new OutcomeException(outcome, cause); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java deleted file mode 100644 index 1a8adb9..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Objects; - -public interface OutcomeMapper { - MappingResult map(OutcomeDefinition definition); - - default MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java b/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java deleted file mode 100644 index 8654380..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Objects; - -public final class OutcomeRegistry { - private final Map definitionsByCode; - - private OutcomeRegistry(Map definitions) { - this.definitionsByCode = Collections.unmodifiableMap(new LinkedHashMap<>(definitions)); - } - - public static OutcomeRegistry empty() { - return new OutcomeRegistry(Map.of()); - } - - public static OutcomeRegistry of(OutcomeDefinition... definitions) { - Objects.requireNonNull(definitions, "definitions"); - return empty().withAll(List.of(definitions)); - } - - public static OutcomeRegistry standard() { - return empty().withAll(StandardOutcomes.all); - } - - public int getSize() { - return definitionsByCode.size(); - } - - public boolean contains(OutcomeCode code) { - return definitionsByCode.containsKey(Objects.requireNonNull(code, "code")); - } - - public @Nullable OutcomeDefinition find(OutcomeCode code) { - return definitionsByCode.get(Objects.requireNonNull(code, "code")); - } - - public @Nullable OutcomeDefinition find(String value) { - OutcomeCode code = OutcomeCode.parseOrNull(value); - return code == null ? null : find(code); - } - - public OutcomeDefinition require(OutcomeCode code) { - OutcomeDefinition definition = find(code); - if (definition == null) { - throw new NoSuchElementException("unknown outcome code: " + code); - } - return definition; - } - - public List definitions() { - return Collections.unmodifiableList(new ArrayList<>(definitionsByCode.values())); - } - - public OutcomeRegistry with(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - if (definitionsByCode.containsKey(definition.getCode())) { - throw new IllegalArgumentException("outcome code already registered: " + definition.getCode()); - } - LinkedHashMap next = new LinkedHashMap<>(definitionsByCode); - next.put(definition.getCode(), definition); - return new OutcomeRegistry(next); - } - - public OutcomeRegistry withAll(Iterable definitions) { - Objects.requireNonNull(definitions, "definitions"); - OutcomeRegistry next = this; - for (OutcomeDefinition definition : definitions) { - next = next.with(definition); - } - return next; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeState.java b/src/main/java/io/github/aalsanie/codes/OutcomeState.java deleted file mode 100644 index 7d69280..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeState.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.aalsanie.codes; - -public enum OutcomeState { - SUCCEEDED, - PENDING, - FAILED -} diff --git a/src/main/java/io/github/aalsanie/codes/ProblemType.java b/src/main/java/io/github/aalsanie/codes/ProblemType.java new file mode 100644 index 0000000..48130c2 --- /dev/null +++ b/src/main/java/io/github/aalsanie/codes/ProblemType.java @@ -0,0 +1,97 @@ +package io.github.aalsanie.codes; + +import java.net.URI; +import java.util.Objects; + +/** + * Immutable definition of a reusable RFC 9457 problem type. + */ +public final class ProblemType { + private static final String ABOUT_SCHEME = "about"; + private static final String BLANK_SCHEME_SPECIFIC_PART = "blank"; + + private final URI type; + private final int status; + private final String title; + + private ProblemType(URI type, int status, String title) { + this.type = type; + this.status = status; + this.title = title; + } + + /** + * Creates a problem type. + * + * @param type absolute problem type URI; must not be {@code about:blank} + * @param status HTTP status from 100 through 599 + * @param title non-blank human-readable title + * @return the problem type + * @throws NullPointerException if {@code type} or {@code title} is {@code null} + * @throws IllegalArgumentException if an argument violates the problem type contract + */ + public static ProblemType of(URI type, int status, String title) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(title, "title"); + + if (!type.isAbsolute()) { + throw new IllegalArgumentException("type must be an absolute URI"); + } + if (isAboutBlank(type)) { + throw new IllegalArgumentException("type must not be about:blank"); + } + if (status < 100 || status > 599) { + throw new IllegalArgumentException("status must be between 100 and 599"); + } + if (title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + + return new ProblemType(type, status, title); + } + + /** + * Returns the stable machine-readable problem type URI. + */ + public URI getType() { + return type; + } + + /** + * Returns the HTTP status associated with this problem type. + */ + public int getStatus() { + return status; + } + + /** + * Returns the human-readable title for this problem type. + */ + public String getTitle() { + return title; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof ProblemType that + && status == that.status + && type.equals(that.type) + && title.equals(that.title)); + } + + @Override + public int hashCode() { + return Objects.hash(type, status, title); + } + + @Override + public String toString() { + return "ProblemType(type=" + type + ", status=" + status + ", title=" + title + ")"; + } + + private static boolean isAboutBlank(URI type) { + return ABOUT_SCHEME.equalsIgnoreCase(type.getScheme()) + && BLANK_SCHEME_SPECIFIC_PART.equalsIgnoreCase(type.getSchemeSpecificPart()); + } +} diff --git a/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java b/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java deleted file mode 100644 index 36d2630..0000000 --- a/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.List; - -public final class StandardOutcomes { - public static final String NAMESPACE = "io.github.aalsanie.codes.standard"; - - public static final OutcomeDefinition OK = OutcomeDefinition.standard("OK"); - public static final OutcomeDefinition INVALID_ARGUMENT = OutcomeDefinition.standard("INVALID_ARGUMENT"); - public static final OutcomeDefinition UNAUTHENTICATED = OutcomeDefinition.standard("UNAUTHENTICATED"); - public static final OutcomeDefinition PERMISSION_DENIED = OutcomeDefinition.standard("PERMISSION_DENIED"); - public static final OutcomeDefinition NOT_FOUND = OutcomeDefinition.standard("NOT_FOUND"); - public static final OutcomeDefinition ALREADY_EXISTS = OutcomeDefinition.standard("ALREADY_EXISTS"); - public static final OutcomeDefinition FAILED_PRECONDITION = OutcomeDefinition.standard("FAILED_PRECONDITION"); - public static final OutcomeDefinition OUT_OF_RANGE = OutcomeDefinition.standard("OUT_OF_RANGE"); - public static final OutcomeDefinition RATE_LIMITED = OutcomeDefinition.standard("RATE_LIMITED"); - public static final OutcomeDefinition CANCELLED = OutcomeDefinition.standard("CANCELLED"); - public static final OutcomeDefinition DEADLINE_EXCEEDED = OutcomeDefinition.standard("DEADLINE_EXCEEDED"); - public static final OutcomeDefinition ABORTED = OutcomeDefinition.standard("ABORTED"); - public static final OutcomeDefinition UNIMPLEMENTED = OutcomeDefinition.standard("UNIMPLEMENTED"); - public static final OutcomeDefinition UNAVAILABLE = OutcomeDefinition.standard("UNAVAILABLE"); - public static final OutcomeDefinition INTERNAL = OutcomeDefinition.standard("INTERNAL"); - public static final OutcomeDefinition DATA_LOSS = OutcomeDefinition.standard("DATA_LOSS"); - public static final OutcomeDefinition RESOURCE_EXHAUSTED = OutcomeDefinition.standard("RESOURCE_EXHAUSTED"); - - public static final List all = List.of( - OK, - INVALID_ARGUMENT, - UNAUTHENTICATED, - PERMISSION_DENIED, - NOT_FOUND, - ALREADY_EXISTS, - FAILED_PRECONDITION, - OUT_OF_RANGE, - RATE_LIMITED, - CANCELLED, - DEADLINE_EXCEEDED, - ABORTED, - UNIMPLEMENTED, - UNAVAILABLE, - INTERNAL, - DATA_LOSS, - RESOURCE_EXHAUSTED - ); - - private StandardOutcomes() { - } -} diff --git a/src/main/java/io/github/aalsanie/codes/ValidationResult.java b/src/main/java/io/github/aalsanie/codes/ValidationResult.java deleted file mode 100644 index 479bcf7..0000000 --- a/src/main/java/io/github/aalsanie/codes/ValidationResult.java +++ /dev/null @@ -1,130 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public abstract sealed class ValidationResult permits ValidationResult.Valid, ValidationResult.Invalid { - private ValidationResult() { - } - - public static ValidationResult valid() { - return Valid.INSTANCE; - } - - public static ValidationResult invalid(Issue issue) { - return new Invalid(List.of(Objects.requireNonNull(issue, "issue"))); - } - - public static ValidationResult invalid(List issues) { - return new Invalid(issues); - } - - public static ValidationResult combine(ValidationResult... results) { - Objects.requireNonNull(results, "results"); - return combine(List.of(results)); - } - - public static ValidationResult combine(List results) { - Objects.requireNonNull(results, "results"); - List issues = new ArrayList<>(); - for (ValidationResult result : results) { - Objects.requireNonNull(result, "result"); - issues.addAll(result.issues()); - } - return issues.isEmpty() ? Valid.INSTANCE : new Invalid(issues); - } - - public final boolean isValid() { - return this == Valid.INSTANCE; - } - - public final boolean isInvalid() { - return !isValid(); - } - - public abstract List issues(); - - public final Outcome toOutcome(OutcomeDefinition failureDefinition) { - return toOutcome(failureDefinition, null); - } - - public final Outcome toOutcome( - OutcomeDefinition failureDefinition, - @Nullable String detail - ) { - Objects.requireNonNull(failureDefinition, "failureDefinition"); - if (failureDefinition.getState() != OutcomeState.FAILED) { - throw new IllegalArgumentException("validation failure definition must have FAILED state"); - } - if (isValid()) { - return Outcome.of(StandardOutcomes.OK, detail); - } - return Outcome.of(failureDefinition, detail, issues()); - } - - public static final class Valid extends ValidationResult { - private static final Valid INSTANCE = new Valid(); - - private Valid() { - } - - @Override - public List issues() { - return List.of(); - } - - @Override - public boolean equals(Object other) { - return other instanceof Valid; - } - - @Override - public int hashCode() { - return 0; - } - - @Override - public String toString() { - return "Valid"; - } - } - - public static final class Invalid extends ValidationResult { - private final List issues; - - private Invalid(List issues) { - Objects.requireNonNull(issues, "issues"); - if (issues.isEmpty()) { - throw new IllegalArgumentException("invalid validation result requires at least one issue"); - } - this.issues = List.copyOf(issues); - } - - @Override - public List issues() { - return issues; - } - - public List getIssues() { - return issues; - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof Invalid invalid && issues.equals(invalid.issues)); - } - - @Override - public int hashCode() { - return issues.hashCode(); - } - - @Override - public String toString() { - return "Invalid(issues=" + issues + ")"; - } - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java deleted file mode 100644 index 887ee08..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeMapper; -import io.github.aalsanie.codes.StandardOutcomes; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class GrpcOutcomeMapper implements OutcomeMapper { - private final Map mappings; - - private GrpcOutcomeMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static GrpcOutcomeMapper empty() { - return new GrpcOutcomeMapper(Map.of()); - } - - public static GrpcOutcomeMapper standard() { - LinkedHashMap values = new LinkedHashMap<>(); - values.put(StandardOutcomes.OK.getCode(), GrpcStatusCode.OK); - values.put(StandardOutcomes.INVALID_ARGUMENT.getCode(), GrpcStatusCode.INVALID_ARGUMENT); - values.put(StandardOutcomes.UNAUTHENTICATED.getCode(), GrpcStatusCode.UNAUTHENTICATED); - values.put(StandardOutcomes.PERMISSION_DENIED.getCode(), GrpcStatusCode.PERMISSION_DENIED); - values.put(StandardOutcomes.NOT_FOUND.getCode(), GrpcStatusCode.NOT_FOUND); - values.put(StandardOutcomes.ALREADY_EXISTS.getCode(), GrpcStatusCode.ALREADY_EXISTS); - values.put(StandardOutcomes.FAILED_PRECONDITION.getCode(), GrpcStatusCode.FAILED_PRECONDITION); - values.put(StandardOutcomes.OUT_OF_RANGE.getCode(), GrpcStatusCode.OUT_OF_RANGE); - values.put(StandardOutcomes.RATE_LIMITED.getCode(), GrpcStatusCode.RESOURCE_EXHAUSTED); - values.put(StandardOutcomes.CANCELLED.getCode(), GrpcStatusCode.CANCELLED); - values.put(StandardOutcomes.DEADLINE_EXCEEDED.getCode(), GrpcStatusCode.DEADLINE_EXCEEDED); - values.put(StandardOutcomes.ABORTED.getCode(), GrpcStatusCode.ABORTED); - values.put(StandardOutcomes.UNIMPLEMENTED.getCode(), GrpcStatusCode.UNIMPLEMENTED); - values.put(StandardOutcomes.UNAVAILABLE.getCode(), GrpcStatusCode.UNAVAILABLE); - values.put(StandardOutcomes.INTERNAL.getCode(), GrpcStatusCode.INTERNAL); - values.put(StandardOutcomes.DATA_LOSS.getCode(), GrpcStatusCode.DATA_LOSS); - values.put(StandardOutcomes.RESOURCE_EXHAUSTED.getCode(), GrpcStatusCode.RESOURCE_EXHAUSTED); - return new GrpcOutcomeMapper(values); - } - - @Override - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - GrpcStatusCode status = mappings.get(definition.getCode()); - return status == null ? MappingResult.unmapped() : MappingResult.mapped(status); - } - - public int getSize() { - return mappings.size(); - } - - public boolean contains(OutcomeCode code) { - return mappings.containsKey(Objects.requireNonNull(code, "code")); - } - - public GrpcOutcomeMapper withMapping(OutcomeDefinition definition, GrpcStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("mapping already exists for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new GrpcOutcomeMapper(next); - } - - public GrpcOutcomeMapper withOverride(OutcomeDefinition definition, GrpcStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (!mappings.containsKey(code)) { - throw new IllegalArgumentException("cannot override missing mapping for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new GrpcOutcomeMapper(next); - } - - public Map mappings() { - return mappings; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java deleted file mode 100644 index c31a6a6..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import org.jspecify.annotations.Nullable; - -import java.util.HashMap; -import java.util.Map; - -public enum GrpcStatusCode { - OK(0), - CANCELLED(1), - UNKNOWN(2), - INVALID_ARGUMENT(3), - DEADLINE_EXCEEDED(4), - NOT_FOUND(5), - ALREADY_EXISTS(6), - PERMISSION_DENIED(7), - RESOURCE_EXHAUSTED(8), - FAILED_PRECONDITION(9), - ABORTED(10), - OUT_OF_RANGE(11), - UNIMPLEMENTED(12), - INTERNAL(13), - UNAVAILABLE(14), - DATA_LOSS(15), - UNAUTHENTICATED(16); - - private static final Map BY_VALUE = createByValue(); - - private final int value; - - GrpcStatusCode(int value) { - this.value = value; - } - - public int getValue() { - return value; - } - - public static @Nullable GrpcStatusCode fromValue(int value) { - return BY_VALUE.get(value); - } - - private static Map createByValue() { - HashMap values = new HashMap<>(); - for (GrpcStatusCode status : values()) { - values.put(status.value, status); - } - return Map.copyOf(values); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java deleted file mode 100644 index 0df9dcb..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.protocol.grpc; - -import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java deleted file mode 100644 index 07c13fe..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java +++ /dev/null @@ -1,83 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeMapper; -import io.github.aalsanie.codes.StandardOutcomes; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class HttpOutcomeMapper implements OutcomeMapper { - private final Map mappings; - - private HttpOutcomeMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static HttpOutcomeMapper empty() { - return new HttpOutcomeMapper(Map.of()); - } - - public static HttpOutcomeMapper standard() { - LinkedHashMap values = new LinkedHashMap<>(); - values.put(StandardOutcomes.OK.getCode(), HttpStatusCode.OK); - values.put(StandardOutcomes.INVALID_ARGUMENT.getCode(), HttpStatusCode.BAD_REQUEST); - values.put(StandardOutcomes.UNAUTHENTICATED.getCode(), HttpStatusCode.UNAUTHORIZED); - values.put(StandardOutcomes.PERMISSION_DENIED.getCode(), HttpStatusCode.FORBIDDEN); - values.put(StandardOutcomes.NOT_FOUND.getCode(), HttpStatusCode.NOT_FOUND); - values.put(StandardOutcomes.ALREADY_EXISTS.getCode(), HttpStatusCode.CONFLICT); - values.put(StandardOutcomes.OUT_OF_RANGE.getCode(), HttpStatusCode.BAD_REQUEST); - values.put(StandardOutcomes.RATE_LIMITED.getCode(), HttpStatusCode.TOO_MANY_REQUESTS); - values.put(StandardOutcomes.UNIMPLEMENTED.getCode(), HttpStatusCode.NOT_IMPLEMENTED); - values.put(StandardOutcomes.UNAVAILABLE.getCode(), HttpStatusCode.SERVICE_UNAVAILABLE); - values.put(StandardOutcomes.INTERNAL.getCode(), HttpStatusCode.INTERNAL_SERVER_ERROR); - values.put(StandardOutcomes.DATA_LOSS.getCode(), HttpStatusCode.INTERNAL_SERVER_ERROR); - return new HttpOutcomeMapper(values); - } - - @Override - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - HttpStatusCode status = mappings.get(definition.getCode()); - return status == null ? MappingResult.unmapped() : MappingResult.mapped(status); - } - - public int getSize() { - return mappings.size(); - } - - public boolean contains(OutcomeCode code) { - return mappings.containsKey(Objects.requireNonNull(code, "code")); - } - - public HttpOutcomeMapper withMapping(OutcomeDefinition definition, HttpStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("mapping already exists for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new HttpOutcomeMapper(next); - } - - public HttpOutcomeMapper withOverride(OutcomeDefinition definition, HttpStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (!mappings.containsKey(code)) { - throw new IllegalArgumentException("cannot override missing mapping for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new HttpOutcomeMapper(next); - } - - public Map mappings() { - return mappings; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java b/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java deleted file mode 100644 index 665a23f..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import java.util.Objects; - -public final class HttpStatusCode implements Comparable { - public static final HttpStatusCode OK = new HttpStatusCode(200); - public static final HttpStatusCode CREATED = new HttpStatusCode(201); - public static final HttpStatusCode ACCEPTED = new HttpStatusCode(202); - public static final HttpStatusCode NO_CONTENT = new HttpStatusCode(204); - public static final HttpStatusCode BAD_REQUEST = new HttpStatusCode(400); - public static final HttpStatusCode UNAUTHORIZED = new HttpStatusCode(401); - public static final HttpStatusCode FORBIDDEN = new HttpStatusCode(403); - public static final HttpStatusCode NOT_FOUND = new HttpStatusCode(404); - public static final HttpStatusCode CONFLICT = new HttpStatusCode(409); - public static final HttpStatusCode PRECONDITION_FAILED = new HttpStatusCode(412); - public static final HttpStatusCode PAYLOAD_TOO_LARGE = new HttpStatusCode(413); - public static final HttpStatusCode TOO_MANY_REQUESTS = new HttpStatusCode(429); - public static final HttpStatusCode INTERNAL_SERVER_ERROR = new HttpStatusCode(500); - public static final HttpStatusCode NOT_IMPLEMENTED = new HttpStatusCode(501); - public static final HttpStatusCode SERVICE_UNAVAILABLE = new HttpStatusCode(503); - public static final HttpStatusCode GATEWAY_TIMEOUT = new HttpStatusCode(504); - - private final int value; - - private HttpStatusCode(int value) { - this.value = value; - } - - public static HttpStatusCode of(int value) { - if (value < 100 || value > 599) { - throw new IllegalArgumentException("HTTP status code must be between 100 and 599"); - } - return switch (value) { - case 200 -> OK; - case 201 -> CREATED; - case 202 -> ACCEPTED; - case 204 -> NO_CONTENT; - case 400 -> BAD_REQUEST; - case 401 -> UNAUTHORIZED; - case 403 -> FORBIDDEN; - case 404 -> NOT_FOUND; - case 409 -> CONFLICT; - case 412 -> PRECONDITION_FAILED; - case 413 -> PAYLOAD_TOO_LARGE; - case 429 -> TOO_MANY_REQUESTS; - case 500 -> INTERNAL_SERVER_ERROR; - case 501 -> NOT_IMPLEMENTED; - case 503 -> SERVICE_UNAVAILABLE; - case 504 -> GATEWAY_TIMEOUT; - default -> new HttpStatusCode(value); - }; - } - - public int getValue() { - return value; - } - - public int getFamily() { - return value / 100; - } - - public boolean isInformational() { - return getFamily() == 1; - } - - public boolean isSuccessful() { - return getFamily() == 2; - } - - public boolean isRedirection() { - return getFamily() == 3; - } - - public boolean isClientError() { - return getFamily() == 4; - } - - public boolean isServerError() { - return getFamily() == 5; - } - - @Override - public int compareTo(HttpStatusCode other) { - return Integer.compare(value, Objects.requireNonNull(other, "other").value); - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof HttpStatusCode status && value == status.value); - } - - @Override - public int hashCode() { - return value; - } - - @Override - public String toString() { - return Integer.toString(value); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java b/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java deleted file mode 100644 index 89f85d9..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.protocol.http; - -import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java b/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java index 048470a..7f3b711 100644 --- a/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java +++ b/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java @@ -1,179 +1,12 @@ package io.github.aalsanie.codes; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.Type; -import java.lang.reflect.TypeVariable; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; +import io.github.aalsanie.codes.testing.PublicApiSnapshot; final class ApiSnapshot { - private ApiSnapshot() { } static String create() { - return publicTypes().stream() - .sorted(Comparator.comparing(Class::getName)) - .map(ApiSnapshot::typeSnapshot) - .collect(Collectors.joining("\n\n")); - } - - private static List> publicTypes() { - try { - Path classesRoot = Path.of( - Issue.class.getProtectionDomain() - .getCodeSource() - .getLocation() - .toURI() - ); - - try (var paths = Files.walk(classesRoot)) { - return paths - .filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".class")) - .map(classesRoot::relativize) - .map(Path::toString) - .map(name -> name.replace('\\', '.').replace('/', '.')) - .map(name -> name.substring(0, name.length() - ".class".length())) - .filter(name -> name.startsWith("io.github.aalsanie.codes.")) - .filter(name -> !name.endsWith("package-info")) - .map(ApiSnapshot::loadClass) - .filter(type -> Modifier.isPublic(type.getModifiers())) - .collect(Collectors.toList()); - } - } catch (IOException | URISyntaxException exception) { - throw new IllegalStateException("Failed to discover public API types", exception); - } - } - - private static Class loadClass(String name) { - try { - return Class.forName(name, false, Issue.class.getClassLoader()); - } catch (ClassNotFoundException exception) { - throw new IllegalStateException("Failed to load API type: " + name, exception); - } - } - - private static String typeSnapshot(Class type) { - List lines = new ArrayList<>(); - lines.add(typeDeclaration(type)); - - for (Field field : type.getDeclaredFields()) { - if (Modifier.isPublic(field.getModifiers()) && !field.isSynthetic()) { - lines.add(" field " + modifiers(field.getModifiers()) + typeName(field.getGenericType()) + " " + field.getName()); - } - } - for (Constructor constructor : type.getDeclaredConstructors()) { - if (Modifier.isPublic(constructor.getModifiers()) && !constructor.isSynthetic()) { - lines.add(" ctor " + modifiers(constructor.getModifiers()) + parameters(constructor.getGenericParameterTypes())); - } - } - for (Method method : type.getDeclaredMethods()) { - if (Modifier.isPublic(method.getModifiers()) && !method.isSynthetic() && !method.isBridge()) { - lines.add( - " method " + modifiers(method.getModifiers()) - + typeName(method.getGenericReturnType()) + " " + method.getName() - + parameters(method.getGenericParameterTypes()) - ); - } - } - - String header = lines.remove(0); - lines.sort(String::compareTo); - return header + (lines.isEmpty() ? "" : "\n" + String.join("\n", lines)); - } - - private static String typeDeclaration(Class type) { - String kind; - if (type.isEnum()) { - kind = "enum"; - } else if (type.isInterface()) { - kind = "interface"; - } else { - kind = "class"; - } - - StringBuilder result = new StringBuilder(); - result.append(modifiers(type.getModifiers())).append(kind).append(' ').append(type.getName()); - - if (type.getTypeParameters().length > 0) { - result.append('<'); - result.append(java.util.Arrays.stream(type.getTypeParameters()) - .map(ApiSnapshot::typeParameter) - .collect(Collectors.joining(", "))); - result.append('>'); - } - - if (!type.isInterface() && !type.isEnum()) { - Type superclass = type.getGenericSuperclass(); - if (superclass != null && superclass != Object.class) { - result.append(" extends ").append(typeName(superclass)); - } - } - - Type[] interfaces = type.getGenericInterfaces(); - if (interfaces.length > 0) { - result.append(type.isInterface() ? " extends " : " implements "); - result.append(java.util.Arrays.stream(interfaces) - .map(ApiSnapshot::typeName) - .sorted() - .collect(Collectors.joining(", "))); - } - - if (type.isSealed()) { - result.append(" permits "); - result.append(java.util.Arrays.stream(type.getPermittedSubclasses()) - .map(Class::getName) - .sorted() - .collect(Collectors.joining(", "))); - } - - return result.toString(); - } - - private static String typeParameter(TypeVariable variable) { - Type[] bounds = variable.getBounds(); - if (bounds.length == 1 && bounds[0] == Object.class) { - return variable.getName(); - } - return variable.getName() + " extends " - + java.util.Arrays.stream(bounds).map(ApiSnapshot::typeName).collect(Collectors.joining(" & ")); - } - - private static String modifiers(int value) { - List values = new ArrayList<>(); - if (Modifier.isPublic(value)) { - values.add("public"); - } - if (Modifier.isProtected(value)) { - values.add("protected"); - } - if (Modifier.isStatic(value)) { - values.add("static"); - } - if (Modifier.isAbstract(value)) { - values.add("abstract"); - } - if (Modifier.isFinal(value)) { - values.add("final"); - } - return values.isEmpty() ? "" : String.join(" ", values) + " "; - } - - private static String parameters(Type[] types) { - return "(" + java.util.Arrays.stream(types).map(ApiSnapshot::typeName).collect(Collectors.joining(", ")) + ")"; - } - - private static String typeName(Type type) { - return type.getTypeName().replace('$', '.'); + return PublicApiSnapshot.create(ProblemType.class, "io.github.aalsanie.codes"); } } diff --git a/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java b/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java deleted file mode 100644 index 36e2e47..0000000 --- a/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class IssueOutcomeTest { - @Test - void createsIssueVariantsWithValueEquality() { - OutcomeCode code = OutcomeCode.of("com.example.validation", "INVALID_EMAIL"); - assertEquals(Issue.of("Invalid."), Issue.of("Invalid.")); - assertEquals("Invalid.", Issue.of("Invalid.").toString()); - assertEquals("email: Invalid.", Issue.at("email", "Invalid.").toString()); - assertEquals(code + " Invalid.", Issue.coded(code, "Invalid.").toString()); - assertEquals(code + " email: Invalid.", Issue.at("email", code, "Invalid.").toString()); - assertEquals(code, Issue.at("email", code, "Invalid.").getCode()); - assertEquals("email", Issue.at("email", code, "Invalid.").getPath()); - assertNull(Issue.of("Invalid.").getCode()); - assertNull(Issue.of("Invalid.").getPath()); - Issue issue = Issue.at("email", code, "Invalid."); - - assertEquals(issue, issue); - assertNotEquals("not-an-issue", issue); - assertNotEquals( - issue, - Issue.at( - "email", - OutcomeCode.of("com.example.validation", "OTHER"), - "Invalid." - ) - ); - assertNotEquals(issue, Issue.at("name", code, "Invalid.")); - assertNotEquals(issue, Issue.at("email", code, "Different.")); - assertEquals( - issue.hashCode(), - Issue.at("email", code, "Invalid.").hashCode() - ); - } - - @Test - void rejectsInvalidIssueTextAndPath() { - assertThrows(NullPointerException.class, () -> Issue.of(null)); - assertThrows(IllegalArgumentException.class, () -> Issue.of(" ")); - assertThrows(IllegalArgumentException.class, () -> Issue.of("x".repeat(1025))); - assertThrows(IllegalArgumentException.class, () -> Issue.of("bad\0message")); - assertThrows(IllegalArgumentException.class, () -> Issue.at(" ", "Bad.")); - assertThrows(IllegalArgumentException.class, () -> Issue.at("x".repeat(257), "Bad.")); - assertThrows(IllegalArgumentException.class, () -> Issue.at("bad\0path", "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.coded(null, "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.at("field", null, "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.at(null, "Bad.")); - assertThrows( - NullPointerException.class, - () -> Issue.at(null, OutcomeCode.of("com.example", "BAD"), "Bad.") - ); - } - - @Test - void outcomeDerivesSemanticsAndDefensivelyCopiesIssues() { - ArrayList source = new ArrayList<>(); - source.add(Issue.at("email", "Invalid.")); - Outcome outcome = Outcome.of(StandardOutcomes.INVALID_ARGUMENT, "request=42", source); - source.clear(); - - assertEquals(StandardOutcomes.INVALID_ARGUMENT, outcome.getDefinition()); - assertEquals(StandardOutcomes.INVALID_ARGUMENT.getCode(), outcome.getCode()); - assertEquals(OutcomeState.FAILED, outcome.getState()); - assertEquals(outcome.getDefaultMessage(), outcome.getMessage()); - assertEquals("request=42", outcome.getDetail()); - assertEquals(1, outcome.getIssues().size()); - assertTrue(outcome.isFailed()); - assertFalse(outcome.isSuccessful()); - assertFalse(outcome.isPending()); - assertTrue(outcome.isTerminal()); - assertFalse(outcome.toString().contains("request=42")); - assertThrows(UnsupportedOperationException.class, () -> outcome.getIssues().add(Issue.of("x"))); - } - - @Test - void successPendingAndValidationRulesAreEnforced() { - Outcome success = Outcome.of(StandardOutcomes.OK); - assertTrue(success.isSuccessful()); - assertTrue(success.isTerminal()); - assertNull(success.getDetail()); - - OutcomeDefinition pendingDefinition = OutcomeDefinition.custom( - "com.example.jobs", "JOB_PROCESSING", OutcomeState.PENDING, "Job processing." - ); - Outcome pending = Outcome.of(pendingDefinition, "job=7"); - assertTrue(pending.isPending()); - assertFalse(pending.isTerminal()); - - assertThrows( - IllegalArgumentException.class, - () -> Outcome.of(StandardOutcomes.OK, null, List.of(Issue.of("No."))) - ); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, " ")); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, "x".repeat(4097))); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, "bad\0detail")); - assertThrows(NullPointerException.class, () -> Outcome.of(null)); - assertThrows(NullPointerException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, null, null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java b/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java deleted file mode 100644 index 9a3f12c..0000000 --- a/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes; - -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -final class JavaInteropTest { - @Test - void createsAndMapsOutcomeFromJava() { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND, "customer 123", List.of()); - require(outcome.isFailed()); - require(StandardOutcomes.NOT_FOUND.getDefaultMessage().equals(outcome.getMessage())); - require(HttpStatusCode.NOT_FOUND.equals(HttpOutcomeMapper.standard().map(outcome).orNull())); - require(GrpcStatusCode.NOT_FOUND.equals(GrpcOutcomeMapper.standard().map(outcome).orNull())); - } - - @Test - void standardCollectionsCannotBeMutatedFromJava() { - expectUnsupported(() -> StandardOutcomes.all.clear()); - expectUnsupported(() -> OutcomeRegistry.standard().definitions().clear()); - expectUnsupported(() -> HttpOutcomeMapper.standard().mappings().clear()); - } - - @Test - void outcomeDefensivelyCopiesMutableJavaList() { - ArrayList source = new ArrayList<>(); - source.add(Issue.of("Bad.")); - Outcome outcome = Outcome.of(StandardOutcomes.INTERNAL, null, source); - source.clear(); - require(outcome.getIssues().size() == 1); - expectUnsupported(() -> outcome.getIssues().clear()); - } - - @Test - void customDefinitionCannotClaimLibraryNamespace() { - expectIllegalArgument(() -> OutcomeDefinition.custom( - StandardOutcomes.NAMESPACE, - "OK", - OutcomeState.FAILED, - "Forged." - )); - } - - @Test - void validationResultIsUsableFromJava() { - ValidationResult result = ValidationResult.combine(List.of( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")) - )); - require(result.isInvalid()); - require(result.issues().size() == 1); - require( - result.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getDefinition() - == StandardOutcomes.INVALID_ARGUMENT - ); - } - - @Test - void outcomeCodeRoundTripsFromJava() { - OutcomeCode code = OutcomeCode.of("com.example", "CODE"); - require(code.equals(OutcomeCode.parse(code.getValue()))); - require(OutcomeCode.parseOrNull("bad") == null); - } - - private static void require(boolean value) { - if (!value) throw new AssertionError("condition failed"); - } - - private static void expectUnsupported(Runnable action) { - try { - action.run(); - throw new AssertionError("expected UnsupportedOperationException"); - } catch (UnsupportedOperationException expected) { - } - } - - private static void expectIllegalArgument(Runnable action) { - try { - action.run(); - throw new AssertionError("expected IllegalArgumentException"); - } catch (IllegalArgumentException expected) { - } - } -} diff --git a/src/test/java/io/github/aalsanie/codes/MappingResultTest.java b/src/test/java/io/github/aalsanie/codes/MappingResultTest.java deleted file mode 100644 index 8626bf8..0000000 --- a/src/test/java/io/github/aalsanie/codes/MappingResultTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.aalsanie.codes; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class MappingResultTest { - @Test - void mappedCarriesValueAndFolds() { - MappingResult result = MappingResult.mapped("ok"); - assertTrue(result.isMapped()); - assertFalse(result.isUnmapped()); - assertEquals("ok", result.orNull()); - assertEquals(2, result.fold(String::length, () -> -1)); - assertEquals(MappingResult.mapped("ok"), result); - assertEquals(result.hashCode(), MappingResult.mapped("ok").hashCode()); - assertEquals("Mapped(value=ok)", result.toString()); - assertThrows(NullPointerException.class, () -> MappingResult.mapped(null)); - assertThrows(NullPointerException.class, () -> result.fold(null, () -> -1)); - assertThrows(NullPointerException.class, () -> result.fold(String::length, null)); - assertEquals(result, result); - assertNotEquals("ok", result); - assertNotEquals(result, MappingResult.mapped("different")); - } - - @Test - void unmappedIsSingletonLikeAndFolds() { - MappingResult result = MappingResult.unmapped(); - assertFalse(result.isMapped()); - assertTrue(result.isUnmapped()); - assertNull(result.orNull()); - assertEquals(-1, result.fold(String::length, () -> -1)); - assertEquals(MappingResult.unmapped(), result); - assertEquals(0, result.hashCode()); - assertEquals("Unmapped", result.toString()); - assertNotEquals("Unmapped", result); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java b/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java deleted file mode 100644 index 59c5982..0000000 --- a/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.aalsanie.codes; - -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class NullabilityContractTest { - - @Test - void publicPackagesAreNullMarked() { - assertTrue( - Outcome.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - assertTrue( - HttpStatusCode.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - assertTrue( - GrpcStatusCode.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - } - - @Test - void nullableReturnTypesRemainAnnotated() throws NoSuchMethodException { - assertNullableReturn( - Outcome.class.getMethod("getDetail") - ); - assertNullableReturn( - Issue.class.getMethod("getCode") - ); - assertNullableReturn( - Issue.class.getMethod("getPath") - ); - assertNullableReturn( - OutcomeCode.class.getMethod("parseOrNull", String.class) - ); - assertNullableReturn( - OutcomeRegistry.class.getMethod("find", OutcomeCode.class) - ); - assertNullableReturn( - OutcomeRegistry.class.getMethod("find", String.class) - ); - assertNullableReturn( - MappingResult.class.getMethod("orNull") - ); - assertNullableReturn( - GrpcStatusCode.class.getMethod("fromValue", int.class) - ); - } - - private static void assertNullableReturn(Method method) { - assertTrue( - method.getAnnotatedReturnType().isAnnotationPresent(Nullable.class), - () -> method + " must retain @Nullable on its return type" - ); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java b/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java deleted file mode 100644 index 96d9988..0000000 --- a/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class OutcomeCodeTest { - @Test - void createsAndParsesValidCodes() { - OutcomeCode code = OutcomeCode.of("com.example.payments", "PAYMENT_DECLINED"); - assertEquals("com.example.payments", code.getNamespace()); - assertEquals("PAYMENT_DECLINED", code.getName()); - assertEquals("com.example.payments:PAYMENT_DECLINED", code.getValue()); - assertEquals(code.getValue(), code.toString()); - assertEquals(code, OutcomeCode.parse(code.getValue())); - assertEquals(code.hashCode(), OutcomeCode.parse(code.getValue()).hashCode()); - } - - @Test - void rejectsInvalidNamespacesAndNames() { - assertThrows(NullPointerException.class, () -> OutcomeCode.of(null, "CODE")); - assertThrows(NullPointerException.class, () -> OutcomeCode.of("com.example", null)); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("a".repeat(129), "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com..example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("a".repeat(64), "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("1example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example-", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("Com.example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example_foo", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "A".repeat(65))); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "bad")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "BAD-NAME")); - } - - @Test - void parseRejectsMalformedValues() { - assertThrows(NullPointerException.class, () -> OutcomeCode.parse(null)); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com.example")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse(":CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com:example:CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com.example:")); - assertThrows( - NullPointerException.class, - () -> OutcomeCode.parseOrNull(null) - ); - assertNull(OutcomeCode.parseOrNull("bad")); - } - - @Test - void identityAndOrderingUseBothParts() { - OutcomeCode a = OutcomeCode.of("com.example", "A"); - OutcomeCode b = OutcomeCode.of("com.example", "B"); - OutcomeCode otherNamespace = OutcomeCode.of("org.example", "A"); - assertTrue(a.compareTo(b) < 0); - assertNotEquals(a, b); - assertNotEquals(a, otherNamespace); - assertNotEquals(a, "com.example:A"); - assertThrows(NullPointerException.class, () -> a.compareTo(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java b/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java deleted file mode 100644 index d756fd1..0000000 --- a/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class OutcomeDefinitionTest { - @Test - void createsCustomDefinition() { - OutcomeDefinition definition = OutcomeDefinition.custom( - "com.example.orders", - "ORDER_REJECTED", - OutcomeState.FAILED, - "Order rejected." - ); - assertEquals("com.example.orders:ORDER_REJECTED", definition.getCode().getValue()); - assertEquals(OutcomeState.FAILED, definition.getState()); - assertEquals("Order rejected.", definition.getDefaultMessage()); - assertTrue(definition.toString().contains("ORDER_REJECTED")); - } - - @Test - void customDefinitionRejectsReservedNamespaceAndBadMessages() { - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom(StandardOutcomes.NAMESPACE, "CUSTOM", OutcomeState.FAILED, "No.") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("io.github.aalsanie.codes.child", "CUSTOM", OutcomeState.FAILED, "No.") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, " ") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, "x".repeat(1025)) - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, "bad\0message") - ); - assertThrows(NullPointerException.class, () -> OutcomeDefinition.custom((OutcomeCode) null, OutcomeState.FAILED, "No.")); - assertThrows(NullPointerException.class, () -> OutcomeDefinition.custom(OutcomeCode.of("com.example", "X"), null, "No.")); - } - - @Test - void standardDefinitionIsSingleton() { - assertSame(StandardOutcomes.NOT_FOUND, OutcomeDefinition.standard("NOT_FOUND")); - assertThrows(IllegalStateException.class, () -> OutcomeDefinition.standard("NOPE")); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java new file mode 100644 index 0000000..962fcb2 --- /dev/null +++ b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java @@ -0,0 +1,93 @@ +package io.github.aalsanie.codes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import org.junit.jupiter.api.Test; + +class ProblemTypeTest { + + @Test + void createsReusableProblemType() { + ProblemType problem = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + assertEquals(URI.create("https://api.example.com/problems/order-not-found"), problem.getType()); + assertEquals(404, problem.getStatus()); + assertEquals("Order not found", problem.getTitle()); + } + + @Test + void supportsAbsoluteNonHttpUris() { + assertEquals( + URI.create("urn:example:problem:order-not-found"), + ProblemType.of( + URI.create("urn:example:problem:order-not-found"), + 404, + "Order not found" + ).getType() + ); + assertEquals( + URI.create("tag:example.com,2026:order-not-found"), + ProblemType.of( + URI.create("tag:example.com,2026:order-not-found"), + 404, + "Order not found" + ).getType() + ); + } + + @Test + void rejectsInvalidDefinitions() { + assertThrows(NullPointerException.class, () -> ProblemType.of(null, 404, "Missing")); + assertThrows( + NullPointerException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 404, null) + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("order-not-found"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("about:blank"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("ABOUT:blank"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 99, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 600, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 400, " ") + ); + } + + @Test + void hasValueSemantics() { + ProblemType first = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); + ProblemType second = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); + + assertEquals(first, first); + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, "not a problem type"); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:missing"), 409, "Missing")); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:other"), 404, "Missing")); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:missing"), 404, "Different")); + assertTrue(first.toString().contains("urn:problem:missing")); + } +} diff --git a/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java b/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java deleted file mode 100644 index 32f0f19..0000000 --- a/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.NoSuchElementException; -import org.junit.jupiter.api.Test; - -class RegistryMappingExceptionTest { - @Test - void registryIsImmutableAndKeyedByCode() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - OutcomeRegistry empty = OutcomeRegistry.empty(); - OutcomeRegistry registry = empty.with(custom); - - assertEquals(0, empty.getSize()); - assertEquals(1, registry.getSize()); - assertTrue(registry.contains(custom.getCode())); - assertSame(custom, registry.find(custom.getCode())); - assertSame(custom, registry.find(custom.getCode().getValue())); - assertNull(registry.find("invalid")); - assertSame(custom, registry.require(custom.getCode())); - assertEquals(List.of(custom), registry.definitions()); - assertThrows(UnsupportedOperationException.class, () -> registry.definitions().add(StandardOutcomes.OK)); - assertThrows(IllegalArgumentException.class, () -> registry.with(custom)); - assertThrows(NoSuchElementException.class, () -> registry.require(StandardOutcomes.NOT_FOUND.getCode())); - } - - @Test - void registryFactoriesComposeDefinitions() { - OutcomeDefinition first = OutcomeDefinition.custom("com.example", "A", OutcomeState.FAILED, "A."); - OutcomeDefinition second = OutcomeDefinition.custom("com.example", "B", OutcomeState.FAILED, "B."); - assertEquals(2, OutcomeRegistry.of(first, second).getSize()); - assertEquals(StandardOutcomes.all.size(), OutcomeRegistry.standard().getSize()); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.of((OutcomeDefinition[]) null)); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.empty().with(null)); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.empty().withAll(null)); - } - - @Test - void exceptionRequiresFailedOutcomeAndPreservesCause() { - Outcome failure = Outcome.of(StandardOutcomes.INTERNAL); - RuntimeException cause = new RuntimeException("cause"); - OutcomeException exception = new OutcomeException(failure, cause); - assertSame(failure, exception.getOutcome()); - assertSame(cause, exception.getCause()); - assertEquals(failure.getMessage(), exception.getMessage()); - assertSame(failure, OutcomeExceptions.toException(failure).getOutcome()); - assertSame(cause, OutcomeExceptions.toException(failure, cause).getCause()); - assertThrows(IllegalArgumentException.class, () -> new OutcomeException(Outcome.of(StandardOutcomes.OK))); - assertThrows(NullPointerException.class, () -> new OutcomeException(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java b/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java deleted file mode 100644 index 2e225f2..0000000 --- a/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Arrays; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.Map; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; - -class SemanticCompatibilityTest { - @Test - void standardOutcomeSemanticsMatchSnapshot() throws IOException { - String actual = StandardOutcomes.all.stream() - .sorted(Comparator.comparing(definition -> definition.getCode().getValue())) - .map(definition -> definition.getCode().getValue() + "|" + definition.getState().name()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("standard-outcomes.snapshot"), actual); - } - - @Test - void standardHttpMappingsMatchSnapshot() throws IOException { - String actual = HttpOutcomeMapper.standard().mappings().entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey().getValue() + "|" + entry.getValue().getValue()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("http-mappings.snapshot"), actual); - } - - @Test - void standardGrpcMappingsMatchSnapshot() throws IOException { - String actual = GrpcOutcomeMapper.standard().mappings().entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey().getValue() + "|" + entry.getValue().name()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("grpc-mappings.snapshot"), actual); - } - - @Test - void publicHttpStatusCodesMatchSnapshot() throws IOException { - String actual = Arrays.stream(HttpStatusCode.class.getFields()) - .filter(field -> Modifier.isStatic(field.getModifiers())) - .filter(field -> field.getType() == HttpStatusCode.class) - .sorted(Comparator.comparing(Field::getName)) - .map(field -> field.getName() + "|" + readHttpStatusValue(field)) - .collect(Collectors.joining("\n")); - - assertEquals(readSnapshot("http-status-codes.snapshot"), actual); - } - - @Test - void grpcStatusCodesMatchSnapshot() throws IOException { - String actual = Arrays.stream(GrpcStatusCode.values()) - .map(status -> status.name() + "|" + status.getValue()) - .collect(Collectors.joining("\n")); - - assertEquals(readSnapshot("grpc-status-codes.snapshot"), actual); - } - - @Test - void standardNamespaceMatchesSnapshot() throws IOException { - assertEquals( - StandardOutcomes.NAMESPACE, - readSnapshot("standard-namespace.snapshot") - ); - } - - private static String readSnapshot(String name) throws IOException { - return Files.readString(Path.of("compatibility", name)).replace("\r\n", "\n").stripTrailing(); - } - - private static int readHttpStatusValue(Field field) { - try { - return ((HttpStatusCode) field.get(null)).getValue(); - } catch (IllegalAccessException exception) { - throw new IllegalStateException(exception); - } - } -} diff --git a/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java b/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java deleted file mode 100644 index 063fba6..0000000 --- a/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.HashSet; -import org.junit.jupiter.api.Test; - -class StandardOutcomesTest { - @Test - void standardCatalogHasStableShape() { - assertEquals(17, StandardOutcomes.all.size()); - assertEquals(17, new HashSet<>(StandardOutcomes.all.stream().map(OutcomeDefinition::getCode).toList()).size()); - assertSame(StandardOutcomes.OK, StandardOutcomes.all.get(0)); - assertEquals(OutcomeState.SUCCEEDED, StandardOutcomes.OK.getState()); - assertTrue( - StandardOutcomes.all.stream() - .filter(definition -> definition != StandardOutcomes.OK) - .allMatch(definition -> definition.getState() == OutcomeState.FAILED) - ); - assertTrue(StandardOutcomes.all.stream().allMatch(definition -> definition.getCode().getNamespace().equals(StandardOutcomes.NAMESPACE))); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java b/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java deleted file mode 100644 index 2fac042..0000000 --- a/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -class ValidationResultTest { - @Test - void validIsSingletonAndConvertsToOk() { - ValidationResult valid = ValidationResult.valid(); - assertSame(valid, ValidationResult.valid()); - assertTrue(valid.isValid()); - assertFalse(valid.isInvalid()); - assertTrue(valid.issues().isEmpty()); - assertEquals("Valid", valid.toString()); - assertEquals(StandardOutcomes.OK, valid.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getDefinition()); - } - - @Test - void invalidCopiesIssuesAndConvertsToExplicitFailure() { - ArrayList issues = new ArrayList<>(); - issues.add(Issue.at("email", "Invalid.")); - ValidationResult result = ValidationResult.invalid(issues); - issues.clear(); - - assertTrue(result.isInvalid()); - assertEquals(1, result.issues().size()); - assertEquals(result, ValidationResult.invalid(Issue.at("email", "Invalid."))); - assertEquals(result.hashCode(), ValidationResult.invalid(Issue.at("email", "Invalid.")).hashCode()); - assertTrue(result.toString().contains("Invalid")); - - Outcome outcome = result.toOutcome(StandardOutcomes.INVALID_ARGUMENT, "request validation failed"); - assertEquals(StandardOutcomes.INVALID_ARGUMENT, outcome.getDefinition()); - assertEquals("request validation failed", outcome.getDetail()); - assertEquals(result.issues(), outcome.getIssues()); - } - - @Test - void combinesIndependentResults() { - ValidationResult result = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")), - ValidationResult.invalid(Issue.at("name", "Required.")) - ); - assertEquals(2, result.issues().size()); - assertTrue(ValidationResult.combine(List.of()).isValid()); - assertThrows(NullPointerException.class, () -> ValidationResult.combine((ValidationResult[]) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.combine((List) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.combine(List.of(ValidationResult.valid(), null))); - } - - @Test - void rejectsInvalidPoliciesAndEmptyInvalidResults() { - OutcomeDefinition success = OutcomeDefinition.custom( - "com.example", "CREATED", OutcomeState.SUCCEEDED, "Created." - ); - assertThrows(IllegalArgumentException.class, () -> ValidationResult.valid().toOutcome(success)); - assertThrows(IllegalArgumentException.class, () -> ValidationResult.invalid(List.of())); - assertThrows(NullPointerException.class, () -> ValidationResult.invalid((Issue) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.invalid((List) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.valid().toOutcome(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java b/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java deleted file mode 100644 index 85649e8..0000000 --- a/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; - -class GrpcOutcomeMapperTest { - @Test - void standardMapperCoversAllStandardOutcomes() { - GrpcOutcomeMapper mapper = GrpcOutcomeMapper.standard(); - assertEquals(StandardOutcomes.all.size(), mapper.getSize()); - assertTrue(StandardOutcomes.all.stream().allMatch(definition -> mapper.map(definition).isMapped())); - assertSame(GrpcStatusCode.NOT_FOUND, mapper.map(StandardOutcomes.NOT_FOUND).orNull()); - assertSame(GrpcStatusCode.RESOURCE_EXHAUSTED, mapper.map(StandardOutcomes.RATE_LIMITED).orNull()); - assertSame(GrpcStatusCode.RESOURCE_EXHAUSTED, mapper.map(StandardOutcomes.RESOURCE_EXHAUSTED).orNull()); - } - - @Test - void customMappingsAreImmutableAndExplicit() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - GrpcOutcomeMapper base = GrpcOutcomeMapper.standard(); - GrpcOutcomeMapper extended = base.withMapping(custom, GrpcStatusCode.FAILED_PRECONDITION); - assertFalse(base.contains(custom.getCode())); - assertTrue(extended.contains(custom.getCode())); - assertSame(GrpcStatusCode.FAILED_PRECONDITION, extended.map(custom).orNull()); - assertSame(GrpcStatusCode.UNKNOWN, base.withOverride(StandardOutcomes.INTERNAL, GrpcStatusCode.UNKNOWN).map(StandardOutcomes.INTERNAL).orNull()); - assertThrows(IllegalArgumentException.class, () -> base.withMapping(StandardOutcomes.OK, GrpcStatusCode.OK)); - assertThrows(IllegalArgumentException.class, () -> base.withOverride(custom, GrpcStatusCode.UNKNOWN)); - assertThrows(UnsupportedOperationException.class, () -> base.mappings().clear()); - } - - @Test - void statusCodesRoundTripByNumericValue() { - for (GrpcStatusCode code : GrpcStatusCode.values()) { - assertSame(code, GrpcStatusCode.fromValue(code.getValue())); - } - assertNull(GrpcStatusCode.fromValue(-1)); - assertNull(GrpcStatusCode.fromValue(17)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java b/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java deleted file mode 100644 index c1553b3..0000000 --- a/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class HttpOutcomeMapperTest { - @Test - void standardMappingsAreConservative() { - HttpOutcomeMapper mapper = HttpOutcomeMapper.standard(); - assertEquals(12, mapper.getSize()); - assertSame(HttpStatusCode.OK, mapper.map(StandardOutcomes.OK).orNull()); - assertSame(HttpStatusCode.BAD_REQUEST, mapper.map(StandardOutcomes.INVALID_ARGUMENT).orNull()); - assertSame(HttpStatusCode.UNAUTHORIZED, mapper.map(StandardOutcomes.UNAUTHENTICATED).orNull()); - assertSame(HttpStatusCode.FORBIDDEN, mapper.map(StandardOutcomes.PERMISSION_DENIED).orNull()); - assertSame(HttpStatusCode.NOT_FOUND, mapper.map(Outcome.of(StandardOutcomes.NOT_FOUND)).orNull()); - assertSame(HttpStatusCode.CONFLICT, mapper.map(StandardOutcomes.ALREADY_EXISTS).orNull()); - assertSame(HttpStatusCode.BAD_REQUEST, mapper.map(StandardOutcomes.OUT_OF_RANGE).orNull()); - assertSame(HttpStatusCode.TOO_MANY_REQUESTS, mapper.map(StandardOutcomes.RATE_LIMITED).orNull()); - assertSame(HttpStatusCode.NOT_IMPLEMENTED, mapper.map(StandardOutcomes.UNIMPLEMENTED).orNull()); - assertSame(HttpStatusCode.SERVICE_UNAVAILABLE, mapper.map(StandardOutcomes.UNAVAILABLE).orNull()); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, mapper.map(StandardOutcomes.INTERNAL).orNull()); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, mapper.map(StandardOutcomes.DATA_LOSS).orNull()); - assertTrue(mapper.map(StandardOutcomes.FAILED_PRECONDITION).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.CANCELLED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.DEADLINE_EXCEEDED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.ABORTED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.RESOURCE_EXHAUSTED).isUnmapped()); - assertSame(HttpStatusCode.OK, HttpStatusCode.of(200)); - assertSame(HttpStatusCode.CREATED, HttpStatusCode.of(201)); - assertSame(HttpStatusCode.ACCEPTED, HttpStatusCode.of(202)); - assertSame(HttpStatusCode.NO_CONTENT, HttpStatusCode.of(204)); - assertSame(HttpStatusCode.BAD_REQUEST, HttpStatusCode.of(400)); - assertSame(HttpStatusCode.UNAUTHORIZED, HttpStatusCode.of(401)); - assertSame(HttpStatusCode.FORBIDDEN, HttpStatusCode.of(403)); - assertSame(HttpStatusCode.NOT_FOUND, HttpStatusCode.of(404)); - assertSame(HttpStatusCode.CONFLICT, HttpStatusCode.of(409)); - assertSame(HttpStatusCode.PRECONDITION_FAILED, HttpStatusCode.of(412)); - assertSame(HttpStatusCode.PAYLOAD_TOO_LARGE, HttpStatusCode.of(413)); - assertSame(HttpStatusCode.TOO_MANY_REQUESTS, HttpStatusCode.of(429)); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, HttpStatusCode.of(500)); - assertSame(HttpStatusCode.NOT_IMPLEMENTED, HttpStatusCode.of(501)); - assertSame(HttpStatusCode.SERVICE_UNAVAILABLE, HttpStatusCode.of(503)); - assertSame(HttpStatusCode.GATEWAY_TIMEOUT, HttpStatusCode.of(504)); - assertFalse(HttpStatusCode.OK.isInformational()); - assertFalse(HttpStatusCode.BAD_REQUEST.isSuccessful()); - assertFalse(HttpStatusCode.OK.isRedirection()); - assertFalse(HttpStatusCode.OK.isClientError()); - assertFalse(HttpStatusCode.BAD_REQUEST.isServerError()); - assertEquals(HttpStatusCode.OK, HttpStatusCode.OK); - assertNotEquals(HttpStatusCode.OK, HttpStatusCode.CREATED); - assertEquals(404, HttpStatusCode.NOT_FOUND.hashCode()); - } - - @Test - void customMappingsAreImmutableAndExplicit() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example.payments", "PAYMENT_DECLINED", OutcomeState.FAILED, "Declined." - ); - HttpOutcomeMapper base = HttpOutcomeMapper.standard(); - HttpOutcomeMapper extended = base.withMapping(custom, HttpStatusCode.of(422)); - assertFalse(base.contains(custom.getCode())); - assertTrue(extended.contains(custom.getCode())); - assertEquals(422, extended.map(custom).orNull().getValue()); - assertEquals(HttpStatusCode.of(410), base.withOverride(StandardOutcomes.NOT_FOUND, HttpStatusCode.of(410)).map(StandardOutcomes.NOT_FOUND).orNull()); - assertThrows(IllegalArgumentException.class, () -> base.withMapping(StandardOutcomes.NOT_FOUND, HttpStatusCode.NOT_FOUND)); - assertThrows(IllegalArgumentException.class, () -> base.withOverride(custom, HttpStatusCode.BAD_REQUEST)); - assertThrows(UnsupportedOperationException.class, () -> base.mappings().clear()); - } - - @Test - void statusCodeValidatesAndClassifies() { - assertTrue(HttpStatusCode.of(199).isInformational()); - assertTrue(HttpStatusCode.OK.isSuccessful()); - assertTrue(HttpStatusCode.of(302).isRedirection()); - assertTrue(HttpStatusCode.BAD_REQUEST.isClientError()); - assertTrue(HttpStatusCode.INTERNAL_SERVER_ERROR.isServerError()); - assertEquals(4, HttpStatusCode.NOT_FOUND.getFamily()); - assertEquals("404", HttpStatusCode.NOT_FOUND.toString()); - assertEquals(HttpStatusCode.of(418), HttpStatusCode.of(418)); - assertTrue(HttpStatusCode.OK.compareTo(HttpStatusCode.BAD_REQUEST) < 0); - assertThrows(IllegalArgumentException.class, () -> HttpStatusCode.of(99)); - assertThrows(IllegalArgumentException.class, () -> HttpStatusCode.of(600)); - assertThrows(NullPointerException.class, () -> HttpStatusCode.OK.compareTo(null)); - } -}