diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..5960381 --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,39 @@ +name: Maven Build +description: "Builds a Maven project." + +inputs: + java-version: + description: "The Java version the build shall run with." + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + mutation-testing: + description: "Whether to run mutation testing." + default: 'true' + required: false + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Piper Maven build + uses: SAP/project-piper-action@main + with: + step-name: mavenBuild + + #- name: Mutation Testing + # if: ${{ inputs.mutation-testing == 'true' }} + # run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-advanced-event-mesh/pom.xml -ntp -B + # shell: bash diff --git a/.github/actions/deploy-release/action.yml b/.github/actions/deploy-release/action.yml new file mode 100644 index 0000000..fa48dee --- /dev/null +++ b/.github/actions/deploy-release/action.yml @@ -0,0 +1,94 @@ +name: Deploy Release to Maven Central +description: "Deploys released artifacts to Maven Central repository." + +inputs: + user: + description: "The user used for the upload (technical user for maven central upload)" + required: true + password: + description: "The password used for the upload (technical user for maven central upload)" + required: true + profile: + description: "The profile id" + required: true + pgp-pub-key: + description: "The public pgp key ID" + required: true + pgp-private-key: + description: "The private pgp key" + required: true + pgp-passphrase: + description: "The passphrase for pgp" + required: true + revision: + description: "The revision of cds-feature-advanced-event-mesh" + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Echo Inputs + run: | + echo "user: ${{ inputs.user }}" + echo "profile: ${{ inputs.profile }}" + echo "revision: ${{ inputs.revision }}" + shell: bash + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: sapmachine + java-version: '17' + cache: maven + server-id: ossrh + server-username: MAVEN_CENTRAL_USER + server-password: MAVEN_CENTRAL_PASSWORD + + - name: Set up Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Import GPG Key + run: | + echo "${{ inputs.pgp-private-key }}" | gpg --batch --passphrase "$PASSPHRASE" --import + shell: bash + env: + PASSPHRASE: ${{ inputs.pgp-passphrase }} + + - name: Deploy Locally + run: > + mvn -B -ntp -fae --show-version + -Durl=file:./temp_local_repo + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -Dgpg.passphrase="$GPG_PASSPHRASE" + -Dgpg.keyname="$GPG_PUB_KEY" + -Drevision="${{ inputs.revision }}" + deploy + working-directory: ./deploy-oss + shell: bash + env: + MAVEN_CENTRAL_USER: ${{ inputs.user }} + MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} + GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} + GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} + + - name: Deploy Staging + run: > + mvn -B -ntp -fae --show-version + org.sonatype.plugins:nexus-staging-maven-plugin:1.6.13:deploy-staged-repository + -DserverId=ossrh + -DnexusUrl=https://oss.sonatype.org + -DrepositoryDirectory=./temp_local_repo + -DstagingProfileId="$MAVEN_CENTRAL_PROFILE_ID" + -Drevision="${{ inputs.revision }}" + working-directory: ./deploy-oss + shell: bash + env: + MAVEN_CENTRAL_USER: ${{ inputs.user }} + MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} + MAVEN_CENTRAL_PROFILE_ID: ${{ inputs.profile }} diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 0000000..fd3e1cd --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,62 @@ +name: Deploy to artifactory +description: "Deploys artifacts to artifactory." + +inputs: + repository-url: + description: "The URL of the repository to upload to." + required: true + server-id: + description: "The service id of the repository to upload to." + required: true + user: + description: "The user used for the upload." + required: true + password: + description: "The password used for the upload." + required: true + pom-file: + description: "The path to the POM file." + required: false + default: "pom.xml" + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Echo Inputs + run: | + echo "repository-url: ${{ inputs.repository-url }}" + echo "user: ${{ inputs.user }}" + echo "password: ${{ inputs.password }}" + echo "pom-file: ${{ inputs.pom-file }}" + echo "altDeploymentRepository: ${{inputs.server-id}}::${{inputs.repository-url}}" + shell: bash + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: sapmachine + java-version: '17' + server-id: ${{ inputs.server-id }} + server-username: DEPLOYMENT_USER + server-password: DEPLOYMENT_PASS + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Deploy + run: > + mvn -B -ntp -fae --show-version + -DaltDeploymentRepository=${{inputs.server-id}}::${{inputs.repository-url}} + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -f ${{ inputs.pom-file }} + deploy + env: + DEPLOYMENT_USER: ${{ inputs.user }} + DEPLOYMENT_PASS: ${{ inputs.password }} + shell: bash diff --git a/.github/actions/newrelease/action.yml b/.github/actions/newrelease/action.yml new file mode 100644 index 0000000..c7b7113 --- /dev/null +++ b/.github/actions/newrelease/action.yml @@ -0,0 +1,36 @@ +name: Update POM with new release +description: Updates the revision property in the POM file with the new release version. + +inputs: + java-version: + description: "The Java version the build shall run with." + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Update version + run: | + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + mvn --no-transfer-progress versions:set-property -Dproperty=revision -DnewVersion=$VERSION + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b main + git commit -am "Update version to $VERSION" + git push --set-upstream origin main + shell: bash \ No newline at end of file diff --git a/.github/actions/scan-with-blackduck/action.yaml b/.github/actions/scan-with-blackduck/action.yaml new file mode 100644 index 0000000..76be691 --- /dev/null +++ b/.github/actions/scan-with-blackduck/action.yaml @@ -0,0 +1,54 @@ +name: "Scan with BlackDuck" +description: "Scans the project with BlackDuck" + +inputs: + blackduck_token: + description: "The token to use for BlackDuck authentication" + required: true + github_token: + description: "The token to use for GitHub authentication" + required: true + java-version: + description: "The version of Java to use" + default: '17' + required: false + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Get Major Version + id: get-major-version + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Version Number + run: echo "${{ steps.get-major-version.outputs.REVISION }}" + shell: bash + + - name: BlackDuck Scan + uses: SAP/project-piper-action@main + with: + step-name: detectExecuteScan + flags: \ + --githubToken=$GITHUB_token \ + --version=${{ steps.get-major-version.outputs.REVISION }} + env: + PIPER_token: ${{ inputs.blackduck_token }} + GITHUB_token: ${{ inputs.github_token }} + SCAN_MODE: FULL diff --git a/.github/actions/scan-with-sonar/action.yaml b/.github/actions/scan-with-sonar/action.yaml new file mode 100644 index 0000000..34522cf --- /dev/null +++ b/.github/actions/scan-with-sonar/action.yaml @@ -0,0 +1,48 @@ +name: Scan with SonarQube +description: Scans the project with SonarQube + +inputs: + sonarq-token: + description: The token to use for SonarQube authentication + required: true + github-token: + description: The token to use for GitHub authentication + required: true + java-version: + description: The version of Java to use + required: true + maven-version: + description: The version of Maven to use + required: true + +runs: + using: composite + + steps: + - name: Set up Java ${{inputs.java-version}} + uses: actions/setup-java@v4 + with: + java-version: ${{inputs.java-version}} + distribution: sapmachine + cache: maven + + - name: Set up Maven ${{inputs.maven-version}} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{inputs.maven-version}} + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Revision + run: echo "${{steps.get-revision.outputs.REVISION}}" + shell: bash + + - name: SonarQube Scan + uses: SAP/project-piper-action@main + with: + step-name: sonarExecuteScan + flags: --token=${{inputs.sonarq-token}} --githubToken=${{inputs.github-token}} --version=${{steps.get-revision.outputs.REVISION}} --inferJavaBinaries=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..50390b2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: maven + directories: + - "/**/*" + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4337ef6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,92 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '24 18 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: none # This mode only analyzes Java. Set this to 'autobuild' or 'manual' to analyze Kotlin too. + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main-build-and-deploy-oss.yml b/.github/workflows/main-build-and-deploy-oss.yml new file mode 100644 index 0000000..c7d91e1 --- /dev/null +++ b/.github/workflows/main-build-and-deploy-oss.yml @@ -0,0 +1,104 @@ +name: Deploy to OSS + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + +on: + release: + types: [ "released" ] + +jobs: + + # blackduck: + # name: "Blackduck Scan" + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: "Scan With Black Duck" + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + update-version: + runs-on: ubuntu-latest + # needs: blackduck + steps: + - name: Checkout + uses: actions/checkout@v4 + #with: + # token: ${{ secrets.GH_TOKEN }} + + #- name: Update version + # uses: ./.github/actions/newrelease + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-new-version + path: . + include-hidden-files: true + retention-days: 1 + + build: + runs-on: ubuntu-latest + needs: update-version + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-new-version + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-build + include-hidden-files: true + path: . + retention-days: 1 + + deploy: + name: Deploy to Maven Central + runs-on: ubuntu-latest + needs: build + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-build + + - name: Deploy + uses: ./.github/actions/deploy-release + with: + user: ${{ secrets.OSSRH_SONATYPE_ORG_API_USER }} + password: ${{ secrets.OSSRH_SONATYPE_ORG_API_PASSWD }} + profile: ${{ secrets.OSSRH_SONATYPE_ORG_PROFILE_ID }} + pgp-pub-key: ${{ secrets.PGP_PUBKEY_ID }} + pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + revision: ${{ github.event.release.tag_name }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Echo Status + run: echo "The job status is ${{ job.status }}" diff --git a/.github/workflows/main-build-and-deploy.yml b/.github/workflows/main-build-and-deploy.yml new file mode 100644 index 0000000..5f10872 --- /dev/null +++ b/.github/workflows/main-build-and-deploy.yml @@ -0,0 +1,96 @@ +name: Deploy to Artifactory + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + DEPLOY_REPOSITORY_URL: 'https://common.repositories.cloud.sap/artifactory/cap-java' + POM_FILE: '.flattened-pom.xml' + +on: + release: + types: [ "prereleased" ] + +jobs: + + # blackduck: + # name: Blackduck Scan + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: Scan With Black Duck + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + update-version: + runs-on: ubuntu-latest + # needs: blackduck + steps: + - name: Checkout + uses: actions/checkout@v4 + #with: + # token: ${{ secrets.GH_TOKEN }} + + #- name: Update version + # uses: ./.github/actions/newrelease + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-new-version + include-hidden-files: true + path: . + retention-days: 1 + + build: + runs-on: ubuntu-latest + needs: update-version + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-new-version + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-build + include-hidden-files: true + path: . + retention-days: 1 + + deploy: + name: Deploy to Artifactory + runs-on: ubuntu-latest + needs: build + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-build + + - name: Deploy with Maven + uses: ./.github/actions/deploy + with: + user: ${{ secrets.DEPLOYMENT_USER }} + password: ${{ secrets.DEPLOYMENT_PASS }} + server-id: artifactory + repository-url: ${{ env.DEPLOY_REPOSITORY_URL }} + pom-file: ${{ env.POM_FILE }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Echo Status + run: echo "The job status is ${{ job.status }}" diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml new file mode 100644 index 0000000..c4c8162 --- /dev/null +++ b/.github/workflows/main-build.yml @@ -0,0 +1,96 @@ +name: Main build and deploy + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + +on: + push: + branches: [ "main" ] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17, 21 ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # if: ${{ matrix.java-version == 17 }} + # with: + # java-version: ${{ matrix.java-version }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} + + # scan: + # name: Blackduck Scan + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: Scan + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + deploy-snapshot: + name: Deploy snapshot to Artifactory + runs-on: ubuntu-latest + needs: [build] # [build, scan] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: sapmachine + cache: maven + server-id: artifactory + server-username: DEPLOYMENT_USER + server-password: DEPLOYMENT_PASS + + - name: Set up Maven ${{ env.MAVEN_VERSION }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Revision + run: echo "Current revision ${{ steps.get-revision.outputs.REVISION }}" + shell: bash + + - name: Deploy snapshot + if: ${{ endsWith(steps.get-revision.outputs.REVISION, '-SNAPSHOT') }} + # https://maven.apache.org/plugins/maven-deploy-plugin/usage.html#the-deploy-deploy-mojo + run: > + mvn -B -ntp -fae + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -DdeployAtEnd=true + deploy + env: + DEPLOYMENT_USER: ${{ secrets.DEPLOYMENT_USER }} + DEPLOYMENT_PASS: ${{ secrets.DEPLOYMENT_PASS }} + shell: bash diff --git a/.github/workflows/pull-request-build.yml b/.github/workflows/pull-request-build.yml new file mode 100644 index 0000000..21f8beb --- /dev/null +++ b/.github/workflows/pull-request-build.yml @@ -0,0 +1,36 @@ +name: Pull Request Voter + +env: + MAVEN_VERSION: '3.6.3' + +on: + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [ 17, 21 ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # if: ${{ matrix.java-version == 17 }} + # with: + # java-version: ${{ matrix.java-version }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e27d89b --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +## IntelliJ +.idea +*.iml + +## Eclipse +.project +.classpath +.settings/ + +## Java +target/ + +.flattened-pom.xml +node_modules + +## PMD +.pmd +.pmdruleset.xml + +## files required for local execution of github actions with act +.env +.secrets +event.json + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk diff --git a/.pipeline/config.yml b/.pipeline/config.yml new file mode 100644 index 0000000..4a2325d --- /dev/null +++ b/.pipeline/config.yml @@ -0,0 +1,40 @@ +steps: + mavenBuild: + verbose: false + verify: true + flatten: true + # https://www.project-piper.io/steps/mavenBuild/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + + detectExecuteScan: + projectName: 'com.sap.cds.feature.advanced-event-mesh' + groups: + - 'CDSJAVA-OPEN-SOURCE' + serverUrl: 'https://sap.blackducksoftware.com/' + mavenExcludedScopes: [ "provided", "test" ] + failOn: [ 'BLOCKER', 'CRITICAL', 'MAJOR' ] + versioningModel: "major-minor" + detectTools: [ 'DETECTOR', 'BINARY_SCAN' ] + installArtifacts: true + repository: '/cap-java/advanced-event-mesh' + verbose: true + scanProperties: + - --detect.included.detector.types=MAVEN + - --detect.excluded.directories='**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar' + - --detect.maven.build.command='-pl com.sap.cds:cds-feature-advanced-event-mesh' + # https://www.project-piper.io/steps/detectExecuteScan/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + + sonarExecuteScan: + serverUrl: https://sonar.tools.sap + projectKey: cds-feature-advanced-event-mesh + # https://www.project-piper.io/steps/sonarExecuteScan/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + options: + - sonar.qualitygate.wait=true + - sonar.java.source=17 + - sonar.exclusions=**/node_modules/**,**/target/** + - sonar.coverage.jacoco.xmlReportPaths=cds-feature-advanced-event-mesh/target/site/jacoco/jacoco.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06f5c56 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Version 0.1.0 - tbd + +### Added + +- Initial release of the plugin. + +### Changed + + +### Deprecated + + +### Removed + + +### Fixed + + +### Security + + diff --git a/README.md b/README.md index 5906896..9dae2a2 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,32 @@ CDS plugin providing integration with SAP Integration Suite, advanced event mesh ## Requirements and Setup -*Insert a short description what is required to get your project running...* +See [Getting Started](https://cap.cloud.sap/docs/get-started/in-a-nutshell?impl-variant=java) on how to jumpstart your development and grow as you go with SAP Cloud Application Programming Model. + +### SAP Integration Suite, Advanced Event Mesh + +For details on how to use SAP Integration Suite, advanced event mesh, please see the [SAP Integration Suite, Advanced Event Mesh Service Guide](https://help.sap.com/docs/sap-integration-suite/sap-integration-suite-advanced-event-mesh-728c56cd25854f0fad611eb26ae17152/what-is-sap-integration-suite-advanced-event-mesh?state=DRAFT). + +### CDS Plugin + +The usage of CAP Java plugins is described in the [CAP Java Documentation](https://cap.cloud.sap/docs/java/building-plugins#reference-the-new-cds-model-in-an-existing-cap-java-project). Following this documentation this plugin needs to be referenced in the `srv/pom.xml` of a CAP Java project: + +```xml + + com.sap.cds + cds-feature-advanced-event-mesh + ${latest-version} + +``` + +The latest version can be found in the [changelog](./CHANGELOG.md) or in the [Maven Central Repository](https://central.sonatype.com/artifact/com.sap.cds/cds-feature-advanced-event-mesh/versions). ## Support, Feedback, Contributing This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-java/cds-feature-advanced-event-mesh/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). ## Security / Disclosure + If you find any bug that may be a security problem, please follow our instructions at [in our security policy](https://github.com/cap-java/cds-feature-advanced-event-mesh/security/policy) on how to report it. Please do not create GitHub issues for security-related doubts or problems. ## Code of Conduct diff --git a/cds-feature-advanced-event-mesh/maven/settings.xml b/cds-feature-advanced-event-mesh/maven/settings.xml new file mode 100644 index 0000000..30417a0 --- /dev/null +++ b/cds-feature-advanced-event-mesh/maven/settings.xml @@ -0,0 +1,9 @@ + + + + artifactory + ${env.SERVER_USER} + ${env.SERVER_PASSWORD} + + + \ No newline at end of file diff --git a/cds-feature-advanced-event-mesh/pom.xml b/cds-feature-advanced-event-mesh/pom.xml new file mode 100644 index 0000000..8858c76 --- /dev/null +++ b/cds-feature-advanced-event-mesh/pom.xml @@ -0,0 +1,280 @@ + + 4.0.0 + + + com.sap.cds + cds-feature-advanced-event-mesh-root + ${revision} + + + cds-feature-advanced-event-mesh + jar + + CDS plugin for SAP Integration Suite, advanced event mesh + https://cap.cloud.sap/docs/plugins/#advanced-event-mesh-plugin + + + + + com.sap.cds + cds-services-messaging + + + + com.sap.cds + cds-services-utils + + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient4 + + + + org.apache.qpid + qpid-jms-client + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + org.slf4j + slf4j-api + + + + com.fasterxml.jackson.core + jackson-databind + + + + + com.sap.cds + cds-services-impl + test + + + + org.junit.jupiter + junit-jupiter + 5.12.0 + test + + + + org.slf4j + slf4j-reload4j + 2.0.17 + test + + + + com.jayway.jsonpath + json-path + 2.9.0 + test + + + + org.mock-server + mockserver-netty + test + 5.15.0 + + + com.google.guava + guava + + + commons-io + commons-io + + + + + + + ${project.artifactId} + + + + org.pitest + pitest-maven + + + com.sap.cds.feature.messaging.aem.** + + + CONSTRUCTOR_CALLS + VOID_METHOD_CALLS + NON_VOID_METHOD_CALLS + REMOVE_CONDITIONALS_ORDER_ELSE + CONDITIONALS_BOUNDARY + EMPTY_RETURNS + NEGATE_CONDITIONALS + REMOVE_CONDITIONALS_EQUAL_IF + REMOVE_CONDITIONALS_EQUAL_ELSE + REMOVE_CONDITIONALS_ORDER_IF + REMOVE_CONDITIONALS_ORDER_ELSE + + 95 + 90 + + + + + org.pitest + pitest-junit5-plugin + 1.2.2 + + + + + + maven-clean-plugin + + + + src/test/resources + + schema.sql + + + + src/test/resources/cds + + csn.json + + + + src/test/resources/gen + + **/* + + false + + + ./ + + .flattened-pom.xml + + + + + + + auto-clean + clean + + clean + + + + + + + org.jacoco + jacoco-maven-plugin + + + + ${excluded.generation.package}**/* + + + + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report-all-tests + verify + + report + + + + jacoco-site-report-only-unit-tests + test + + report + + + + jacoco-check-unit-tests-only + test + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.3 + + + BRANCH + COVEREDRATIO + 0.2 + + + COMPLEXITY + COVEREDRATIO + 0.3 + + + CLASS + MISSEDCOUNT + 4 + + + + + + + + + + + maven-javadoc-plugin + + ${skipDuringDeploy} + true + all,-missing + + + + + jar + + + + + + + maven-source-plugin + + + + jar + + + + + + + + + diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemManagementClient.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemManagementClient.java new file mode 100644 index 0000000..8567c72 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemManagementClient.java @@ -0,0 +1,139 @@ +package com.sap.cds.feature.messaging.aem.client; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.sap.cds.feature.messaging.aem.client.binding.AemEndpointView; +import com.sap.cds.services.ServiceException; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; + +public class AemManagementClient extends RestClient { + private static final Logger logger = LoggerFactory.getLogger(AemManagementClient.class); + + private static final String API_BASE = "/SEMP/v2/config/msgVpns/%s"; + private static final String API_QUEUE = API_BASE + "/queues"; + private static final String API_QUEUE_NAME = API_BASE + "/queues/%s"; + private static final String API_QUEUE_NAME_SUBSCRIPTION = API_BASE + "/queues/%s/subscriptions"; + + private static final String ATTR_EGRESS_ENABLED = "egressEnabled"; + private static final String ATTR_INGRESS_ENABLED = "ingressEnabled"; + public static final String ATTR_DEAD_MSG_QUEUE = "deadMsgQueue"; + private static final String ATTR_PERMISSION = "permission"; + private static final String ATTR_QUEUE_NAME = "queueName"; + private static final String ATTR_SUBSCRIPTION_TOPIC = "subscriptionTopic"; + private static final String VAL_CONSUME = "consume"; + + private final AemEndpointView endpointView; + private final String vpn; + + public AemManagementClient(ServiceBinding binding) { + super(ServiceBindingDestinationOptions.forService(binding).build()); + this.endpointView = new AemEndpointView(binding); + this.vpn = getVpn(); + } + + public String getEndpoint() { + return this.endpointView.getUri().orElseThrow(() -> new ServiceException("Management endpoint not available in binding")); + } + + public void removeQueue(String queue) throws IOException { + logger.debug("Removing queue {}", queue); + + deleteRequest(uri(API_QUEUE_NAME, this.vpn, queue)); + + logger.debug("Successfully removed queue {}", queue); + } + + public JsonNode getQueue(String name) throws IOException { + try { + logger.debug("Retrieving information for queue {}", name); + + JsonNode result = getRequest(uri(API_QUEUE_NAME, this.vpn, name)); + + logger.debug("Successfully retrieved information for queue {}: {}", name, result.asText()); + + return result; + } catch (Exception e) { + logger.error("Failed to retrieve information for queue {}", name, e); + return null; + } + } + + public void createQueue(String name, Map properties) throws IOException { + // We have to read the queue first to check if it exists; only create it if it doesn't + logger.debug("Checking if queue {} exists", name); + JsonNode queue = getQueue(name); + + if (queue == null) { + logger.debug("Queue {} does not exist, creating it", name); + + Map attributes = new HashMap<>(properties); + attributes.put(ATTR_QUEUE_NAME, name); + attributes.put(ATTR_PERMISSION, VAL_CONSUME); + attributes.put(ATTR_INGRESS_ENABLED, true); + attributes.put(ATTR_EGRESS_ENABLED, true); + + postRequest(uri(API_QUEUE, this.vpn), attributes); + } + } + + public JsonNode getQueueSubscription(String queue) throws IOException { + logger.debug("Retrieving information for queue subscription {}", queue); + JsonNode result = getRequest(uri(API_QUEUE_NAME_SUBSCRIPTION, this.vpn, queue)); + + return result; + } + + public void createQueueSubscription(String queue, String topic) throws IOException { + logger.debug("Checking if queue {} is subscribed to topic {}", queue, topic); + + if (!isTopicSubscribed(getQueueSubscription(queue), queue, topic)) { + logger.debug("Queue {} is not subscribed to topic {}, subscribing it", queue, topic); + + Map attributes = Map.of(ATTR_SUBSCRIPTION_TOPIC, topic); + postRequest(uri(API_QUEUE_NAME_SUBSCRIPTION, this.vpn, queue), attributes); + } + } + + private String uri(String path, Object... args) { + return String.format( + path, + (Object[]) Arrays.stream(args).map(Object::toString).map(this::urlEncode).toArray(String[]::new)); + } + + private String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + public boolean isTopicSubscribed(JsonNode jsonNode, String queueName, String topic) { + String rawTopic = topic.replace("topic://", ""); + if (jsonNode.has("data")) { + for (JsonNode dataNode : jsonNode.get("data")) { + if (dataNode.has("msgVpnName") && dataNode.has("queueName") && dataNode.has("subscriptionTopic")) { + String nodeMsgVpnName = dataNode.get("msgVpnName").asText(); + String nodeQueueName = dataNode.get("queueName").asText(); + String nodeSubscriptionTopic = dataNode.get("subscriptionTopic").asText(); + + if (this.getVpn().equals(nodeMsgVpnName) && queueName.equals(nodeQueueName) && rawTopic.equals(nodeSubscriptionTopic)) { + return true; + } + } + } + } + return false; + } + + private String getVpn() { + return this.endpointView.getVpn().get(); + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemValidationClient.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemValidationClient.java new file mode 100644 index 0000000..65c282e --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/AemValidationClient.java @@ -0,0 +1,25 @@ +package com.sap.cds.feature.messaging.aem.client; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; + +public class AemValidationClient extends RestClient { + + public AemValidationClient(ServiceBinding binding) { + super(ServiceBindingDestinationOptions.forService(binding).build()); + } + + public void validate(String managementUri) throws IOException, URISyntaxException { + URI uri = new URI(managementUri); + String payload = this.mapper.writeValueAsString(Map.of("hostName", uri.getHost())); + + // The response is not used, only the status code is relevant. If there is a status code not equal to 200, + // an exception is thrown which means that the validation failed. + postRequest("", payload, Map.of()); + } +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/RestClient.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/RestClient.java new file mode 100644 index 0000000..792bb4c --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/RestClient.java @@ -0,0 +1,117 @@ +package com.sap.cds.feature.messaging.aem.client; + +import static org.apache.http.entity.ContentType.APPLICATION_JSON; + +import java.io.IOException; +import java.util.Map; + +import org.apache.http.HttpHeaders; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.services.ServiceException; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; + +class RestClient { + + private static final Logger logger = LoggerFactory.getLogger(RestClient.class); + + private final HttpDestination destination; + + protected final ObjectMapper mapper = new ObjectMapper(); + + RestClient(ServiceBindingDestinationOptions bindingDestinationOptions) { + this.destination = ServiceBindingDestinationLoader.defaultLoaderChain().getDestination(bindingDestinationOptions); + } + + public JsonNode getRequest(String path) throws IOException { + HttpGet get = new HttpGet(path); + try { + return handleJsonResponse(handleRequest(get)); + } finally { + get.releaseConnection(); + } + } + + public JsonNode postRequest(String path, Map data) throws IOException { + String strData = mapper.writer().writeValueAsString(data); + return postRequest(path, strData, null); + } + + public JsonNode postRequest(String path, String data, Map headers) throws IOException { + HttpPost post = new HttpPost(path); + + if (headers != null) { + headers.forEach((k, v) -> post.setHeader(k, v.toString())); + } + + post.setHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON.toString()); + post.setHeader(HttpHeaders.ACCEPT, APPLICATION_JSON.toString()); + + if (data != null) { + post.setEntity(new StringEntity(data, APPLICATION_JSON)); + } + try { + return handleJsonResponse(handleRequest(post)); + } finally { + post.releaseConnection(); + } + } + + public JsonNode deleteRequest(String path) throws IOException { + HttpDelete del = new HttpDelete(path); + try { + return handleJsonResponse(handleRequest(del)); + } finally { + del.releaseConnection(); + } + } + + private CloseableHttpResponse handleRequest(HttpRequestBase request) throws IOException { + HttpClient httpClient = HttpClientAccessor.getHttpClient(destination); + return (CloseableHttpResponse) httpClient.execute(request); + } + + private JsonNode handleJsonResponse(CloseableHttpResponse response) throws IOException { + try (CloseableHttpResponse resp = response) { + int code = resp.getStatusLine().getStatusCode(); + + logger.debug("Responded with status code '{}'", code); + + if (code >= 200 && code <= 207) { + String contentType = APPLICATION_JSON.toString(); + + if (resp.getEntity() != null) { + if (resp.getEntity().getContentType() != null) { + contentType = resp.getEntity().getContentType().getValue(); + } + if (APPLICATION_JSON.toString().startsWith(contentType)) { + String jsonData = EntityUtils.toString(resp.getEntity()); + return mapper.readValue(jsonData, JsonNode.class); + } else { + throw new IOException("Unexpected response format: Expected JSON but found '" + contentType + "'"); + } + } else { + return mapper.readValue("{}", JsonNode.class); + } + } else { + String reason = resp.getStatusLine().getReasonPhrase(); + throw new ServiceException("Unexpected request HTTP response (" + code + ") " + reason); + } + } + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceView.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceView.java new file mode 100644 index 0000000..4b25941 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceView.java @@ -0,0 +1,72 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import java.util.Map; +import java.util.Optional; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +/** + * AemAuthenticationServiceView provides a view of the authentication service + * credentials from a given ServiceBinding. + */ +public class AemAuthenticationServiceView { + private static final String CLIENTSECRET_KEY = "clientsecret"; + private static final String CLIENTID_KEY = "clientid"; + private static final String TOKENENDPOINT_KEY = "tokenendpoint"; + private static final String AUTHENTICATION_SERVICE_KEY = "authentication-service"; + + private final ServiceBinding binding; + + public AemAuthenticationServiceView(ServiceBinding binding) { + this.binding = binding; + } + + @SuppressWarnings("unchecked") + private Map getAuthenticationService() { + if (binding.getCredentials().containsKey(AUTHENTICATION_SERVICE_KEY)) { + return (Map) binding.getCredentials().get(AUTHENTICATION_SERVICE_KEY); + } else { + return Map.of(); + } + } + + /** + * Checks if the token endpoint, client ID, and client secret are present. + * + * @return {@code true} if the token endpoint, client ID, and client secret are + * present; {@code false} otherwise. + */ + public boolean isTokenEndpointPresent() { + return getTokenEndpoint().isPresent() && getClientId().isPresent() && getClientSecret().isPresent(); + } + + /** + * Retrieves the token endpoint URL from the authorization service. + * + * @return an {@link Optional} containing the token endpoint URL if present, + * otherwise an empty {@link Optional}. + */ + public Optional getTokenEndpoint() { + return Optional.ofNullable((String) this.getAuthenticationService().get(TOKENENDPOINT_KEY)); + } + + /** + * Retrieves the client ID from the authorization service. + * + * @return an {@link Optional} containing the client ID if present, otherwise an + * empty {@link Optional} + */ + public Optional getClientId() { + return Optional.ofNullable((String) this.getAuthenticationService().get(CLIENTID_KEY)); + } + + /** + * Retrieves the client secret from the authorization service. + * + * @return an {@link Optional} containing the client secret if present, + * otherwise an empty {@link Optional} + */ + public Optional getClientSecret() { + return Optional.ofNullable((String) this.getAuthenticationService().get(CLIENTSECRET_KEY)); + } +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemClientIdentity.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemClientIdentity.java new file mode 100644 index 0000000..431f0dd --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemClientIdentity.java @@ -0,0 +1,15 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +record AemClientIdentity(String clientId, String clientSecret) + implements com.sap.cloud.security.config.ClientIdentity { + + @Override + public String getId() { + return this.clientId; + } + + @Override + public String getSecret() { + return this.clientSecret; + } +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointView.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointView.java new file mode 100644 index 0000000..d567d61 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointView.java @@ -0,0 +1,78 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; + +import com.sap.cds.feature.messaging.aem.service.AemMessagingServiceConfiguration; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +/** + * AemEndpointView is a class that provides access to the AMQP URI and URI of an + * AEM endpoint + * from a given ServiceBinding. It retrieves the endpoint information from the + * service binding's + * credentials. + */ +public class AemEndpointView { + private static final String ENDPOINTS_KEY = "endpoints"; + private static final String AMQP_URI_KEY = "amqp_uri"; + private static final String URI_KEY = "uri"; + + private final ServiceBinding binding; + + public AemEndpointView(ServiceBinding binding) { + this.binding = binding; + } + + /** + * Retrieves the URI from the AEM endpoint. + * + * @return an {@link Optional} containing the URI if present, otherwise an empty + * {@link Optional} + */ + public Optional getUri() { + return Optional.ofNullable((String) getAemEndpoint().get(URI_KEY)); + } + + /** + * Retrieves the AMQP URI from the AEM endpoint. + * + * @return an {@link Optional} containing the AMQP URI if present, otherwise an + * empty {@link Optional}. + */ + public Optional getAmqpUri() { + return Optional.ofNullable((String) getAemEndpoint().get(AMQP_URI_KEY)); + } + + /** + * Retrieves the VPN value from the binding credentials. + * + * @return an {@link Optional} containing the VPN value if present, otherwise an + * empty {@link Optional} + */ + public Optional getVpn() { + return Optional.ofNullable((String) this.binding.getCredentials().get("vpn")); + } + + @SuppressWarnings("unchecked") + private Map getEndpointsKey() { + Map endpoints = (Map) binding.getCredentials().get(ENDPOINTS_KEY); + + return endpoints != null ? endpoints : Map.of(); + } + + @SuppressWarnings("unchecked") + private Map getAemEndpoint() { + Map endpoints = getEndpointsKey(); + Iterator iterator = endpoints.values().iterator(); + Map aemEndpoint = iterator.hasNext() ? (Map) iterator.next() : null; + + if (endpoints.containsKey(AemMessagingServiceConfiguration.BINDING_AEM_LABEL) && aemEndpoint != null) { + return aemEndpoint; + } else { + return Map.of(); + } + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemManagementOauth2PropertySupplier.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemManagementOauth2PropertySupplier.java new file mode 100644 index 0000000..7a6bea3 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemManagementOauth2PropertySupplier.java @@ -0,0 +1,87 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import java.net.URI; +import java.net.URISyntaxException; + +import javax.annotation.Nonnull; + +import com.sap.cds.feature.messaging.aem.service.AemMessagingServiceConfiguration; +import com.sap.cds.services.ServiceException; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.OAuth2PropertySupplier; +import com.sap.cloud.sdk.cloudplatform.connectivity.OAuth2ServiceBindingDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; + +public class AemManagementOauth2PropertySupplier implements OAuth2PropertySupplier { + + private static boolean initialized = false; + + private final ServiceBinding binding; + private final AemAuthenticationServiceView authenticationServiceView; + private final AemEndpointView endpointView; + + public static synchronized void initialize() { + if (!initialized) { + OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( + options -> (options.getServiceBinding().getName().isPresent() && + AemMessagingServiceConfiguration.BINDING_AEM_LABEL.equals(options.getServiceBinding().getName().get())) + || options.getServiceBinding().getTags().contains(AemMessagingServiceConfiguration.BINDING_AEM_LABEL), + AemManagementOauth2PropertySupplier::new); + initialized = true; + } + } + + public AemManagementOauth2PropertySupplier(@Nonnull ServiceBindingDestinationOptions options) { + this.binding = options.getServiceBinding(); + this.authenticationServiceView = new AemAuthenticationServiceView(binding); + this.endpointView = new AemEndpointView(binding); + } + + @Override + public boolean isOAuth2Binding() { + return isAemBinding(this.binding) && areOAuth2ParametersPresent(this.binding); + } + + @Nonnull + @Override + public URI getServiceUri() { + String uri = endpointView.getUri() + .orElseThrow(() -> new ServiceException("Service endpoint not found in binding")); + + try { + return new URI(uri); + } catch (URISyntaxException e) { + throw new ServiceException("Invalid Service URI: " + uri, e); + } + } + + @Nonnull + @Override + public URI getTokenUri() { + try { + return new URI(this.authenticationServiceView.getTokenEndpoint().get()); + } catch (URISyntaxException e) { + throw new ServiceException(e.getMessage(), e); + } + } + + @Nonnull + @Override + public com.sap.cloud.security.config.ClientIdentity getClientIdentity() { + return new AemClientIdentity(this.authenticationServiceView.getClientId().get(), + this.authenticationServiceView.getClientSecret().get()); + } + + private boolean isAemBinding(ServiceBinding binding) { + return (binding.getName().isPresent() && + AemMessagingServiceConfiguration.BINDING_AEM_LABEL.equals(binding.getName().get())) + || binding.getTags().contains(AemMessagingServiceConfiguration.BINDING_AEM_LABEL); + } + + private boolean areOAuth2ParametersPresent(ServiceBinding binding) { + return this.authenticationServiceView.getTokenEndpoint().isPresent() + && this.authenticationServiceView.getClientId().isPresent() + && this.authenticationServiceView.getClientSecret().isPresent(); + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemValidationOAuth2PropertySupplier.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemValidationOAuth2PropertySupplier.java new file mode 100644 index 0000000..9a11974 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/client/binding/AemValidationOAuth2PropertySupplier.java @@ -0,0 +1,97 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Optional; + +import com.sap.cds.feature.messaging.aem.service.AemMessagingServiceConfiguration; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.sdk.cloudplatform.connectivity.OAuth2PropertySupplier; +import com.sap.cloud.sdk.cloudplatform.connectivity.OAuth2ServiceBindingDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; +import com.sap.cloud.security.config.ClientIdentity; + +public class AemValidationOAuth2PropertySupplier implements OAuth2PropertySupplier { + + private static boolean initialized = false; + + private final CredentialsView credentialsView; + + public static synchronized void initialize() { + if (!initialized) { + OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( + options -> ServiceBindingUtils.matches(options.getServiceBinding(), AemMessagingServiceConfiguration.BINDING_AEM_VALIDATION_LABEL), + AemValidationOAuth2PropertySupplier::new); + initialized = true; + } + } + + protected AemValidationOAuth2PropertySupplier(ServiceBindingDestinationOptions options) { + this.credentialsView = new CredentialsView(options.getServiceBinding().getCredentials()); + } + + @Override + public boolean isOAuth2Binding() { + return this.credentialsView.getServiceUri().isPresent() + && this.credentialsView.getTokenUri().isPresent() + && this.credentialsView.getClientId().isPresent() + && this.credentialsView.getClientSecret().isPresent(); + } + + @Override + public URI getServiceUri() { + try { + return new URI(this.credentialsView.getServiceUri().get()); + } catch (URISyntaxException e) { + throw new ServiceException("Invalid AEM Validation Service URI.", e); + } + } + + @Override + public URI getTokenUri() { + String uri = this.credentialsView.getTokenUri().get(); + + try { + return new URI(uri); + } catch (URISyntaxException e) { + throw new ServiceException("Invalid AEM Validation Service token endpoint URI.", e); + } + } + + @Override + public ClientIdentity getClientIdentity() { + return new AemClientIdentity(this.credentialsView.getClientId().get(), this.credentialsView.getClientSecret().get()); + } + + private record CredentialsView(Map credentials) { + + public Optional getServiceUri() { + return getHandshake().map(handshake -> (String) handshake.get("uri")); + } + + public Optional getTokenUri() { + return getOAuth2().map(oa2 -> (String) oa2.get("tokenendpoint")); + } + + public Optional getClientId() { + return getOAuth2().map(oa2 -> (String) oa2.get("clientid")); + } + + public Optional getClientSecret() { + return getOAuth2().map(oa2 -> (String) oa2.get("clientsecret")); + } + + @SuppressWarnings("unchecked") + private Optional> getHandshake() { + return Optional.ofNullable((Map) credentials.get("handshake")); + } + + @SuppressWarnings("unchecked") + private Optional> getOAuth2() { + return getHandshake().map(handshake -> (Map) handshake.get("oa2")); + } + + } +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/jms/AemMessagingConnectionProvider.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/jms/AemMessagingConnectionProvider.java new file mode 100644 index 0000000..0cac792 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/jms/AemMessagingConnectionProvider.java @@ -0,0 +1,99 @@ +package com.sap.cds.feature.messaging.aem.jms; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.apache.http.HttpHeaders; +import org.apache.qpid.jms.JmsConnectionExtensions; +import org.apache.qpid.jms.JmsConnectionFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sap.cds.feature.messaging.aem.client.binding.AemEndpointView; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.messaging.jms.BrokerConnection; +import com.sap.cds.services.messaging.jms.BrokerConnectionProvider; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationProperty; +import com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; + +import jakarta.jms.Connection; + +public class AemMessagingConnectionProvider extends BrokerConnectionProvider { + + private static final Logger logger = LoggerFactory.getLogger(AemMessagingConnectionProvider.class); + + private static final String SASL_MECHANISM_URI_PARAMETER = "/?amqp.saslMechanisms=XOAUTH2"; + + private final ServiceBinding binding; + private final Destination destination; + + public AemMessagingConnectionProvider(ServiceBinding binding) { + super(binding.getName().get()); + this.binding = binding; + + AemEndpointView endpointView = new AemEndpointView(binding); + String amqpUri = endpointView.getAmqpUri().orElseThrow(() -> new ServiceException( + "AMQP URI key is missing in the service binding. Please check the service binding configuration.")); + amqpUri = amqpUri + SASL_MECHANISM_URI_PARAMETER; + + ServiceBindingDestinationOptions options = ServiceBindingDestinationOptions.forService(binding). + onBehalfOf(OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT).build(); + + this.destination = DefaultHttpDestination.fromDestination( + ServiceBindingDestinationLoader.defaultLoaderChain().getDestination(options)) + .uri(amqpUri) + .property("vpn", endpointView.getVpn().get()) + .build(); + } + + @Override + protected BrokerConnection createBrokerConnection(String name, Map clientProperties) throws Exception { + // see https://solace.community/discussion/1677/how-oauth-can-be-used-with-apache-qpid-jms-2-0-amqp + logger.debug("Retrieving credentials for Basic Auth from service binding '{}'", binding.getName().get()); + + final BiFunction tokenExtension = new BiFunction<>() { + @Override + public Object apply(final Connection connection, final URI uri) { + String token = fetchToken().orElseThrow(() -> new ServiceException("Token is missing")); + + return token; + } + }; + + logger.debug("Creating connection factory fo service binding '{}'", this.binding.getName().get()); + // the password is going to be replaced by the token + JmsConnectionFactory factory = new JmsConnectionFactory(destination.get("vpn", String.class).get(), "token", destination.get(DestinationProperty.URI).get()); + + factory.setExtension(JmsConnectionExtensions.PASSWORD_OVERRIDE.toString(), tokenExtension); + + return new BrokerConnection(name, factory); + } + + private Optional fetchToken() { + Optional token = this.destination.asHttp() + .getHeaders() + .stream() + .filter(h -> h.getName().equals(HttpHeaders.AUTHORIZATION)) + .findFirst() + .map(h -> getToken(h.getValue())); + return token; + } + + private String getToken(String value) { + String token = null; + if (value != null) { + String[] parts = value.split(" "); + token = parts.length > 1 ? parts[1] : null; + } + + return token; + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingService.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingService.java new file mode 100644 index 0000000..7103c54 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingService.java @@ -0,0 +1,143 @@ +package com.sap.cds.feature.messaging.aem.service; + +import static com.sap.cds.feature.messaging.aem.client.AemManagementClient.ATTR_DEAD_MSG_QUEUE; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.qpid.jms.message.JmsBytesMessage; +import org.apache.qpid.jms.message.JmsTextMessage; +import org.apache.qpid.jms.provider.amqp.message.AmqpJmsBytesMessageFacade; +import org.apache.qpid.jms.provider.amqp.message.AmqpJmsTextMessageFacade; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sap.cds.feature.messaging.aem.client.AemManagementClient; +import com.sap.cds.feature.messaging.aem.client.AemValidationClient; +import com.sap.cds.feature.messaging.aem.jms.AemMessagingConnectionProvider; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsProperties.Messaging.MessagingServiceConfig; +import com.sap.cds.services.messaging.TopicMessageEventContext; +import com.sap.cds.services.messaging.jms.BrokerConnection; +import com.sap.cds.services.messaging.service.AbstractMessagingService; +import com.sap.cds.services.messaging.service.MessagingBrokerQueueListener; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +import jakarta.jms.JMSException; +import jakarta.jms.Message; + +public class AemMessagingService extends AbstractMessagingService { + private static final Logger logger = LoggerFactory.getLogger(AemMessagingService.class); + + private final AemMessagingConnectionProvider connectionProvider; + private final AemManagementClient managementClient; + private final ServiceBinding validationBinding; + + private volatile BrokerConnection connection; + private volatile Boolean aemBrokerValidated = false; + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + protected AemMessagingService(ServiceBinding binding, ServiceBinding validationBinding, + MessagingServiceConfig serviceConfig, AemMessagingConnectionProvider connectionProvider, CdsRuntime runtime) { + + super(serviceConfig, runtime); + + this.connectionProvider = connectionProvider; + this.managementClient = new AemManagementClient(binding); + this.validationBinding = validationBinding; + } + + @Override + public void init() { + logger.debug("Creating the broker connection asynchronously with topic subscriptions."); + this.asyncConnectionInitialization(connection -> { + this.connection = connection; + super.init(); + + logger.debug("The broker connection has been created."); + }); + } + + @Override + public void stop() { + logger.debug("Stopping the broker connection..."); + + if (connection != null) { + try { + connection.close(); + logger.debug("The broker connection has been stopped."); + } catch (JMSException e) { + // ignored + logger.debug("An error occurred while stopping the broker connection.", e); + } + } else { + logger.debug("No broker connection available.."); + } + } + + @Override + protected void removeQueue(String name) throws IOException { + managementClient.removeQueue(name); + } + + @Override + protected void createQueue(String name, Map properties) throws IOException { + if (properties.containsKey(ATTR_DEAD_MSG_QUEUE)) { + String dmQueue = (String) properties.get(ATTR_DEAD_MSG_QUEUE); + if (managementClient.getQueue(dmQueue) == null) { + managementClient.createQueue(dmQueue, Collections.emptyMap()); + } + } + managementClient.createQueue(name, properties); + } + + @Override + protected void createQueueSubscription(String queue, String topic) throws IOException { + managementClient.createQueueSubscription(queue, topic); + } + + @Override + protected void registerQueueListener(String queue, MessagingBrokerQueueListener listener) throws IOException { + connection.registerQueueListener(queue, listener, this::getMessageTopic); + } + + @Override + protected void emitTopicMessage(String topic, TopicMessageEventContext messageEventContext) { + this.validate(this.managementClient.getEndpoint()); + this.connection.emitTopicMessage("topic://" + topic, messageEventContext); + } + + private void asyncConnectionInitialization(Consumer connectionConsumer) { + connectionProvider.asyncConnectionInitialization(serviceConfig, connectionConsumer); + } + + private String getMessageTopic(Message message) { + if (message instanceof JmsTextMessage textMessage) { + if (textMessage.getFacade() instanceof AmqpJmsTextMessageFacade textMessageFacade) { + return textMessageFacade.getDestination().getAddress(); + } + } else if (message instanceof JmsBytesMessage bytesMessage + && bytesMessage.getFacade() instanceof AmqpJmsBytesMessageFacade bytesMessageFacade) { + return bytesMessageFacade.getDestination().getAddress(); + } + return null; + } + + private void validate(String endpoint) { + if (!this.aemBrokerValidated) { + AemValidationClient validationClient = new AemValidationClient(this.validationBinding); + + try { + validationClient.validate(endpoint); + this.aemBrokerValidated = true; + } catch (IOException | URISyntaxException e) { + throw new ServiceException("Failed to validate the AEM endpoint.", e); + } + } + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfiguration.java b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfiguration.java new file mode 100644 index 0000000..365ee37 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfiguration.java @@ -0,0 +1,135 @@ +package com.sap.cds.feature.messaging.aem.service; + +import static com.sap.cds.services.messaging.utils.MessagingOutboxUtils.outboxed; + +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Strings; +import com.sap.cds.feature.messaging.aem.client.binding.AemManagementOauth2PropertySupplier; +import com.sap.cds.feature.messaging.aem.client.binding.AemValidationOAuth2PropertySupplier; +import com.sap.cds.feature.messaging.aem.jms.AemMessagingConnectionProvider; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsProperties.Messaging; +import com.sap.cds.services.environment.CdsProperties.Messaging.MessagingServiceConfig; +import com.sap.cds.services.messaging.MessagingService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +public class AemMessagingServiceConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AemMessagingServiceConfiguration.class); + + public static final String BINDING_AEM_LABEL = "advanced-event-mesh"; + public static final String BINDING_AEM_VALIDATION_LABEL = "aem-validation-service"; + public static final String AEM_KIND = "aem"; + + @Override + public void services(CdsRuntimeConfigurer configurer) { + AemManagementOauth2PropertySupplier.initialize(); + AemValidationOAuth2PropertySupplier.initialize(); + + Messaging config = configurer.getCdsRuntime().getEnvironment().getCdsProperties().getMessaging(); + List bindings = configurer.getCdsRuntime().getEnvironment().getServiceBindings() + .filter(binding -> (binding.getName().isPresent() && BINDING_AEM_LABEL.equals(binding.getName().get())) + || binding.getTags().contains(BINDING_AEM_LABEL)) + .toList(); + Optional validationBinding = configurer.getCdsRuntime().getEnvironment().getServiceBindings() + .filter(binding -> ServiceBindingUtils.matches(binding, BINDING_AEM_VALIDATION_LABEL)).findFirst(); + + if (bindings.isEmpty()) { + logger.info("No service bindings with name '{}' found", BINDING_AEM_LABEL); + } else { + boolean isSingleBinding = bindings.size() == 1; + + bindings.forEach(binding -> { + + logger.debug("Starting the initialization of the advanced-event-mesh service binding '{}'", + binding.getName().get()); + + AemMessagingConnectionProvider sharedConnectionProvider = new AemMessagingConnectionProvider(binding); + + // determines whether no configuration is available and the default service + // should be created + boolean createDefaultService = true; + + // check the services configured by binding + List serviceConfigs = config.getServicesByBinding(binding.getName().get()); + + if (!serviceConfigs.isEmpty()) { + logger.debug("Service configurations found, not creating default service."); + + createDefaultService = false; + serviceConfigs.forEach(serviceConfig -> { + if (serviceConfig.isEnabled()) { + // register the service + configurer.service(createMessagingService(binding, validationBinding, sharedConnectionProvider, serviceConfig, + configurer.getCdsRuntime())); + } + }); + } + + // check the services configured by kind if only one service binding is + // available + logger.debug("Checking the services configured by kind if only one service binding is available."); + List serviceConfigsByKind = config.getServicesByKind(BINDING_AEM_LABEL); + serviceConfigsByKind.addAll(config.getServicesByKind(AEM_KIND)); + + if (isSingleBinding && !serviceConfigsByKind.isEmpty()) { + logger.debug( + "Service configurations by kind for single service binding found, not creating default service."); + createDefaultService = false; + serviceConfigsByKind.forEach(serviceConfig -> { + // check that the service is enabled and whether not already found by name or + // binding + if (serviceConfig.isEnabled() && serviceConfigs.stream() + .noneMatch(c -> c.getName().equals(serviceConfig.getName()))) { + // register the service + configurer.service(createMessagingService(binding, validationBinding, sharedConnectionProvider, serviceConfig, + configurer.getCdsRuntime())); + } + }); + } + + if (createDefaultService) { + logger.debug("No service configurations found, creating default service."); + + // otherwise create default service instance for the binding + MessagingServiceConfig defConfig = config.getService(binding.getName().get()); + + if (Strings.isNullOrEmpty(defConfig.getBinding()) && Strings.isNullOrEmpty(defConfig.getKind())) { + // register the service + configurer.service(createMessagingService(binding, validationBinding, sharedConnectionProvider, defConfig, + configurer.getCdsRuntime())); + } else { + logger.warn( + "Could not create service for binding '{}': A configuration with the same name is already defined for another kind or binding.", + binding.getName().get()); + } + } + + logger.debug("Finished the initialization of the advanced-event-mesh service binding '{}'", + binding.getName().get()); + }); + } + } + + private MessagingService createMessagingService(ServiceBinding binding, Optional validationBinding, + AemMessagingConnectionProvider sharedConnectionProvider, MessagingServiceConfig serviceConfig, CdsRuntime runtime) { + + ServiceBinding valBnd = validationBinding.orElseThrow(() -> new ServiceException("No binding for AEM Validation Service found.")); + MessagingService service = new AemMessagingService(binding, valBnd, serviceConfig, sharedConnectionProvider, runtime); + + logger.debug("Created messaging service '{}' for binding '{}'", serviceConfig.getName(), + binding.getName().get()); + + return outboxed(service, serviceConfig, runtime); + } + +} diff --git a/cds-feature-advanced-event-mesh/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-advanced-event-mesh/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..e7447c7 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.messaging.aem.service.AemMessagingServiceConfiguration diff --git a/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceViewTest.java b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceViewTest.java new file mode 100644 index 0000000..6aef224 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemAuthenticationServiceViewTest.java @@ -0,0 +1,111 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +public class AemAuthenticationServiceViewTest { + + @Mock + private ServiceBinding serviceBinding; + + private AemAuthenticationServiceView authenticationServiceView; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + authenticationServiceView = new AemAuthenticationServiceView(serviceBinding); + } + + @Test + void testIsTokenEndpointPresent() { + Map credentials = Map.of( + "authentication-service", Map.of( + "tokenendpoint", "http://example.com/token", + "clientid", "test-client-id", + "clientsecret", "test-client-secret" + ) + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + assertTrue(authenticationServiceView.isTokenEndpointPresent()); + } + + @Test + void testIsTokenEndpointPresent_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + assertFalse(authenticationServiceView.isTokenEndpointPresent()); + } + + @Test + void testGetTokenEndpoint() { + Map credentials = Map.of( + "authentication-service", Map.of("tokenendpoint", "http://example.com/token") + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional tokenEndpoint = authenticationServiceView.getTokenEndpoint(); + assertTrue(tokenEndpoint.isPresent()); + assertEquals("http://example.com/token", tokenEndpoint.get()); + } + + @Test + void testGetTokenEndpoint_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional tokenEndpoint = authenticationServiceView.getTokenEndpoint(); + assertFalse(tokenEndpoint.isPresent()); + } + + @Test + void testGetClientId() { + Map credentials = Map.of( + "authentication-service", Map.of("clientid", "test-client-id") + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional clientId = authenticationServiceView.getClientId(); + assertTrue(clientId.isPresent()); + assertEquals("test-client-id", clientId.get()); + } + + @Test + void testGetClientId_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional clientId = authenticationServiceView.getClientId(); + assertFalse(clientId.isPresent()); + } + + @Test + void testGetClientSecret() { + Map credentials = Map.of( + "authentication-service", Map.of("clientsecret", "test-client-secret") + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional clientSecret = authenticationServiceView.getClientSecret(); + assertTrue(clientSecret.isPresent()); + assertEquals("test-client-secret", clientSecret.get()); + } + + @Test + void testGetClientSecret_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional clientSecret = authenticationServiceView.getClientSecret(); + assertFalse(clientSecret.isPresent()); + } +} diff --git a/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointViewTest.java b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointViewTest.java new file mode 100644 index 0000000..d5cbe0f --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/client/binding/AemEndpointViewTest.java @@ -0,0 +1,92 @@ +package com.sap.cds.feature.messaging.aem.client.binding; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +public class AemEndpointViewTest { + + @Mock + private ServiceBinding serviceBinding; + + private AemEndpointView endpointView; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + endpointView = new AemEndpointView(serviceBinding); + } + + @Test + void testGetUri() { + Map credentials = Map.of( + "endpoints", Map.of( + "advanced-event-mesh", Map.of("uri", "http://example.com") + ) + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional uri = endpointView.getUri(); + assertTrue(uri.isPresent()); + assertEquals("http://example.com", uri.get()); + } + + @Test + void testGetUri_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional uri = endpointView.getUri(); + assertFalse(uri.isPresent()); + } + + @Test + void testGetAmqpUri() { + Map credentials = Map.of( + "endpoints", Map.of( + "advanced-event-mesh", Map.of("amqp_uri", "amqp://example.com") + ) + ); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional amqpUri = endpointView.getAmqpUri(); + assertTrue(amqpUri.isPresent()); + assertEquals("amqp://example.com", amqpUri.get()); + } + + @Test + void testGetAmqpUri_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional amqpUri = endpointView.getAmqpUri(); + assertFalse(amqpUri.isPresent()); + } + + @Test + void testGetVpn() { + Map credentials = Map.of("vpn", "test-vpn"); + when(serviceBinding.getCredentials()).thenReturn(credentials); + + Optional vpn = endpointView.getVpn(); + assertTrue(vpn.isPresent()); + assertEquals("test-vpn", vpn.get()); + } + + @Test + void testGetVpn_NotPresent() { + when(serviceBinding.getCredentials()).thenReturn(Map.of()); + + Optional vpn = endpointView.getVpn(); + assertFalse(vpn.isPresent()); + } +} diff --git a/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfigurationTest.java b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfigurationTest.java new file mode 100644 index 0000000..fd907d2 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/test/java/com/sap/cds/feature/messaging/aem/service/AemMessagingServiceConfigurationTest.java @@ -0,0 +1,114 @@ +package com.sap.cds.feature.messaging.aem.service; + +import static com.sap.cds.services.outbox.OutboxService.unboxed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import com.sap.cds.services.Service; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Messaging.MessagingServiceConfig; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; + +public class AemMessagingServiceConfigurationTest { + + @Test + void testDefaultServConfiguration() { + CdsProperties properties = new CdsProperties(); + properties.getOutbox().getInMemory().setEnabled(false); + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)); + + configurer.serviceConfigurations(); + configurer.eventHandlerConfigurations(); + + List services = configurer.getCdsRuntime().getServiceCatalog().getServices(AemMessagingService.class).toList(); + + assertEquals(1, services.size()); + assertEquals("my-aem-instance", services.get(0).getName()); + } + + @Test + void testNoServiceBindings() { + CdsProperties properties = new CdsProperties(); + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)); + + configurer.serviceConfigurations(); + configurer.eventHandlerConfigurations(); + + List services = configurer.getCdsRuntime().getServiceCatalog().getServices(AemMessagingService.class).toList(); + assertTrue(services.isEmpty()); + } + + @Test + void testSingleServConfiguration() { + CdsProperties properties = new CdsProperties(); + MessagingServiceConfig config = new MessagingServiceConfig("cfg"); + config.setBinding("advanced-event-mesh"); + config.getOutbox().setEnabled(false); + properties.getMessaging().getServices().put(config.getName(), config); + + assertEquals(0, properties.getMessaging().getServicesByBinding("").size()); + assertEquals(1, properties.getMessaging().getServicesByBinding("advanced-event-mesh").size()); + assertEquals(0, properties.getMessaging().getServicesByKind("").size()); + assertEquals(0, properties.getMessaging().getServicesByKind("aem").size()); + + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)); + + configurer.serviceConfigurations(); + configurer.eventHandlerConfigurations(); + + List services = configurer.getCdsRuntime().getServiceCatalog().getServices().filter(srv -> unboxed(srv).getClass().equals(AemMessagingService.class)).toList(); + + assertEquals(1, services.size()); + assertEquals("my-aem-instance", services.get(0).getName()); + } + + @Test + void testMultipleServiceConfigurations() { + CdsProperties properties = new CdsProperties(); + MessagingServiceConfig config1 = new MessagingServiceConfig("cfg1"); + config1.setBinding("my-aem-instance"); + config1.getOutbox().setEnabled(false); + + MessagingServiceConfig config2 = new MessagingServiceConfig("cfg2"); + config2.setBinding("my-aem-instance"); + config2.getOutbox().setEnabled(false); + + properties.getMessaging().getServices().put(config1.getName(), config1); + properties.getMessaging().getServices().put(config2.getName(), config2); + + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)); + + configurer.serviceConfigurations(); + configurer.eventHandlerConfigurations(); + + List services = configurer.getCdsRuntime().getServiceCatalog().getServices(AemMessagingService.class).collect(Collectors.toList()); + + assertEquals(2, services.size()); + assertTrue(services.stream().anyMatch(service -> "cfg1".equals(service.getName()))); + assertTrue(services.stream().anyMatch(service -> "cfg2".equals(service.getName()))); + } + + @Test + void testServiceConfigurationWithInvalidBinding() { + CdsProperties properties = new CdsProperties(); + MessagingServiceConfig config = new MessagingServiceConfig("cfg"); + config.setBinding("invalid-binding"); + config.getOutbox().setEnabled(false); + properties.getMessaging().getServices().put(config.getName(), config); + + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)); + + configurer.serviceConfigurations(); + configurer.eventHandlerConfigurations(); + + List services = configurer.getCdsRuntime().getServiceCatalog().getServices(AemMessagingService.class).collect(Collectors.toList()); + + assertTrue(services.isEmpty(), "Expected no services to be configured with invalid binding"); + } +} diff --git a/cds-feature-advanced-event-mesh/src/test/resources/default-env.json b/cds-feature-advanced-event-mesh/src/test/resources/default-env.json new file mode 100644 index 0000000..5595414 --- /dev/null +++ b/cds-feature-advanced-event-mesh/src/test/resources/default-env.json @@ -0,0 +1,64 @@ +{ + "VCAP_SERVICES": { + "user-provided": [ + { + "label": "user-provided", + "name": "my-aem-instance", + "tags": ["advanced-event-mesh"], + "instance_guid": "515066ab-029d-4cdd-a116-a2c254633b6b", + "instance_name": "advanced-event-mesh", + "binding_guid": "5aec22e5-8380-47a2-99ae-1c4011cdcf88", + "binding_name": null, + "credentials": { + "vpn": "capservice", + "authentication-service": { + "tokenendpoint": "https://ad8obf0da.accounts400.ondemand.com/oauth2/token", + "clientid": "a5fe563c-76a3-4507-b107-63f2a112ee4a", + "clientsecret": "ch80Nj]-L[vw[EjZyPB1D-:iRW._:Mm0=" + }, + "endpoints": { + "advanced-event-mesh": { + "uri": "https://mr-connection-i90693rt5n6.messaging.solace.cloud:943", + "amqp_uri": "amqps://mr-connection-i90693rt5n6.messaging.solace.cloud:5671", + "smf_uri": "wss://mr-connection-i90693rt5n6.messaging.solace.cloud:443" + } + } + }, + "syslog_drain_url": null, + "volume_mounts": [] + } + ], + "aem-validation-service": [ + { + "label": "aem-validation-service", + "provider": null, + "plan": "aem-validation-service-plan", + "name": "cap-aem-validation", + "tags": [ + "eventing", + "aem", + "eventmesh" + ], + "instance_guid": "6a2c31d5-31dc-4ece-8833-eae016af64f2", + "instance_name": "cap-aem-validation", + "binding_guid": "a832ecaf-eb81-4582-a344-87354243bf07", + "binding_name": null, + "credentials": { + "xsappname": "46B0354E-C61B-11ED-AFA1-0242AC120002-6a2c31d5-31dc-4ece-8833-eae016af64f2!b191218|em-pubsub-service-broker!b38991", + "handshake": { + "oa2": { + "clientid": "sb-46B0354E-C61B-11ED-AFA1-0242AC120002-6a2c31d5-31dc-4ece-8833-eae016af64f2!b191218|em-pubsub-service-broker!b38991", + "clientsecret": "0c0251d8-c1f4-45ba-8921-075bbf986bbd$Im-xm-iWbtWC-1WDueF7UqsUxIqOUOXijWJITxFLd1Q=", + "granttype": "client_credentials", + "tokenendpoint": "https://cap-aem-nodejs.authentication.sap.hana.ondemand.com/oauth/token" + }, + "uri": "https://em-pubsub-broker.mesh.cf.sap.hana.ondemand.com/handshake" + }, + "serviceinstanceid": "6a2c31d5-31dc-4ece-8833-eae016af64f2" + }, + "syslog_drain_url": null, + "volume_mounts": [] + } + ] + } +} diff --git a/deploy-oss/pom.xml b/deploy-oss/pom.xml new file mode 100644 index 0000000..bd91590 --- /dev/null +++ b/deploy-oss/pom.xml @@ -0,0 +1,96 @@ + + 4.0.0 + com.sap.cds + deploy-oss + ${revision} + pom + + Deploy to OSS + This artifact can be used to deploy all required artifacts of cds-feature-advanced-event-mesh to OSS Nexus + + + true + true + + + + + + maven-install-plugin + 3.1.4 + + + install-cds-feature-advanced-event-mesh + install + + install-file + + + ${project.groupId} + cds-feature-advanced-event-mesh + jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh.jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh-sources.jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh-javadoc.jar + ../cds-feature-advanced-event-mesh/.flattened-pom.xml + ${revision} + + + + install-cds-feature-advanced-event-mesh-root + install + + install-file + + + ${project.groupId} + cds-feature-advanced-event-mesh-root + pom + ../.flattened-pom.xml + ../.flattened-pom.xml + ${revision} + + + + + + maven-gpg-plugin + 3.2.7 + + + deploy-cds-feature-advanced-event-mesh + deploy + + sign-and-deploy-file + + + ${project.groupId} + cds-feature-advanced-event-mesh + jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh.jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh-sources.jar + ../cds-feature-advanced-event-mesh/target/cds-feature-advanced-event-mesh-javadoc.jar + ../cds-feature-advanced-event-mesh/.flattened-pom.xml + ${revision} + + + + deploy-cds-feature-advanced-event-mesh-root + deploy + + sign-and-deploy-file + + + ${project.groupId} + cds-feature-advanced-event-mesh-root + pom + ../.flattened-pom.xml + ../.flattened-pom.xml + ${revision} + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9399404 --- /dev/null +++ b/pom.xml @@ -0,0 +1,258 @@ + + 4.0.0 + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + SAP SE + https://www.sap.com + + + + + SAP SE + https://www.sap.com + + + + + 0.1.0-SNAPSHOT + 17 + ${java.version} + UTF-8 + + 3.8.0 + 8.7.3 + 2.7.0 + + + com.sap.cds + cds-feature-advanced-event-mesh-root + ${revision} + pom + + CDS plugin for SAP Integration Suite, advanced event mesh - Root + This artifact is a CAP Java plugin that provides out-of-the box integration with SAP Integration Suite, advanced event mesh. + https://cap.cloud.sap/docs/plugins/#advanced-event-mesh-plugin + + + cds-feature-advanced-event-mesh + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + com.sap.cloud.sdk + sdk-bom + 5.17.0 + pom + import + + + + org.mockito + mockito-bom + 5.16.0 + pom + import + + + + com.sap.cds + cds-feature-advanced-event-mesh + ${revision} + + + + org.apache.qpid + qpid-jms-client + ${qpid.version} + + + + + commons-codec + commons-codec + 1.18.0 + + + + + + + com.sap.cds + cds-services-api + + + + org.junit.jupiter + junit-jupiter + 5.12.0 + test + + + + org.assertj + assertj-core + 3.27.3 + test + + + + org.mockito + mockito-core + test + + + + + + + + maven-surefire-plugin + + + + + org.codehaus.mojo + flatten-maven-plugin + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + maven-enforcer-plugin + + + no-duplicate-declared-dependencies + + enforce + + + + + + 3.6.3 + + + ${java.version} + + + + + + + + + + + + + maven-clean-plugin + 3.4.1 + + + maven-compiler-plugin + 3.14.0 + + + maven-source-plugin + 3.3.1 + + + maven-deploy-plugin + 3.1.4 + + + maven-javadoc-plugin + 3.11.2 + + + maven-surefire-plugin + 3.5.2 + + + maven-pmd-plugin + 3.26.0 + + + maven-enforcer-plugin + 3.5.0 + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.0 + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + org.pitest + pitest-maven + 1.18.2 + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.2.0 + + + + + + + + artifactory + Artifactory_DMZ-snapshots + https://common.repositories.cloud.sap/artifactory/cap-java + + + ossrh + MavenCentral + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + https://github.com/cap-java/advanced-event-mesh + scm:git:git@github.com:cap-java/cds-feature-advanced-event-mesh.git + scm:git:git@github.com:cap-java/cds-feature-advanced-event-mesh.git + + +