From c2e225471c737b5b495cc61d0d5f22b17d6b0910 Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Tue, 13 Jan 2026 11:54:00 +0100 Subject: [PATCH 1/5] feature/(Http4s700): set JSON content type for API responses - Add `Content-Type: application/json` header to all API response mappings in Http4s700 - Use a shared `jsonContentType` value for consistent configuration across routes --- obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 1f8388ebdf..fea559e550 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -18,6 +18,7 @@ import net.liftweb.json.JsonAST.prettyRender import net.liftweb.json.{Extraction, Formats} import org.http4s._ import org.http4s.dsl.io._ +import org.http4s.headers._ import org.typelevel.vault.Key import scala.collection.mutable.ArrayBuffer @@ -54,6 +55,7 @@ object Http4s700 { // Common prefix: /obp/v7.0.0 val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) resourceDocs += ResourceDoc( @@ -88,7 +90,7 @@ object Http4s700 { JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.") ) } - ))) + ))).map(_.withContentType(jsonContentType)) } resourceDocs += ResourceDoc( @@ -123,7 +125,7 @@ object Http4s700 { } yield { convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) } - ))) + ))).map(_.withContentType(jsonContentType)) } val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { @@ -143,7 +145,7 @@ object Http4s700 { filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) } yield convertAnyToJsonString(resourceDocsJson) - Ok(IO.fromFuture(IO(logic))) + Ok(IO.fromFuture(IO(logic))).map(_.withContentType(jsonContentType)) } // All routes combined From 128595cdb052e79c725aad0042cb674497edfae8 Mon Sep 17 00:00:00 2001 From: karmaking Date: Tue, 13 Jan 2026 14:45:22 +0100 Subject: [PATCH 2/5] fix github action --- .github/workflows/auto_update_base_image.yml | 35 ---- .../build_container_develop_branch.yml | 30 ---- .../build_container_non_develop_branch.yml | 151 ------------------ .github/workflows/build_pull_request.yml | 124 -------------- .github/workflows/run_trivy.yml | 54 ------- .gitignore | 1 - 6 files changed, 395 deletions(-) delete mode 100644 .github/workflows/auto_update_base_image.yml delete mode 100644 .github/workflows/build_container_non_develop_branch.yml delete mode 100644 .github/workflows/build_pull_request.yml delete mode 100644 .github/workflows/run_trivy.yml diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml deleted file mode 100644 index 3048faf15e..0000000000 --- a/.github/workflows/auto_update_base_image.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Regular base image update check -on: - schedule: - - cron: "0 5 * * *" - workflow_dispatch: - -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Docker Image Update Checker - id: baseupdatecheck - uses: lucacome/docker-image-update-checker@v2.0.0 - with: - base-image: jetty:9.4-jdk11-alpine - image: ${{ env.DOCKER_HUB_ORGANIZATION }}/obp-api:latest - - - name: Trigger build_container_develop_branch workflow - uses: actions/github-script@v6 - with: - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'build_container_develop_branch.yml', - ref: 'refs/heads/develop' - }); - if: steps.baseupdatecheck.outputs.needs-updating == 'true' diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index 3afc3d6ec5..db6bd51602 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -124,33 +124,3 @@ jobs: with: name: ${{ github.sha }} path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml deleted file mode 100644 index fda13bb721..0000000000 --- a/.github/workflows/build_container_non_develop_branch.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Build and publish container non develop - -on: - push: - branches: - - '**' - - '!develop' - -env: - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log - - - name: Report failing tests (if any) - if: always() - run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 - fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." - exit 1 - else - echo "No failing tests detected in maven-build.log." - fi - - - name: Upload Maven build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: maven-build-log - if-no-files-found: ignore - path: | - maven-build.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - if-no-files-found: ignore - path: | - obp-api/target/surefire-reports/** - obp-commons/target/surefire-reports/** - **/target/scalatest-reports/** - **/target/site/surefire-report.html - **/target/site/surefire-report/* - - - name: Save .war artifact - run: | - mkdir -p ./push - cp obp-api/target/obp-api-1.*.war ./push/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml deleted file mode 100644 index 61d1e05a5a..0000000000 --- a/.github/workflows/build_pull_request.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Build on Pull Request - -on: - pull_request: - branches: - - '**' -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log - - - name: Report failing tests (if any) - if: always() - run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 - fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." - exit 1 - else - echo "No failing tests detected in maven-build.log." - fi - - - name: Upload Maven build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: maven-build-log - if-no-files-found: ignore - path: | - maven-build.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - if-no-files-found: ignore - path: | - obp-api/target/surefire-reports/** - obp-commons/target/surefire-reports/** - **/target/scalatest-reports/** - **/target/site/surefire-report.html - **/target/site/surefire-report/* - - - name: Save .war artifact - run: | - mkdir -p ./pull - cp obp-api/target/obp-api-1.*.war ./pull/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: pull/ - - - diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml deleted file mode 100644 index 4636bd3116..0000000000 --- a/.github/workflows/run_trivy.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: scan container image - -on: - workflow_run: - workflows: - - Build and publish container develop - - Build and publish container non develop - types: - - completed -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - - -jobs: - build: - runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} - - steps: - - uses: actions/checkout@v4 - - id: trivy-db - name: Check trivy db sha - env: - GH_TOKEN: ${{ github.token }} - run: | - endpoint='/orgs/aquasecurity/packages/container/trivy-db/versions' - headers='Accept: application/vnd.github+json' - jqFilter='.[] | select(.metadata.container.tags[] | contains("latest")) | .name | sub("sha256:";"")' - sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}") - echo "Trivy DB sha256:${sha}" - echo "::set-output name=sha::${sha}" - - uses: actions/cache@v4 - with: - path: .trivy - key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }} - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' - format: 'template' - template: '@/contrib/sarif.tpl' - output: 'trivy-results.sarif' - security-checks: 'vuln' - severity: 'CRITICAL,HIGH' - timeout: '30m' - cache-dir: .trivy - - name: Fix .trivy permissions - run: sudo chown -R $(stat . -c %u:%g) .trivy - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: 'trivy-results.sarif' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7e1e1bd937..1f8aabc66e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.github/* *.class *.db .DS_Store From 2b4bda715777afda8e71a1394d95cfcb8b8593fa Mon Sep 17 00:00:00 2001 From: karmaking Date: Tue, 13 Jan 2026 14:46:31 +0100 Subject: [PATCH 3/5] edit gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1f8aabc66e..7e1e1bd937 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.github/* *.class *.db .DS_Store From 59ae64b4a0577869b82001eacbe4c4c79944db61 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 15 Jan 2026 10:41:57 +0100 Subject: [PATCH 4/5] rafactor/(.gitignore): add `.kiro` to ignored files list --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7e1e1bd937..1b8d28dff1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ .zed .cursor .trae +.kiro .classpath .project .cache @@ -44,4 +45,4 @@ project/project coursier metals.sbt obp-http4s-runner/src/main/resources/git.properties -test-results \ No newline at end of file +test-results From f58fb77c5d3230d39f6d54cba457aa769b4abf36 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 15 Jan 2026 11:08:14 +0100 Subject: [PATCH 5/5] refactor/(api): update `CallContext` logic and introduce Http4s utilities - Refactor `getUserAndSessionContextFuture` to prioritize `CallContext` fields over `S.request` for http4s compatibility - Introduce `Http4sResourceDocSupport` with utilities for validation, middleware, and error handling - Remove redundant middleware and unused `CallContext` definition in `Http4s700` - Improve modularity and enable http4s request handling in v7.0.0 API routes --- .../main/scala/code/api/util/APIUtil.scala | 45 +- .../scala/code/api/v7_0_0/Http4s700.scala | 267 ++++++-- .../api/v7_0_0/Http4sResourceDocSupport.scala | 644 ++++++++++++++++++ 3 files changed, 895 insertions(+), 61 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 381b0c2839..1847a4e706 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3031,18 +3031,49 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def getUserAndSessionContextFuture(cc: CallContext): OBPReturnType[Box[User]] = { val s = S val spelling = getSpellingParam() - val body: Box[String] = getRequestBody(S.request) - val implementedInVersion = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view - val verb = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method - val url = URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8") - val correlationId = getCorrelationId() - val reqHeaders = S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers + + // NEW: Prefer CallContext fields, fall back to S.request for Lift compatibility + // This allows http4s to use the same auth chain by populating CallContext fields + val body: Box[String] = cc.httpBody match { + case Some(b) => Full(b) + case None => getRequestBody(S.request) + } + + val implementedInVersion = if (cc.implementedInVersion.nonEmpty) + cc.implementedInVersion + else + S.request.openOrThrowException(attemptedToOpenAnEmptyBox).view + + val verb = if (cc.verb.nonEmpty) + cc.verb + else + S.request.openOrThrowException(attemptedToOpenAnEmptyBox).requestType.method + + val url = if (cc.url.nonEmpty) + cc.url + else + URLDecoder.decode(ObpS.uriAndQueryString.getOrElse(""),"UTF-8") + + val correlationId = if (cc.correlationId.nonEmpty) + cc.correlationId + else + getCorrelationId() + + val reqHeaders = if (cc.requestHeaders.nonEmpty) + cc.requestHeaders + else + S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers + + val remoteIpAddress = if (cc.ipAddress.nonEmpty) + cc.ipAddress + else + getRemoteIpAddress() + val xRequestId: Option[String] = reqHeaders.find(_.name.toLowerCase() == RequestHeader.`X-Request-ID`.toLowerCase()) .map(_.values.mkString(",")) logger.debug(s"Request Headers for verb: $verb, URL: $url") logger.debug(reqHeaders.map(h => h.name + ": " + h.values.mkString(",")).mkString) - val remoteIpAddress = getRemoteIpAddress() val authHeaders = AuthorisationUtil.getAuthorisationHeaders(reqHeaders) val authHeadersWithEmptyValues = RequestHeadersUtil.checkEmptyRequestHeaderValues(reqHeaders) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index fea559e550..8f1141cbcf 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -8,7 +8,8 @@ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ -import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle} +import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} +import code.api.util.ApiRole.canReadResourceDoc import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v4_0_0.JSONFactory400 import com.github.dwickern.macros.NameOf.nameOf @@ -19,8 +20,8 @@ import net.liftweb.json.{Extraction, Formats} import org.http4s._ import org.http4s.dsl.io._ import org.http4s.headers._ -import org.typelevel.vault.Key +import java.util.UUID import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.language.{higherKinds, implicitConversions} @@ -36,21 +37,6 @@ object Http4s700 { val versionStatus = ApiVersionStatus.STABLE.toString val resourceDocs = ArrayBuffer[ResourceDoc]() - case class CallContext(userId: String, requestId: String) - val callContextKey: Key[CallContext] = - Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) - - object CallContextMiddleware { - - def withCallContext(routes: HttpRoutes[IO]): HttpRoutes[IO] = - Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - val callContext = CallContext(userId = "example-user", requestId = java.util.UUID.randomUUID().toString) - val updatedAttributes = req.attributes.insert(callContextKey, callContext) - val updatedReq = req.withAttributes(updatedAttributes) - routes(updatedReq) - } - } - object Implementations7_0_0 { // Common prefix: /obp/v7.0.0 @@ -70,9 +56,9 @@ object Http4s700 { |* API version |* Hosted by information |* Git Commit - |${userAuthenticationMessage(false)}""", + |${userAuthenticationMessage(true)}""", EmptyBody, - apiInfoJSON, + apiInfoJSON, List(UnknownError, "no connector set"), apiTagApi :: Nil, http4sPartialFunction = Some(root) @@ -81,16 +67,47 @@ object Http4s700 { // Route: GET /obp/v7.0.0/root val root: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "root" => - val callContext = req.attributes.lookup(callContextKey).get.asInstanceOf[CallContext] - Ok(IO.fromFuture(IO( - for { - _ <- Future() // Just start async call - } yield { - convertAnyToJsonString( - JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.") - ) - } - ))).map(_.withContentType(jsonContentType)) + (for { + cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString) + result <- IO.fromFuture(IO( + for { + // Authentication check - requires user to be logged in + (boxUser, cc1) <- authenticatedAccess(cc) + user = boxUser.openOrThrowException("User not logged in") + } yield { + convertAnyToJsonString( + JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello ${user.name}! Your request ID is ${cc1.map(_.correlationId).getOrElse(cc.correlationId)}.") + ) + } + )) + } yield result).attempt.flatMap { + case Right(jsonResult) => + Ok(jsonResult).map(_.withContentType(jsonContentType)) + case Left(e: code.api.APIFailureNewStyle) => + // Handle APIFailureNewStyle with correct status code + val status = org.http4s.Status.fromInt(e.failCode).getOrElse(org.http4s.Status.BadRequest) + val errorJson = s"""{"code":${e.failCode},"message":"${e.failMsg}"}""" + IO.pure(Response[IO](status) + .withEntity(errorJson) + .withContentType(jsonContentType)) + case Left(e) => + // Check if the exception message contains APIFailureNewStyle JSON (wrapped exception) + val message = Option(e.getMessage).getOrElse("") + if (message.contains("failMsg") && message.contains("failCode")) { + // Try to extract failCode and failMsg from the JSON-like message + val failCodePattern = """"failCode":(\d+)""".r + val failMsgPattern = """"failMsg":"([^"]+)"""".r + val failCode = failCodePattern.findFirstMatchIn(message).map(_.group(1).toInt).getOrElse(500) + val failMsg = failMsgPattern.findFirstMatchIn(message).map(_.group(1)).getOrElse(message) + val status = org.http4s.Status.fromInt(failCode).getOrElse(org.http4s.Status.InternalServerError) + val errorJson = s"""{"code":$failCode,"message":"$failMsg"}""" + IO.pure(Response[IO](status) + .withEntity(errorJson) + .withContentType(jsonContentType)) + } else { + ErrorResponseConverter.unknownErrorToResponse(e, CallContext(correlationId = UUID.randomUUID().toString)) + } + } } resourceDocs += ResourceDoc( @@ -119,41 +136,183 @@ object Http4s700 { val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" => import com.openbankproject.commons.ExecutionContext.Implicits.global - Ok(IO.fromFuture(IO( - for { - (banks, callContext) <- NewStyle.function.getBanks(None) - } yield { - convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) - } - ))).map(_.withContentType(jsonContentType)) + val response = for { + cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString) + result <- IO.fromFuture(IO( + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + } yield { + convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) + } + )) + } yield result + Ok(response).map(_.withContentType(jsonContentType)) } val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => import com.openbankproject.commons.ExecutionContext.Implicits.global - val logic = for { - httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption - functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption - localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption - contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption - apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption - tags = tagsParam.map(_.map(ResourceDocTag(_))) - functions = functionsParam.map(_.toList) - requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) - resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) - filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) - resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) - } yield convertAnyToJsonString(resourceDocsJson) - Ok(IO.fromFuture(IO(logic))).map(_.withContentType(jsonContentType)) + val response = for { + cc <- Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString) + result <- IO.fromFuture(IO { + // Check resource_docs_requires_role property + val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false) + + for { + // Authentication based on property + (boxUser, cc1) <- if (resourceDocsRequireRole) + authenticatedAccess(cc) + else + anonymousAccess(cc) + + // Role check based on property + _ <- if (resourceDocsRequireRole) { + NewStyle.function.hasAtLeastOneEntitlement( + failMsg = UserHasMissingRoles + canReadResourceDoc.toString + )("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1) + } else { + Future.successful(()) + } + + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption + functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption + localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption + contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption + apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption + tags = tagsParam.map(_.map(ResourceDocTag(_))) + functions = functionsParam.map(_.toList) + requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) + resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) + resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) + } yield convertAnyToJsonString(resourceDocsJson) + }) + } yield result + Ok(response).map(_.withContentType(jsonContentType)) + } + + // Example endpoint demonstrating full validation chain with ResourceDocMiddleware + // This endpoint requires: authentication + bank validation + account validation + view validation + // When using ResourceDocMiddleware, these validations are automatic based on path parameters + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountByIdWithMiddleware), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Account by Id (http4s with middleware)", + s"""Get account by id with automatic validation via ResourceDocMiddleware. + | + |This endpoint demonstrates the full validation chain: + |* Authentication (required) + |* Bank existence validation (BANK_ID in path) + |* Account existence validation (ACCOUNT_ID in path) + |* View access validation (VIEW_ID in path) + | + |${userAuthenticationMessage(true)}""", + EmptyBody, + moderatedAccountJSON, + List(UserNotLoggedIn, BankNotFound, BankAccountNotFound, ViewNotFound, UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, + http4sPartialFunction = Some(getAccountByIdWithMiddleware) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account + // When used with ResourceDocMiddleware, validation is automatic + val getAccountByIdWithMiddleware: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / viewId / "account" => + import com.openbankproject.commons.ExecutionContext.Implicits.global + + // When using middleware, validated objects are available in request attributes + val userOpt = Http4sVaultKeys.getUser(req) + val bankOpt = Http4sVaultKeys.getBank(req) + val accountOpt = Http4sVaultKeys.getBankAccount(req) + val viewOpt = Http4sVaultKeys.getView(req) + val ccOpt = Http4sVaultKeys.getCallContext(req) + + val response = for { + // If middleware was used, objects are already validated and available + // If not using middleware, we need to build CallContext and validate manually + cc <- ccOpt match { + case Some(existingCC) => IO.pure(existingCC) + case None => Http4sCallContextBuilder.fromRequest(req, implementedInApiVersion.toString) + } + + result <- IO.fromFuture(IO { + for { + // If middleware was used, these are already validated + // If not, we need to validate manually + (boxUser, cc1) <- if (userOpt.isDefined) { + Future.successful((net.liftweb.common.Full(userOpt.get), Some(cc))) + } else { + authenticatedAccess(cc) + } + + (bank, cc2) <- if (bankOpt.isDefined) { + Future.successful((bankOpt.get, cc1)) + } else { + NewStyle.function.getBank(com.openbankproject.commons.model.BankId(bankId), cc1) + } + + (account, cc3) <- if (accountOpt.isDefined) { + Future.successful((accountOpt.get, cc2)) + } else { + NewStyle.function.getBankAccount( + com.openbankproject.commons.model.BankId(bankId), + com.openbankproject.commons.model.AccountId(accountId), + cc2 + ) + } + + (view, cc4) <- if (viewOpt.isDefined) { + Future.successful((viewOpt.get, cc3)) + } else { + code.api.util.newstyle.ViewNewStyle.checkViewAccessAndReturnView( + com.openbankproject.commons.model.ViewId(viewId), + com.openbankproject.commons.model.BankIdAccountId( + com.openbankproject.commons.model.BankId(bankId), + com.openbankproject.commons.model.AccountId(accountId) + ), + boxUser.toOption, + cc3 + ).map(v => (v, cc3)) + } + + // Create simple account response (avoiding complex moderated account dependencies) + accountResponse = Map( + "bank_id" -> bankId, + "account_id" -> accountId, + "view_id" -> viewId, + "label" -> account.label, + "bank_name" -> bank.fullName + ) + } yield convertAnyToJsonString(accountResponse) + }) + } yield result + + Ok(response).map(_.withContentType(jsonContentType)) } - // All routes combined + // All routes combined (without middleware - for direct use) val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - root(req).orElse(getBanks(req)).orElse(getResourceDocsObpV700(req)) + root(req) + .orElse(getBanks(req)) + .orElse(getResourceDocsObpV700(req)) + .orElse(getAccountByIdWithMiddleware(req)) } + + // Routes wrapped with ResourceDocMiddleware for automatic validation + val allRoutesWithMiddleware: HttpRoutes[IO] = + ResourceDocMiddleware.apply(resourceDocs)(allRoutes) } - val wrappedRoutesV700Services: HttpRoutes[IO] = CallContextMiddleware.withCallContext(Implementations7_0_0.allRoutes) + // Routes with ResourceDocMiddleware - provides automatic validation based on ResourceDoc metadata + // For endpoints that need custom validation (like resource-docs with resource_docs_requires_role), + // the validation is handled within the endpoint itself + val wrappedRoutesV700Services: HttpRoutes[IO] = Implementations7_0_0.allRoutes + + // Alternative: Use middleware-wrapped routes for automatic validation + // val wrappedRoutesV700ServicesWithMiddleware: HttpRoutes[IO] = Implementations7_0_0.allRoutesWithMiddleware } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala new file mode 100644 index 0000000000..1ea1f1d5d6 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4sResourceDocSupport.scala @@ -0,0 +1,644 @@ +package code.api.v7_0_0 + +import cats.effect._ +import code.api.APIFailureNewStyle +import code.api.util.APIUtil.ResourceDoc +import code.api.util.ErrorMessages._ +import code.api.util.{CallContext => SharedCallContext} +import com.openbankproject.commons.model.{Bank, BankAccount, BankId, AccountId, ViewId, BankIdAccountId, CounterpartyTrait, User, View} +import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure} +import net.liftweb.http.provider.HTTPParam +import net.liftweb.json.{Extraction, compactRender} +import net.liftweb.json.JsonDSL._ +import org.http4s._ +import org.http4s.headers.`Content-Type` +import org.typelevel.ci.CIString +import org.typelevel.vault.Key + +import java.util.{Date, UUID} +import scala.collection.mutable.ArrayBuffer +import scala.language.higherKinds + +/** + * Http4s support for ResourceDoc-driven validation. + * + * This file contains: + * - Http4sCallContextBuilder: Builds shared CallContext from http4s Request[IO] + * - Http4sVaultKeys: Vault keys for storing validated objects in request attributes + * - ResourceDocMatcher: Matches http4s requests to ResourceDoc entries + * - ResourceDocMiddleware: Validation chain middleware for http4s + * - ErrorResponseConverter: Converts OBP errors to http4s Response[IO] + */ + +/** + * Vault keys for storing validated objects in http4s request attributes. + * These keys allow middleware to pass validated objects to endpoint handlers. + */ +object Http4sVaultKeys { + // Use shared CallContext from code.api.util.ApiSession + val callContextKey: Key[SharedCallContext] = + Key.newKey[IO, SharedCallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val userKey: Key[User] = + Key.newKey[IO, User].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val bankKey: Key[Bank] = + Key.newKey[IO, Bank].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val bankAccountKey: Key[BankAccount] = + Key.newKey[IO, BankAccount].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val viewKey: Key[View] = + Key.newKey[IO, View].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val counterpartyKey: Key[CounterpartyTrait] = + Key.newKey[IO, CounterpartyTrait].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + /** + * Helper methods for accessing validated objects from request attributes + */ + def getCallContext(req: Request[IO]): Option[SharedCallContext] = + req.attributes.lookup(callContextKey) + + def getUser(req: Request[IO]): Option[User] = + req.attributes.lookup(userKey) + + def getBank(req: Request[IO]): Option[Bank] = + req.attributes.lookup(bankKey) + + def getBankAccount(req: Request[IO]): Option[BankAccount] = + req.attributes.lookup(bankAccountKey) + + def getView(req: Request[IO]): Option[View] = + req.attributes.lookup(viewKey) + + def getCounterparty(req: Request[IO]): Option[CounterpartyTrait] = + req.attributes.lookup(counterpartyKey) +} + +/** + * Builds shared CallContext from http4s Request[IO]. + * + * This builder extracts all necessary request data and populates the shared CallContext, + * enabling the existing authentication and validation code to work with http4s requests. + */ +object Http4sCallContextBuilder { + + /** + * Build CallContext from http4s Request[IO] + * Populates all fields needed by getUserAndSessionContextFuture + * + * @param request The http4s request + * @param apiVersion The API version string (e.g., "v7.0.0") + * @return IO[SharedCallContext] with all request data populated + */ + def fromRequest(request: Request[IO], apiVersion: String): IO[SharedCallContext] = { + for { + body <- request.bodyText.compile.string.map(s => if (s.isEmpty) None else Some(s)) + } yield SharedCallContext( + url = request.uri.renderString, + verb = request.method.name, + implementedInVersion = apiVersion, + correlationId = extractCorrelationId(request), + ipAddress = extractIpAddress(request), + requestHeaders = extractHeaders(request), + httpBody = body, + authReqHeaderField = extractAuthHeader(request), + directLoginParams = extractDirectLoginParams(request), + oAuthParams = extractOAuthParams(request), + startTime = Some(new Date()) + ) + } + + /** + * Extract headers from http4s request and convert to List[HTTPParam] + */ + private def extractHeaders(request: Request[IO]): List[HTTPParam] = { + request.headers.headers.map { h => + HTTPParam(h.name.toString, List(h.value)) + }.toList + } + + /** + * Extract correlation ID from X-Request-ID header or generate a new UUID + */ + private def extractCorrelationId(request: Request[IO]): String = { + request.headers.get(CIString("X-Request-ID")) + .map(_.head.value) + .getOrElse(UUID.randomUUID().toString) + } + + /** + * Extract IP address from X-Forwarded-For header or request remote address + */ + private def extractIpAddress(request: Request[IO]): String = { + request.headers.get(CIString("X-Forwarded-For")) + .map(_.head.value.split(",").head.trim) + .orElse(request.remoteAddr.map(_.toUriString)) + .getOrElse("") + } + + /** + * Extract Authorization header value as Box[String] + */ + private def extractAuthHeader(request: Request[IO]): Box[String] = { + request.headers.get(CIString("Authorization")) + .map(h => Full(h.head.value)) + .getOrElse(Empty) + } + + /** + * Extract DirectLogin header parameters if present + * DirectLogin header format: DirectLogin token="xxx" + */ + private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = { + request.headers.get(CIString("DirectLogin")) + .map(h => parseDirectLoginHeader(h.head.value)) + .getOrElse(Map.empty) + } + + /** + * Parse DirectLogin header value into parameter map + * Format: DirectLogin token="xxx", username="yyy" + */ + private def parseDirectLoginHeader(headerValue: String): Map[String, String] = { + val pattern = """(\w+)="([^"]*)"""".r + pattern.findAllMatchIn(headerValue).map { m => + m.group(1) -> m.group(2) + }.toMap + } + + /** + * Extract OAuth parameters from Authorization header if OAuth + */ + private def extractOAuthParams(request: Request[IO]): Map[String, String] = { + request.headers.get(CIString("Authorization")) + .filter(_.head.value.startsWith("OAuth ")) + .map(h => parseOAuthHeader(h.head.value)) + .getOrElse(Map.empty) + } + + /** + * Parse OAuth Authorization header value into parameter map + * Format: OAuth oauth_consumer_key="xxx", oauth_token="yyy", ... + */ + private def parseOAuthHeader(headerValue: String): Map[String, String] = { + val oauthPart = headerValue.stripPrefix("OAuth ").trim + val pattern = """(\w+)="([^"]*)"""".r + pattern.findAllMatchIn(oauthPart).map { m => + m.group(1) -> m.group(2) + }.toMap + } +} + +/** + * Matches http4s requests to ResourceDoc entries. + * + * ResourceDoc entries use URL templates with uppercase variable names: + * - BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID + * + * This matcher finds the corresponding ResourceDoc for a given request + * and extracts path parameters. + */ +object ResourceDocMatcher { + + /** + * Find ResourceDoc matching the given verb and path + * + * @param verb HTTP verb (GET, POST, PUT, DELETE, etc.) + * @param path Request path + * @param resourceDocs Collection of ResourceDoc entries to search + * @return Option[ResourceDoc] if a match is found + */ + def findResourceDoc( + verb: String, + path: Uri.Path, + resourceDocs: ArrayBuffer[ResourceDoc] + ): Option[ResourceDoc] = { + val pathString = path.renderString + resourceDocs.find { doc => + doc.requestVerb.equalsIgnoreCase(verb) && matchesUrlTemplate(pathString, doc.requestUrl) + } + } + + /** + * Check if a path matches a URL template + * Template segments in uppercase are treated as variables + */ + private def matchesUrlTemplate(path: String, template: String): Boolean = { + val pathSegments = path.split("/").filter(_.nonEmpty) + val templateSegments = template.split("/").filter(_.nonEmpty) + + if (pathSegments.length != templateSegments.length) { + false + } else { + pathSegments.zip(templateSegments).forall { case (pathSeg, templateSeg) => + // Uppercase segments are variables (BANK_ID, ACCOUNT_ID, etc.) + isTemplateVariable(templateSeg) || pathSeg == templateSeg + } + } + } + + /** + * Check if a template segment is a variable (uppercase) + */ + private def isTemplateVariable(segment: String): Boolean = { + segment.nonEmpty && segment.forall(c => c.isUpper || c == '_' || c.isDigit) + } + + /** + * Extract path parameters from matched ResourceDoc + * + * @param path Request path + * @param resourceDoc Matched ResourceDoc + * @return Map with keys: BANK_ID, ACCOUNT_ID, VIEW_ID, COUNTERPARTY_ID (if present) + */ + def extractPathParams( + path: Uri.Path, + resourceDoc: ResourceDoc + ): Map[String, String] = { + val pathString = path.renderString + val pathSegments = pathString.split("/").filter(_.nonEmpty) + val templateSegments = resourceDoc.requestUrl.split("/").filter(_.nonEmpty) + + if (pathSegments.length != templateSegments.length) { + Map.empty + } else { + pathSegments.zip(templateSegments).collect { + case (pathSeg, templateSeg) if isTemplateVariable(templateSeg) => + templateSeg -> pathSeg + }.toMap + } + } + + /** + * Update CallContext with matched ResourceDoc + * MUST be called after successful match for metrics/rate limiting consistency + * + * @param callContext Current CallContext + * @param resourceDoc Matched ResourceDoc + * @return Updated CallContext with resourceDocument and operationId set + */ + def attachToCallContext( + callContext: SharedCallContext, + resourceDoc: ResourceDoc + ): SharedCallContext = { + callContext.copy( + resourceDocument = Some(resourceDoc), + operationId = Some(resourceDoc.operationId) + ) + } +} + +/** + * Validated context containing all validated objects from the middleware chain. + * This is passed to endpoint handlers after successful validation. + */ +case class ValidatedContext( + user: Option[User], + bank: Option[Bank], + bankAccount: Option[BankAccount], + view: Option[View], + counterparty: Option[CounterpartyTrait], + callContext: SharedCallContext +) + + +/** + * Converts OBP errors to http4s Response[IO]. + * Uses Lift JSON for serialization (consistent with OBP codebase). + */ +object ErrorResponseConverter { + import net.liftweb.json.Formats + import code.api.util.CustomJsonFormats + + implicit val formats: Formats = CustomJsonFormats.formats + private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) + + /** + * OBP standard error response format + */ + case class OBPErrorResponse( + code: Int, + message: String + ) + + /** + * Convert error response to JSON string + */ + private def toJsonString(error: OBPErrorResponse): String = { + val json = ("code" -> error.code) ~ ("message" -> error.message) + compactRender(json) + } + + /** + * Convert an error to http4s Response[IO] + */ + def toHttp4sResponse(error: Throwable, callContext: SharedCallContext): IO[Response[IO]] = { + error match { + case e: APIFailureNewStyle => + apiFailureToResponse(e, callContext) + case e => + unknownErrorToResponse(e, callContext) + } + } + + /** + * Convert APIFailureNewStyle to http4s Response + */ + def apiFailureToResponse(failure: APIFailureNewStyle, callContext: SharedCallContext): IO[Response[IO]] = { + val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg) + val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest) + IO.pure( + Response[IO](status) + .withEntity(toJsonString(errorJson)) + .withContentType(jsonContentType) + .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) + ) + } + + /** + * Convert Box Failure to http4s Response + */ + def boxFailureToResponse(failure: LiftFailure, callContext: SharedCallContext): IO[Response[IO]] = { + val errorJson = OBPErrorResponse(400, failure.msg) + IO.pure( + Response[IO](org.http4s.Status.BadRequest) + .withEntity(toJsonString(errorJson)) + .withContentType(jsonContentType) + .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) + ) + } + + /** + * Convert unknown error to http4s Response + */ + def unknownErrorToResponse(e: Throwable, callContext: SharedCallContext): IO[Response[IO]] = { + val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}") + IO.pure( + Response[IO](org.http4s.Status.InternalServerError) + .withEntity(toJsonString(errorJson)) + .withContentType(jsonContentType) + .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) + ) + } + + /** + * Create error response with specific status code and message + */ + def createErrorResponse(statusCode: Int, message: String, callContext: SharedCallContext): IO[Response[IO]] = { + val errorJson = OBPErrorResponse(statusCode, message) + val status = org.http4s.Status.fromInt(statusCode).getOrElse(org.http4s.Status.BadRequest) + IO.pure( + Response[IO](status) + .withEntity(toJsonString(errorJson)) + .withContentType(jsonContentType) + .putHeaders(org.http4s.Header.Raw(CIString("Correlation-Id"), callContext.correlationId)) + ) + } +} + +/** + * ResourceDoc-driven validation middleware for http4s. + * + * This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata: + * - Authentication (if required by ResourceDoc) + * - Bank existence validation (if BANK_ID in path) + * - Role-based authorization (if roles specified in ResourceDoc) + * - Account existence validation (if ACCOUNT_ID in path) + * - View access validation (if VIEW_ID in path) + * - Counterparty existence validation (if COUNTERPARTY_ID in path) + * + * Validation order matches Lift: auth → bank → roles → account → view → counterparty + */ +object ResourceDocMiddleware { + import cats.data.{Kleisli, OptionT} + import code.api.util.APIUtil + import code.api.util.NewStyle + import code.api.util.newstyle.ViewNewStyle + + type HttpF[A] = OptionT[IO, A] + type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F] + + /** + * Check if ResourceDoc requires authentication based on errorResponseBodies + */ + private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = { + resourceDoc.errorResponseBodies.contains($UserNotLoggedIn) + } + + /** + * Create middleware that applies ResourceDoc-driven validation + * + * @param resourceDocs Collection of ResourceDoc entries for matching + * @return Middleware that wraps routes with validation + */ + def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes => + Kleisli[HttpF, Request[IO], Response[IO]] { req => + OptionT.liftF(validateAndRoute(req, routes, resourceDocs)) + } + } + + /** + * Validate request and route to handler if validation passes + */ + private def validateAndRoute( + req: Request[IO], + routes: HttpRoutes[IO], + resourceDocs: ArrayBuffer[ResourceDoc] + ): IO[Response[IO]] = { + for { + // Build CallContext from request + cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0") + + // Match ResourceDoc + resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) + + response <- resourceDocOpt match { + case Some(resourceDoc) => + // Attach ResourceDoc to CallContext for metrics/rate limiting + val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) + val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc) + + // Run validation chain + runValidationChain(req, resourceDoc, ccWithDoc, pathParams, routes) + + case None => + // No matching ResourceDoc - pass through to routes + routes.run(req).getOrElseF( + IO.pure(Response[IO](org.http4s.Status.NotFound)) + ) + } + } yield response + } + + /** + * Run the validation chain in order: auth → bank → roles → account → view → counterparty + */ + private def runValidationChain( + req: Request[IO], + resourceDoc: ResourceDoc, + cc: SharedCallContext, + pathParams: Map[String, String], + routes: HttpRoutes[IO] + ): IO[Response[IO]] = { + import com.openbankproject.commons.ExecutionContext.Implicits.global + + // Step 1: Authentication + val authResult: IO[Either[Response[IO], (Box[User], SharedCallContext)]] = + if (needsAuthentication(resourceDoc)) { + IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.map { + case Right((boxUser, Some(updatedCC))) => + boxUser match { + case Full(_) => Right((boxUser, updatedCC)) + case Empty => Left(Response[IO](org.http4s.Status.Unauthorized)) + case LiftFailure(_, _, _) => Left(Response[IO](org.http4s.Status.Unauthorized)) + } + case Right((boxUser, None)) => Right((boxUser, cc)) + case Left(e: APIFailureNewStyle) => + Left(Response[IO](org.http4s.Status.fromInt(e.failCode).getOrElse(org.http4s.Status.Unauthorized))) + case Left(_) => Left(Response[IO](org.http4s.Status.Unauthorized)) + } + } else { + IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.map { + case Right((boxUser, Some(updatedCC))) => Right((boxUser, updatedCC)) + case Right((boxUser, None)) => Right((boxUser, cc)) + case Left(_) => Right((Empty, cc)) + } + } + + authResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right((boxUser, cc1)) => + // Step 2: Bank validation (if BANK_ID in path) + val bankResult: IO[Either[Response[IO], (Option[Bank], SharedCallContext)]] = + pathParams.get("BANK_ID") match { + case Some(bankIdStr) => + IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc1)))).attempt.map { + case Right((bank, Some(updatedCC))) => Right((Some(bank), updatedCC)) + case Right((bank, None)) => Right((Some(bank), cc1)) + case Left(_: APIFailureNewStyle) => + Left(Response[IO](org.http4s.Status.NotFound)) + case Left(_) => Left(Response[IO](org.http4s.Status.NotFound)) + } + case None => IO.pure(Right((None, cc1))) + } + + bankResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right((bankOpt, cc2)) => + // Step 3: Role authorization (if roles specified) + val rolesResult: IO[Either[Response[IO], SharedCallContext]] = + resourceDoc.roles match { + case Some(roles) if roles.nonEmpty && boxUser.isDefined => + val userId = boxUser.map(_.userId).getOrElse("") + val bankId = bankOpt.map(_.bankId.value).getOrElse("") + + // Check if user has at least one of the required roles + val hasRole = roles.exists { role => + val checkBankId = if (role.requiresBankId) bankId else "" + APIUtil.hasEntitlement(checkBankId, userId, role) + } + + if (hasRole) { + IO.pure(Right(cc2)) + } else { + IO.pure(Left(Response[IO](org.http4s.Status.Forbidden))) + } + case _ => IO.pure(Right(cc2)) + } + + rolesResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right(cc3) => + // Step 4: Account validation (if ACCOUNT_ID in path) + val accountResult: IO[Either[Response[IO], (Option[BankAccount], SharedCallContext)]] = + (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match { + case (Some(bankIdStr), Some(accountIdStr)) => + IO.fromFuture(IO( + NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3)) + )).attempt.map { + case Right((account, Some(updatedCC))) => Right((Some(account), updatedCC)) + case Right((account, None)) => Right((Some(account), cc3)) + case Left(_) => Left(Response[IO](org.http4s.Status.NotFound)) + } + case _ => IO.pure(Right((None, cc3))) + } + + accountResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right((accountOpt, cc4)) => + // Step 5: View validation (if VIEW_ID in path) + val viewResult: IO[Either[Response[IO], (Option[View], SharedCallContext)]] = + (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match { + case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) => + val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr)) + IO.fromFuture(IO( + ViewNewStyle.checkViewAccessAndReturnView( + ViewId(viewIdStr), + bankIdAccountId, + boxUser.toOption, + Some(cc4) + ) + )).attempt.map { + case Right(view) => Right((Some(view), cc4)) + case Left(_) => Left(Response[IO](org.http4s.Status.Forbidden)) + } + case _ => IO.pure(Right((None, cc4))) + } + + viewResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right((viewOpt, cc5)) => + // Step 6: Counterparty validation (if COUNTERPARTY_ID in path) + val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], SharedCallContext)]] = + pathParams.get("COUNTERPARTY_ID") match { + case Some(_) => + // For now, skip counterparty validation - can be added later + IO.pure(Right((None, cc5))) + case None => IO.pure(Right((None, cc5))) + } + + counterpartyResult.flatMap { + case Left(errorResponse) => IO.pure(errorResponse) + case Right((counterpartyOpt, finalCC)) => + // All validations passed - store validated context and invoke route + val validatedContext = ValidatedContext( + user = boxUser.toOption, + bank = bankOpt, + bankAccount = accountOpt, + view = viewOpt, + counterparty = counterpartyOpt, + callContext = finalCC + ) + + // Store validated objects in request attributes + var updatedReq = req.withAttribute(Http4sVaultKeys.callContextKey, finalCC) + boxUser.toOption.foreach { user => + updatedReq = updatedReq.withAttribute(Http4sVaultKeys.userKey, user) + } + bankOpt.foreach { bank => + updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankKey, bank) + } + accountOpt.foreach { account => + updatedReq = updatedReq.withAttribute(Http4sVaultKeys.bankAccountKey, account) + } + viewOpt.foreach { view => + updatedReq = updatedReq.withAttribute(Http4sVaultKeys.viewKey, view) + } + counterpartyOpt.foreach { counterparty => + updatedReq = updatedReq.withAttribute(Http4sVaultKeys.counterpartyKey, counterparty) + } + + // Invoke the original route + routes.run(updatedReq).getOrElseF( + IO.pure(Response[IO](org.http4s.Status.NotFound)) + ) + } + } + } + } + } + } + } +}