diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 00000000..bece83ba --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1 @@ +{"functions-framework-api":"2.0.1","invoker":"2.0.1","function-maven-plugin":"1.0.1"} diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1 @@ + diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 00000000..cfe9f42a --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,73 @@ +{ + "separate-pull-requests": true, + "packages": { + "functions-framework-api": { + "release-type": "maven", + "component": "functions-framework-api", + "extra-files": [ + { + "type": "xml", + "path": "pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='functions-framework-api']/parent::*/*[local-name()='version']" + } + ] + }, + "invoker": { + "release-type": "maven", + "component": "java-function-invoker", + "extra-files": [ + { + "type": "xml", + "path": "pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-parent']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "core/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-parent']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "core/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "core/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-testfunction']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "conformance/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-parent']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "conformance/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='conformance']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "testfunction/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-parent']/parent::*/*[local-name()='version']" + }, + { + "type": "xml", + "path": "testfunction/pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='java-function-invoker-testfunction']/parent::*/*[local-name()='version']" + } + ] + }, + "function-maven-plugin": { + "release-type": "maven", + "component": "function-maven-plugin", + "extra-files": [ + { + "type": "xml", + "path": "pom.xml", + "xpath": "//*[local-name()='artifactId' and text()='function-maven-plugin']/parent::*/*[local-name()='version']" + } + ] + } + } +} diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 00000000..2e936db0 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,5 @@ +handleGHRelease: true +monorepoTags: true +manifest: true +manifestConfig: '.github/release-please-config.json' +manifestFile: '.github/.release-please-manifest.json' \ No newline at end of file diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml new file mode 100644 index 00000000..a97dad2f --- /dev/null +++ b/.github/release-trigger.yml @@ -0,0 +1,2 @@ +enabled: true +multiScmName: functions-framework-java diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..2e79135d --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["group:allNonMajor", "schedule:monthly"], + "ignoreDeps": ["org.apache.maven.plugins:maven-source-plugin"], + "packageRules": [ + { + "description": "Create a PR whenever there is a new major version", + "matchUpdateTypes": [ + "major" + ] + } + ] +} diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml new file mode 100644 index 00000000..c97377e7 --- /dev/null +++ b/.github/workflows/buildpack-integration-test.yml @@ -0,0 +1,38 @@ +# Validates Functions Framework with GCF buildpacks. +name: Buildpack Integration Test +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + # Runs every day on 12:00 AM PST + schedule: + - cron: "0 0 * * *" + +# Declare default permissions as read only. +permissions: read-all + +jobs: + java21-buildpack-test: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main + with: + http-builder-source: '/tmp/tests/conformance' + http-builder-target: 'com.google.cloud.functions.conformance.HttpConformanceFunction' + cloudevent-builder-source: '/tmp/tests/conformance' + cloudevent-builder-target: 'com.google.cloud.functions.conformance.CloudEventsConformanceFunction' + prerun: 'invoker/conformance/prerun.sh' + builder-runtime: 'java21' + builder-runtime-version: '21' + builder-url: gcr.io/serverless-runtimes/google-22-full/builder/java:latest + java17-buildpack-test: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main + with: + http-builder-source: '/tmp/tests/conformance' + http-builder-target: 'com.google.cloud.functions.conformance.HttpConformanceFunction' + cloudevent-builder-source: '/tmp/tests/conformance' + cloudevent-builder-target: 'com.google.cloud.functions.conformance.CloudEventsConformanceFunction' + prerun: 'invoker/conformance/prerun.sh' + builder-runtime: 'java17' + builder-runtime-version: '17' + builder-url: gcr.io/serverless-runtimes/google-22-full/builder/java:latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..841500f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,70 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + # Autobuild each of these seperate maven projects + working-directory: ['invoker', 'functions-framework-api', 'function-maven-plugin'] + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + release-assets.githubusercontent.com:443 + repo.maven.apache.org:443 + storage.googleapis.com:443 + uploads.github.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + languages: java + # 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. + + # 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 + + - name: Build + run: | + (cd functions-framework-api/ && mvn install) + (cd invoker/ && mvn clean install) + (cd function-maven-plugin && mvn install) + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + category: ${{ matrix.working-directory }} diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 00000000..091a77a4 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,99 @@ +name: Java Conformance CI +on: + push: + branches: + - main + pull_request: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ + 17.x, + 21.x + ] + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + release-assets.githubusercontent.com:443 + repo.maven.apache.org:443 + storage.googleapis.com:443 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: ${{ matrix.java }} + distribution: temurin + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.26' + + - name: Build API with Maven + run: (cd functions-framework-api/ && mvn install) + + - name: Build invoker with Maven + run: (cd invoker/ && mvn install) + + - name: Build invoker Maven Plugin + run: (cd function-maven-plugin/ && mvn install) + + - name: Run HTTP conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main + with: + functionType: 'http' + useBuildpacks: false + cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.HttpConformanceFunction'" + startDelay: 10 + + - name: Run Typed conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main + with: + functionType: 'http' + declarativeType: 'typed' + useBuildpacks: false + cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.TypedConformanceFunction'" + startDelay: 10 + + - name: Run background event conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main + with: + functionType: 'legacyevent' + useBuildpacks: false + validateMapping: true + cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.BackgroundEventConformanceFunction'" + startDelay: 10 + + - name: Run cloudevent conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main + with: + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: true + cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.CloudEventsConformanceFunction'" + startDelay: 10 + + - name: Run HTTP concurrency conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main + with: + functionType: 'http' + useBuildpacks: false + validateConcurrency: true + cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.ConcurrentHttpConformanceFunction'" + startDelay: 10 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..2c88f621 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,58 @@ +name: Java Lint CI +on: + push: + branches: + - main + pull_request: + workflow_dispatch: +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + repo.maven.apache.org:443 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: 17.x + distribution: temurin + - name: Build API with Maven + run: (cd functions-framework-api/ && mvn install) + - name: Lint Functions Framework API + run: (cd functions-framework-api/ && mvn clean verify -DskipTests -P lint) + - name: Build Invoker with Maven + run: (cd functions-framework-api/ && mvn install) + - name: Lint Invoker + run: (cd invoker/ && mvn clean verify -DskipTests -P lint) + formatting: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # v2 minimum required + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: 21.x + distribution: temurin + - name: Run formatter + id: formatter + uses: axel-op/googlejavaformat-action@dbff853fb823671ec5781365233bf86543b13215 # v3 + with: + args: "--replace" + skip-commit: true + - name: Print diffs + run: git --no-pager diff --exit-code diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..ba1d718d --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,67 @@ +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '0 */12 * * *' + push: + branches: [ "main" ] + workflow_dispatch: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.osv.dev:443 + api.scorecard.dev:443 + api.securityscorecards.dev:443 + auth.docker.io:443 + bestpractices.coreinfrastructure.org:443 + github.com:443 + index.docker.io:443 + oss-fuzz-build-logs.storage.googleapis.com:443 + sigstore-tuf-root.storage.googleapis.com:443 + www.bestpractices.dev:443 + *.sigstore.dev:443 + *.github.com:443 + + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + publish_results: true + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml new file mode 100644 index 00000000..fd3ffc97 --- /dev/null +++ b/.github/workflows/unit.yaml @@ -0,0 +1,39 @@ +name: Java Unit CI +on: + push: + branches: + - main + pull_request: +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ + 17.x, + 21.x + ] + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + repo.maven.apache.org:443 + api.adoptium.net:443 + *.githubusercontent.com:443 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: ${{ matrix.java }} + distribution: temurin + - name: Build with Maven + run: (cd functions-framework-api/ && mvn install) + - name: Test + run: (cd invoker/ && mvn test) diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4cd4e4f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# Maven +target/ +dependency-reduced-pom.xml + +# Gradle +.gradle +.idea/ +build/ +credentials/ +out/ +gradlew +gradlew.bat +*.iml +*.properties + +client_secret.json +credentials.json +tokens/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +.DS_Store +.settings/ +.classpath +.project +properties + +# IDE +.vscode/ + +# Conformance testing +function_output.json +serverlog_stderr.txt +serverlog_stdout.txt diff --git a/.kokoro/build.cfg b/.kokoro/build.cfg new file mode 100644 index 00000000..59cdfe4c --- /dev/null +++ b/.kokoro/build.cfg @@ -0,0 +1,24 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "functions-framework-java/.kokoro/build.sh" +container_properties { + # Use the full image which has Java, Maven, and gcloud CLI pre-installed + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/kokoro/ubuntu/ubuntu2204/full:current" +} + +fileset_artifacts { + name: "artifacts" + # We will copy the built jars to this folder in build.sh for signing + artifact_globs: "artifacts/*" + error_if_missing: true + destinations { + store_attestation: true + gcs { + gcs_root_path: "oss-exit-gate-prod-projects-bucket/ff-releases/mavencentral/attestations" + } + } + generate_sbom_from_fileset: true + generate_attestation: true +} diff --git a/.kokoro/build.sh b/.kokoro/build.sh new file mode 100755 index 00000000..597b5cf3 --- /dev/null +++ b/.kokoro/build.sh @@ -0,0 +1,114 @@ +#!/bin/bash +set -euo pipefail + +# The repo is cloned to $KOKORO_ARTIFACTS_DIR/git/functions-framework-java +REPO_DIR="${KOKORO_ARTIFACTS_DIR}/git/functions-framework-java" +cd "${REPO_DIR}" + +# ============================================================================== +# 1. Configure Airlock and AR Credentials +# ============================================================================== +# Get OAuth token from GCE metadata server inside Kokoro VM +MAVEN_TOKEN=$(curl -s "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" -H "Metadata-Flavor: Google" | grep -oP '"access_token":"\K[^"]+') + +# Create a temporary settings.xml to configure Airlock mirror and AR auth +cat > settings.xml < + + + + airlock-mirror + Airlock Maven Central mirror + https://us-maven.pkg.dev/artifact-foundry-prod/maven-3p-trusted + * + + + + + + airlock-mirror + oauth2accesstoken + ${MAVEN_TOKEN} + + + + exit-gate-ar + oauth2accesstoken + ${MAVEN_TOKEN} + + + +EOF + +# ============================================================================== +# 2. Retrieve GPG keys from Secret Manager +# ============================================================================== +GPG_KEYRING="${KOKORO_ARTIFACTS_DIR}/gpg-keyring" +GPG_PASSPHRASE_FILE="${KOKORO_ARTIFACTS_DIR}/gpg-passphrase" + +# Read names from environment variables injected by Louhi +PROJECT_ID="${_LOUHI_SECRET_PROJECT_ID}" +KEYRING_NAME="${_LOUHI_GPG_KEYRING_SECRET_NAME}" +PASSPHRASE_NAME="${_LOUHI_GPG_PASSPHRASE_SECRET_NAME}" + +echo "Fetching secrets from project: ${PROJECT_ID}" +gcloud secrets versions access latest --secret="${KEYRING_NAME}" --project="${PROJECT_ID}" > "${GPG_KEYRING}" +gcloud secrets versions access latest --secret="${PASSPHRASE_NAME}" --project="${PROJECT_ID}" > "${GPG_PASSPHRASE_FILE}" + +export GPG_TTY=$(tty) +export GPG_PASSPHRASE=$(cat "${GPG_PASSPHRASE_FILE}") +export GNUPGHOME=/tmp/gpg +mkdir -p "${GNUPGHOME}" +gpg --batch --import "${GPG_KEYRING}" + +# ============================================================================== +# 3. Build, Sign, and Deploy +# ============================================================================== +# Detect which package to build based on the Louhi trigger tag +if [[ -n "${_LOUHI_REF_NAME:-}" ]]; then + echo "Triggered by Louhi tag: ${_LOUHI_REF_NAME}" + if [[ "${_LOUHI_REF_NAME}" == *functions-framework-api* ]]; then + PACKAGE_DIR="functions-framework-api" + elif [[ "${_LOUHI_REF_NAME}" == *function-maven-plugin* ]]; then + PACKAGE_DIR="function-maven-plugin" + elif [[ "${_LOUHI_REF_NAME}" == *java-function-invoker* ]]; then + PACKAGE_DIR="invoker" + else + echo "Unknown tag format: ${_LOUHI_REF_NAME}. Defaulting to invoker." + PACKAGE_DIR="invoker" + fi +else + # Fallback for manual/non-tag builds (e.g. testing) + echo "No Louhi tag detected. Falling back to KOKORO_JOB_NAME detection." + if [[ $KOKORO_JOB_NAME == *"function-maven-plugin"* ]]; then + PACKAGE_DIR="function-maven-plugin" + elif [[ $KOKORO_JOB_NAME == *"functions-framework-api"* ]]; then + PACKAGE_DIR="functions-framework-api" + else + PACKAGE_DIR="invoker" + fi +fi + +echo "Building package in directory: ${PACKAGE_DIR}" +cd "${PACKAGE_DIR}" + +# Run maven deploy using the temporary settings.xml +# We use altDeploymentRepository to override the deploy target without editing pom.xml +mvn clean deploy -B \ + -P sonatype-oss-release \ + --settings=../settings.xml \ + -DaltDeploymentRepository=exit-gate-ar::https://us-maven.pkg.dev/oss-exit-gate-prod/ff-releases--mavencentral \ + -Dgpg.executable=gpg \ + -Dgpg.passphrase="${GPG_PASSPHRASE}" \ + -Dgpg.homedir="${GNUPGHOME}" + +# ============================================================================== +# 4. Copy artifacts to 'artifacts/' folder for Kokoro Attestation Generation +# ============================================================================== +ARTIFACTS_DIR="${REPO_DIR}/artifacts" +mkdir -p "${ARTIFACTS_DIR}" + +# Copy target jars and poms (excluding test jars) to be captured by build.cfg +find target/ -maxdepth 1 -name "*.jar" -o -name "*.pom" | grep -v "test" | xargs -I {} cp {} "${ARTIFACTS_DIR}/" diff --git a/.kokoro/release.cfg b/.kokoro/release.cfg new file mode 100644 index 00000000..c617e165 --- /dev/null +++ b/.kokoro/release.cfg @@ -0,0 +1,23 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "functions-framework-java/.kokoro/release.sh" +container_properties { + docker_image: "us-docker.pkg.dev/artifact-foundry-prod/docker-3p-trusted/ubuntu:22.04" +} + +fileset_artifacts { + name: "manifest" + artifact_globs: "manifest.json" + error_if_missing: true + destinations { + store_attestation: false + gcs { + gcs_root_path: "oss-exit-gate-prod-projects-bucket/ff-releases/mavencentral/manifests" + populate_content_type: true + } + } + generate_sbom_from_fileset: false + generate_attestation: false +} diff --git a/.kokoro/release.sh b/.kokoro/release.sh new file mode 100755 index 00000000..58b865a6 --- /dev/null +++ b/.kokoro/release.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +cd "${KOKORO_ARTIFACTS_DIR}" + +cat > manifest.json <<'EOF' +{ + "publish_all": true +} +EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 939e5341..5d18935a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,3 +26,57 @@ information on using pull requests. This project follows [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). + +## Developing + +This project is divided into multiple packages, primarily: + +- [`functions-framework-api`](./functions-framework-api) – The interfaces for functions. +- [`java-function-invoker`](./invoker) + - `core` - The function invoker + - `testfunction` - A set of test functions + - `function-maven-plugin` - The Maven plugin for building functions + - `conformance` - A set of functions used for conformance testing + +### Setup JDK 17 / 21 + +Install JDK 17 and 21. One way to install these is through [SDK man](https://sdkman.io/). + +```sh +sdk install java 17-open +sdk install java 21-open +sdk use java 17-open +sdk use java 21-open +``` + +Verify Java version with: + +```sh +java --version +``` + +### Setup Apache Maven + +Install `mvn`: + +https://maven.apache.org/install.html + +### Formatting +This repo follows the Google Java Style guide for formatting. You can setup the +formatting tool locally using one of the options provided at +[google/google-java-format](https://github.com/google/google-java-format#google-java-format). + +## Install and Run Invoker Tests Locally + +``` +cd invoker; +mvn test; +``` + +### Running Conformance Tests Locally + +First, install Go 1.16+, then run the conformance tests with this script: + +``` +./run_conformance_tests.sh +``` diff --git a/README.md b/README.md index 2d201a8b..850b806c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,319 @@ -# Functions Framework API for Java +# Functions Framework for Java -**This is a placeholder for the Functions Framework for Java. Stay tuned for -updates.** +[![Maven Central (functions-framework-api)](https://img.shields.io/maven-central/v/com.google.cloud.functions/functions-framework-api.svg?label=functions-framework-api)](https://search.maven.org/artifact/com.google.cloud.functions/functions-framework-api) +[![Maven Central (java-function-invoker)](https://img.shields.io/maven-central/v/com.google.cloud.functions.invoker/java-function-invoker.svg?label=java-function-invoker)](https://search.maven.org/artifact/com.google.cloud.functions.invoker/java-function-invoker) +[![Maven Central (function-maven-plugin)](https://img.shields.io/maven-central/v/com.google.cloud.functions/function-maven-plugin.svg?label=function-maven-plugin)](https://search.maven.org/artifact/com.google.cloud.functions/function-maven-plugin) -For now, it only includes definitions of types used in Java function signatures. +[![Java Unit CI](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/unit.yaml/badge.svg)](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/unit.yaml) +[![Java Lint CI](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/lint.yaml/badge.svg)](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/lint.yaml) +[![Java Conformance CI](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/conformance.yaml/badge.svg)](https://github.com/GoogleCloudPlatform/functions-framework-java/actions/workflows/conformance.yaml) +![Security Scorecard](https://api.securityscorecards.dev/projects/github.com/GoogleCloudPlatform/functions-framework-java/badge) -* `com.google.cloud.functions.Context` - optional second argument of a - background function. +An open source FaaS (Function as a service) framework for writing portable +Java functions. + +The Functions Framework lets you write lightweight functions that run in many +different environments, including: + +* [Google Cloud Run functions](https://cloud.google.com/functions/) +* Your local development machine +* [Knative](https://github.com/knative/)-based environments + +## Installation + +The Functions Framework for Java uses +[Java](https://java.com/en/download/help/download_options.xml) and +[Maven](http://maven.apache.org/install.html) (the `mvn` command), +for building and deploying functions from source. + +However, it is also possible to build your functions using +[Gradle](https://gradle.org/), as JAR archives, that you will deploy with the +`gcloud` command-line. + +## Quickstart: Hello, World on your local machine + +A function is typically structured as a Maven project. We recommend using an IDE +that supports Maven to create the Maven project. Add this dependency in the +`pom.xml` file of your project: + +```xml + + com.google.cloud.functions + functions-framework-api + 1.1.2 + provided + +``` + +If you are using Gradle to build your functions, you can define the Functions +Framework dependency in your `build.gradle` project file as follows: + +```groovy + dependencies { + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' + } + +``` + +### Writing an HTTP function + +Create a file `src/main/java/com/example/HelloWorld.java` with the following +contents: + +```java +package com.example; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class HelloWorld implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) + throws Exception { + response.getWriter().write("Hello, World\n"); + } +} +``` + + +## Quickstart: Create a Background Function + +There are two ways to write a Background function, which differ in how the +payload of the incoming event is represented. In a "raw" background function +this payload is presented as a JSON-encoded Java string. In a "typed" background +function the Functions Framework deserializes the JSON payload into a Plain Old +Java Object (POJO). + +### Writing a Raw Background Function + +Create a file `src/main/java/com/example/Background.java` with the following +contents: + +```java +package com.example; + +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.util.logging.Logger; + +public class Background implements RawBackgroundFunction { + private static final Logger logger = + Logger.getLogger(Background.class.getName()); + + @Override + public void accept(String json, Context context) { + Gson gson = new Gson(); + JsonObject jsonObject = gson.fromJson(json, JsonObject.class); + logger.info("Received JSON object: " + jsonObject); + } +} +``` + +### Writing a Typed Background Function + +Create a file `src/main/java/com/example/PubSubBackground` with the following +contents: + +```java +package com.example; + +import com.google.cloud.functions.BackgroundFunction; +import com.google.cloud.functions.Context; +import java.util.Map; +import java.util.logging.Logger; + +// This is the Pub/Sub message format from the Pub/Sub emulator. +class PubSubMessage { + String data; + Map attributes; + String messageId; + String publishTime; +} + +public class PubSubBackground implements BackgroundFunction { + private static final Logger logger = + Logger.getLogger(PubSubBackground.class.getName()); + + @Override + public void accept(PubSubMessage pubSubMessage, Context context) { + logger.info("Received message with id " + context.eventId()); + } +} +``` + + +## Running a function with the Maven plugin + +The Maven plugin called `function-maven-plugin` allows you to run functions +on your development machine. + +### Configuration in `pom.xml` + +You can configure the plugin in `pom.xml`: + +```xml + + com.google.cloud.functions + function-maven-plugin + 0.10.1 + + com.example.HelloWorld + + +``` + +Then run it from the command line: + +```sh +mvn function:run +``` + +### Configuration on the command line + +You can alternatively configure the plugin with properties on the command line: + +```sh + mvn com.google.cloud.functions:function-maven-plugin:0.10.1:run \ + -Drun.functionTarget=com.example.HelloWorld +``` + +### Running the Functions Framework directly + +You can also run a function by using the Functions Framework jar directly. +Copy the Functions Framework jar to a local location like this: + +```sh +mvn dependency:copy \ + -Dartifact='com.google.cloud.functions.invoker:java-function-invoker:1.3.2' \ + -DoutputDirectory=. +``` + +In this example we use the current directory `.` but you can specify any other +directory to copy to. Then run your function: + +```sh +java -jar java-function-invoker-1.3.2 \ + --classpath myfunction.jar \ + --target com.example.HelloWorld +``` + + +## Running a function with Gradle + +From Gradle, similarily to running functions with the Functions Framework jar, +we can invoke the `Invoker` class with a `JavaExec` task. + +### Configuration in `build.gradle` + +```groovy +configurations { + invoker +} + +dependencies { + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' + invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.3.2' +} + +tasks.register("runFunction", JavaExec) { + main = 'com.google.cloud.functions.invoker.runner.Invoker' + classpath(configurations.invoker) + inputs.files(configurations.runtimeClasspath, sourceSets.main.output) + args( + '--target', project.findProperty('run.functionTarget'), + '--port', project.findProperty('run.port') ?: 8080 + ) + doFirst { + args('--classpath', files(configurations.runtimeClasspath, sourceSets.main.output).asPath) + } +} +``` + +Then in your terminal or IDE, you will be able to run the function locally with: + +```sh +gradle runFunction -Prun.functionTarget=com.example.HelloWorld \ + -Prun.port=8080 +``` + +Or if you use the Gradle wrapper provided by your Gradle project build: + +```sh +./gradlew runFunction -Prun.functionTarget=com.example.HelloWorld \ + -Prun.port=8080 +``` + +## Functions Framework configuration + +There are a number of options that can be used to configure the Functions +Framework, whether run directly or on the command line. + +### Which function to run + +A function is a Java class. You must specify the name of that class when running +the Functions Framework: + +``` +--target com.example.HelloWorld +com.example.HelloWorld +-Drun.functionTarget=com.example.HelloWorld +-Prun.functionTarget=com.example.HelloWorld +``` + +* Invoker argument: `--target com.example.HelloWorld` +* Maven `pom.xml`: `com.example.HelloWorld` +* Maven CLI argument: `-Drun.functionTarget=com.example.HelloWorld` +* Gradle CLI argument: `-Prun.functionTarget=com.example.HelloWorld` + +### Which port to listen on + +The Functions Framework is an HTTP server that directs incoming HTTP requests to +the function code. By default this server listens on port 8080. Specify an +alternative value like this: + +* Invoker argument: `--port 12345` +* Maven `pom.xml`: `12345` +* Maven CLI argument: `-Drun.port=12345` +* Gradle CLI argument: `-Prun.port=12345` + +### Function classpath + +Function code runs with a classpath that includes the function code itself and +its dependencies. The Maven plugin automatically computes the classpath based +on the dependencies expressed in `pom.xml`. When invoking the Functions +Framework directly, you must use `--classpath` to indicate how to find the code +and its dependencies. For example: + +``` +java -jar java-function-invoker-1.3.2 \ + --classpath 'myfunction.jar:/some/directory:/some/library/*' \ + --target com.example.HelloWorld +``` + +The `--classpath` option works like +[`java -classpath`](https://docs.oracle.com/en/java/javase/13/docs/specs/man/java.html#standard-options-for-java). +It is a list of entries separated by `:` (`;` on Windows), where each entry is: + +* a directory, in which case class `com.example.Foo` is looked for in a file + `com/example/Foo.class` under that directory; +* a jar file, in which case class `com.example.Foo` is looked for in a file + `com/example/Foo.class` in that jar file; +* a directory followed by `/*` (`\*` on Windows), in which case each jar file + in that directory (file called `foo.jar`) is treated the same way as if it + had been named explicitly. + +#### Simplifying the classpath + +Specifying the right classpath can be tricky. A simpler alternative is to +build the function as a "fat jar", where the function code and all its +dependencies are in a single jar file. Then `--classpath myfatfunction.jar` +is enough. An example of how this is done is the Functions Framework jar itself, +as seen +[here](https://github.com/GoogleCloudPlatform/functions-framework-java/blob/b627f28/invoker/core/pom.xml#L153). + +Alternatively, you can arrange for your jar to have its own classpath, as +described +[here](https://maven.apache.org/shared/maven-archiver/examples/classpath.html). diff --git a/function-maven-plugin/CHANGELOG.md b/function-maven-plugin/CHANGELOG.md new file mode 100644 index 00000000..6004a955 --- /dev/null +++ b/function-maven-plugin/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## [1.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v1.0.0...function-maven-plugin-v1.0.1) (2026-04-10) + + +### Dependencies + +* update Functions Framework Invoker dependency to 2.0.1 ([#385](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/385)) ([9e69efe](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9e69efe14b779ab21a4f85e54c51283d0ec1a2ec)) + +## [1.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.2...function-maven-plugin-v1.0.0) (2025-11-06) + + +### ⚠ BREAKING CHANGES + +* update functions-framework-invoker dependency version to 2.0.0 for ff maven plugin ([#368](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/368)) +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + + +### Miscellaneous Chores + +* update functions-framework-invoker dependency version to 2.0.0 for ff maven plugin ([#368](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/368)) ([9dd6008](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9dd6008b254d7188a675e40f6ff234b82b07a11b)) + +## [0.11.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.1...function-maven-plugin-v0.11.2) (2025-10-21) + + +### Bug Fixes + +* several minor fixes ([#341](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/341)) ([e152e0d](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/e152e0d697c1a3f4a90a6480347b4ddf0b73f3e3)) +* update dependency com.google.cloud.functions.invoker to 1.4.3 ([#354](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/354)) ([0e24d42](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/0e24d42573d104f8b73921513f6f296ada4a255c)) + +## [0.11.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.0...function-maven-plugin-v0.11.1) (2024-11-27) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + +## [0.11.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.10.1...function-maven-plugin-v0.11.0) (2023-05-31) + + +### Features + +* Add --gen2 support ([#172](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/172)) ([3b7b701](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/3b7b70152ca614e2a3b52f1a7c07d89221095a7d)) +* Define strongly typed function interface ([#186](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/186)) ([5264e35](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/5264e35b2522a789d65f0e0fd9bb5584694529eb)) diff --git a/function-maven-plugin/pom.xml b/function-maven-plugin/pom.xml new file mode 100644 index 00000000..6dca952c --- /dev/null +++ b/function-maven-plugin/pom.xml @@ -0,0 +1,178 @@ + + 4.0.0 + + + org.sonatype.oss + oss-parent + 9 + + + com.google.cloud.functions + function-maven-plugin + maven-plugin + 1.0.2-SNAPSHOT + Functions Framework Plugin + A Maven plugin that allows functions to be deployed, and to be run locally + using the Java Functions Framework. + https://github.com/GoogleCloudPlatform/functions-framework-java + + + http://github.com/GoogleCloudPlatform/functions-framework-java + scm:git:git://github.com/GoogleCloudPlatform/functions-framework-java.git + scm:git:ssh://git@github.com/GoogleCloudPlatform/functions-framework-java.git + HEAD + + + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 17 + 17 + + + + + + org.apache.maven + maven-plugin-api + 3.9.14 + provided + + + org.apache.maven + maven-core + 3.9.14 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.2 + provided + + + + com.google.cloud.functions.invoker + java-function-invoker + 2.0.1 + + + + com.google.cloud.tools + appengine-maven-plugin + 2.8.7 + jar + + + + com.google.truth + truth + 1.4.5 + test + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.15.2 + + + help-goal + + helpmojo + + + + + + + + + sonatype-oss-release + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 + true + + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ + + + + + + + diff --git a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java new file mode 100644 index 00000000..08bff714 --- /dev/null +++ b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java @@ -0,0 +1,395 @@ +package com.google.cloud.functions.plugin; + +import com.google.cloud.tools.appengine.operations.CloudSdk; +import com.google.cloud.tools.appengine.operations.Gcloud; +import com.google.cloud.tools.appengine.operations.cloudsdk.CloudSdkNotFoundException; +import com.google.cloud.tools.appengine.operations.cloudsdk.CloudSdkOutOfDateException; +import com.google.cloud.tools.appengine.operations.cloudsdk.CloudSdkVersionFileException; +import com.google.cloud.tools.appengine.operations.cloudsdk.process.ProcessHandlerException; +import com.google.cloud.tools.managedcloudsdk.BadCloudSdkVersionException; +import com.google.cloud.tools.managedcloudsdk.ManagedCloudSdk; +import com.google.cloud.tools.managedcloudsdk.UnsupportedOsException; +import com.google.cloud.tools.managedcloudsdk.Version; +import com.google.cloud.tools.maven.cloudsdk.CloudSdkChecker; +import com.google.cloud.tools.maven.cloudsdk.CloudSdkDownloader; +import com.google.cloud.tools.maven.cloudsdk.CloudSdkMojo; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Execute; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; + +/** Deploy a Java function via mvn functions:deploy with optional flags. */ +@Mojo( + name = "deploy", + defaultPhase = LifecyclePhase.GENERATE_RESOURCES, + requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +@Execute(phase = LifecyclePhase.NONE) +public class DeployFunction extends CloudSdkMojo { + + /** The Google Cloud Platform project Id to use for this invocation. */ + @Parameter(alias = "deploy.projectId", property = "function.deploy.projectId") + protected String projectId; + + /** + * ID of the function or fully qualified identifier for the function. This property must be + * specified if any of the other arguments in this group are specified. + */ + @Parameter(alias = "deploy.name", property = "function.deploy.name", required = true) + String name; + + /** + * The Cloud region for the function. Overrides the default functions/region property value for + * this command invocation. + */ + @Parameter(alias = "deploy.region", property = "function.deploy.region") + String region; + + /** + * If set, makes this a public function. This will allow all callers, without checking + * authentication. + */ + @Parameter( + alias = "deploy.allowunauthenticated", + property = "function.deploy.allowunauthenticated") + Boolean allowUnauthenticated; + + /** + * Name of a Google Cloud Function (as defined in source code) that will be executed. Defaults to + * the resource name suffix, if not specified. + * + *

For Java this is fully qualified class name implementing the function, for example + * `com.google.testfunction.HelloWorld`. + */ + @Parameter(alias = "deploy.functiontarget", property = "function.deploy.functiontarget") + String functionTarget; + + /** Override the .gcloudignore file and use the specified file instead. */ + @Parameter(alias = "deploy.ignorefile", property = "function.deploy.ignorefile") + String ignoreFile; + + /** + * Limit on the amount of memory the function can use. + * + *

Allowed values are: 128MB, 256MB, 512MB, 1024MB, and 2048MB. By default, a new function is + * limited to 256MB of memory. When deploying an update to an existing function, the function will + * keep its old memory limit unless you specify this flag. + */ + @Parameter(alias = "deploy.memory", property = "function.deploy.memory") + String memory; + + /** If specified, then the function will be retried in case of a failure. */ + @Parameter(alias = "deploy.retry", property = "function.deploy.retry") + String retry; + + /** + * Runtime in which to run the function. + * + *

Required when deploying a new function; optional when updating an existing function. Default + * to Java17. + */ + @Parameter( + alias = "deploy.runtime", + defaultValue = "java17", + property = "function.deploy.runtime") + String runtime = "java17"; + + /** + * The email address of the IAM service account associated with the function at runtime. The + * service account represents the identity of the running function, and determines what + * permissions the function has. + * + *

If not provided, the function will use the project's default service account. + */ + @Parameter(alias = "deploy.serviceaccount", property = "function.deploy.serviceaccount") + String serviceAccount; + + /** Location of source code to deploy. */ + @Parameter(alias = "deploy.source", property = "function.deploy.source") + String source; + + /** + * This flag's value is the name of the Google Cloud Storage bucket in which source code will be + * stored. + */ + @Parameter(alias = "deploy.stagebucket", property = "function.deploy.stagebucket") + String stageBucket; + + /** + * The function execution timeout, e.g. 30s for 30 seconds. Defaults to original value for + * existing function or 60 seconds for new functions. Cannot be more than 540s. + */ + @Parameter(alias = "deploy.timeout", property = "function.deploy.timeout") + String timeout; + + /** + * List of label KEY=VALUE pairs to update. If a label exists its value is modified, otherwise a + * new label is created. + */ + @Parameter(alias = "deploy.updatelabels", property = "function.deploy.updatelabels") + List updateLabels; + + /** + * Function will be assigned an endpoint, which you can view by using the describe command. Any + * HTTP request (of a supported type) to the endpoint will trigger function execution. Supported + * HTTP request types are: POST, PUT, GET, DELETE, and OPTIONS. + */ + @Parameter(alias = "deploy.triggerhttp", property = "function.deploy.triggerhttp") + Boolean triggerHttp; + + /** + * Name of Pub/Sub topic. Every message published in this topic will trigger function execution + * with message contents passed as input data. + */ + @Parameter(alias = "deploy.triggertopic", property = "function.deploy.triggertopic") + String triggerTopic; + + /** + * Specifies which action should trigger the function. For a list of acceptable values, call + * gcloud functions event-types list. + */ + @Parameter(alias = "deploy.triggerevent", property = "function.deploy.triggerevent") + String triggerEvent; + + /** + * Specifies which resource from {@link #triggerEvent} is being observed. E.g. if {@link + * #triggerEvent} is providers/cloud.storage/eventTypes/object.change, {@link #triggerResource} + * must be a bucket name. For a list of expected resources, run {@code gcloud functions + * event-types list}. + */ + @Parameter(alias = "deploy.triggerresource", property = "function.deploy.triggerresource") + String triggerResource; + + /** + * The VPC Access connector that the function can connect to. It can be either the fully-qualified + * URI, or the short name of the VPC Access connector resource. If the short name is used, the + * connector must belong to the same project. The format of this field is either + * projects/${PROJECT}/locations/${LOCATION}/connectors/${CONNECTOR} or ${CONNECTOR}, where + * ${CONNECTOR} is the short name of the VPC Access connector. + */ + @Parameter(alias = "deploy.vpcconnector", property = "function.deploy.vpcconnector") + String vpcConnector; + + /** + * Sets the maximum number of instances for the function. A function execution that would exceed + * max-instances times out. + */ + @Parameter(alias = "deploy.maxinstances", property = "function.deploy.maxinstances") + Integer maxInstances; + + /** + * List of key-value pairs to set as environment variables. All existing environment variables + * will be removed first. + */ + @Parameter(alias = "deploy.setenvvars", property = "function.deploy.setenvvars") + Map environmentVariables; + + /** + * Path to a local YAML file with definitions for all environment variables. All existing + * environment variables will be removed before the new environment variables are added. + */ + @Parameter(alias = "deploy.envvarsfile", property = "function.deploy.envvarsfile") + String envVarsFile; + + /** + * List of key-value pairs to set as build environment variables. All existing environment + * variables will be removed first. + */ + @Parameter(alias = "deploy.setbuildenvvars", property = "function.deploy.setbuildenvvars") + Map buildEnvironmentVariables; + + /** + * Path to a local YAML file with definitions for all build environment variables. All existing + * environment variables will be removed before the new environment variables are added. + */ + @Parameter(alias = "deploy.buildenvvarsfile", property = "function.deploy.buildenvvarsfile") + String buildEnvVarsFile; + + /** If true, deploys the function in the 2nd Generation Environment. */ + @Parameter(alias = "deploy.gen2", property = "function.deploy.gen2", defaultValue = "false") + boolean gen2; + + boolean hasEnvVariables() { + return (this.environmentVariables != null && !this.environmentVariables.isEmpty()); + } + + boolean hasBuildEnvVariables() { + return (this.buildEnvironmentVariables != null && !this.buildEnvironmentVariables.isEmpty()); + } + + // Select a downloaded Cloud SDK or a user defined Cloud SDK version. + static Function newManagedSdkFactory() { + return version -> { + try { + if (Strings.isNullOrEmpty(version)) { + return ManagedCloudSdk.newManagedSdk(); + } else { + return ManagedCloudSdk.newManagedSdk(new Version(version)); + } + } catch (UnsupportedOsException | BadCloudSdkVersionException ex) { + throw new RuntimeException(ex); + } + }; + } + + CloudSdk buildCloudSdkMinimal() { + return buildCloudSdk( + (CloudSdkMojo) this, new CloudSdkChecker(), new CloudSdkDownloader(newManagedSdkFactory())); + } + + static CloudSdk buildCloudSdk( + CloudSdkMojo mojo, CloudSdkChecker cloudSdkChecker, CloudSdkDownloader cloudSdkDownloader) { + + try { + if (mojo.getCloudSdkHome() != null) { + // Check if the user has defined a specific Cloud SDK. + CloudSdk cloudSdk = new CloudSdk.Builder().sdkPath(mojo.getCloudSdkHome()).build(); + + if (mojo.getCloudSdkVersion() != null) { + cloudSdkChecker.checkCloudSdk(cloudSdk, mojo.getCloudSdkVersion()); + } + + return cloudSdk; + } else { + + return new CloudSdk.Builder() + .sdkPath( + cloudSdkDownloader.downloadIfNecessary( + mojo.getCloudSdkVersion(), + mojo.getLog(), + Collections.emptyList(), + mojo.getMavenSession().isOffline())) + .build(); + } + } catch (CloudSdkNotFoundException + | CloudSdkOutOfDateException + | CloudSdkVersionFileException ex) { + throw new RuntimeException(ex); + } + } + + /** Return a Gcloud instance using global configuration. */ + public Gcloud getGcloud() { + return Gcloud.builder(buildCloudSdkMinimal()) + .setMetricsEnvironment(this.getArtifactId(), this.getArtifactVersion()) + .setCredentialFile(this.getServiceAccountKeyFile()) + .build(); + } + + /** Return the list of command parameters to give to the Cloud SDK for execution */ + public List getCommands() { + List commands = new ArrayList<>(); + + commands.add("functions"); + commands.add("deploy"); + commands.add(name); + if (gen2) { + commands.add("--gen2"); + } + if (region != null) { + commands.add("--region=" + region); + } + if (triggerResource == null && triggerTopic == null && triggerEvent == null) { + commands.add("--trigger-http"); + } + if (triggerResource != null) { + commands.add("--trigger-resource=" + triggerResource); + } + if (triggerTopic != null) { + commands.add("--trigger-topic=" + triggerTopic); + } + if (triggerEvent != null) { + commands.add("--trigger-event=" + triggerEvent); + } + if (allowUnauthenticated != null) { + if (allowUnauthenticated) { + commands.add("--allow-unauthenticated"); + } else { + commands.add("--no-allow-unauthenticated"); + } + } + if (functionTarget != null) { + commands.add("--entry-point=" + functionTarget); + } + if (ignoreFile != null) { + commands.add("--ignore-file=" + ignoreFile); + } + if (memory != null) { + commands.add("--memory=" + memory); + } + if (retry != null) { + commands.add("--retry=" + retry); + } + if (serviceAccount != null) { + commands.add("--service-account=" + serviceAccount); + } + if (source != null) { + commands.add("--source=" + source); + } + if (stageBucket != null) { + commands.add("--stage-bucket=" + stageBucket); + } + if (timeout != null) { + commands.add("--timeout=" + timeout); + } + if (updateLabels != null && !updateLabels.isEmpty()) { + commands.add("--update-labels=" + String.join(",", updateLabels)); + } + + if (vpcConnector != null) { + commands.add("--vpc-connector=" + vpcConnector); + } + if (maxInstances != null) { + commands.add("--max-instances=" + maxInstances); + } + + if (hasEnvVariables()) { + Joiner.MapJoiner mapJoiner = Joiner.on(",").withKeyValueSeparator("="); + commands.add("--set-env-vars=" + mapJoiner.join(environmentVariables)); + } + if (envVarsFile != null) { + commands.add("--env-vars-file=" + envVarsFile); + } + if (hasBuildEnvVariables()) { + Joiner.MapJoiner mapJoiner = Joiner.on(",").withKeyValueSeparator("="); + commands.add("--set-build-env-vars=" + mapJoiner.join(buildEnvironmentVariables)); + } + if (buildEnvVarsFile != null) { + commands.add("--build-env-vars-file=" + buildEnvVarsFile); + } + commands.add("--runtime=" + runtime); + + if (projectId != null) { + commands.add("--project=" + projectId); + } + + commands.add("--quiet"); + return Collections.unmodifiableList(commands); + } + + @Override + public void execute() throws MojoExecutionException { + try { + Gcloud gcloud = getGcloud(); + List params = getCommands(); + System.out.println("Executing Cloud SDK command: gcloud " + String.join(" ", params)); + gcloud.runCommand(params); + } catch (CloudSdkNotFoundException | IOException | ProcessHandlerException ex) { + Logger.getLogger(DeployFunction.class.getName()) + .log(Level.SEVERE, "Function deployment failed", ex); + throw new MojoExecutionException("Function deployment failed", ex); + } + } +} diff --git a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/RunFunction.java b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/RunFunction.java new file mode 100644 index 00000000..c79fcd05 --- /dev/null +++ b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/RunFunction.java @@ -0,0 +1,83 @@ +package com.google.cloud.functions.plugin; + +import com.google.cloud.functions.invoker.runner.Invoker; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Execute; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; + +/** + * Runs a function using the Java Functions Framework. Typically this plugin is configured in one of + * two ways. Either in the pom.xml file, like this... + * + *

{@code
+ * 
+ *   com.google.cloud.functions
+ *   function-maven-plugin
+ *   1.0.0-alpha-2-rc3
+ *   
+ *     com.example.function.Echo
+ *   
+ * 
+ * }
+ * + * ...and then run using {@code mvn function:run}. Or using properties on the command line, like + * this...
+ * + *
{@code
+ * mvn com.google.cloud.functions:function:1.0.0-alpha-2-rc3:run \
+ *     -Drun.functionTarget=com.example.function.Echo
+ * }
+ */ +@Mojo( + name = "run", + defaultPhase = LifecyclePhase.GENERATE_RESOURCES, + requiresDependencyResolution = ResolutionScope.RUNTIME, + requiresDependencyCollection = ResolutionScope.RUNTIME) +@Execute(phase = LifecyclePhase.COMPILE) +public class RunFunction extends AbstractMojo { + + /** + * The name of the function to run. This is the name of a class that implements one of the + * interfaces in {@code com.google.cloud.functions}. + */ + @Parameter(property = "run.functionTarget") + private String functionTarget; + + /** The port on which the HTTP server wrapping the function should listen. */ + @Parameter(property = "run.port", defaultValue = "8080") + private Integer port; + + /** + * Used to determine what classpath needs to be used to load the function. This parameter is + * injected by Maven and can't be set explicitly in a pom.xml file. + */ + @Parameter(defaultValue = "${project.runtimeClasspathElements}", readonly = true, required = true) + private List runtimePath; + + public void execute() throws MojoExecutionException { + String classpath = String.join(File.pathSeparator, runtimePath); + List args = new ArrayList<>(); + args.addAll(Arrays.asList("--classpath", classpath)); + if (functionTarget != null) { + args.addAll(Arrays.asList("--target", functionTarget)); + } + if (port != null) { + args.addAll(Arrays.asList("--port", String.valueOf(port))); + } + try { + getLog().info("Calling Invoker with " + args); + Invoker.main(args.toArray(new String[0])); + } catch (Exception e) { + getLog().error("Could not invoke function: " + e, e); + throw new MojoExecutionException("Could not invoke function", e); + } + } +} diff --git a/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java b/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java new file mode 100644 index 00000000..6f107cff --- /dev/null +++ b/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java @@ -0,0 +1,60 @@ +package com.google.cloud.functions.plugin; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class DeployFunctionTest { + + @Test + public void testDeployFunctionCommandLine() { + DeployFunction mojo = new DeployFunction(); + mojo.envVarsFile = "myfile"; + mojo.buildEnvVarsFile = "myfile2"; + mojo.functionTarget = "function"; + mojo.ignoreFile = "ff"; + mojo.maxInstances = new Integer(3); + mojo.memory = "234"; + mojo.name = "a name"; + mojo.region = "a region"; + mojo.retry = "44"; + mojo.source = "a source"; + mojo.stageBucket = "a bucket"; + mojo.timeout = "timeout"; + mojo.vpcConnector = "a connector"; + mojo.triggerHttp = true; + mojo.allowUnauthenticated = true; + mojo.environmentVariables = ImmutableMap.of("env1", "a", "env2", "b"); + mojo.buildEnvironmentVariables = ImmutableMap.of("env1", "a", "env2", "b"); + List expected = + ImmutableList.of( + "functions", + "deploy", + "a name", + "--region=a region", + "--trigger-http", + "--allow-unauthenticated", + "--entry-point=function", + "--ignore-file=ff", + "--memory=234", + "--retry=44", + "--source=a source", + "--stage-bucket=a bucket", + "--timeout=timeout", + "--vpc-connector=a connector", + "--max-instances=3", + "--set-env-vars=env1=a,env2=b", + "--env-vars-file=myfile", + "--set-build-env-vars=env1=a,env2=b", + "--build-env-vars-file=myfile2", + "--runtime=java17", + "--quiet"); + assertThat(mojo.getCommands()).isEqualTo(expected); + } +} diff --git a/functions-framework-api/CHANGELOG.md b/functions-framework-api/CHANGELOG.md new file mode 100644 index 00000000..c2999393 --- /dev/null +++ b/functions-framework-api/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +## [2.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v2.0.0...functions-framework-api-v2.0.1) (2026-04-10) + + +### Bug Fixes + +* update CloudEvents dependencyFixes [#378](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/378) ([#379](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/379)) ([de983d9](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/de983d907ebba9fba6809e816f6c70f207d6c252)) + +## [2.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.4...functions-framework-api-v2.0.0) (2025-11-05) + + +### ⚠ BREAKING CHANGES + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + +## [1.1.4](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.3...functions-framework-api-v1.1.4) (2024-11-22) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + +## [1.1.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.2...functions-framework-api-v1.1.3) (2024-10-05) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#297](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/297)) ([8f1fd84](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/8f1fd84ca4cc43b2e93b66fe160f78a868b55ffe)) + +## [1.1.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.1...functions-framework-api-v1.1.2) (2024-09-27) + + +### Bug Fixes + +* use a version of maven-source-plugin that's available in mavencentral ([#288](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/288)) ([f8c1d57](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f8c1d575660312101532a1f579c0492593248f37)) + +## [1.1.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.0...functions-framework-api-v1.1.1) (2024-06-27) + + +### Bug Fixes + +* release 1.1.1; this updates transitive dependencies on jackson ([#277](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/277)) ([7e4ca5d](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/7e4ca5d15d5b200787b999f82da6d6cd1cbd4b7e)) + +## [1.1.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.0.4...functions-framework-api-v1.1.0) (2023-05-31) + + +### Features + +* Define strongly typed function interface ([#186](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/186)) ([5264e35](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/5264e35b2522a789d65f0e0fd9bb5584694529eb)) + + +### Bug Fixes + +* remove warnings from mvn install ([#66](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/66)) ([270f4ec](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/270f4ec7936239eff9c00b8d3ff0f09a8615b9c9)) diff --git a/functions-framework-api/pom.xml b/functions-framework-api/pom.xml index a60105ae..250d5c2b 100644 --- a/functions-framework-api/pom.xml +++ b/functions-framework-api/pom.xml @@ -13,29 +13,199 @@ See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 - com.google.cloud + + + org.sonatype.oss + oss-parent + 9 + + + com.google.cloud.functions functions-framework-api - 1.0.0-alpha-1 + 2.0.2-SNAPSHOT + Functions Framework Java API + An open source FaaS (Function as a service) framework for writing portable Java functions. + https://github.com/GoogleCloudPlatform/functions-framework-java UTF-8 - 3.8.0 + 3.15.0 + 3.12.0 5.3.2 + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + + + scm:git:https://github.com/GoogleCloudPlatform/functions-framework-java.git + scm:git:git@github.com:GoogleCloudPlatform/functions-framework-java.git + https://github.com/GoogleCloudPlatform/functions-framework-java + HEAD + + + + + io.cloudevents + cloudevents-api + 4.0.2 + + + - org.apache.maven.plugins maven-compiler-plugin ${maven-compiler-plugin.version} - 1.8 - 1.8 + 17 + 17 + + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-release-plugin + 3.3.1 + + + default + + perform + + + functions-framework-api/pom.xml + + + + + + + + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + true + true + UTF-8 + UTF-8 + UTF-8 + + -XDignore.symbol.file + + true + 8 + false + + + + attach-docs + post-integration-test + + jar + + + + + + - \ No newline at end of file + + + sonatype-oss-release + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 + true + + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ + + + + + + + diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/BackgroundFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/BackgroundFunction.java new file mode 100644 index 00000000..5dc0a97e --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/BackgroundFunction.java @@ -0,0 +1,62 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +/** + * Represents a Cloud Function that is activated by an event and parsed into a user-supplied class. + * The payload of the event is a JSON object, which is deserialized into a user-defined class as + * described for Gson. + * + *

Here is an example of an implementation that accesses the {@code messageId} property from a + * payload that matches a user-defined {@code PubSubMessage} class: + * + * + *

+ * public class Example implements{@code BackgroundFunction} {
+ *   private static final Logger logger = Logger.getLogger(Example.class.getName());
+ *
+ *  {@code @Override}
+ *   public void accept(PubSubMessage pubSubMessage, Context context) {
+ *     logger.info("Got messageId " + pubSubMessage.messageId);
+ *   }
+ * }
+ *
+ * // Where PubSubMessage is a user-defined class like this:
+ * public class PubSubMessage {
+ *   String data;
+ *  {@code Map} attributes;
+ *   String messageId;
+ *   String publishTime;
+ * }
+ * 
+ * + * @param the class of payload objects that this function expects. + */ +@FunctionalInterface +public interface BackgroundFunction { + /** + * Called to service an incoming event. This interface is implemented by user code to provide the + * action for a given background function. If this method throws any exception (including any + * {@link Error}) then the HTTP response will have a 500 status code. + * + * @param payload the payload of the event, deserialized from the original JSON string. + * @param context the context of the event. This is a set of values that every event has, + * separately from the payload, such as timestamp and event type. + * @throws Exception to produce a 500 status code in the HTTP response. + */ + void accept(T payload, Context context) throws Exception; +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/CloudEventsFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/CloudEventsFunction.java new file mode 100644 index 00000000..7e34ae68 --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/CloudEventsFunction.java @@ -0,0 +1,20 @@ +package com.google.cloud.functions; + +import io.cloudevents.CloudEvent; + +/** + * Represents a Cloud Function that is activated by an event and parsed into a {@link CloudEvent} + * object. + */ +@FunctionalInterface +public interface CloudEventsFunction { + /** + * Called to service an incoming event. This interface is implemented by user code to provide the + * action for a given background function. If this method throws any exception (including any + * {@link Error}) then the HTTP response will have a 500 status code. + * + * @param event the event. + * @throws Exception to produce a 500 status code in the HTTP response. + */ + void accept(CloudEvent event) throws Exception; +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/Context.java b/functions-framework-api/src/main/java/com/google/cloud/functions/Context.java index 8969a8f9..5100451e 100644 --- a/functions-framework-api/src/main/java/com/google/cloud/functions/Context.java +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/Context.java @@ -14,17 +14,54 @@ package com.google.cloud.functions; +import java.util.Collections; +import java.util.Map; + /** An interface for event function context. */ public interface Context { - /** Returns event ID. */ + /** + * Returns event ID. + * + * @return event ID + */ String eventId(); - /** Returns event timestamp. */ + /** + * Returns event timestamp. + * + * @return event timestamp + */ String timestamp(); - /** Returns event type. */ + /** + * Returns event type. + * + * @return event type + */ String eventType(); - /** Returns event resource. */ + /** + * Returns event resource. + * + * @return event resource + */ String resource(); + + /** + * Returns additional attributes from this event. For CloudEvents, the entries in this map will + * include the required + * attributes and may include optional + * attributes and + * extension attributes. + * + *

The map returned by this method may be empty but is never null. + * + * @return additional attributes form this event. + */ + default Map attributes() { + return Collections.emptyMap(); + } } diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/HttpFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpFunction.java new file mode 100644 index 00000000..6357724d --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpFunction.java @@ -0,0 +1,30 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +/** Represents a Cloud Function that is activated by an HTTP request. */ +@FunctionalInterface +public interface HttpFunction { + /** + * Called to service an incoming HTTP request. This interface is implemented by user code to + * provide the action for a given function. If the method throws any exception (including any + * {@link Error}) then the HTTP response will have a 500 status code. + * + * @param request a representation of the incoming HTTP request. + * @param response an object that can be used to provide the corresponding HTTP response. + * @throws Exception if thrown, the HTTP response will have a 500 status code. + */ + void service(HttpRequest request, HttpResponse response) throws Exception; +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/HttpMessage.java b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpMessage.java new file mode 100644 index 00000000..24a70c9b --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpMessage.java @@ -0,0 +1,114 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Represents an HTTP message, either an HTTP request or a part of a multipart HTTP request. */ +public interface HttpMessage { + /** + * Returns the value of the {@code Content-Type} header, if any. + * + * @return the content type, if any. + */ + Optional getContentType(); + + /** + * Returns the numeric value of the {@code Content-Length} header. + * + * @return the content length. + */ + long getContentLength(); + + /** + * Returns the character encoding specified in the {@code Content-Type} header, or {@code + * Optional.empty()} if there is no {@code Content-Type} header or it does not have the {@code + * charset} parameter. + * + * @return the character encoding for the content type, if one is specified. + */ + Optional getCharacterEncoding(); + + /** + * Returns an {@link InputStream} that can be used to read the body of this HTTP request. Every + * call to this method on the same {@link HttpMessage} will return the same object. This method is + * typically used to read binary data. If the body is text, the {@link #getReader()} method is + * more appropriate. + * + * @return an {@link InputStream} that can be used to read the body of this HTTP request. + * @throws IOException if a valid {@link InputStream} cannot be returned for some reason. + * @throws IllegalStateException if {@link #getReader()} has already been called on this instance. + */ + InputStream getInputStream() throws IOException; + + /** + * Returns a {@link BufferedReader} that can be used to read the text body of this HTTP request. + * Every call to this method on the same {@link HttpMessage} will return the same object. + * + * @return a {@link BufferedReader} that can be used to read the text body of this HTTP request. + * @throws IOException if a valid {@link BufferedReader} cannot be returned for some reason. + * @throws IllegalStateException if {@link #getInputStream()} has already been called on this + * instance. + */ + BufferedReader getReader() throws IOException; + + /** + * Returns a map describing the headers of this HTTP request, or this part of a multipart request. + * If the headers look like this... + * + *

+   *   Content-Type: text/plain
+   *   Some-Header: some value
+   *   Some-Header: another value
+   * 
+ * + * ...then the returned value will map {@code "Content-Type"} to a one-element list containing + * {@code "text/plain"}, and {@code "Some-Header"} to a two-element list containing {@code "some + * value"} and {@code "another value"}. + * + * @return a map where each key is an HTTP header and the corresponding {@code List} value has one + * element for each occurrence of that header. + */ + Map> getHeaders(); + + /** + * Convenience method that returns the value of the first header with the given name. If the + * headers look like this... + * + *
+   *   Content-Type: text/plain
+   *   Some-Header: some value
+   *   Some-Header: another value
+   * 
+ * + * ...then {@code getFirstHeader("Some-Header")} will return {@code Optional.of("some value")}, + * and {@code getFirstHeader("Another-Header")} will return {@code Optional.empty()}. + * + * @param name an HTTP header name. + * @return the first value of the given header, if present. + */ + default Optional getFirstHeader(String name) { + List headers = getHeaders().get(name); + if (headers == null || headers.isEmpty()) { + return Optional.empty(); + } + return Optional.of(headers.get(0)); + } +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/HttpRequest.java b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpRequest.java new file mode 100644 index 00000000..d50f1cf7 --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpRequest.java @@ -0,0 +1,107 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Represents the contents of an HTTP request that is being serviced by a Cloud Function. */ +public interface HttpRequest extends HttpMessage { + /** + * The HTTP method of this request, such as {@code "POST"} or {@code "GET"}. + * + * @return the HTTP method of this request. + */ + String getMethod(); + + /** + * The full URI of this request as it arrived at the server. + * + * @return the full URI of this request. + */ + String getUri(); + + /** + * The path part of the URI for this request, without any query. If the full URI is {@code + * http://foo.com/bar/baz?this=that}, then this method will return {@code /bar/baz}. + * + * @return the path part of the URI for this request. + */ + String getPath(); + + /** + * The query part of the URI for this request. If the full URI is {@code + * http://foo.com/bar/baz?this=that}, then this method will return {@code this=that}. If there is + * no query part, the returned {@code Optional} is empty. + * + * @return the query part of the URI, if any. + */ + Optional getQuery(); + + /** + * The query parameters of this request. If the full URI is {@code + * http://foo.com/bar?thing=thing1&thing=thing2&cat=hat}, then the returned map will map {@code + * thing} to the list {@code ["thing1", "thing2"]} and {@code cat} to the list with the single + * element {@code "hat"}. + * + * @return a map where each key is the name of a query parameter and the corresponding {@code + * List} value indicates every value that was associated with that name. + */ + Map> getQueryParameters(); + + /** + * The first query parameter with the given name, if any. If the full URI is {@code + * http://foo.com/bar?thing=thing1&thing=thing2&cat=hat}, then {@code + * getFirstQueryParameter("thing")} will return {@code Optional.of("thing1")} and {@code + * getFirstQueryParameter("something")} will return {@code Optional.empty()}. This is a more + * convenient alternative to {@link #getQueryParameters}. + * + * @param name a query parameter name. + * @return the first query parameter value with the given name, if any. + */ + default Optional getFirstQueryParameter(String name) { + List parameters = getQueryParameters().get(name); + if (parameters == null || parameters.isEmpty()) { + return Optional.empty(); + } + return Optional.of(parameters.get(0)); + } + + /** + * Represents one part inside a multipart ({@code multipart/form-data}) HTTP request. Each such + * part can have its own HTTP headers, which can be retrieved with the methods inherited from + * {@link HttpMessage}. + */ + interface HttpPart extends HttpMessage { + /** + * Returns the filename associated with this part, if any. + * + * @return the filename associated with this part, if any. + */ + Optional getFileName(); + } + + /** + * Returns the parts inside this multipart ({@code multipart/form-data}) HTTP request. Each entry + * in the returned map has the name of the part as its key and the contents as the associated + * value. + * + * @return a map from part names to part contents. + * @throws IllegalStateException if the {@link #getContentType() content type} is not {@code + * multipart/form-data}. + */ + Map getParts(); +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/HttpResponse.java b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpResponse.java new file mode 100644 index 00000000..c3f87ea2 --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpResponse.java @@ -0,0 +1,114 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Represents the contents of an HTTP response that is being sent by a Cloud Function in response to + * an HTTP request. + */ +public interface HttpResponse { + /** + * Sets the numeric HTTP status + * code to use in the response. Most often this will be 200, which is the OK status. The named + * constants in {@link java.net.HttpURLConnection}, such as {@link + * java.net.HttpURLConnection#HTTP_OK HTTP_OK}, can be used as an alternative to writing numbers + * in your source code. + * + * @param code the status code. + */ + void setStatusCode(int code); + + /** + * Sets the numeric HTTP status + * code and reason message to use in the response. For example
+ * {@code setStatusCode(400, "Something went wrong")}. The named constants in {@link + * java.net.HttpURLConnection}, such as {@link java.net.HttpURLConnection#HTTP_BAD_REQUEST + * HTTP_BAD_REQUEST}, can be used as an alternative to writing numbers in your source code. + * + * @param code the status code. + * @param message the status message. + */ + void setStatusCode(int code, String message); + + /** + * Sets the value to use for the {@code Content-Type} header in the response. This may include a + * character encoding, for example {@code setContentType("text/plain; charset=utf-8")}. + * + * @param contentType the content type. + */ + void setContentType(String contentType); + + /** + * Returns the {@code Content-Type} that was previously set by {@link #setContentType}, or by + * {@link #appendHeader} with a header name of {@code Content-Type}. If no {@code Content-Type} + * has been set, returns {@code Optional.empty()}. + * + * @return the content type, if any. + */ + Optional getContentType(); + + /** + * Includes the given header name with the given value in the response. This method may be called + * several times for the same header, in which case the response will contain the header the same + * number of times. + * + * @param header an HTTP header, such as {@code Content-Type}. + * @param value a value to associate with that header. + */ + void appendHeader(String header, String value); + + /** + * Returns the headers that have been defined for the response so far. This will contain at least + * the headers that have been set via {@link #appendHeader} or {@link #setContentType}, and may + * contain additional headers such as {@code Date}. + * + * @return a map where each key is a header name and the corresponding {@code List} value has one + * entry for every value associated with that header. + */ + Map> getHeaders(); + + /** + * Returns an {@link OutputStream} that can be used to write the body of the response. This method + * is typically used to write binary data. If the body is text, the {@link #getWriter()} method is + * more appropriate. + * + * @return the output stream. + * @throws IOException if a valid {@link OutputStream} cannot be returned for some reason. + * @throws IllegalStateException if {@link #getWriter} has already been called on this instance. + */ + OutputStream getOutputStream() throws IOException; + + /** + * Returns a {@link BufferedWriter} that can be used to write the text body of the response. If + * the written text will not be US-ASCII, you should specify a character encoding by calling + * {@link #setContentType setContentType("text/foo; charset=bar")} or {@link #appendHeader + * appendHeader("Content-Type", "text/foo; charset=bar")} before calling this method. + * + * @return the writer. + * @throws IOException if a valid {@link BufferedWriter} cannot be returned for some reason. + * @throws IllegalStateException if {@link #getOutputStream} has already been called on this + * instance. + */ + BufferedWriter getWriter() throws IOException; +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/RawBackgroundFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/RawBackgroundFunction.java new file mode 100644 index 00000000..5f9706ba --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/RawBackgroundFunction.java @@ -0,0 +1,77 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +/** + * Represents a Cloud Function that is activated by an event. The payload of the event is a JSON + * object, which can be parsed using a JSON package such as GSON. + * + *

Here is an example of an implementation that parses the JSON payload using Gson, to access its + * {@code messageId} property: + * + * + *

+ * public class Example implements RawBackgroundFunction {
+ *   private static final Logger logger = Logger.getLogger(Example.class.getName());
+ *
+ *  {@code @Override}
+ *   public void accept(String json, Context context) {
+ *     JsonObject jsonObject = new Gson().fromJson(json, JsonObject.class);
+ *     JsonElement messageId = jsonObject.get("messageId");
+ *     String messageIdString = messageId.getAsJsonString();
+ *     logger.info("Got messageId " + messageIdString);
+ *   }
+ * }
+ * 
+ * + *

Here is an example of an implementation that deserializes the JSON payload into a Java object + * for simpler access, again using Gson: + * + *

+ * public class Example implements RawBackgroundFunction {
+ *   private static final Logger logger = Logger.getLogger(Example.class.getName());
+ *
+ *  {@code @Override}
+ *   public void accept(String json, Context context) {
+ *     PubSubMessage message = new Gson().fromJson(json, PubSubMessage.class);
+ *     logger.info("Got messageId " + message.messageId);
+ *   }
+ * }
+ *
+ * // Where PubSubMessage is a user-defined class like this:
+ * public class PubSubMessage {
+ *   String data;
+ *  {@code Map} attributes;
+ *   String messageId;
+ *   String publishTime;
+ * }
+ * 
+ */ +@FunctionalInterface +public interface RawBackgroundFunction { + /** + * Called to service an incoming event. This interface is implemented by user code to provide the + * action for a given background function. If this method throws any exception (including any + * {@link Error}) then the HTTP response will have a 500 status code. + * + * @param json the payload of the event, as a JSON string. + * @param context the context of the event. This is a set of values that every event has, + * separately from the payload, such as timestamp and event type. + * @throws Exception to produce a 500 status code in the HTTP response. + */ + void accept(String json, Context context) throws Exception; +} diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java new file mode 100644 index 00000000..16baf0b6 --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java @@ -0,0 +1,59 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions; + +import java.lang.reflect.Type; + +/** + * Represents a Cloud Function with a strongly typed interface that is activated by an HTTP request. + */ +@FunctionalInterface +public interface TypedFunction { + /** + * Called to service an incoming HTTP request. This interface is implemented by user code to + * provide the action for a given HTTP function. If this method throws any exception (including + * any {@link Error}) then the HTTP response will have a 500 status code. + * + * @param arg the payload of the event, deserialized from the original JSON string. + * @return invocation result or null to indicate the body of the response should be empty. + * @throws Exception to produce a 500 status code in the HTTP response. + */ + public ResponseT apply(RequestT arg) throws Exception; + + /** + * Called to get the the format object that handles request decoding and response encoding. If + * null is returned a default JSON format is used. + * + * @return the {@link WireFormat} to use for serialization + */ + public default WireFormat getWireFormat() { + return null; + } + + /** + * Describes how to deserialize request object and serialize response objects for an HTTP + * invocation. + */ + public interface WireFormat { + /** Serialize is expected to encode the object to the provided HttpResponse. */ + void serialize(Object object, HttpResponse response) throws Exception; + + /** + * Deserialize is expected to read an object of {@code Type} from the HttpRequest. The Type is + * determined through reflection on the user's function. + */ + Object deserialize(HttpRequest request, Type type) throws Exception; + } +} diff --git a/invoker/CHANGELOG.md b/invoker/CHANGELOG.md new file mode 100644 index 00000000..87db009f --- /dev/null +++ b/invoker/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +## [2.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v2.0.0...java-function-invoker-v2.0.1) (2026-04-10) + + +### Dependencies + +* update Functions Framework API dependency to 2.0.1 ([#382](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/382)) ([a453a31](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/a453a315f1e6abd0720929f451bb6f3aec1aa498)) + +## [2.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.3...java-function-invoker-v2.0.0) (2025-11-06) + + +### ⚠ BREAKING CHANGES + +* update functions-framework-api dependency to 2.0.0 ([#365](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/365)) +* **implementation:** use Java 17 or above, as required by Eclipse Jetty-12. +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + + +### Miscellaneous Chores + +* **implementation:** use Jetty-12.1 core without servlets ([#333](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/333)) ([e23f98f](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/e23f98f2dc7cbcdbd036a46423c99f82bddd80bc)) +* update functions-framework-api dependency to 2.0.0 ([#365](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/365)) ([f351c1a](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f351c1ade94a08bc4116b40e2d343e1b5d9a6db6)) + +## [1.4.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.2...java-function-invoker-v1.4.3) (2025-10-20) + + +### Bug Fixes + +* add autovalue plugin config to invoker/core/pom.xml to fix local development ([#339](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/339)) ([4203279](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/420327967b96b4aa66c0edd6753a9d6db781478d)) + +## [1.4.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.1...java-function-invoker-v1.4.2) (2025-10-20) + + +### Bug Fixes + +* add autovalue plugin config to invoker/core/pom.xml to fix local development ([#339](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/339)) ([4203279](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/420327967b96b4aa66c0edd6753a9d6db781478d)) + +## [1.4.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.0...java-function-invoker-v1.4.1) (2025-03-07) + + +### Bug Fixes + +* correct Cloud Event retry functionality ([#326](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/326)) ([9899a67](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9899a67a9a8cb6ebb27a92cccb740e7e23d48578)) + +## [1.4.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.3...java-function-invoker-v1.4.0) (2025-02-12) + + +### Features + +* Add execution id logging to uniquely identify request logs ([#319](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/319)) ([5ef5317](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/5ef53174b6cdbc644336121bc19bab6c4b90892d)) + +## [1.3.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.2...java-function-invoker-v1.3.3) (2024-11-27) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + +## [1.3.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.1...java-function-invoker-v1.3.2) (2024-09-18) + + +### Bug Fixes + +* avoid executing function when /favicon.ico or /robots.txt is called ([#226](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/226)) ([fca8676](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/fca867667db593699193da01b69a4cca7ca48fc8)) +* server times out when specified by CLOUD_RUN_TIMEOUT_SECONDS ([#275](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/275)) ([9e91f57](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9e91f57b12d73c655e3d7e226d21d54ccec32b73)) +* set Thread Context ClassLoader correctly when invoking handler constructor ([#239](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/239)) ([9f7155b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9f7155b77574ec980ecf9e6dffbd2ee0398db8a7)) + +## [1.3.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.0...java-function-invoker-v1.3.1) (2023-09-13) + + +### Bug Fixes + +* **functions:** include Implementation-Version key in invoker package manifest ([#221](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/221)) ([f3fe2ce](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f3fe2ce46fcb1885137cdf504649612e7c31dc4c)) +* typed declaration works correctly with http trigger ([#212](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/212)) ([b3045ad](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/b3045ad380cd23e37f5edec0d758031438bcb568)) + +## [1.3.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.2.1...java-function-invoker-v1.3.0) (2023-06-01) + + +### Features + +* Define strongly typed function interface ([#186](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/186)) ([5264e35](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/5264e35b2522a789d65f0e0fd9bb5584694529eb)) + + +### Bug Fixes + +* bump org.eclipse.jetty dependency to 9.4.51 ([#201](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/201)) ([0102c8f](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/0102c8f543280ff5ba5727508f87083a9f54ef74)) + +## [1.2.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.2.0...java-function-invoker-v1.2.1) (2023-03-02) + + +### Bug Fixes + +* retrieving http headers on request object should be case insenstive ([#178](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/178)) ([44da871](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/44da871e06e967ce132bea06c3b7c5d1b06ddd6b)) + +## [1.2.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.1.1...java-function-invoker-v1.2.0) (2022-10-05) + + +### Features + +* allow to stop the invoker ([#128](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/128)) ([14908ca](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/14908caa9e5be824dfb74fff3a3234c4bce688e7)) +* enable converting CloudEvent requests to Background Event requests ([#123](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/123)) ([1c4a014](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/1c4a01470cc4ee7b3de3c3d7ae4af24e47eb2810)) +* Increase maximum concurrent requests for jetty server to 1000. ([#144](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/144)) ([439d0b5](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/439d0b5d77b2f765e65d84e7d5f31399e547d004)) + + +### Bug Fixes + +* Add build env vars support for function deployment. ([#133](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/133)) ([0e052f3](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/0e052f376231192278061ec79bcf9d710ec310f4)) +* bump dependency versions ([#134](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/134)) ([faff79d](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/faff79d16c6df178d66f0185fb78fba003e60745)) +* bump jetty version to 9.4.49.v20220914 ([#164](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/164)) ([f5231a2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f5231a2303aa3565b29d494936e40ee1ec78fdbb)) +* make user function exceptions log level SEVERE ([#113](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/113)) ([1684c0e](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/1684c0ef55dc33f2c4c7f7514d99b0e7af75c44f)) +* update conformance tests ([#108](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/108)) ([72852d0](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/72852d0f23cdaed48569245440dcd1533c8c7563)) diff --git a/invoker/conformance/buildpack_pom.xml b/invoker/conformance/buildpack_pom.xml new file mode 100644 index 00000000..cd769638 --- /dev/null +++ b/invoker/conformance/buildpack_pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + com.google.cloud.functions.invoker + conformance + 0.0.0-SNAPSHOT + + GCF Conformance Tests + + A GCF project used to validate conformance to the Functions Framework contract + using the Functions Framework Conformance tools. + + https://github.com/GoogleCloudPlatform/functions-framework-conformance + + + UTF-8 + 11 + 11 + + + + conformance + file:./artifacts + + + + com.google.cloud.functions + functions-framework-api + FRAMEWORK-API-VERSION + + + com.google.cloud.functions.invoker + java-function-invoker + INVOKER-VERSION + + + com.google.code.gson + gson + 2.8.9 + + + io.cloudevents + cloudevents-core + 2.2.0 + + + io.cloudevents + cloudevents-json-jackson + 2.2.0 + + + \ No newline at end of file diff --git a/invoker/conformance/pom.xml b/invoker/conformance/pom.xml new file mode 100644 index 00000000..331f08d1 --- /dev/null +++ b/invoker/conformance/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + java-function-invoker-parent + com.google.cloud.functions.invoker + 2.0.2-SNAPSHOT + + + com.google.cloud.functions.invoker + conformance + 2.0.2-SNAPSHOT + + GCF Confromance Tests + + A GCF project used to validate conformance to the Functions Framework contract + using the Functions Framework Conformance tools. + + https://github.com/GoogleCloudPlatform/functions-framework-java + + + UTF-8 + 17 + 17 + + + + + com.google.cloud.functions + functions-framework-api + 2.0.0 + + + com.google.code.gson + gson + 2.13.2 + + + io.cloudevents + cloudevents-core + 4.0.1 + + + io.cloudevents + cloudevents-json-jackson + 4.0.1 + + + + + + + + com.google.cloud.functions + function-maven-plugin + 1.0.0 + + + + + diff --git a/invoker/conformance/prerun.sh b/invoker/conformance/prerun.sh new file mode 100755 index 00000000..504e3aa8 --- /dev/null +++ b/invoker/conformance/prerun.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# This script is used for buildpack validation. +# Its purpose is to run conformance tests using a buildpack +# with the latest code of functions-framework. +# +# Note: buildpack_pom.xml contains the configuration to use the local versions +# java-function-invoker and functions-framework-api. We will be using this file +# for running our conformance tests with buildpack. +# +# Steps: +# +# - Clear out the temp directory +# - Copy the conformance tests folder into temp +# - Build java-function-invoker with version 0.0.0-SNAPSHOT into artifacts +# folder +# - Build functions-framework-api with version 0.0.0-SNAPSHOT into artifacts +# folder +# - Ensure that we use the buildpack_pom.xml file by renaming it to pom.xml + +set -e +REPO_ROOT=$(git rev-parse --show-toplevel) + +rm -rf /tmp/tests +mkdir /tmp/tests + +cp -r $REPO_ROOT/invoker/conformance /tmp/tests + +function get_mvn_version() { + mvn -q \ + -Dexec.executable=echo \ + -Dexec.args='${project.version}' \ + --non-recursive \ + exec:exec +} + +# Must first install a local version of the API package +cd $REPO_ROOT/functions-framework-api +mvn install -Dmaven.repo.local=/tmp/tests/conformance/artifacts +FRAMEWORK_API_VERSION=$(get_mvn_version) + +# Build invoker packages against the latest API package +cd $REPO_ROOT/invoker +mvn install -Dmaven.repo.local=/tmp/tests/conformance/artifacts +INVOKER_VERSION=$(get_mvn_version) + +rm /tmp/tests/conformance/pom.xml +mv /tmp/tests/conformance/buildpack_pom.xml /tmp/tests/conformance/pom.xml + +sed -i "s/FRAMEWORK-API-VERSION/${FRAMEWORK_API_VERSION}/g" /tmp/tests/conformance/pom.xml +sed -i "s/INVOKER-VERSION/${INVOKER_VERSION}/g" /tmp/tests/conformance/pom.xml + +cat /tmp/tests/conformance/pom.xml diff --git a/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/BackgroundEventConformanceFunction.java b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/BackgroundEventConformanceFunction.java new file mode 100644 index 00000000..b21e68c8 --- /dev/null +++ b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/BackgroundEventConformanceFunction.java @@ -0,0 +1,57 @@ +package com.google.cloud.functions.conformance; + +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedWriter; +import java.io.FileWriter; + +/** + * This class is used by the Functions Framework Conformance Tools to validate the framework's + * Background Event API. It can be run with the following command: + * + *
{@code
+ * $ functions-framework-conformance-client \
+ *   -cmd="mvn function:run -Drun.functionTarget=com.google.cloud.functions.conformance.BackgroundEventConformanceFunction" \
+ *   -type=legacyevent \
+ *   -buildpacks=false \
+ *   -validate-mapping=false \
+ *   -start-delay=10
+ * }
+ */ +public class BackgroundEventConformanceFunction implements RawBackgroundFunction { + + private static final Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); + + @Override + public void accept(String data, Context context) throws Exception { + try (BufferedWriter writer = new BufferedWriter(new FileWriter("function_output.json"))) { + writer.write(serialize(data, context)); + } + } + + /** Create a structured JSON representation of the request context and data */ + private String serialize(String data, Context context) { + JsonObject contextJson = new JsonObject(); + contextJson.addProperty("eventId", context.eventId()); + contextJson.addProperty("timestamp", context.timestamp()); + contextJson.addProperty("eventType", context.eventType()); + + if (context.resource().startsWith("{")) { + JsonElement resource = gson.fromJson(context.resource(), JsonElement.class); + contextJson.add("resource", resource); + } else { + contextJson.addProperty("resource", context.resource()); + } + + JsonObject dataJson = gson.fromJson(data, JsonObject.class); + + JsonObject json = new JsonObject(); + json.add("data", dataJson); + json.add("context", contextJson); + return gson.toJson(json); + } +} diff --git a/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/CloudEventsConformanceFunction.java b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/CloudEventsConformanceFunction.java new file mode 100644 index 00000000..7faa079c --- /dev/null +++ b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/CloudEventsConformanceFunction.java @@ -0,0 +1,49 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.conformance; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.cloud.functions.CloudEventsFunction; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import io.cloudevents.jackson.JsonFormat; +import java.io.BufferedWriter; +import java.io.FileWriter; + +/** + * This class is used by the Functions Framework Conformance Tools to validate the framework's Cloud + * Events API. It can be run with the following command: + * + *
{@code
+ * $ functions-framework-conformance-client \
+ *   -cmd="mvn function:run -Drun.functionTarget=com.google.cloud.functions.conformance.CloudEventsConformanceFunction" \
+ *   -type=cloudevent \
+ *   -buildpacks=false \
+ *   -validate-mapping=false \
+ *   -start-delay=5
+ * }
+ */ +public class CloudEventsConformanceFunction implements CloudEventsFunction { + + @Override + public void accept(CloudEvent event) throws Exception { + try (BufferedWriter writer = new BufferedWriter(new FileWriter("function_output.json"))) { + EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + writer.write(new String(format.serialize(event), UTF_8)); + } + } +} diff --git a/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/ConcurrentHttpConformanceFunction.java b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/ConcurrentHttpConformanceFunction.java new file mode 100644 index 00000000..a342b2ea --- /dev/null +++ b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/ConcurrentHttpConformanceFunction.java @@ -0,0 +1,41 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.conformance; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +/** + * This class is used by the Functions Framework Conformance Tools to validate concurrency for HTTP + * functions. It can be run with the following command: + * + *
{@code
+ * $ functions-framework-conformance-client \
+ *   -cmd="mvn function:run -Drun.functionTarget=com.google.cloud.functions.conformance.ConcurrentHttpConformanceFunction" \
+ *   -type=http \
+ *   -buildpacks=false \
+ *   -validate-mapping=false \
+ *   -start-delay=5 \
+ *   -validate-concurrency=true
+ * }
+ */ +public class ConcurrentHttpConformanceFunction implements HttpFunction { + + @Override + public void service(HttpRequest request, HttpResponse response) throws InterruptedException { + Thread.sleep(1000); + } +} diff --git a/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/HttpConformanceFunction.java b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/HttpConformanceFunction.java new file mode 100644 index 00000000..46eafd09 --- /dev/null +++ b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/HttpConformanceFunction.java @@ -0,0 +1,50 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.conformance; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; + +/** + * This class is used by the Functions Framework Conformance Tools to validate the framework's HTTP + * API. It can be run with the following command: + * + *
{@code
+ * $ functions-framework-conformance-client \
+ *   -cmd="mvn function:run -Drun.functionTarget=com.google.cloud.functions.conformance.HttpConformanceFunction" \
+ *   -type=http \
+ *   -buildpacks=false \
+ *   -validate-mapping=false \
+ *   -start-delay=5
+ * }
+ */ +public class HttpConformanceFunction implements HttpFunction { + + @Override + public void service(HttpRequest request, HttpResponse response) throws IOException { + try (BufferedReader reader = request.getReader(); + BufferedWriter writer = new BufferedWriter(new FileWriter("function_output.json"))) { + int c; + while ((c = reader.read()) != -1) { + writer.write(c); + } + } + } +} diff --git a/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/TypedConformanceFunction.java b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/TypedConformanceFunction.java new file mode 100644 index 00000000..7b57c6ae --- /dev/null +++ b/invoker/conformance/src/main/java/com/google/cloud/functions/conformance/TypedConformanceFunction.java @@ -0,0 +1,26 @@ +package com.google.cloud.functions.conformance; + +import com.google.cloud.functions.TypedFunction; +import com.google.gson.annotations.SerializedName; + +public class TypedConformanceFunction + implements TypedFunction { + @Override + public ConformanceResponse apply(ConformanceRequest req) throws Exception { + return new ConformanceResponse(req); + } +} + +class ConformanceRequest { + @SerializedName("message") + public String message; +} + +class ConformanceResponse { + @SerializedName("payload") + public ConformanceRequest payload = null; + + ConformanceResponse(ConformanceRequest payload) { + this.payload = payload; + } +} diff --git a/invoker/core/pom.xml b/invoker/core/pom.xml new file mode 100644 index 00000000..cd8984ba --- /dev/null +++ b/invoker/core/pom.xml @@ -0,0 +1,204 @@ + + 4.0.0 + + + com.google.cloud.functions.invoker + java-function-invoker-parent + 2.0.2-SNAPSHOT + + + com.google.cloud.functions.invoker + java-function-invoker + 2.0.2-SNAPSHOT + GCF Java Invoker + + Application that invokes a GCF Java function. This application is a + complete HTTP server that interprets incoming HTTP requests appropriately + and forwards them to the function code. + + https://github.com/GoogleCloudPlatform/functions-framework-java + + + UTF-8 + 5.3.2 + 17 + 17 + 4.0.1 + 12.1.8 + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + scm:git:https://github.com/GoogleCloudPlatform/functions-framework-java.git + scm:git:git@github.com:GoogleCloudPlatform/functions-framework-java.git + https://github.com/GoogleCloudPlatform/functions-framework-java + HEAD + + + + + com.google.cloud.functions + functions-framework-api + 2.0.0 + + + io.cloudevents + cloudevents-core + ${cloudevents.sdk.version} + + + io.cloudevents + cloudevents-http-basic + ${cloudevents.sdk.version} + + + io.cloudevents + cloudevents-json-jackson + ${cloudevents.sdk.version} + + + com.google.code.gson + gson + 2.13.2 + + + com.ryanharter.auto.value + auto-value-gson + 1.3.1 + provided + + + com.ryanharter.auto.value + auto-value-gson-annotations + 0.8.0 + provided + + + com.google.auto.value + auto-value + 1.11.1 + provided + + + com.google.auto.value + auto-value-annotations + 1.11.1 + provided + + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.slf4j + slf4j-jdk14 + 2.0.17 + + + com.beust + jcommander + 1.82 + + + + + com.google.cloud.functions.invoker + java-function-invoker-testfunction + 2.0.2-SNAPSHOT + test-jar + test + + + org.mockito + mockito-core + 5.23.0 + test + + + junit + junit + 4.13.2 + test + + + com.google.re2j + re2j + 1.8 + + + com.google.truth + truth + 1.4.5 + test + + + com.google.truth.extensions + truth-java8-extension + 1.4.5 + test + + + org.eclipse.jetty + jetty-client + ${jetty.version} + test + + + + + + + maven-compiler-plugin + 3.15.0 + + + + com.google.auto.value + auto-value + 1.11.1 + + + com.ryanharter.auto.value + auto-value-gson + 1.3.1 + + + + + + maven-jar-plugin + 3.5.0 + + + + com.google.cloud.functions.invoker.runner.Invoker + true + true + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + + + diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java new file mode 100644 index 00000000..097b9a67 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java @@ -0,0 +1,419 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; + +import com.google.cloud.functions.BackgroundFunction; +import com.google.cloud.functions.CloudEventsFunction; +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; +import com.google.cloud.functions.invoker.gcf.ExecutionIdUtil; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.message.MessageReader; +import io.cloudevents.http.HttpMessageFactory; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +/** Executes the user's background function. */ +public final class BackgroundFunctionExecutor extends Handler.Abstract { + private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); + + private final FunctionExecutor functionExecutor; + private final ExecutionIdUtil executionIdUtil = new ExecutionIdUtil(); + + private BackgroundFunctionExecutor(FunctionExecutor functionExecutor) { + this.functionExecutor = functionExecutor; + } + + private enum FunctionKind { + BACKGROUND(BackgroundFunction.class), + RAW_BACKGROUND(RawBackgroundFunction.class), + CLOUD_EVENTS(CloudEventsFunction.class); + + static final List VALUES = Arrays.asList(values()); + + final Class functionClass; + + FunctionKind(Class functionClass) { + this.functionClass = functionClass; + } + + /** Returns the {@link FunctionKind} that the given class implements, if any. */ + static Optional forClass(Class functionClass) { + return VALUES.stream() + .filter(v -> v.functionClass.isAssignableFrom(functionClass)) + .findFirst(); + } + } + + /** + * Optionally makes a {@link BackgroundFunctionExecutor} for the given class, if it implements one + * of {@link BackgroundFunction}, {@link RawBackgroundFunction}, or {@link CloudEventsFunction}. + * Otherwise returns {@link Optional#empty()}. + * + * @param functionClass the class of a possible background function implementation. + * @throws RuntimeException if the given class does implement one of the required interfaces, but + * we are unable to construct an instance using its no-arg constructor. + */ + public static Optional maybeForClass(Class functionClass) { + Optional maybeFunctionKind = FunctionKind.forClass(functionClass); + if (!maybeFunctionKind.isPresent()) { + return Optional.empty(); + } + return Optional.of(forClass(functionClass, maybeFunctionKind.get())); + } + + /** + * Makes a {@link BackgroundFunctionExecutor} for the given class. + * + * @throws RuntimeException if either the class does not implement one of {@link + * BackgroundFunction}, {@link RawBackgroundFunction}, or {@link CloudEventsFunction}; or we + * are unable to construct an instance using its no-arg constructor. + */ + public static BackgroundFunctionExecutor forClass(Class functionClass) { + Optional maybeFunctionKind = FunctionKind.forClass(functionClass); + if (!maybeFunctionKind.isPresent()) { + List classNames = + FunctionKind.VALUES.stream().map(v -> v.functionClass.getName()).collect(toList()); + throw new RuntimeException( + "Class " + + functionClass.getName() + + " must implement one of these interfaces: " + + String.join(", ", classNames)); + } + return forClass(functionClass, maybeFunctionKind.get()); + } + + private static BackgroundFunctionExecutor forClass( + Class functionClass, FunctionKind functionKind) { + Object instance; + try { + instance = functionClass.getConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException( + "Could not construct an instance of " + functionClass.getName() + ": " + e, e); + } + FunctionExecutor executor; + switch (functionKind) { + case RAW_BACKGROUND: + executor = new RawFunctionExecutor((RawBackgroundFunction) instance); + break; + case BACKGROUND: + BackgroundFunction backgroundFunction = (BackgroundFunction) instance; + @SuppressWarnings("unchecked") + Class> c = + (Class>) backgroundFunction.getClass(); + Optional maybeTargetType = backgroundFunctionTypeArgument(c); + if (!maybeTargetType.isPresent()) { + // This is probably because the user implemented just BackgroundFunction rather than + // BackgroundFunction. + throw new RuntimeException( + "Could not determine the payload type for BackgroundFunction of type " + + instance.getClass().getName() + + "; must implement BackgroundFunction for some T"); + } + executor = new TypedFunctionExecutor<>(maybeTargetType.get(), backgroundFunction); + break; + case CLOUD_EVENTS: + executor = new CloudEventFunctionExecutor((CloudEventsFunction) instance); + break; + default: // can't happen, we've listed all the FunctionKind values already. + throw new AssertionError(functionKind); + } + return new BackgroundFunctionExecutor(executor); + } + + /** + * Returns the {@code T} of a concrete class that implements {@link BackgroundFunction + * BackgroundFunction}. Returns an empty {@link Optional} if {@code T} can't be determined. + */ + static Optional backgroundFunctionTypeArgument( + Class> functionClass) { + // If this is BackgroundFunction then the user must have implemented a method + // accept(Foo, Context), so we look for that method and return the type of its first argument. + // We must be careful because the compiler will also have added a synthetic method + // accept(Object, Context). + return Arrays.stream(functionClass.getMethods()) + .filter( + m -> + m.getName().equals("accept") + && m.getParameterCount() == 2 + && m.getParameterTypes()[1] == Context.class + && m.getParameterTypes()[0] != Object.class) + .map(m -> m.getGenericParameterTypes()[0]) + .findFirst(); + } + + private static Event parseLegacyEvent(Request req) throws IOException { + try (BufferedReader bodyReader = + new BufferedReader( + new InputStreamReader( + Content.Source.asInputStream(req), + Objects.requireNonNullElse( + Request.getCharset(req), StandardCharsets.ISO_8859_1)))) { + return parseLegacyEvent(bodyReader); + } + } + + static Event parseLegacyEvent(Reader reader) throws IOException { + // A Type Adapter is required to set the type of the JsonObject because CloudFunctionsContext + // is abstract and Gson default behavior instantiates the type provided. + TypeAdapter typeAdapter = CloudFunctionsContext.typeAdapter(new Gson()); + Gson gson = + new GsonBuilder() + .registerTypeAdapter(CloudFunctionsContext.class, typeAdapter) + .registerTypeAdapter(Event.class, new Event.EventDeserializer()) + .create(); + return gson.fromJson(reader, Event.class); + } + + private static Context contextFromCloudEvent(CloudEvent cloudEvent) { + OffsetDateTime timestamp = + Optional.ofNullable(cloudEvent.getTime()).orElse(OffsetDateTime.now()); + String timestampString = DateTimeFormatter.ISO_INSTANT.format(timestamp); + // We don't have an obvious replacement for the Context.resource field, which with legacy events + // corresponded to a value present for some proprietary Google event types. + String resource = "{}"; + Map attributesMap = + cloudEvent.getAttributeNames().stream() + .collect(toMap(a -> a, a -> String.valueOf(cloudEvent.getAttribute(a)))); + return CloudFunctionsContext.builder() + .setEventId(cloudEvent.getId()) + .setEventType(cloudEvent.getType()) + .setResource(resource) + .setTimestamp(timestampString) + .setAttributes(attributesMap) + .build(); + } + + /** + * A background function, either "raw" or "typed". A raw background function is one where the user + * code receives a String parameter that is the JSON payload of the triggering event. A typed + * background function is one where the payload is deserialized into a user-provided class whose + * field names correspond to the keys of the JSON object. + * + *

In addition to these two flavours, events can be either "legacy events" or "CloudEvents". + * Legacy events are the only kind that GCF originally supported, and use proprietary encodings + * for the various triggers. CloudEvents are ones that follow the standards defined by cloudevents.io. + * + * @param the type to be used in the {code Unmarshallers} call when + * unmarshalling this event, if it is a CloudEvent. + */ + private abstract static class FunctionExecutor { + private final Class functionClass; + + FunctionExecutor(Class functionClass) { + this.functionClass = functionClass; + } + + final String functionName() { + return functionClass.getCanonicalName(); + } + + final ClassLoader functionClassLoader() { + return functionClass.getClassLoader(); + } + + abstract void serviceLegacyEvent(Event legacyEvent) throws Exception; + + abstract void serviceCloudEvent(CloudEvent cloudEvent) throws Exception; + } + + private static class RawFunctionExecutor extends FunctionExecutor> { + private static Gson gson = new GsonBuilder().serializeNulls().create(); + private final RawBackgroundFunction function; + + RawFunctionExecutor(RawBackgroundFunction function) { + super(function.getClass()); + this.function = function; + } + + @Override + void serviceLegacyEvent(Event legacyEvent) throws Exception { + function.accept(gson.toJson(legacyEvent.getData()), legacyEvent.getContext()); + } + + @Override + void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { + serviceLegacyEvent(CloudEvents.convertToLegacyEvent(cloudEvent)); + } + } + + private static class TypedFunctionExecutor extends FunctionExecutor { + private final Type type; // T + private final BackgroundFunction function; + + private TypedFunctionExecutor(Type type, BackgroundFunction function) { + super(function.getClass()); + this.type = type; + this.function = function; + } + + static TypedFunctionExecutor of(Type type, BackgroundFunction instance) { + @SuppressWarnings("unchecked") + BackgroundFunction function = (BackgroundFunction) instance; + return new TypedFunctionExecutor<>(type, function); + } + + @Override + void serviceLegacyEvent(Event legacyEvent) throws Exception { + T payload = new Gson().fromJson(legacyEvent.getData(), type); + function.accept(payload, legacyEvent.getContext()); + } + + @Override + void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { + if (cloudEvent.getData() != null) { + serviceLegacyEvent(CloudEvents.convertToLegacyEvent(cloudEvent)); + } else { + throw new IllegalStateException("Event has no \"data\" component"); + } + } + } + + private static class CloudEventFunctionExecutor extends FunctionExecutor { + private final CloudEventsFunction function; + + CloudEventFunctionExecutor(CloudEventsFunction function) { + super(function.getClass()); + this.function = function; + } + + @Override + void serviceLegacyEvent(Event legacyEvent) throws Exception { + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + function.accept(cloudEvent); + } + + @Override + void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { + function.accept(cloudEvent); + } + } + + /** Executes the user's background function. This can handle all HTTP methods. */ + @Override + public boolean handle(Request req, Response res, Callback callback) throws Exception { + String contentType = req.getHeaders().get(HttpHeader.CONTENT_TYPE); + try { + executionIdUtil.storeExecutionId(req); + if ((contentType != null && contentType.startsWith("application/cloudevents+json")) + || req.getHeaders().get("ce-specversion") != null) { + serviceCloudEvent(req); + } else { + serviceLegacyEvent(req); + } + res.setStatus(HttpStatus.OK_200); + callback.succeeded(); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to execute " + functionExecutor.functionName(), t); + Response.writeError(req, res, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null); + } finally { + executionIdUtil.removeExecutionId(); + } + return true; + } + + private enum CloudEventKind { + BINARY, + STRUCTURED + } + + /** + * Service a CloudEvent. + * + * @param a fake type parameter, which corresponds to the type parameter of {@link + * FunctionExecutor}. + */ + private void serviceCloudEvent(Request req) throws Exception { + @SuppressWarnings("unchecked") + FunctionExecutor executor = (FunctionExecutor) functionExecutor; + + // Read the entire request body into a byte array. + // TODO: this method is deprecated for removal, use the method introduced by + // https://github.com/jetty/jetty.project/pull/13939 when it is released. + byte[] body = Content.Source.asByteArrayAsync(req, -1).get(); + MessageReader reader = HttpMessageFactory.createReaderFromMultimap(headerMap(req), body); + // It's important not to set the context ClassLoader earlier, because MessageUtils will use + // ServiceLoader.load(EventFormat.class) to find a handler to deserialize a binary CloudEvent + // and if it finds something from the function ClassLoader then that something will implement + // the EventFormat interface as defined by that ClassLoader rather than ours. Then + // ServiceLoader.load + // will throw ServiceConfigurationError. At this point we're still running with the default + // context ClassLoader, which is the system ClassLoader that has loaded the code here. + runWithContextClassLoader(() -> executor.serviceCloudEvent(reader.toEvent(data -> data))); + // The data->data is a workaround for a bug fixed since Milestone 4 of the SDK, in + // https://github.com/cloudevents/sdk-java/pull/259. + } + + private static Map> headerMap(Request req) { + Map> headerMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (HttpField field : req.getHeaders()) { + headerMap + .computeIfAbsent(field.getName(), unused -> new ArrayList<>()) + .addAll(field.getValueList()); + } + return headerMap; + } + + private void serviceLegacyEvent(Request req) throws Exception { + Event event = parseLegacyEvent(req); + runWithContextClassLoader(() -> functionExecutor.serviceLegacyEvent(event)); + } + + private void runWithContextClassLoader(ContextClassLoaderTask task) throws Exception { + ClassLoader oldLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(functionExecutor.functionClassLoader()); + task.run(); + } finally { + Thread.currentThread().setContextClassLoader(oldLoader); + } + } + + @FunctionalInterface + private interface ContextClassLoaderTask { + void run() throws Exception; + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudEvents.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudEvents.java new file mode 100644 index 00000000..0021d44e --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudEvents.java @@ -0,0 +1,296 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static java.util.Map.entry; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import io.cloudevents.CloudEvent; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; + +/** Conversions from CloudEvents events to GCF Background Events. */ +class CloudEvents { + private static final String PUB_SUB_MESSAGE_TYPE = + "type.googleapis.com/google.pubsub.v1.PubsubMessage"; + + private static final Map EVENT_TYPE_MAPPING = + Map.ofEntries( + entry( + "google.cloud.pubsub.topic.v1.messagePublished", + new PubSubEventAdapter("google.pubsub.topic.publish")), + entry( + "google.cloud.storage.object.v1.finalized", + new StorageEventAdapter("google.storage.object.finalize")), + entry( + "google.cloud.storage.object.v1.deleted", + new StorageEventAdapter("google.storage.object.delete")), + entry( + "google.cloud.storage.object.v1.archived", + new StorageEventAdapter("google.storage.object.archive")), + entry( + "google.cloud.storage.object.v1.metadataUpdated", + new StorageEventAdapter("google.storage.object.metadataUpdate")), + entry( + "google.cloud.firestore.document.v1.written", + new EventAdapter("providers/cloud.firestore/eventTypes/document.write")), + entry( + "google.cloud.firestore.document.v1.created", + new EventAdapter("providers/cloud.firestore/eventTypes/document.create")), + entry( + "google.cloud.firestore.document.v1.updated", + new EventAdapter("providers/cloud.firestore/eventTypes/document.update")), + entry( + "google.cloud.firestore.document.v1.deleted", + new EventAdapter("providers/cloud.firestore/eventTypes/document.delete")), + entry( + "google.firebase.analytics.log.v1.written", + new EventAdapter("providers/google.firebase.analytics/eventTypes/event.log")), + entry( + "google.firebase.auth.user.v1.created", + new FirebaseAuthEventAdapter("providers/firebase.auth/eventTypes/user.create")), + entry( + "google.firebase.auth.user.v1.deleted", + new FirebaseAuthEventAdapter("providers/firebase.auth/eventTypes/user.delete")), + entry( + "google.firebase.database.ref.v1.created", + new FirebaseDatabaseEventAdapter( + "providers/google.firebase.database/eventTypes/ref.create")), + entry( + "google.firebase.database.ref.v1.written", + new FirebaseDatabaseEventAdapter( + "providers/google.firebase.database/eventTypes/ref.write")), + entry( + "google.firebase.database.ref.v1.updated", + new FirebaseDatabaseEventAdapter( + "providers/google.firebase.database/eventTypes/ref.update")), + entry( + "google.firebase.database.ref.v1.deleted", + new FirebaseDatabaseEventAdapter( + "providers/google.firebase.database/eventTypes/ref.delete")), + entry( + "google.cloud.storage.object.v1.changed", + new StorageEventAdapter("providers/cloud.storage/eventTypes/object.change"))); + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + /** + * Converts a CloudEvent to the legacy event format. + * + * @param cloudEvent the CloudEvent to convert + * @return the legacy event representation of the Cloud Event + */ + static Event convertToLegacyEvent(CloudEvent cloudEvent) { + String eventType = cloudEvent.getType(); + EventAdapter eventAdapter = EVENT_TYPE_MAPPING.get(eventType); + if (eventAdapter == null) { + throw new IllegalArgumentException("Unrecognized CloudEvent type \"" + eventType + "\""); + } + return eventAdapter.convertToLegacyEvent(cloudEvent); + } + + private static class EventAdapter { + private final String legacyEventType; + private static Pattern sourcePattern = Pattern.compile("//([^/]+)/(.+)"); + + protected class ParsedCloudEvent { + public final String Resource; + public final String Service; + public final String Name; + + public ParsedCloudEvent(String resource, String service, String name) { + this.Resource = resource; + this.Service = service; + this.Name = name; + } + } + ; + + /** + * Creates an adapter to convert from the CloudEvent to a legacy event. + * + * @param legacyEventType the event type of the legacy event being created + */ + EventAdapter(String legacyEventType) { + this.legacyEventType = legacyEventType; + } + + /** + * Converts a CloudEvent to the legacy event format. + * + * @param cloudEvent the CloudEvent to convert + * @return the legacy event representation of the Cloud Event + */ + final Event convertToLegacyEvent(CloudEvent cloudEvent) { + /* + Ex 1: "//firebaseauth.googleapis.com/projects/my-project-id" + m.group(0): "//firebaseauth.googleapis.com/projects/my-project-id" + m.group(1): "firebaseauth.googleapis.com" + m.group(2): "projects/my-project-id" + + Ex 2: "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test" + m.group(0): "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test" + m.group(1): "pubsub.googleapis.com" + m.group(2): "projects/sample-project/topics/gcf-test" + */ + Matcher m = sourcePattern.matcher(cloudEvent.getSource().toString()); + if (!m.find() || m.groupCount() != 2) { + throw new IllegalArgumentException( + String.format( + "Invalid CloudEvent source '%s', unable to parse into resource service and name", + cloudEvent.getSource().toString())); + } + + String service = m.group(1); + String name = m.group(2); + String resource = String.format("%s/%s", name, cloudEvent.getSubject()); + ParsedCloudEvent parsed = new ParsedCloudEvent(resource, service, name); + + OffsetDateTime timestamp = + Optional.ofNullable(cloudEvent.getTime()).orElse(OffsetDateTime.now()); + + CloudFunctionsContext.Builder ctxBuilder = + CloudFunctionsContext.builder() + .setEventId(cloudEvent.getId()) + .setEventType(this.legacyEventType) + .setResource(resource) + .setTimestamp(DateTimeFormatter.ISO_INSTANT.format(timestamp)); + + JsonObject data = + GSON.fromJson( + new String(cloudEvent.getData().toBytes(), java.nio.charset.StandardCharsets.UTF_8), + JsonObject.class); + return createLegacyEvent(parsed, ctxBuilder, data); + } + + /** + * Provides a hook to furither modify the converted event for specific event adapter subclasses. + * + * @param event convenient information parsed from the original CloudEvent + * @param builder the builder for the converted legacy event's context, pre-populated with + * defaults from the original CloudEvent + * @param data the data for the converted legacy event's data, pre-populated with defaults from + * the original CloudEvent + * @return the fully converted legacy event + */ + Event createLegacyEvent( + ParsedCloudEvent event, CloudFunctionsContext.Builder builder, JsonObject data) { + return Event.of(data, builder.build()); + } + } + + private static class PubSubEventAdapter extends EventAdapter { + PubSubEventAdapter(String legacyEventType) { + super(legacyEventType); + } + + @Override + Event createLegacyEvent( + ParsedCloudEvent event, CloudFunctionsContext.Builder builder, JsonObject data) { + JsonObject resource = new JsonObject(); + resource.addProperty("service", event.Service); + resource.addProperty("name", event.Name); + resource.addProperty("type", PUB_SUB_MESSAGE_TYPE); + builder.setResource(GSON.toJson(resource)); + + // Lift the "message" field into the main "data" field. + if (data.has("message")) { + JsonElement message = data.get("message"); + if (message.isJsonObject()) { + data = message.getAsJsonObject(); + } + } + + data.remove("messageId"); + data.remove("publishTime"); + + return Event.of(data, builder.build()); + } + } + + private static class FirebaseAuthEventAdapter extends EventAdapter { + FirebaseAuthEventAdapter(String legacyEventType) { + super(legacyEventType); + } + + @Override + Event createLegacyEvent( + ParsedCloudEvent event, CloudFunctionsContext.Builder builder, JsonObject data) { + builder.setResource(event.Name); + + if (data.has("metadata")) { + JsonElement meta = data.get("metadata"); + if (meta.isJsonObject()) { + JsonObject metaObj = meta.getAsJsonObject(); + + JsonElement createTime = metaObj.get("createTime"); + if (createTime != null) { + metaObj.add("createdAt", createTime); + metaObj.remove("createTime"); + } + + JsonElement lastSignInTime = metaObj.get("lastSignInTime"); + if (lastSignInTime != null) { + metaObj.add("lastSignedInAt", lastSignInTime); + metaObj.remove("lastSignInTime"); + } + } + } + return Event.of(data, builder.build()); + } + } + + private static class FirebaseDatabaseEventAdapter extends EventAdapter { + private static Pattern resourcePattern = Pattern.compile("/locations/[^/]+"); + + FirebaseDatabaseEventAdapter(String legacyEventType) { + super(legacyEventType); + } + + @Override + Event createLegacyEvent( + ParsedCloudEvent event, CloudFunctionsContext.Builder builder, JsonObject data) { + builder.setResource(resourcePattern.matcher(event.Resource).replaceAll("")); + return Event.of(data, builder.build()); + } + } + + private static class StorageEventAdapter extends EventAdapter { + StorageEventAdapter(String legacyEventType) { + super(legacyEventType); + } + + @Override + Event createLegacyEvent( + ParsedCloudEvent event, CloudFunctionsContext.Builder builder, JsonObject data) { + JsonObject resource = new JsonObject(); + resource.addProperty("service", event.Service); + resource.addProperty("name", event.Resource); + if (data.has("kind")) { + resource.addProperty("type", data.get("kind").getAsString()); + } + + builder.setResource(GSON.toJson(resource)); + return Event.of(data, builder.build()); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudFunctionsContext.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudFunctionsContext.java new file mode 100644 index 00000000..65df5411 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/CloudFunctionsContext.java @@ -0,0 +1,129 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import com.google.auto.value.AutoValue; +import com.google.cloud.functions.Context; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.Map; + +/** Event context (metadata) for events handled by Cloud Functions. */ +@AutoValue +abstract class CloudFunctionsContext implements Context { + // AutoValue recognizes any annotation called @Nullable, so no need to import this from anywhere. + @Retention(RetentionPolicy.SOURCE) + @interface Nullable {} + + @Override + @Nullable + public abstract String eventId(); + + @Override + @Nullable + public abstract String timestamp(); + + @Override + @Nullable + public abstract String eventType(); + + @Override + @Nullable + public abstract String resource(); + + // TODO: expose this in the Context interface (as a default method). + abstract Map params(); + + @Nullable + abstract String domain(); + + @Override + public abstract Map attributes(); + + public static TypeAdapter typeAdapter(Gson gson) { + return new AutoValue_CloudFunctionsContext.GsonTypeAdapter(gson); + } + + static Builder builder() { + return new AutoValue_CloudFunctionsContext.Builder() + .setParams(Collections.emptyMap()) + .setAttributes(Collections.emptyMap()); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setEventId(String x); + + abstract Builder setTimestamp(String x); + + abstract Builder setEventType(String x); + + abstract Builder setResource(String x); + + abstract Builder setParams(Map x); + + abstract Builder setAttributes(Map value); + + abstract Builder setDomain(String x); + + abstract CloudFunctionsContext build(); + } + + /** + * Depending on the event type, the {@link Context#resource()} field is either a JSON string + * (complete with encosing quotes) or a JSON object. This class allows us to redeserialize that + * JSON representation into its components. + */ + @AutoValue + abstract static class Resource { + abstract @Nullable String service(); + + abstract String name(); + + abstract @Nullable String type(); + + static TypeAdapter typeAdapter(Gson gson) { + return new AutoValue_CloudFunctionsContext_Resource.GsonTypeAdapter(gson); + } + + static Resource from(String s) { + if (s.startsWith("{") && (s.endsWith("}") || s.endsWith("}\n"))) { + TypeAdapter typeAdapter = typeAdapter(new Gson()); + Gson gson = new GsonBuilder().registerTypeAdapter(Resource.class, typeAdapter).create(); + return gson.fromJson(s, Resource.class); + } + return builder().setName(s).build(); + } + + static Builder builder() { + return new AutoValue_CloudFunctionsContext_Resource.Builder(); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setService(String x); + + abstract Builder setName(String x); + + abstract Builder setType(String x); + + abstract Resource build(); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/Event.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/Event.java new file mode 100644 index 00000000..642e5118 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/Event.java @@ -0,0 +1,119 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import com.google.auto.value.AutoValue; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import java.lang.reflect.Type; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Represents an event that should be handled by a background function. This is an internal format + * which is later converted to actual background function parameter types. + */ +@AutoValue +abstract class Event { + static Event of(JsonElement data, CloudFunctionsContext context) { + return new AutoValue_Event(data, context); + } + + abstract JsonElement getData(); + + abstract CloudFunctionsContext getContext(); + + /** Custom deserializer that supports both GCF beta and GCF GA event formats. */ + static class EventDeserializer implements JsonDeserializer { + + @Override + public Event deserialize( + JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) + throws JsonParseException { + JsonObject root = jsonElement.getAsJsonObject(); + + JsonElement data = root.get("data"); + CloudFunctionsContext context; + + if (root.has("context")) { + JsonObject contextCopy = root.getAsJsonObject("context").deepCopy(); + context = + jsonDeserializationContext.deserialize( + adjustContextResource(contextCopy), CloudFunctionsContext.class); + } else if (isPubSubEmulatorPayload(root)) { + JsonObject message = root.getAsJsonObject("message"); + + String timestampString = + message.has("publishTime") + ? message.get("publishTime").getAsString() + : DateTimeFormatter.ISO_INSTANT.format(OffsetDateTime.now()); + + context = + CloudFunctionsContext.builder() + .setEventType("google.pubsub.topic.publish") + .setTimestamp(timestampString) + .setEventId(message.get("messageId").getAsString()) + .setResource( + "{" + + "\"name\":null," + + "\"service\":\"pubsub.googleapis.com\"," + + "\"type\":\"type.googleapis.com/google.pubsub.v1.PubsubMessage\"" + + "}") + .build(); + + JsonObject marshalledData = new JsonObject(); + marshalledData.addProperty("@type", "type.googleapis.com/google.pubsub.v1.PubsubMessage"); + marshalledData.add("data", message.get("data")); + if (message.has("attributes")) { + marshalledData.add("attributes", message.get("attributes")); + } + data = marshalledData; + } else { + JsonObject rootCopy = root.deepCopy(); + rootCopy.remove("data"); + context = + jsonDeserializationContext.deserialize( + adjustContextResource(rootCopy), CloudFunctionsContext.class); + } + return Event.of(data, context); + } + + private boolean isPubSubEmulatorPayload(JsonObject root) { + if (root.has("subscription") && root.has("message") && root.get("message").isJsonObject()) { + JsonObject message = root.getAsJsonObject("message"); + return message.has("data") && message.has("messageId"); + } + return false; + } + + /** + * Replaces 'resource' member from context JSON with its string equivalent. The original + * 'resource' member can be a JSON object itself while {@link CloudFunctionsContext} requires it + * to be a string. + */ + private JsonObject adjustContextResource(JsonObject contextObject) { + if (contextObject.has("resource")) { + JsonElement resourceElement = contextObject.get("resource"); + if (resourceElement.isJsonObject()) { + contextObject.addProperty("resource", resourceElement.toString()); + } + } + return contextObject; + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/GcfEvents.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/GcfEvents.java new file mode 100644 index 00000000..d78365dc --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/GcfEvents.java @@ -0,0 +1,315 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Map.entry; + +import com.google.auto.value.AutoValue; +import com.google.cloud.functions.invoker.CloudFunctionsContext.Nullable; +import com.google.cloud.functions.invoker.CloudFunctionsContext.Resource; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import java.net.URI; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Conversions from GCF events to CloudEvents. */ +class GcfEvents { + private static final String FIREBASE_SERVICE = "firebase.googleapis.com"; + private static final String FIREBASE_AUTH_SERVICE = "firebaseauth.googleapis.com"; + private static final String FIREBASE_DB_SERVICE = "firebasedatabase.googleapis.com"; + private static final String FIRESTORE_SERVICE = "firestore.googleapis.com"; + private static final String PUB_SUB_SERVICE = "pubsub.googleapis.com"; + private static final String STORAGE_SERVICE = "storage.googleapis.com"; + + private static final String PUB_SUB_MESSAGE_PUBLISHED = + "google.cloud.pubsub.topic.v1.messagePublished"; + + private static final Map EVENT_TYPE_MAPPING = + Map.ofEntries( + entry("google.pubsub.topic.publish", new PubSubEventAdapter(PUB_SUB_MESSAGE_PUBLISHED)), + entry( + "google.storage.object.finalize", + new StorageEventAdapter("google.cloud.storage.object.v1.finalized")), + entry( + "google.storage.object.delete", + new StorageEventAdapter("google.cloud.storage.object.v1.deleted")), + entry( + "google.storage.object.archive", + new StorageEventAdapter("google.cloud.storage.object.v1.archived")), + entry( + "google.storage.object.metadataUpdate", + new StorageEventAdapter("google.cloud.storage.object.v1.metadataUpdated")), + entry( + "providers/cloud.firestore/eventTypes/document.write", + new FirestoreFirebaseEventAdapter( + "google.cloud.firestore.document.v1.written", FIRESTORE_SERVICE)), + entry( + "providers/cloud.firestore/eventTypes/document.create", + new FirestoreFirebaseEventAdapter( + "google.cloud.firestore.document.v1.created", FIRESTORE_SERVICE)), + entry( + "providers/cloud.firestore/eventTypes/document.update", + new FirestoreFirebaseEventAdapter( + "google.cloud.firestore.document.v1.updated", FIRESTORE_SERVICE)), + entry( + "providers/cloud.firestore/eventTypes/document.delete", + new FirestoreFirebaseEventAdapter( + "google.cloud.firestore.document.v1.deleted", FIRESTORE_SERVICE)), + entry( + "providers/firebase.auth/eventTypes/user.create", + new FirebaseAuthEventAdapter("google.firebase.auth.user.v1.created")), + entry( + "providers/firebase.auth/eventTypes/user.delete", + new FirebaseAuthEventAdapter("google.firebase.auth.user.v1.deleted")), + entry( + "providers/google.firebase.analytics/eventTypes/event.log", + new FirestoreFirebaseEventAdapter( + "google.firebase.analytics.log.v1.written", FIREBASE_SERVICE)), + entry( + "providers/google.firebase.database/eventTypes/ref.create", + new FirebaseDatabaseEventAdapter("google.firebase.database.ref.v1.created")), + entry( + "providers/google.firebase.database/eventTypes/ref.write", + new FirebaseDatabaseEventAdapter("google.firebase.database.ref.v1.written")), + entry( + "providers/google.firebase.database/eventTypes/ref.update", + new FirebaseDatabaseEventAdapter("google.firebase.database.ref.v1.updated")), + entry( + "providers/google.firebase.database/eventTypes/ref.delete", + new FirebaseDatabaseEventAdapter("google.firebase.database.ref.v1.deleted")), + entry( + "providers/cloud.pubsub/eventTypes/topic.publish", + new PubSubEventAdapter(PUB_SUB_MESSAGE_PUBLISHED)), + entry( + "providers/cloud.storage/eventTypes/object.change", + new StorageEventAdapter("google.cloud.storage.object.v1.changed"))); + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + static CloudEvent convertToCloudEvent(Event legacyEvent) { + String eventType = legacyEvent.getContext().eventType(); + EventAdapter eventAdapter = EVENT_TYPE_MAPPING.get(eventType); + if (eventAdapter == null) { + throw new IllegalArgumentException("Unrecognized event type \"" + eventType + "\""); + } + return eventAdapter.convertToCloudEvent(legacyEvent); + } + + @AutoValue + abstract static class SourceAndSubject { + /** The source URI, without the initial {@code ///}. */ + abstract String source(); + + abstract @Nullable String subject(); + + static SourceAndSubject of(String source, String subject) { + return new AutoValue_GcfEvents_SourceAndSubject(source, subject); + } + } + + private abstract static class EventAdapter { + private final String cloudEventType; + private final String defaultService; + + EventAdapter(String cloudEventType, String defaultService) { + this.cloudEventType = cloudEventType; + this.defaultService = defaultService; + } + + final CloudEvent convertToCloudEvent(Event legacyEvent) { + String jsonData = GSON.toJson(legacyEvent.getData()); + jsonData = maybeReshapeData(legacyEvent, jsonData); + Resource resource = Resource.from(legacyEvent.getContext().resource()); + String service = Optional.ofNullable(resource.service()).orElse(defaultService); + String resourceName = resource.name(); + SourceAndSubject sourceAndSubject = + convertResourceToSourceAndSubject(resourceName, legacyEvent); + URI source = URI.create("//" + service + "/" + sourceAndSubject.source()); + OffsetDateTime timestamp = + Optional.ofNullable(legacyEvent.getContext().timestamp()) + .map(s -> OffsetDateTime.parse(s, DateTimeFormatter.ISO_DATE_TIME)) + .orElse(null); + return CloudEventBuilder.v1() + .withData(jsonData.getBytes(UTF_8)) + .withDataContentType("application/json") + .withId(legacyEvent.getContext().eventId()) + .withSource(source) + .withSubject(sourceAndSubject.subject()) + .withTime(timestamp) + .withType(cloudEventType) + .build(); + } + + String maybeReshapeData(Event legacyEvent, String jsonData) { + return jsonData; + } + + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { + return SourceAndSubject.of(resourceName, null); + } + } + + private static class PubSubEventAdapter extends EventAdapter { + PubSubEventAdapter(String cloudEventType) { + super(cloudEventType, PUB_SUB_SERVICE); + } + + @Override + String maybeReshapeData(Event legacyEvent, String jsonData) { + JsonObject jsonObject = GSON.fromJson(jsonData, JsonObject.class); + jsonObject.addProperty("messageId", legacyEvent.getContext().eventId()); + jsonObject.addProperty("publishTime", legacyEvent.getContext().timestamp()); + JsonObject wrapped = new JsonObject(); + wrapped.add("message", jsonObject); + return GSON.toJson(wrapped); + } + } + + private static class StorageEventAdapter extends EventAdapter { + private static final Pattern STORAGE_RESOURCE_PATTERN = + Pattern.compile("^(projects/_/buckets/[^/]+)/(objects/.*?)(?:#\\d+)?$"); + + StorageEventAdapter(String cloudEventType) { + super(cloudEventType, STORAGE_SERVICE); + } + + @Override + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { + Matcher matcher = STORAGE_RESOURCE_PATTERN.matcher(resourceName); + if (matcher.matches()) { + String resource = matcher.group(1); + String subject = matcher.group(2); + return SourceAndSubject.of(resource, subject); + } + return super.convertResourceToSourceAndSubject(resourceName, legacyEvent); + } + } + + private static class FirestoreFirebaseEventAdapter extends EventAdapter { + private static final Pattern FIRESTORE_RESOURCE_PATTERN = + Pattern.compile("^(projects/.+)/((documents|refs)/.+)$"); + + FirestoreFirebaseEventAdapter(String cloudEventType, String defaultService) { + super(cloudEventType, defaultService); + } + + @Override + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { + Matcher matcher = FIRESTORE_RESOURCE_PATTERN.matcher(resourceName); + if (matcher.matches()) { + String resource = matcher.group(1); + String subject = matcher.group(2); + return SourceAndSubject.of(resource, subject); + } + return super.convertResourceToSourceAndSubject(resourceName, legacyEvent); + } + + @Override + String maybeReshapeData(Event legacyEvent, String jsonData) { + // The reshaping code is disabled for now, because the specification for how the legacy + // "params" + // field should be represented in a CloudEvent is in flux. + if (true || legacyEvent.getContext().params().isEmpty()) { + return jsonData; + } + JsonObject jsonObject = GSON.fromJson(jsonData, JsonObject.class); + JsonObject wildcards = new JsonObject(); + legacyEvent.getContext().params().forEach((k, v) -> wildcards.addProperty(k, v)); + jsonObject.add("wildcards", wildcards); + return GSON.toJson(jsonObject); + } + } + + private static class FirebaseDatabaseEventAdapter extends EventAdapter { + private static final Pattern FIREBASE_DB_RESOURCE_PATTERN = + Pattern.compile("^projects/_/(instances/[^/]+)/((documents|refs)/.+)$"); + + FirebaseDatabaseEventAdapter(String cloudEventType) { + super(cloudEventType, FIREBASE_DB_SERVICE); + } + + @Override + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { + Matcher matcher = FIREBASE_DB_RESOURCE_PATTERN.matcher(resourceName); + String location = parseLocation(legacyEvent); + if (matcher.matches() && location != null) { + String resource = String.format("projects/_/locations/%s/%s", location, matcher.group(1)); + String subject = matcher.group(2); + return SourceAndSubject.of(resource, subject); + } + return super.convertResourceToSourceAndSubject(resourceName, legacyEvent); + } + + private String parseLocation(Event legacyEvent) { + String domain = legacyEvent.getContext().domain(); + if (domain == null) { + return null; + } + // The default location for firebaseio.com is us-central1 + if ("firebaseio.com".equals(domain)) { + return "us-central1"; + } + // Otherwise the location can be inferred from the first subdomain + String[] subdomains = domain.split("\\."); + if (subdomains.length > 1) { + return subdomains[0]; + } + return null; + } + } + + private static class FirebaseAuthEventAdapter extends EventAdapter { + FirebaseAuthEventAdapter(String cloudEventType) { + super(cloudEventType, FIREBASE_AUTH_SERVICE); + } + + @Override + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { + String subject = null; + JsonObject data = legacyEvent.getData().getAsJsonObject(); + if (data.has("uid")) { + subject = "users/" + data.get("uid").getAsString(); + } + return SourceAndSubject.of(resourceName, subject); + } + + @Override + String maybeReshapeData(Event legacyEvent, String jsonData) { + JsonObject jsonObject = GSON.fromJson(jsonData, JsonObject.class); + if (!jsonObject.has("metadata")) { + return jsonData; + } + JsonObject metadata = jsonObject.getAsJsonObject("metadata"); + if (metadata.has("createdAt")) { + metadata.add("createTime", metadata.get("createdAt")); + metadata.remove("createdAt"); + } + if (metadata.has("lastSignedInAt")) { + metadata.add("lastSignInTime", metadata.get("lastSignedInAt")); + metadata.remove("lastSignedInAt"); + } + return GSON.toJson(jsonObject); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java new file mode 100644 index 00000000..b414f110 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java @@ -0,0 +1,89 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.invoker.gcf.ExecutionIdUtil; +import com.google.cloud.functions.invoker.http.HttpRequestImpl; +import com.google.cloud.functions.invoker.http.HttpResponseImpl; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +/** Executes the user's method. */ +public class HttpFunctionExecutor extends Handler.Abstract { + private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); + + private final HttpFunction function; + private final ExecutionIdUtil executionIdUtil = new ExecutionIdUtil(); + + private HttpFunctionExecutor(HttpFunction function) { + this.function = function; + } + + /** + * Makes a {@link HttpFunctionExecutor} for the given class. + * + * @throws RuntimeException if either the given class does not implement {@link HttpFunction} or + * we are unable to construct an instance using its no-arg constructor. + */ + public static HttpFunctionExecutor forClass(Class functionClass) { + if (!HttpFunction.class.isAssignableFrom(functionClass)) { + throw new RuntimeException( + "Class " + + functionClass.getName() + + " does not implement " + + HttpFunction.class.getName()); + } + Class httpFunctionClass = functionClass.asSubclass(HttpFunction.class); + ClassLoader oldContextLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(httpFunctionClass.getClassLoader()); + HttpFunction httpFunction = httpFunctionClass.getConstructor().newInstance(); + return new HttpFunctionExecutor(httpFunction); + } catch (ReflectiveOperationException e) { + throw new RuntimeException( + "Could not construct an instance of " + functionClass.getName() + ": " + e, e); + } finally { + Thread.currentThread().setContextClassLoader(oldContextLoader); + } + } + + /** Executes the user's method, can handle all HTTP type methods. */ + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + + HttpRequestImpl reqImpl = new HttpRequestImpl(request); + HttpResponseImpl respImpl = new HttpResponseImpl(response); + ClassLoader oldContextLoader = Thread.currentThread().getContextClassLoader(); + try { + executionIdUtil.storeExecutionId(request); + Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader()); + function.service(reqImpl, respImpl); + respImpl.close(callback); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); + Response.writeError(request, response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null); + } finally { + Thread.currentThread().setContextClassLoader(oldContextLoader); + executionIdUtil.removeExecutionId(); + } + return true; + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java new file mode 100644 index 00000000..63418705 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java @@ -0,0 +1,170 @@ +package com.google.cloud.functions.invoker; + +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import com.google.cloud.functions.TypedFunction; +import com.google.cloud.functions.TypedFunction.WireFormat; +import com.google.cloud.functions.invoker.http.HttpRequestImpl; +import com.google.cloud.functions.invoker.http.HttpResponseImpl; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +public class TypedFunctionExecutor extends Handler.Abstract { + private static final String APPLY_METHOD = "apply"; + private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); + + private final Type argType; + private final TypedFunction function; + private final WireFormat format; + + private TypedFunctionExecutor( + Type argType, TypedFunction func, WireFormat format) { + this.argType = argType; + this.function = func; + this.format = format; + } + + public static TypedFunctionExecutor forClass(Class functionClass) { + if (!TypedFunction.class.isAssignableFrom(functionClass)) { + throw new RuntimeException( + "Class " + + functionClass.getName() + + " does not implement " + + TypedFunction.class.getName()); + } + @SuppressWarnings("unchecked") + Class> typedFunctionClass = + (Class>) functionClass.asSubclass(TypedFunction.class); + + Optional argType = handlerTypeArgument(typedFunctionClass); + if (argType.isEmpty()) { + throw new RuntimeException( + "Class " + + typedFunctionClass.getName() + + " does not implement " + + TypedFunction.class.getName()); + } + + TypedFunction typedFunction; + try { + typedFunction = typedFunctionClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException( + "Class " + + typedFunctionClass.getName() + + " must declare a valid default constructor to be usable as a strongly typed" + + " function. Could not use constructor: " + + e.toString()); + } + + WireFormat format = typedFunction.getWireFormat(); + if (format == null) { + format = LazyDefaultFormatHolder.defaultFormat; + } + + @SuppressWarnings("unchecked") + TypedFunctionExecutor executor = + new TypedFunctionExecutor( + argType.orElseThrow(), (TypedFunction) typedFunction, format); + return executor; + } + + /** + * Returns the {@code ReqT} of a concrete class that implements {@link TypedFunction + * TypedFunction}. Returns an empty {@link Optional} if {@code ReqT} can't be + * determined. + */ + static Optional handlerTypeArgument(Class> functionClass) { + return Arrays.stream(functionClass.getMethods()) + .filter(method -> method.getName().equals(APPLY_METHOD) && method.getParameterCount() == 1) + .map(method -> method.getGenericParameterTypes()[0]) + .filter(type -> type != Object.class) + .findFirst(); + } + + /** Executes the user's method, can handle all HTTP type methods. */ + @Override + public boolean handle(Request req, Response res, Callback callback) throws Exception { + HttpRequestImpl reqImpl = new HttpRequestImpl(req); + HttpResponseImpl resImpl = new HttpResponseImpl(res); + ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader(); + + try { + Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader()); + handleRequest(reqImpl, resImpl); + resImpl.close(callback); + } catch (Throwable t) { + Response.writeError(req, res, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null, t); + } finally { + Thread.currentThread().setContextClassLoader(oldContextClassLoader); + } + return true; + } + + private void handleRequest(HttpRequest req, HttpResponse res) { + Object reqObj; + try { + reqObj = format.deserialize(req, argType); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to parse request for " + function.getClass().getName(), t); + res.setStatusCode(HttpStatus.BAD_REQUEST_400); + return; + } + + Object resObj; + try { + resObj = function.apply(reqObj); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); + res.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + + try { + format.serialize(resObj, res); + } catch (Throwable t) { + logger.log( + Level.SEVERE, "Failed to serialize response for " + function.getClass().getName(), t); + res.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + } + + private static class LazyDefaultFormatHolder { + static final WireFormat defaultFormat = new GsonWireFormat(); + } + + private static class GsonWireFormat implements TypedFunction.WireFormat { + private final Gson gson = new GsonBuilder().create(); + + @Override + public void serialize(Object object, HttpResponse response) throws Exception { + if (object == null) { + response.setStatusCode(HttpStatus.NO_CONTENT_204); + return; + } + try (BufferedWriter bodyWriter = response.getWriter()) { + gson.toJson(object, bodyWriter); + } + } + + @Override + public Object deserialize(HttpRequest request, Type type) throws Exception { + try (BufferedReader bodyReader = request.getReader()) { + return gson.fromJson(bodyReader, type); + } + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java new file mode 100644 index 00000000..becf2c5c --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java @@ -0,0 +1,63 @@ +package com.google.cloud.functions.invoker.gcf; + +import java.util.Base64; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Handler; +import java.util.logging.Logger; +import org.eclipse.jetty.server.Request; + +/** + * A helper class that either fetches a unique execution id from request HTTP headers or generates a + * random id. + */ +public final class ExecutionIdUtil { + private static final Logger rootLogger = Logger.getLogger(""); + private static final int EXECUTION_ID_LENGTH = 12; + private static final String EXECUTION_ID_HTTP_HEADER = "HTTP_FUNCTION_EXECUTION_ID"; + private static final String LOG_EXECUTION_ID_ENV_NAME = "LOG_EXECUTION_ID"; + + private final Random random = ThreadLocalRandom.current(); + + /** + * Add mapping to root logger from current thread id to execution id. This mapping will be used to + * append the execution id to log lines. + */ + public void storeExecutionId(Request request) { + if (!executionIdLoggingEnabled()) { + return; + } + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof JsonLogHandler) { + String id = getOrGenerateExecutionId(request); + ((JsonLogHandler) handler).addExecutionId(Thread.currentThread().getId(), id); + } + } + } + + /** Remove mapping from curent thread to request execution id */ + public void removeExecutionId() { + if (!executionIdLoggingEnabled()) { + return; + } + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof JsonLogHandler) { + ((JsonLogHandler) handler).removeExecutionId(Thread.currentThread().getId()); + } + } + } + + private String getOrGenerateExecutionId(Request request) { + String executionId = request.getHeaders().get(EXECUTION_ID_HTTP_HEADER); + if (executionId == null) { + byte[] array = new byte[EXECUTION_ID_LENGTH]; + random.nextBytes(array); + executionId = Base64.getEncoder().encodeToString(array); + } + return executionId; + } + + private boolean executionIdLoggingEnabled() { + return Boolean.parseBoolean(System.getenv().getOrDefault(LOG_EXECUTION_ID_ENV_NAME, "false")); + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java new file mode 100644 index 00000000..51aad21a --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java @@ -0,0 +1,152 @@ +package com.google.cloud.functions.invoker.gcf; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +/** + * A log handler that publishes log messages in a json format. This is StackDriver's "structured logging" format. + */ +public final class JsonLogHandler extends Handler { + private static final String SOURCE_LOCATION_KEY = "\"logging.googleapis.com/sourceLocation\": "; + private static final String LOG_EXECUTION_ID_ENV_NAME = "LOG_EXECUTION_ID"; + + private static final String DEBUG = "DEBUG"; + private static final String INFO = "INFO"; + private static final String WARNING = "WARNING"; + private static final String ERROR = "ERROR"; + private static final String DEFAULT = "DEFAULT"; + + private final PrintStream out; + private final boolean closePrintStreamOnClose; + // This map is used to track execution id for currently running Jetty requests. Mapping thread + // id to request works because of an implementation detail of Jetty thread pool handling. + // Jetty worker threads completely handle a request before beginning work on a new request. + // NOTE: Store thread id as a string to avoid comparison failures between int and long. + // + // Jetty Documentation (https://jetty.org/docs/jetty/10/programming-guide/arch/threads.html) + private static final ConcurrentMap executionIdByThreadMap = + new ConcurrentHashMap<>(); + + public JsonLogHandler(PrintStream out, boolean closePrintStreamOnClose) { + this.out = out; + this.closePrintStreamOnClose = closePrintStreamOnClose; + } + + @Override + public void publish(LogRecord record) { + // We avoid String.format and String.join even though they would simplify the code. + // Logging code often shows up in profiling so we want to make this fast and StringBuilder is + // more performant. + StringBuilder json = new StringBuilder("{"); + appendSeverity(json, record); + appendSourceLocation(json, record); + appendExecutionId(json, record); + appendMessage(json, record); // must be last, see appendMessage + json.append("}"); + // We must output the log all at once (should only call println once per call to publish) + out.println(json); + } + + private static void appendMessage(StringBuilder json, LogRecord record) { + // This must be the last item in the JSON object, because it has no trailing comma. JSON is + // unforgiving about commas and you can't have one just before }. + json.append("\"message\": \"").append(escapeString(record.getMessage())); + if (record.getThrown() != null) { + json.append("\\n").append(escapeString(getStackTraceAsString(record.getThrown()))); + } + json.append("\""); + } + + private static void appendSeverity(StringBuilder json, LogRecord record) { + json.append("\"severity\": \"").append(levelToSeverity(record.getLevel())).append("\", "); + } + + private static String levelToSeverity(Level level) { + int intLevel = (level == null) ? 0 : level.intValue(); + switch (intLevel) { + case 300: // FINEST + case 400: // FINER + case 500: // FINE + return DEBUG; + case 700: // CONFIG + case 800: // INFO + // Java's CONFIG is lower than its INFO, while Stackdriver's NOTICE is greater than its + // INFO. So despite the similarity, we don't try to use NOTICE for CONFIG. + return INFO; + case 900: // WARNING + return WARNING; + case 1000: // SEVERE + return ERROR; + default: + return DEFAULT; + } + } + + private static void appendSourceLocation(StringBuilder json, LogRecord record) { + if (record.getSourceClassName() == null && record.getSourceMethodName() == null) { + return; + } + List entries = new ArrayList<>(); + if (record.getSourceClassName() != null) { + // TODO: Handle nested classes. If the source class name is com.example.Foo$Bar then the + // source file is com/example/Foo.java, not com/example/Foo$Bar.java. + String fileName = record.getSourceClassName().replace('.', '/') + ".java"; + entries.add("\"file\": \"" + escapeString(fileName) + "\""); + } + if (record.getSourceMethodName() != null) { + entries.add("\"method\": \"" + escapeString(record.getSourceMethodName()) + "\""); + } + json.append(SOURCE_LOCATION_KEY).append("{").append(String.join(", ", entries)).append("}, "); + } + + private void appendExecutionId(StringBuilder json, LogRecord record) { + if (executionIdLoggingEnabled()) { + json.append("\"execution_id\": \"") + .append(executionIdByThreadMap.get(Integer.toString(record.getThreadID()))) + .append("\", "); + } + } + + private static String escapeString(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private static String getStackTraceAsString(Throwable t) { + StringWriter stringWriter = new StringWriter(); + t.printStackTrace(new PrintWriter(stringWriter)); + return stringWriter.toString(); + } + + @Override + public void flush() { + out.flush(); + } + + @Override + public void close() throws SecurityException { + if (closePrintStreamOnClose) { + out.close(); + } + } + + public void addExecutionId(long threadId, String executionId) { + executionIdByThreadMap.put(Long.toString(threadId), executionId); + } + + public void removeExecutionId(long threadId) { + executionIdByThreadMap.remove(Long.toString(threadId)); + } + + private boolean executionIdLoggingEnabled() { + return Boolean.parseBoolean(System.getenv().getOrDefault(LOG_EXECUTION_ID_ENV_NAME, "false")); + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java new file mode 100644 index 00000000..31ee4ac6 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java @@ -0,0 +1,205 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import com.google.cloud.functions.HttpRequest; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPart.Part; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.util.Fields; + +public class HttpRequestImpl implements HttpRequest { + private final Request request; + private InputStream inputStream; + private BufferedReader reader; + + public HttpRequestImpl(Request request) { + this.request = request; + } + + @Override + public String getMethod() { + return request.getMethod(); + } + + @Override + public String getUri() { + return request.getHttpURI().asString(); + } + + @Override + public String getPath() { + return request.getHttpURI().getCanonicalPath(); + } + + @Override + public Optional getQuery() { + return Optional.ofNullable(request.getHttpURI().getQuery()); + } + + @Override + public Map> getQueryParameters() { + Fields fields = Request.extractQueryParameters(request); + if (fields.isEmpty()) { + return Collections.emptyMap(); + } + + Map> map = new HashMap<>(); + fields.forEach( + field -> map.put(field.getName(), Collections.unmodifiableList(field.getValues()))); + return Collections.unmodifiableMap(map); + } + + @Override + public Map getParts() { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + if (contentType == null + || !contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { + throw new IllegalStateException("Content-Type must be multipart/form-data: " + contentType); + } + + // The multipart parsing is done by the EagerContentHandler, so we just call getParts. + MultiPartFormData.Parts parts = MultiPartFormData.getParts(request); + if (parts == null) { + throw new IllegalStateException(); + } + + if (parts.size() == 0) { + return Collections.emptyMap(); + } + + Map map = new HashMap<>(); + parts.forEach(part -> map.put(part.getName(), new HttpPartImpl(part))); + return Collections.unmodifiableMap(map); + } + + @Override + public Optional getContentType() { + return Optional.ofNullable(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); + } + + @Override + public long getContentLength() { + return request.getLength(); + } + + @Override + public Optional getCharacterEncoding() { + Charset charset = Request.getCharset(request); + return Optional.ofNullable(charset == null ? null : charset.name()); + } + + @Override + public InputStream getInputStream() throws IOException { + if (reader != null) { + throw new IllegalStateException("getReader() already called"); + } + if (inputStream == null) { + inputStream = Content.Source.asInputStream(request); + } + return inputStream; + } + + @Override + public BufferedReader getReader() throws IOException { + if (reader == null) { + if (inputStream != null) { + throw new IllegalStateException("getInputStream already called"); + } + inputStream = Content.Source.asInputStream(request); + reader = + new BufferedReader( + new InputStreamReader( + getInputStream(), + Objects.requireNonNullElse(Request.getCharset(request), StandardCharsets.UTF_8))); + } + return reader; + } + + @Override + public Map> getHeaders() { + return HttpUtil.toStringListMap(request.getHeaders()); + } + + private static class HttpPartImpl implements HttpPart { + private final Part part; + private final String contentType; + + private HttpPartImpl(Part part) { + this.part = part; + contentType = part.getHeaders().get(HttpHeader.CONTENT_TYPE); + } + + @Override + public Optional getFileName() { + return Optional.ofNullable(part.getFileName()); + } + + @Override + public Optional getContentType() { + return Optional.ofNullable(contentType); + } + + @Override + public long getContentLength() { + return part.getLength(); + } + + @Override + public Optional getCharacterEncoding() { + return Optional.ofNullable(MimeTypes.getCharsetFromContentType(contentType)); + } + + @Override + public InputStream getInputStream() throws IOException { + Content.Source contentSource = part.createContentSource(); + return Content.Source.asInputStream(contentSource); + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader( + new InputStreamReader( + getInputStream(), + Objects.requireNonNullElse( + MimeTypes.DEFAULTS.getCharset(contentType), StandardCharsets.UTF_8))); + } + + @Override + public Map> getHeaders() { + return HttpUtil.toStringListMap(part.getHeaders()); + } + + @Override + public String toString() { + return "%s{%s}".formatted(super.toString(), part); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java new file mode 100644 index 00000000..5773de4b --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java @@ -0,0 +1,224 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.WriteThroughWriter; +import org.eclipse.jetty.io.content.BufferedContentSink; +import org.eclipse.jetty.io.content.ContentSinkOutputStream; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +public class HttpResponseImpl implements HttpResponse { + private final Response response; + private ContentSinkOutputStream outputStream; + private BufferedWriter writer; + private Charset charset; + + public HttpResponseImpl(Response response) { + this.response = response; + } + + @Override + public void setStatusCode(int code) { + response.setStatus(code); + } + + @Override + @SuppressWarnings("deprecation") + public void setStatusCode(int code, String message) { + response.setStatus(code); + } + + @Override + public void setContentType(String contentType) { + response.getHeaders().put(HttpHeader.CONTENT_TYPE, contentType); + charset = response.getRequest().getContext().getMimeTypes().getCharset(contentType); + } + + @Override + public Optional getContentType() { + return Optional.ofNullable(response.getHeaders().get(HttpHeader.CONTENT_TYPE)); + } + + @Override + public void appendHeader(String key, String value) { + if (HttpHeader.CONTENT_TYPE.is(key)) { + setContentType(value); + } else { + response.getHeaders().add(key, value); + } + } + + @Override + public Map> getHeaders() { + return HttpUtil.toStringListMap(response.getHeaders()); + } + + @Override + public OutputStream getOutputStream() { + if (writer != null) { + throw new IllegalStateException("getWriter called"); + } + if (outputStream == null) { + Request request = response.getRequest(); + int outputBufferSize = + request.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize(); + BufferedContentSink bufferedContentSink = + new BufferedContentSink( + response, + request.getComponents().getByteBufferPool(), + false, + outputBufferSize / 2, + outputBufferSize); + + // TODO: remove override of close() when changes from + // https://github.com/jetty/jetty.project/pull/13972 are released. + outputStream = + new ContentSinkOutputStream(bufferedContentSink) { + boolean closed = false; + + @Override + public void close(Callback callback) throws IOException { + if (closed) { + callback.succeeded(); + } + + closed = true; + super.close(callback); + } + }; + } + return outputStream; + } + + @Override + public synchronized BufferedWriter getWriter() throws IOException { + if (writer == null) { + if (outputStream != null) { + throw new IllegalStateException("getOutputStream called"); + } + + writer = + new NonBufferedWriter( + WriteThroughWriter.newWriter( + getOutputStream(), Objects.requireNonNullElse(charset, StandardCharsets.UTF_8))); + } + return writer; + } + + /** + * Close the response, flushing all content. + * + * @param callback a {@link Callback} to be completed when the response is closed. + */ + public void close(Callback callback) { + try { + // The writer has been constructed to do no buffering, so it does not need to be flushed + if (outputStream != null) { + // Do an asynchronous close, so large buffered content may be written without blocking + outputStream.close(callback); + } else { + callback.succeeded(); + } + } catch (IOException e) { + // Too bad, can't close. + } + } + + /** + * A {@link BufferedWriter} that does not buffer. It is generally more efficient to buffer at the + * {@link Content.Sink} level, since frequently total content is smaller than a single buffer and + * the {@link Content.Sink} can turn a close into a last write that will avoid chunking the + * response if at all possible. However, {@link BufferedWriter} is in the API for {@link + * HttpResponse}, so we must return a writer of that type. + */ + private static class NonBufferedWriter extends BufferedWriter { + private final Writer writer; + + public NonBufferedWriter(Writer out) { + super(out, 1); + writer = out; + } + + @Override + public void write(int c) throws IOException { + writer.write(c); + } + + @Override + public void write(char[] cbuf) throws IOException { + writer.write(cbuf); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + writer.write(cbuf, off, len); + } + + @Override + public void write(String str) throws IOException { + writer.write(str); + } + + @Override + public void write(String str, int off, int len) throws IOException { + writer.write(str, off, len); + } + + @Override + public Writer append(CharSequence csq) throws IOException { + return writer.append(csq); + } + + @Override + public Writer append(CharSequence csq, int start, int end) throws IOException { + return writer.append(csq, start, end); + } + + @Override + public Writer append(char c) throws IOException { + return writer.append(c); + } + + @Override + public void flush() throws IOException { + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } + + @Override + public void newLine() throws IOException { + writer.write(System.lineSeparator()); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java new file mode 100644 index 00000000..042af255 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java @@ -0,0 +1,32 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; + +class HttpUtil { + public static Map> toStringListMap(HttpFields headers) { + Map> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (HttpField field : headers) { + map.computeIfAbsent(field.getName(), key -> new ArrayList<>()).add(field.getValue()); + } + return map; + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java new file mode 100644 index 00000000..96295fd2 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java @@ -0,0 +1,82 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.Scheduler; + +public class TimeoutHandler extends Handler.Wrapper { + private final Duration timeout; + + public TimeoutHandler(int timeoutSeconds, Handler handler) { + setHandler(handler); + timeout = Duration.ofSeconds(timeoutSeconds); + } + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + // Wrap the callback to ensure it is only completed once between the + // handler and the timeout task. + Callback wrappedCallback = new ProtectedCallback(callback); + Scheduler.Task timeoutTask = + request + .getComponents() + .getScheduler() + .schedule( + () -> + wrappedCallback.failed( + new BadMessageException( + HttpStatus.REQUEST_TIMEOUT_408, "Function execution timed out")), + timeout); + + // Cancel the timeout if the request completes the callback first. + return super.handle(request, response, Callback.from(timeoutTask::cancel, wrappedCallback)); + } + + private static class ProtectedCallback implements Callback { + private final Callback callback; + private final AtomicBoolean completed = new AtomicBoolean(false); + + public ProtectedCallback(Callback callback) { + this.callback = callback; + } + + @Override + public void succeeded() { + if (completed.compareAndSet(false, true)) { + callback.succeeded(); + } + } + + @Override + public void failed(Throwable x) { + if (completed.compareAndSet(false, true)) { + callback.failed(x); + } + } + + @Override + public InvocationType getInvocationType() { + return callback.getInvocationType(); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java new file mode 100644 index 00000000..20c3f248 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java @@ -0,0 +1,552 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.runner; + +import static java.util.stream.Collectors.toList; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.TypedFunction; +import com.google.cloud.functions.invoker.BackgroundFunctionExecutor; +import com.google.cloud.functions.invoker.HttpFunctionExecutor; +import com.google.cloud.functions.invoker.TypedFunctionExecutor; +import com.google.cloud.functions.invoker.gcf.JsonLogHandler; +import com.google.cloud.functions.invoker.http.TimeoutHandler; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.EagerContentHandler; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.QueuedThreadPool; + +/** + * Java server that runs the user's code (a jar file) on HTTP request and an HTTP response is sent + * once the user's function is completed. The server accepts HTTP requests at '/' for executing the + * user's function, handles all HTTP methods. + * + *

This class requires the following environment variables: + * + *

    + *
  • PORT - defines the port on which this server listens to HTTP requests. + *
  • FUNCTION_TARGET - defines the name of the class defining the function. + *
  • FUNCTION_SIGNATURE_TYPE - determines whether the loaded code defines an HTTP or event + * function. + *
+ */ +public class Invoker { + private static final Logger rootLogger = Logger.getLogger(""); + private static final Logger logger = Logger.getLogger(Invoker.class.getName()); + + static { + if (isGcf()) { + // If we're running with Google Cloud Functions, we'll get better-looking logs + // if we arrange for them to be formatted using StackDriver's "structured + // logging" JSON format. Remove the JDK's standard logger and replace it with + // the JSON one. + for (java.util.logging.Handler handler : rootLogger.getHandlers()) { + rootLogger.removeHandler(handler); + } + rootLogger.addHandler(new JsonLogHandler(System.out, false)); + } + } + + private static class Options { + @Parameter(description = "Port on which to listen for HTTP requests.", names = "--port") + private String port = System.getenv().getOrDefault("PORT", "8080"); + + @Parameter( + description = "Name of function class to execute when servicing incoming requests.", + names = "--target") + private String target = System.getenv().getOrDefault("FUNCTION_TARGET", "Function"); + + @Parameter( + description = + "List of files or directories where the compiled Java classes making up the function" + + " will be found. This functions like the -classpath option to the java command." + + " It is a list of filenames separated by '${path.separator}'. If an entry in the" + + " list names a directory then the class foo.bar.Baz will be looked for in" + + " foo${file.separator}bar${file.separator}Baz.class under that directory. If an" + + " entry in the list names a file and that file is a jar file then class" + + " foo.bar.Baz will be looked for in an entry foo/bar/Baz.class in that jar file." + + " If an entry is a directory followed by '${file.separator}*' then every file in" + + " the directory whose name ends with '.jar' will be searched for classes.", + names = "--classpath") + private String classPath = null; + + @Parameter(names = "--help", help = true) + private boolean help = false; + } + + public static void main(String[] args) throws Exception { + Optional invoker = makeInvoker(args); + if (invoker.isPresent()) { + invoker.get().startServer(); + } + } + + static Optional makeInvoker(String... args) { + return makeInvoker(System.getenv(), args); + } + + static Optional makeInvoker(Map environment, String... args) { + Options options = new Options(); + JCommander jCommander = JCommander.newBuilder().addObject(options).build(); + try { + jCommander.parse(args); + } catch (ParameterException e) { + usage(jCommander); + throw e; + } + + if (options.help) { + usage(jCommander); + return Optional.empty(); + } + + int port; + try { + port = Integer.parseInt(options.port); + } catch (NumberFormatException e) { + System.err.println("--port value should be an integer: " + options.port); + usage(jCommander); + throw e; + } + String functionTarget = options.target; + Path standardFunctionJarPath = Paths.get("function/function.jar"); + Optional functionClasspath = + Arrays.asList( + options.classPath, + environment.get("FUNCTION_CLASSPATH"), + Files.exists(standardFunctionJarPath) ? standardFunctionJarPath.toString() : null) + .stream() + .filter(Objects::nonNull) + .findFirst(); + ClassLoader functionClassLoader = makeClassLoader(functionClasspath); + Invoker invoker = + new Invoker( + port, functionTarget, environment.get("FUNCTION_SIGNATURE_TYPE"), functionClassLoader); + return Optional.of(invoker); + } + + private static void usage(JCommander jCommander) { + StringBuilder usageBuilder = new StringBuilder(); + jCommander.getUsageFormatter().usage(usageBuilder); + String usage = + usageBuilder + .toString() + .replace("${file.separator}", File.separator) + .replace("${path.separator}", File.pathSeparator); + jCommander.getConsole().println(usage); + } + + private static ClassLoader makeClassLoader(Optional functionClasspath) { + ClassLoader runtimeLoader = Invoker.class.getClassLoader(); + if (functionClasspath.isPresent()) { + ClassLoader parent = new OnlyApiClassLoader(runtimeLoader); + return new FunctionClassLoader(classpathToUrls(functionClasspath.get()), parent); + } + return runtimeLoader; + } + + // This is a subclass just so we can identify it from its toString(). + private static class FunctionClassLoader extends URLClassLoader { + FunctionClassLoader(URL[] urls, ClassLoader parent) { + super(urls, parent); + } + } + + private final Integer port; + private final String functionTarget; + private final String functionSignatureType; + private final ClassLoader functionClassLoader; + + private Server server; + + public Invoker( + Integer port, + String functionTarget, + String functionSignatureType, + ClassLoader functionClassLoader) { + this.port = port; + this.functionTarget = functionTarget; + this.functionSignatureType = functionSignatureType; + this.functionClassLoader = functionClassLoader; + } + + Integer getPort() { + return port; + } + + String getFunctionTarget() { + return functionTarget; + } + + String getFunctionSignatureType() { + return functionSignatureType; + } + + ClassLoader getFunctionClassLoader() { + return functionClassLoader; + } + + /** + * This will start the server and wait (join) for function calls. To start the server inside a + * unit or integration test, use {@link #startTestServer()} instead. + * + * @see #stopServer() + * @throws Exception If there was a problem starting the server + */ + public void startServer() throws Exception { + startServer(true); + } + + /** + * This will start the server and return. + * + *

This method is designed to be used for unit or integration testing only. For other use cases + * use {@link #startServer()}. + * + *

Inside a test a typical usage will be: + * + *

{@code
+   * // Create an invoker
+   * Invoker invoker = new Invoker(
+   *     8081,
+   *     "org.example.MyHttpFunction",
+   *     "http",
+   *     Thread.currentThread().getContextClassLoader());
+   *
+   * // Start the test server
+   * invoker.startTestServer();
+   *
+   * // Test the function
+   *
+   * // Stop the test server
+   * invoker.stopServer();
+   * }
+ * + * @see #stopServer() + * @throws Exception If there was a problem starting the server + */ + public void startTestServer() throws Exception { + startServer(false); + } + + private void startServer(boolean join) throws Exception { + if (server != null) { + throw new IllegalStateException("Server already started"); + } + + QueuedThreadPool pool = new QueuedThreadPool(1024); + server = new Server(pool); + server.setErrorHandler( + new ErrorHandler() { + @Override + protected void generateResponse( + Request request, + Response response, + int code, + String message, + Throwable cause, + Callback callback) { + // Suppress error body + callback.succeeded(); + } + }); + ServerConnector connector = new ServerConnector(server); + connector.setPort(port); + connector.setReuseAddress(true); + connector.setReusePort(true); + server.addConnector(connector); + + Class functionClass = loadFunctionClass(); + + Handler handler; + if (functionSignatureType == null) { + handler = handlerForDeducedSignatureType(functionClass); + } else { + switch (functionSignatureType) { + case "http": + if (TypedFunction.class.isAssignableFrom(functionClass)) { + handler = TypedFunctionExecutor.forClass(functionClass); + } else { + handler = HttpFunctionExecutor.forClass(functionClass); + } + break; + case "event": + case "cloudevent": + handler = BackgroundFunctionExecutor.forClass(functionClass); + break; + case "typed": + handler = TypedFunctionExecutor.forClass(functionClass); + break; + default: + String error = + String.format( + "Function signature type %s is unknown; should be \"http\", \"event\"," + + " or \"cloudevent\"", + functionSignatureType); + throw new RuntimeException(error); + } + } + + // Possibly wrap with TimeoutHandler if CLOUD_RUN_TIMEOUT_SECONDS is set. + handler = addTimerHandlerForRequestTimeout(handler); + server.setHandler(handler); + + // Add a handler to asynchronously parse multipart before invoking the function. + MultiPartConfig config = new MultiPartConfig.Builder().maxMemoryPartSize(-1).build(); + EagerContentHandler.MultiPartContentLoaderFactory factory = + new EagerContentHandler.MultiPartContentLoaderFactory(config); + server.insertHandler(new EagerContentHandler(factory)); + + server.insertHandler(new NotFoundHandler()); + + server.start(); + logServerInfo(); + if (join) { + server.join(); + } + } + + /** + * Stop the server. + * + * @see #startServer() + * @see #startTestServer() + * @throws Exception + */ + public void stopServer() throws Exception { + if (server == null) { + throw new IllegalStateException("Server not yet started"); + } + + server.stop(); + // setting the server to null, so it can be started again + server = null; + } + + private Class loadFunctionClass() throws ClassNotFoundException { + String target = functionTarget; + ClassNotFoundException firstException = null; + while (true) { + try { + return functionClassLoader.loadClass(target); + } catch (ClassNotFoundException e) { + if (firstException == null) { + firstException = e; + } + // This might be a nested class like com.example.Foo.Bar. That will actually + // appear as com.example.Foo$Bar as far as Class.forName is concerned. So we try to replace + // every dot from the last to the first with a $ in the hope of finding a class we can load. + int lastDot = target.lastIndexOf('.'); + if (lastDot < 0) { + throw firstException; + } + target = target.substring(0, lastDot) + '$' + target.substring(lastDot + 1); + } + } + } + + private Handler handlerForDeducedSignatureType(Class functionClass) { + if (HttpFunction.class.isAssignableFrom(functionClass)) { + return HttpFunctionExecutor.forClass(functionClass); + } + if (TypedFunction.class.isAssignableFrom(functionClass)) { + return TypedFunctionExecutor.forClass(functionClass); + } + Optional maybeExecutor = + BackgroundFunctionExecutor.maybeForClass(functionClass); + if (maybeExecutor.isPresent()) { + return maybeExecutor.get(); + } + String error = + String.format( + "Could not determine function signature type from target %s. Either this should be a" + + " class implementing one of the interfaces in com.google.cloud.functions, or the" + + " environment variable FUNCTION_SIGNATURE_TYPE should be set to \"http\" or" + + " \"event\".", + functionTarget); + throw new RuntimeException(error); + } + + private Handler addTimerHandlerForRequestTimeout(Handler handler) { + String timeoutSeconds = System.getenv("CLOUD_RUN_TIMEOUT_SECONDS"); + if (timeoutSeconds == null) { + return handler; + } + int seconds = Integer.parseInt(timeoutSeconds); + return new TimeoutHandler(seconds, handler); + } + + static URL[] classpathToUrls(String classpath) { + String[] components = classpath.split(File.pathSeparator); + List urls = new ArrayList<>(); + for (String component : components) { + if (component.endsWith(File.separator + "*")) { + urls.addAll(jarsIn(component.substring(0, component.length() - 2))); + } else { + Path path = Paths.get(component); + try { + urls.add(path.toUri().toURL()); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + } + } + return urls.toArray(new URL[0]); + } + + private static List jarsIn(String dir) { + Path path = Paths.get(dir); + if (!Files.isDirectory(path)) { + return Collections.emptyList(); + } + Stream stream; + try { + stream = Files.list(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return stream + .filter(p -> p.getFileName().toString().endsWith(".jar")) + .map( + p -> { + try { + return p.toUri().toURL(); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + }) + .collect(toList()); + } + + private void logServerInfo() { + if (!isGcf()) { + logger.log(Level.INFO, "Serving function..."); + logger.log(Level.INFO, "Function: {0}", functionTarget); + logger.log(Level.INFO, "URL: http://localhost:{0,number,#}/", port); + } + } + + private static boolean isGcf() { + // This environment variable is set in the GCF environment but won't be set when invoking the + // Functions Framework directly. We don't use its value, just whether it is set. + return System.getenv("K_SERVICE") != null; + } + + /** + * Wrapper that intercepts requests for {@code /favicon.ico} and {@code /robots.txt} and causes + * them to produce a 404 status. Otherwise, they would be sent to the function code, like any + * other URL, meaning that someone testing their function by using a browser as an HTTP client can + * see two requests, one for {@code /favicon.ico} and one for {@code /} (or whatever). + */ + private static class NotFoundHandler extends Handler.Wrapper { + + private static final Set NOT_FOUND_PATHS = + new HashSet<>(Arrays.asList("/favicon.ico", "/robots.txt")); + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + if (NOT_FOUND_PATHS.contains(request.getHttpURI().getCanonicalPath())) { + response.setStatus(HttpStatus.NOT_FOUND_404); + callback.succeeded(); + return true; + } + + return super.handle(request, response, callback); + } + } + + /** + * A loader that only loads GCF API classes. Those are classes whose package is exactly {@code + * com.google.cloud.functions}. The package can't be a subpackage, such as {@code + * com.google.cloud.functions.invoker}. + * + *

This loader allows us to load the classes from a user function, without making the runtime + * classes visible to them. We will make this loader the parent of the {@link URLClassLoader} that + * loads the user code in order to filter out those runtime classes. + * + *

The reason we do need to share the API classes between the runtime and the user function is + * so that the runtime can instantiate the function class and cast it to {@link + * com.google.cloud.functions.HttpFunction} or whatever. + */ + private static class OnlyApiClassLoader extends ClassLoader { + private final ClassLoader runtimeClassLoader; + + OnlyApiClassLoader(ClassLoader runtimeClassLoader) { + super(getSystemOrBootstrapClassLoader()); + this.runtimeClassLoader = runtimeClassLoader; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + String prefix = "com.google.cloud.functions."; + if ((name.startsWith(prefix) && Character.isUpperCase(name.charAt(prefix.length()))) + || isCloudEventsApiClass(name)) { + return runtimeClassLoader.loadClass(name); + } + return super.findClass(name); // should throw ClassNotFoundException + } + + private static final String CLOUD_EVENTS_API_PREFIX = "io.cloudevents."; + private static final int CLOUD_EVENTS_API_PREFIX_LENGTH = CLOUD_EVENTS_API_PREFIX.length(); + + private static boolean isCloudEventsApiClass(String name) { + return name.startsWith(CLOUD_EVENTS_API_PREFIX) + && Character.isUpperCase(name.charAt(CLOUD_EVENTS_API_PREFIX_LENGTH)); + } + + private static ClassLoader getSystemOrBootstrapClassLoader() { + try { + // We're still building against the Java 8 API, so we have to use reflection for + // now. + Method getPlatformClassLoader = ClassLoader.class.getMethod("getPlatformClassLoader"); + return (ClassLoader) getPlatformClassLoader.invoke(null); + } catch (ReflectiveOperationException e) { + return null; + } + } + } +} diff --git a/invoker/core/src/test/java/PackagelessHelloWorld.java b/invoker/core/src/test/java/PackagelessHelloWorld.java new file mode 100644 index 00000000..e590fef1 --- /dev/null +++ b/invoker/core/src/test/java/PackagelessHelloWorld.java @@ -0,0 +1,27 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A function in the default package. + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class PackagelessHelloWorld implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + response.setContentType("text/plain; charset=utf-8"); + response.getWriter().write("hello, world\n"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java new file mode 100644 index 00000000..87b9bd31 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java @@ -0,0 +1,109 @@ +package com.google.cloud.functions.invoker; + +import static com.google.cloud.functions.invoker.BackgroundFunctionExecutor.backgroundFunctionTypeArgument; +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.functions.BackgroundFunction; +import com.google.cloud.functions.Context; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class BackgroundFunctionExecutorTest { + private static class PubSubMessage { + String data; + Map attributes; + String messageId; + String publishTime; + } + + private static class PubSubFunction implements BackgroundFunction { + @Override + public void accept(PubSubMessage payload, Context context) {} + } + + @Test + public void backgroundFunctionTypeArgument_simple() { + assertThat(backgroundFunctionTypeArgument(PubSubFunction.class)).hasValue(PubSubMessage.class); + } + + private abstract static class Parent implements BackgroundFunction {} + + private static class Child extends Parent { + @Override + public void accept(PubSubMessage payload, Context context) {} + } + + @Test + public void backgroundFunctionTypeArgument_superclass() { + assertThat(backgroundFunctionTypeArgument(Child.class)).hasValue(PubSubMessage.class); + } + + private interface GenericParent extends BackgroundFunction {} + + private static class GenericChild implements GenericParent { + @Override + public void accept(PubSubMessage payload, Context context) {} + } + + @Test + public void backgroundFunctionTypeArgument_genericInterface() { + assertThat(backgroundFunctionTypeArgument(GenericChild.class)).hasValue(PubSubMessage.class); + } + + @SuppressWarnings("rawtypes") + private static class ForgotTypeParameter implements BackgroundFunction { + @Override + public void accept(Object payload, Context context) {} + } + + @Test + public void backgroundFunctionTypeArgument_raw() { + @SuppressWarnings("unchecked") + Class> c = + (Class>) (Class) ForgotTypeParameter.class; + assertThat(backgroundFunctionTypeArgument(c)).isEmpty(); + } + + @Test + public void parseLegacyEventPubSub() throws IOException { + try (Reader reader = + new InputStreamReader(getClass().getResourceAsStream("/pubsub_background.json"))) { + Event event = BackgroundFunctionExecutor.parseLegacyEvent(reader); + + Context context = event.getContext(); + assertThat(context.eventType()).isEqualTo("google.pubsub.topic.publish"); + assertThat(context.eventId()).isEqualTo("1"); + assertThat(context.timestamp()).isEqualTo("2021-06-28T05:46:32.390Z"); + + JsonObject data = event.getData().getAsJsonObject(); + assertThat(data.get("data").getAsString()).isEqualTo("eyJmb28iOiJiYXIifQ=="); + String attr = data.get("attributes").getAsJsonObject().get("test").getAsString(); + assertThat(attr).isEqualTo("123"); + } + } + + @Test + public void parseLegacyEventPubSubEmulator() throws IOException { + try (Reader reader = + new InputStreamReader(getClass().getResourceAsStream("/pubsub_emulator.json"))) { + Event event = BackgroundFunctionExecutor.parseLegacyEvent(reader); + + Context context = event.getContext(); + assertThat(context.eventType()).isEqualTo("google.pubsub.topic.publish"); + assertThat(context.eventId()).isEqualTo("1"); + assertThat(context.timestamp()).isNotNull(); + + JsonObject data = event.getData().getAsJsonObject(); + assertThat(data.get("data").getAsString()).isEqualTo("eyJmb28iOiJiYXIifQ=="); + String attr = data.get("attributes").getAsJsonObject().get("test").getAsString(); + assertThat(attr).isEqualTo("123"); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/CloudEventsTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/CloudEventsTest.java new file mode 100644 index 00000000..3e964d7b --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/CloudEventsTest.java @@ -0,0 +1,100 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.nio.charset.StandardCharsets.UTF_8; + +import io.cloudevents.CloudEvent; +import io.cloudevents.jackson.JsonFormat; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import org.junit.Test; + +public class CloudEventsTest { + @Test + public void firebaseFirestoreTest() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("firestore_complex-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("firestore_complex-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + @Test + public void pubSubTest() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("pubsub_text-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("pubsub_text-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + @Test + public void firebaseAuthTest() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("firebase-auth-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("firebase-auth-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + @Test + public void firebaseDb1Test() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("firebase-db1-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("firebase-db1-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + @Test + public void firebaseDb2Test() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("firebase-db2-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("firebase-db2-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + @Test + public void storageTest() throws Exception { + CloudEvent cloudEvent = cloudEventForResource("storage-cloudevent-input.json"); + Event actualEvent = CloudEvents.convertToLegacyEvent(cloudEvent); + + Event expEvent = legacyEventForResource("storage-legacy-output.json"); + assertThat(actualEvent).isEqualTo(expEvent); + } + + private CloudEvent cloudEventForResource(String resourceName) throws IOException { + try (InputStream in = getClass().getResourceAsStream("/" + resourceName)) { + assertWithMessage("No such resource /%s", resourceName).that(in).isNotNull(); + byte[] req = in.readAllBytes(); + return io.cloudevents.core.provider.EventFormatProvider.getInstance() + .resolveFormat(JsonFormat.CONTENT_TYPE) + .deserialize(req); + } + } + + private Event legacyEventForResource(String resourceName) throws IOException { + try (InputStream in = getClass().getResourceAsStream("/" + resourceName)) { + assertWithMessage("No such resource /%s", resourceName).that(in).isNotNull(); + String legacyEventString = new String(in.readAllBytes(), UTF_8); + return BackgroundFunctionExecutor.parseLegacyEvent(new StringReader(legacyEventString)); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/GcfEventsTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/GcfEventsTest.java new file mode 100644 index 00000000..24939fff --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/GcfEventsTest.java @@ -0,0 +1,317 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.truth.Expect; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.cloudevents.CloudEvent; +import io.cloudevents.SpecVersion; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; + +public class GcfEventsTest { + @Rule public Expect expect = Expect.create(); + + private static final String[][] EVENT_DATA = { + { + "storage.json", + "google.cloud.storage.object.v1.finalized", + "//storage.googleapis.com/projects/_/buckets/some-bucket", + "objects/folder/Test.cs" + }, + { + "legacy_storage_change.json", + "google.cloud.storage.object.v1.changed", + "//storage.googleapis.com/projects/_/buckets/sample-bucket", + "objects/MyFile" + }, + { + "firestore_simple.json", + "google.cloud.firestore.document.v1.written", + "//firestore.googleapis.com/projects/project-id/databases/(default)", + "documents/gcf-test/2Vm2mI1d0wIaK2Waj5to" + }, + { + "pubsub_text.json", + "google.cloud.pubsub.topic.v1.messagePublished", + "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + null + }, + { + "legacy_pubsub.json", + "google.cloud.pubsub.topic.v1.messagePublished", + "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + null + }, + { + "firebase-db1.json", + "google.firebase.database.ref.v1.written", + "//firebasedatabase.googleapis.com/projects/_/locations/us-central1/instances/my-project-id", + "refs/gcf-test/xyz" + }, + { + "firebase-db2.json", + "google.firebase.database.ref.v1.written", + "//firebasedatabase.googleapis.com/projects/_/locations/europe-west1/instances/my-project-id", + "refs/gcf-test/xyz" + }, + { + "firebase-auth1.json", + "google.firebase.auth.user.v1.created", + "//firebaseauth.googleapis.com/projects/my-project-id", + "users/UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + { + "firebase-auth2.json", + "google.firebase.auth.user.v1.deleted", + "//firebaseauth.googleapis.com/projects/my-project-id", + "users/UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + }; + + @Test + public void convertGcfEvent() throws IOException { + for (String[] eventData : EVENT_DATA) { + Event legacyEvent = legacyEventForResource(eventData[0]); + convertGcfEvent(legacyEvent, eventData[1], eventData[2], eventData[3]); + } + } + + private void convertGcfEvent( + Event legacyEvent, String expectedType, String expectedSource, String expectedSubject) { + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + expect.that(cloudEvent.getType()).isEqualTo(expectedType); + expect.that(cloudEvent.getSource().toString()).isEqualTo(expectedSource); + expect.that(cloudEvent.getSubject()).isEqualTo(expectedSubject); + } + + // Checks everything we know about a single event. + @Test + public void checkAllProperties() throws IOException { + Event legacyEvent = legacyEventForResource("storage.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + assertThat(cloudEvent.getDataContentType()).isEqualTo("application/json"); + assertThat(cloudEvent.getId()).isEqualTo("1147091835525187"); + assertThat(cloudEvent.getType()).isEqualTo("google.cloud.storage.object.v1.finalized"); + assertThat(cloudEvent.getTime()) + .isEqualTo(OffsetDateTime.of(2020, 4, 23, 7, 38, 57, 772_000_000, ZoneOffset.UTC)); + assertThat(cloudEvent.getSource().toString()) + .isEqualTo("//storage.googleapis.com/projects/_/buckets/some-bucket"); + assertThat(cloudEvent.getSubject()).isEqualTo("objects/folder/Test.cs"); + assertThat(cloudEvent.getSpecVersion()).isEqualTo(SpecVersion.V1); + assertThat(cloudEvent.getDataSchema()).isNull(); + } + + // The next set of tests checks the result of using Gson to deserialize the JSON "data" field of + // the + // CloudEvent that we get from converting a legacy event. For the most part we're not testing much + // here, + // since the "data" field is essentially copied from the input legacy event. In some cases we + // adjust it, + // though. + // Later, when we have support for handling these types properly in Java, we can change the tests + // to use + // that. See https://github.com/googleapis/google-cloudevents-java + + @Test + public void storageData() throws IOException { + Event legacyEvent = legacyEventForResource("storage.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + Map data = cloudEventDataJson(cloudEvent); + assertThat(data) + .containsAtLeast( + "bucket", "some-bucket", + "timeCreated", "2020-04-23T07:38:57.230Z", + "generation", "1587627537231057", + "metageneration", "1", + "size", "352"); + } + + @Test + public void firestoreSimpleData() throws IOException { + Event legacyEvent = legacyEventForResource("firestore_simple.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + Map data = cloudEventDataJson(cloudEvent); + Map expectedValue = + Map.of( + "name", + "projects/project-id/databases/(default)/documents/gcf-test/2Vm2mI1d0wIaK2Waj5to", + "createTime", "2020-04-23T09:58:53.211035Z", + "updateTime", "2020-04-23T12:00:27.247187Z", + "fields", + Map.of( + "another test", Map.of("stringValue", "asd"), + "count", Map.of("integerValue", "4"), + "foo", Map.of("stringValue", "bar"))); + Map expectedOldValue = + Map.of( + "name", + "projects/project-id/databases/(default)/documents/gcf-test/2Vm2mI1d0wIaK2Waj5to", + "createTime", "2020-04-23T09:58:53.211035Z", + "updateTime", "2020-04-23T12:00:27.247187Z", + "fields", + Map.of( + "another test", Map.of("stringValue", "asd"), + "count", Map.of("integerValue", "3"), + "foo", Map.of("stringValue", "bar"))); + assertThat(data) + .containsAtLeast( + "value", expectedValue, + "oldValue", expectedOldValue, + "updateMask", Map.of("fieldPaths", List.of("count"))); + } + + @Test + public void firestoreComplexData() throws IOException { + Event legacyEvent = legacyEventForResource("firestore_complex.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + Map data = cloudEventDataJson(cloudEvent); + Map value = (Map) data.get("value"); + Map fields = (Map) value.get("fields"); + Map expectedFields = + Map.of( + "arrayValue", + Map.of( + "arrayValue", + Map.of( + "values", + List.of(Map.of("integerValue", "1"), Map.of("integerValue", "2")))), + "booleanValue", Map.of("booleanValue", true), + "geoPointValue", + Map.of("geoPointValue", Map.of("latitude", 51.4543, "longitude", -0.9781)), + "intValue", Map.of("integerValue", "50"), + "doubleValue", Map.of("doubleValue", 5.5), + "nullValue", Collections.singletonMap("nullValue", null), + "referenceValue", + Map.of( + "referenceValue", + "projects/project-id/databases/(default)/documents/foo/bar/baz/qux"), + "stringValue", Map.of("stringValue", "text"), + "timestampValue", Map.of("timestampValue", "2020-04-23T14:23:53.241Z"), + "mapValue", + Map.of( + "mapValue", + Map.of( + "fields", + Map.of( + "field1", + Map.of("stringValue", "x"), + "field2", + Map.of( + "arrayValue", + Map.of( + "values", + List.of( + Map.of("stringValue", "x"), + Map.of("integerValue", "1")))))))); + assertThat(fields).containsExactlyEntriesIn(expectedFields); + } + + @Test + public void pubSubTextData() throws IOException { + Event legacyEvent = legacyEventForResource("pubsub_text.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + Map data = cloudEventDataJson(cloudEvent); + + Map message = (Map) data.get("message"); + assertThat(message).isNotNull(); + assertThat(message).containsKey("data"); + // Later we should provide support for doing this more simply and test that: + String base64 = (String) message.get("data"); + byte[] bytes = Base64.getDecoder().decode(base64); + String text = new String(bytes, UTF_8); + assertThat(text).isEqualTo("test message 3"); + + assertThat(message).containsEntry("attributes", Map.of("attr1", "attr1-value")); + } + + @Test + public void pubSubBinaryData() throws IOException { + Event legacyEvent = legacyEventForResource("pubsub_binary.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + Map data = cloudEventDataJson(cloudEvent); + + Map message = (Map) data.get("message"); + assertThat(message).isNotNull(); + assertThat(message).containsKey("data"); + // Later we should provide support for doing this more simply and test that: + String base64 = (String) message.get("data"); + byte[] bytes = Base64.getDecoder().decode(base64); + assertThat(bytes).isEqualTo(new byte[] {1, 2, 3, 4}); + + assertThat(message).doesNotContainKey("attributes"); + } + + // Checks that a PubSub event correctly gets its payload wrapped in a "message" dictionary. + @Test + public void pubSubWrapping() throws IOException { + Event legacyEvent = legacyEventForResource("legacy_pubsub.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + assertThat(new String(cloudEvent.getData().toBytes(), UTF_8)) + .isEqualTo( + "{\"message\":{\"@type\":\"type.googleapis.com/google.pubsub.v1.PubsubMessage\"," + + "\"attributes\":{\"attribute1\":\"value1\"}," + + "\"data\":\"VGhpcyBpcyBhIHNhbXBsZSBtZXNzYWdl\"," + + "\"messageId\":\"1215011316659232\"," + + "\"publishTime\":\"2020-05-18T12:13:19.209Z\"}}"); + } + + // Checks that a Firestore event correctly gets an extra "wildcards" property in its CloudEvent + // data + // reflecting the "params" field in the legacy event. + // This test is currently ignored because the final representation of the "params" field is in + // flux. + @Test + @Ignore + public void firestoreWildcards() throws IOException { + Event legacyEvent = legacyEventForResource("firestore_simple.json"); + CloudEvent cloudEvent = GcfEvents.convertToCloudEvent(legacyEvent); + JsonObject payload = + new Gson().fromJson(new String(cloudEvent.getData().toBytes(), UTF_8), JsonObject.class); + JsonObject wildcards = payload.getAsJsonObject("wildcards"); + assertThat(wildcards.keySet()).containsExactly("doc"); + assertThat(wildcards.getAsJsonPrimitive("doc").getAsString()).isEqualTo("2Vm2mI1d0wIaK2Waj5to"); + } + + private Event legacyEventForResource(String resourceName) throws IOException { + try (InputStream in = getClass().getResourceAsStream("/" + resourceName)) { + assertWithMessage("No such resource /%s", resourceName).that(in).isNotNull(); + String legacyEventString = new String(in.readAllBytes(), UTF_8); + return BackgroundFunctionExecutor.parseLegacyEvent(new StringReader(legacyEventString)); + } + } + + private static Map cloudEventDataJson(CloudEvent cloudEvent) { + String data = new String(cloudEvent.getData().toBytes(), UTF_8); + @SuppressWarnings("unchecked") + Map map = new Gson().fromJson(data, Map.class); + return map; + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/HttpFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/HttpFunctionExecutorTest.java new file mode 100644 index 00000000..080e3851 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/HttpFunctionExecutorTest.java @@ -0,0 +1,36 @@ +package com.google.cloud.functions.invoker; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HttpFunctionExecutorTest { + private static ClassLoader customClassLoader = + new ClassLoader(ClassLoader.getSystemClassLoader()) {}; + + public static class ClassLoaderVerifier implements HttpFunction { + public ClassLoaderVerifier() { + assertThat(Thread.currentThread().getContextClassLoader()) + .isNotSameInstanceAs(customClassLoader); + } + + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + throw new UnsupportedOperationException("Not implemented"); + } + } + + @Test + public void usesCorrectClassLoaderOverride() { + ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(customClassLoader); + HttpFunctionExecutor.forClass(ClassLoaderVerifier.class); + Thread.currentThread().setContextClassLoader(oldClassLoader); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java new file mode 100644 index 00000000..d6e3b14a --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java @@ -0,0 +1,1035 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.toList; + +import com.google.auto.value.AutoValue; +import com.google.cloud.functions.invoker.runner.Invoker; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.io.Resources; +import com.google.common.truth.Expect; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import io.cloudevents.http.HttpMessageFactory; +import io.cloudevents.jackson.JsonFormat; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import org.eclipse.jetty.client.ByteBufferRequestContent; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPart.ContentSourcePart; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.rules.TestName; + +/** + * Integration test that starts up a web server running the Function Framework and sends HTTP + * requests to it. + */ +public class IntegrationTest { + @Rule public final Expect expect = Expect.create(); + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Rule public final TestName testName = new TestName(); + + private static final String SERVER_READY_STRING = "Started oejs.ServerConnector"; + private static final String EXECUTION_ID_HTTP_HEADER = "HTTP_FUNCTION_EXECUTION_ID"; + private static final String EXECUTION_ID = "1234abcd"; + + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(); + + private static String sampleLegacyEvent(File snoopFile) { + return "{\n" + + " \"data\": {\n" + + " \"a\": 2,\n" + + " \"b\": 3,\n" + + " \"targetFile\": \"" + + snoopFile + + "\"" + + " },\n" + + " \"context\": {\n" + + " \"eventId\": \"B234-1234-1234\",\n" + + " \"timestamp\": \"2018-04-05T17:31:00Z\",\n" + + " \"eventType\": \"google.pubsub.topic.publish\",\n" + + " \"resource\": {\n" + + " \"service\":\"pubsub.googleapis.com\",\n" + + " \"name\":\"projects/sample-project/topics/gcf-test\",\n" + + " \"type\":\"type.googleapis.com/google.pubsub.v1.PubsubMessage\"\n" + + " }\n" + + " }\n" + + "}"; + } + + private static CloudEvent sampleCloudEvent(File snoopFile) { + return CloudEventBuilder.v1() + .withId("B234-1234-1234") + .withSource(URI.create("//pubsub.googleapis.com/projects/sample-project/topics/gcf-test")) + .withSubject("documents/gcf-test/2Vm2mI1d0wIaK2Waj5to") + .withType("google.cloud.pubsub.topic.v1.messagePublished") + .withDataSchema(URI.create("/schema")) + .withDataContentType("application/json") + .withData(("{\"a\": 2, \"b\": 3, \"targetFile\": \"" + snoopFile + "\"}").getBytes(UTF_8)) + .withTime(OffsetDateTime.of(2018, 4, 5, 17, 31, 0, 0, ZoneOffset.UTC)) + .build(); + } + + private static JsonObject expectedCloudEventAttributes() { + JsonObject attributes = new JsonObject(); + attributes.addProperty("datacontenttype", "application/json"); + attributes.addProperty("specversion", "1.0"); + attributes.addProperty("id", "B234-1234-1234"); + attributes.addProperty("source", "/source"); + attributes.addProperty("time", "2018-04-05T17:31Z"); + attributes.addProperty("type", "com.example.someevent.new"); + attributes.addProperty("dataschema", "/schema"); + return attributes; + } + + private static int serverPort; + + /** + * Each test method will start up a server on the same port, make one or more HTTP requests to + * that port, then kill the server. So the port should be free when the next test method runs. + */ + @BeforeClass + public static void allocateServerPort() throws IOException { + try (ServerSocket serverSocket = new ServerSocket(0)) { + serverPort = serverSocket.getLocalPort(); + } + } + + /** + * Description of a test case. When we send an HTTP POST to the given {@link #url()} in the + * server, with the given {@link #requestContent()} ()} as the body of the POST, then we expect to + * get back the given {@link #expectedResponseText()} in the body of the response. + */ + @AutoValue + abstract static class TestCase { + + abstract String url(); + + abstract Request.Content requestContent(); + + abstract int expectedResponseCode(); + + /** + * Expected response headers map, header name -> value. Value "*" asserts the header is present + * with any value. Value "-" asserts the header is not present. + * + * @return the expected response headers for this test case. + */ + abstract Optional> expectedResponseHeaders(); + + abstract Optional expectedResponseText(); + + abstract Optional expectedJson(); + + abstract Optional expectedContentType(); + + abstract Optional expectedOutput(); + + abstract Optional httpContentType(); + + abstract ImmutableMap httpHeaders(); + + abstract Optional snoopFile(); + + static Builder builder() { + return new AutoValue_IntegrationTest_TestCase.Builder() + .setUrl("/") + .setRequestText("") + .setExpectedResponseCode(HttpStatus.OK_200) + .setExpectedResponseHeaders(ImmutableMap.of()) + .setExpectedResponseText("") + .setHttpContentType("text/plain") + .setHttpHeaders(ImmutableMap.of()); + } + + @AutoValue.Builder + abstract static class Builder { + + abstract Builder setUrl(String x); + + abstract Builder setRequestContent(Request.Content x); + + Builder setRequestText(String text) { + return setRequestContent(new StringRequestContent(text)); + } + + abstract Builder setExpectedResponseCode(int x); + + abstract Builder setExpectedResponseHeaders(Map x); + + abstract Builder setExpectedResponseText(String x); + + abstract Builder setExpectedResponseText(Optional x); + + abstract Builder setExpectedContentType(String x); + + abstract Builder setExpectedOutput(String x); + + abstract Builder setExpectedJson(JsonObject x); + + abstract Builder setHttpContentType(String x); + + abstract Builder setHttpContentType(Optional x); + + abstract Builder setHttpHeaders(ImmutableMap x); + + abstract Builder setSnoopFile(File x); + + abstract TestCase build(); + } + } + + private static String fullTarget(String nameWithoutPackage) { + return "com.google.cloud.functions.invoker.testfunctions." + nameWithoutPackage; + } + + private static final TestCase FAVICON_TEST_CASE = + TestCase.builder() + .setUrl("/favicon.ico?foo=bar") + .setExpectedResponseCode(HttpStatus.NOT_FOUND_404) + .setExpectedResponseText(Optional.empty()) + .build(); + + private static final TestCase ROBOTS_TXT_TEST_CASE = + TestCase.builder() + .setUrl("/robots.txt?foo=bar") + .setExpectedResponseCode(HttpStatus.NOT_FOUND_404) + .setExpectedResponseText(Optional.empty()) + .build(); + + @Test + public void helloWorld() throws Exception { + testHttpFunction( + fullTarget("HelloWorld"), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseHeaders(ImmutableMap.of("Content-Length", "*")) + .setExpectedResponseText("hello\n") + .build(), + FAVICON_TEST_CASE, + ROBOTS_TXT_TEST_CASE)); + } + + @Test + public void timeoutHttpSuccess() throws Exception { + testFunction( + SignatureType.HTTP, + fullTarget("TimeoutHttp"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseText("finished\n") + .setExpectedResponseText(Optional.empty()) + .build()), + ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "3")); + } + + @Test + public void timeoutHttpTimesOut() throws Exception { + testFunction( + SignatureType.HTTP, + fullTarget("TimeoutHttp"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseCode(408) + .setExpectedResponseText(Optional.empty()) + .build()), + ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "1")); + } + + @Test + public void bufferedWrites() throws Exception { + // This test checks that writes are buffered, and are written + // in an efficient way with known content-length if possible + // instead of doing a chunked response. + testHttpFunction( + fullTarget("BufferedWrites"), + ImmutableList.of( + TestCase.builder() + .setUrl("/target?writes=2") + .setExpectedResponseText("write 0\nwrite 1\n") + .setExpectedResponseHeaders( + ImmutableMap.of( + "x-write-0", "true", + "x-write-1", "true", + "x-written", "true", + "Content-Length", "16")) + .build(), + TestCase.builder() + .setUrl("/target?writes=2&flush=true") + .setExpectedResponseText("write 0\nwrite 1\n") + .setExpectedResponseHeaders( + ImmutableMap.of( + "x-write-0", "true", + "x-write-1", "true", + "x-written", "-", + "Transfer-Encoding", "chunked")) + .build())); + } + + @Test + public void exceptionHttp() throws Exception { + String exceptionExpectedOutput = + "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + + " \"com/google/cloud/functions/invoker/HttpFunctionExecutor.java\", \"method\":" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\"," + + " \"message\": \"Failed to execute" + + " com.google.cloud.functions.invoker.testfunctions.ExceptionHttp\\n" + + "java.lang.RuntimeException: exception thrown for test"; + testHttpFunction( + fullTarget("ExceptionHttp"), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseCode(500) + .setExpectedResponseText("") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedOutput(exceptionExpectedOutput) + .build())); + } + + @Test + public void exceptionBackground() throws Exception { + String exceptionExpectedOutput = + "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"Failed to execute" + + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n" + + "java.lang.RuntimeException: exception thrown for test"; + + File snoopFile = snoopFile(); + String gcfRequestText = sampleLegacyEvent(snoopFile); + + testFunction( + SignatureType.BACKGROUND, + fullTarget("ExceptionBackground"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setRequestText(gcfRequestText) + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedResponseCode(500) + .setExpectedOutput(exceptionExpectedOutput) + .build()), + Collections.emptyMap()); + } + + @Test + public void echo() throws Exception { + String testText = "hello\nworld\n"; + testHttpFunction( + fullTarget("Echo"), + ImmutableList.of( + TestCase.builder() + .setRequestText(testText) + .setExpectedResponseText(testText) + .setExpectedContentType("text/plain") + .build(), + TestCase.builder() + .setHttpContentType("application/octet-stream") + .setRequestText(testText) + .setExpectedResponseText(testText) + .setExpectedContentType("application/octet-stream") + .build())); + } + + @Test + public void echoUrl() throws Exception { + String[] testUrls = {"/", "/foo/bar", "/?foo=bar&baz=buh", "/foo?bar=baz"}; + List testCases = + Arrays.stream(testUrls) + .map(url -> TestCase.builder().setUrl(url).setExpectedResponseText(url + "\n").build()) + .collect(toList()); + testHttpFunction(fullTarget("EchoUrl"), testCases); + } + + @Test + public void stackDriverLogging() throws Exception { + String simpleExpectedOutput = + "{\"severity\": \"INFO\", " + + "\"logging.googleapis.com/sourceLocation\": " + + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\"," + + " \"method\": \"service\"}," + + " \"execution_id\": \"" + + EXECUTION_ID + + "\"," + + " \"message\": \"blim\"}"; + TestCase simpleTestCase = + TestCase.builder() + .setUrl("/?message=blim") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedOutput(simpleExpectedOutput) + .build(); + String quotingExpectedOutput = "\"message\": \"foo\\nbar\\\""; + TestCase quotingTestCase = + TestCase.builder() + .setUrl("/?message=" + URLEncoder.encode("foo\nbar\"", "UTF-8")) + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedOutput(quotingExpectedOutput) + .build(); + String exceptionExpectedOutput = + "{\"severity\": \"ERROR\", " + + "\"logging.googleapis.com/sourceLocation\": " + + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\", " + + "\"method\": \"service\"}, " + + "\"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"oops\\njava.lang.Exception: disaster\\n" + + " at com.google.cloud.functions.invoker.testfunctions.Log.service(Log.java:"; + TestCase exceptionTestCase = + TestCase.builder() + .setUrl("/?message=oops&level=severe&exception=disaster") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedOutput(exceptionExpectedOutput) + .build(); + testHttpFunction( + fullTarget("Log"), ImmutableList.of(simpleTestCase, quotingTestCase, exceptionTestCase)); + } + + private static int getJavaVersion() { + String version = System.getProperty("java.version"); + if (version.startsWith("1.")) { + version = version.substring(2, 3); + } else { + int dot = version.indexOf("."); + if (dot != -1) { + version = version.substring(0, dot); + } + int dash = version.indexOf("-"); + if (dash != -1) { + version = version.substring(0, dash); + } + } + return Integer.parseInt(version); + } + + @Test + public void background() throws Exception { + // TODO: Only enable background tests for < 17 + if (getJavaVersion() < 17) { + backgroundTest("BackgroundSnoop"); + } + } + + @Test + public void typedBackground() throws Exception { + // TODO: Only enable background tests for < 17 + if (getJavaVersion() < 17) { + backgroundTest("TypedBackgroundSnoop"); + } + } + + @Test + public void typedFunction() throws Exception { + URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json"); + assertThat(resourceUrl).isNotNull(); + String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + testFunction( + SignatureType.TYPED, + fullTarget("Typed"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setRequestText(originalJson) + .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}") + .build()), + Collections.emptyMap()); + } + + @Test + public void typedVoidFunction() throws Exception { + testFunction( + SignatureType.TYPED, + fullTarget("TypedVoid"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder().setRequestText("{}").setExpectedResponseCode(204).build()), + Collections.emptyMap()); + } + + @Test + public void typedCustomFormat() throws Exception { + testFunction( + SignatureType.TYPED, + fullTarget("TypedCustomFormat"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setRequestText("abc\n123\n$#@\n") + .setExpectedResponseText("abc123$#@") + .setExpectedResponseCode(200) + .build()), + Collections.emptyMap()); + } + + private void backgroundTest(String target) throws Exception { + File snoopFile = snoopFile(); + String gcfRequestText = sampleLegacyEvent(snoopFile); + JsonObject expectedJson = new Gson().fromJson(gcfRequestText, JsonObject.class); + TestCase gcfTestCase = + TestCase.builder() + .setRequestText(gcfRequestText) + .setSnoopFile(snoopFile) + .setExpectedJson(expectedJson) + .build(); + + // A CloudEvent using the "structured content mode", where both the metadata and the payload + // are in the body of the HTTP request. + EventFormat jsonFormat = + EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + String cloudEventRequestText = + new String(jsonFormat.serialize(sampleCloudEvent(snoopFile)), UTF_8); + // For CloudEvents, we don't currently populate Context#getResource with anything interesting, + // so we excise that from the expected text we would have with legacy events. + JsonObject cloudEventExpectedJson = new Gson().fromJson(gcfRequestText, JsonObject.class); + TestCase cloudEventsStructuredTestCase = + TestCase.builder() + .setSnoopFile(snoopFile) + .setRequestText(cloudEventRequestText) + .setHttpContentType("application/cloudevents+json; charset=utf-8") + .setExpectedJson(cloudEventExpectedJson) + .build(); + + // A CloudEvent using the "binary content mode", where the metadata is in HTTP headers and the + // payload is the body of the HTTP request. + Map headers = new TreeMap<>(); + AtomicReference bodyRef = new AtomicReference<>(); + HttpMessageFactory.createWriter(headers::put, bodyRef::set) + .writeBinary(sampleCloudEvent(snoopFile)); + TestCase cloudEventsBinaryTestCase = + TestCase.builder() + .setSnoopFile(snoopFile) + .setRequestText(new String(bodyRef.get(), UTF_8)) + .setHttpContentType(headers.get("Content-Type")) + .setHttpHeaders(ImmutableMap.copyOf(headers)) + .setExpectedJson(cloudEventExpectedJson) + .build(); + + backgroundTest( + SignatureType.BACKGROUND, + fullTarget(target), + ImmutableList.of(gcfTestCase, cloudEventsStructuredTestCase, cloudEventsBinaryTestCase)); + } + + /** + * Tests a CloudEvent being handled by a CloudEvent handler (no translation to or from legacy). + */ + @Test + public void nativeCloudEvent() throws Exception { + File snoopFile = snoopFile(); + CloudEvent cloudEvent = sampleCloudEvent(snoopFile); + EventFormat jsonFormat = + EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + String cloudEventJson = new String(jsonFormat.serialize(cloudEvent), UTF_8); + + // A CloudEvent using the "structured content mode", where both the metadata and the payload + // are in the body of the HTTP request. + JsonObject cloudEventJsonObject = new Gson().fromJson(cloudEventJson, JsonObject.class); + TestCase cloudEventsStructuredTestCase = + TestCase.builder() + .setSnoopFile(snoopFile) + .setRequestText(cloudEventJson) + .setHttpContentType("application/cloudevents+json; charset=utf-8") + .setExpectedJson(cloudEventJsonObject) + .build(); + + // A CloudEvent using the "binary content mode", where the metadata is in HTTP headers and the + // payload is the body of the HTTP request. + Map headers = new TreeMap<>(); + AtomicReference bodyRef = new AtomicReference<>(); + HttpMessageFactory.createWriter(headers::put, bodyRef::set) + .writeBinary(sampleCloudEvent(snoopFile)); + TestCase cloudEventsBinaryTestCase = + TestCase.builder() + .setSnoopFile(snoopFile) + .setRequestText(new String(bodyRef.get(), UTF_8)) + .setHttpContentType(headers.get("Content-Type")) + .setHttpHeaders(ImmutableMap.copyOf(headers)) + .setExpectedJson(cloudEventJsonObject) + .build(); + + backgroundTest( + SignatureType.CLOUD_EVENT, + fullTarget("CloudEventSnoop"), + ImmutableList.of(cloudEventsStructuredTestCase, cloudEventsBinaryTestCase)); + } + + /** Tests a CloudEvent being handled by a CloudEvent handler throws exception */ + @Test + public void nativeCloudEventException() throws Exception { + String exceptionExpectedOutput = + "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"Failed to execute" + + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n" + + "java.lang.RuntimeException: exception thrown for test"; + File snoopFile = snoopFile(); + CloudEvent cloudEvent = sampleCloudEvent(snoopFile); + EventFormat jsonFormat = + EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + String cloudEventJson = new String(jsonFormat.serialize(cloudEvent), UTF_8); + + // A CloudEvent using the "structured content mode", where both the metadata and the payload + // are in the body of the HTTP request. + TestCase cloudEventsStructuredTestCase = + TestCase.builder() + .setRequestText(cloudEventJson) + .setHttpContentType("application/cloudevents+json; charset=utf-8") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedResponseCode(500) + .setExpectedOutput(exceptionExpectedOutput) + .build(); + + testFunction( + SignatureType.CLOUD_EVENT, + fullTarget("ExceptionBackground"), + ImmutableList.of(), + ImmutableList.of(cloudEventsStructuredTestCase), + Collections.emptyMap()); + } + + @Test + public void nested() throws Exception { + String testText = "sic transit gloria mundi"; + testHttpFunction( + fullTarget("Nested.Echo"), + ImmutableList.of( + TestCase.builder().setRequestText(testText).setExpectedResponseText(testText).build())); + } + + @Test + public void packageless() throws Exception { + testHttpFunction( + "PackagelessHelloWorld", + ImmutableList.of(TestCase.builder().setExpectedResponseText("hello, world\n").build())); + } + + @Test + public void multipart() throws Exception { + MultiPartRequestContent multiPartRequestContent = new MultiPartRequestContent(); + byte[] bytes = new byte[17]; + multiPartRequestContent.addPart( + new ContentSourcePart( + "bytes", null, HttpFields.EMPTY, new ByteBufferRequestContent(ByteBuffer.wrap(bytes)))); + multiPartRequestContent.addPart( + new MultiPart.ContentSourcePart( + "string", null, HttpFields.EMPTY, new StringRequestContent("1234567890"))); + multiPartRequestContent.close(); + + String expectedResponse = + "part bytes type application/octet-stream length 17\n" + + "part string type text/plain;charset=UTF-8 length 10\n"; + testHttpFunction( + fullTarget("Multipart"), + ImmutableList.of( + TestCase.builder() + .setHttpContentType(multiPartRequestContent.getContentType()) + .setRequestContent(multiPartRequestContent) + .setExpectedResponseText(expectedResponse) + .build())); + } + + private File snoopFile() throws IOException { + return temporaryFolder.newFile(testName.getMethodName() + ".txt"); + } + + /** Any runtime class that user code shouldn't be able to see. */ + private static final Class INTERNAL_CLASS = CloudFunctionsContext.class; + + private String functionJarString() throws IOException { + Path functionJarTargetDir = Paths.get("../testfunction/target"); + Pattern functionJarPattern = + Pattern.compile("java-function-invoker-testfunction-.*-tests\\.jar"); + List functionJars = + Files.list(functionJarTargetDir) + .map(path -> path.getFileName().toString()) + .filter(s -> functionJarPattern.matcher(s).matches()) + .map(s -> functionJarTargetDir.resolve(s)) + .collect(toList()); + assertWithMessage("Number of jars in %s matching %s", functionJarTargetDir, functionJarPattern) + .that(functionJars) + .hasSize(1); + return Iterables.getOnlyElement(functionJars).toString(); + } + + /** + * Tests that if we launch an HTTP function with {@code --classpath}, then the function code + * cannot see the classes from the runtime. This is allows us to avoid conflicts between versions + * of libraries that we use in the runtime and different versions of the same libraries that the + * function might use. + */ + @Test + public void classpathOptionHttp() throws Exception { + TestCase testCase = + TestCase.builder() + .setUrl("/?class=" + INTERNAL_CLASS.getName()) + .setExpectedResponseText("OK") + .build(); + testFunction( + SignatureType.HTTP, + "com.example.functionjar.Foreground", + ImmutableList.of("--classpath", functionJarString()), + ImmutableList.of(testCase), + Collections.emptyMap()); + } + + /** Like {@link #classpathOptionHttp} but for background functions. */ + @Test + public void classpathOptionBackground() throws Exception { + Gson gson = new Gson(); + URL resourceUrl = getClass().getResource("/adder_gcf_ga_event.json"); + assertThat(resourceUrl).isNotNull(); + String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + JsonObject json = gson.fromJson(originalJson, JsonObject.class); + JsonObject jsonData = json.getAsJsonObject("data"); + jsonData.addProperty("class", INTERNAL_CLASS.getName()); + testFunction( + SignatureType.BACKGROUND, + "com.example.functionjar.Background", + ImmutableList.of("--classpath", functionJarString()), + ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build()), + Collections.emptyMap()); + } + + /** Like {@link #classpathOptionHttp} but for typed functions. */ + @Test + public void classpathOptionTyped() throws Exception { + URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json"); + assertThat(resourceUrl).isNotNull(); + String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + testFunction( + SignatureType.TYPED, + "com.example.functionjar.Typed", + ImmutableList.of("--classpath", functionJarString()), + ImmutableList.of( + TestCase.builder() + .setRequestText(originalJson) + .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}") + .build()), + Collections.emptyMap()); + } + + // In these tests, we test a number of different functions that express the same functionality + // in different ways. Each function is invoked with a complete HTTP body that looks like a real + // event. We start with a fixed body and insert into its JSON an extra property that tells the + // function where to write what it received. We have to do this since background functions, by + // design, don't return a value. + private void backgroundTest( + SignatureType signatureType, String functionTarget, List testCases) + throws Exception { + for (TestCase testCase : testCases) { + File snoopFile = testCase.snoopFile().get(); + snoopFile.delete(); + testFunction( + signatureType, + functionTarget, + ImmutableList.of(), + ImmutableList.of(testCase), + Collections.emptyMap()); + String snooped = new String(Files.readAllBytes(snoopFile.toPath()), StandardCharsets.UTF_8); + Gson gson = new Gson(); + JsonObject snoopedJson = gson.fromJson(snooped, JsonObject.class); + JsonObject expectedJson = testCase.expectedJson().get(); + expect + .withMessage( + "Testing %s with %s\nGOT %s\nNOT %s", + functionTarget, testCase, snoopedJson, expectedJson) + .that(snoopedJson) + .isEqualTo(expectedJson); + } + } + + private void checkSnoopFile(TestCase testCase) throws IOException { + File snoopFile = testCase.snoopFile().get(); + JsonObject expectedJson = testCase.expectedJson().get(); + String snooped = new String(Files.readAllBytes(snoopFile.toPath()), StandardCharsets.UTF_8); + Gson gson = new Gson(); + JsonObject snoopedJson = gson.fromJson(snooped, JsonObject.class); + expect.withMessage("Testing with %s", testCase).that(snoopedJson).isEqualTo(expectedJson); + } + + private void testHttpFunction(String target, List testCases) throws Exception { + testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases, Collections.emptyMap()); + } + + private void testFunction( + SignatureType signatureType, + String target, + ImmutableList extraArgs, + List testCases, + Map environmentVariables) + throws Exception { + ServerProcess serverProcess = + startServer(signatureType, target, extraArgs, environmentVariables); + HttpClient httpClient = new HttpClient(); + try { + httpClient.start(); + for (TestCase testCase : testCases) { + testCase.snoopFile().ifPresent(File::delete); + String uri = "http://localhost:" + serverPort + testCase.url(); + Request request = httpClient.POST(uri); + + request.headers( + headers -> { + testCase + .httpContentType() + .ifPresent(contentType -> headers.put(HttpHeader.CONTENT_TYPE, contentType)); + testCase.httpHeaders().forEach(headers::put); + }); + request.body(testCase.requestContent()); + ContentResponse response = request.send(); + expect + .withMessage("Response to %s is %s %s", uri, response.getStatus(), response.getReason()) + .that(response.getStatus()) + .isEqualTo(testCase.expectedResponseCode()); + testCase + .expectedResponseHeaders() + .ifPresent( + expectedResponseHeaders -> { + for (Map.Entry entry : expectedResponseHeaders.entrySet()) { + if ("*".equals(entry.getValue())) { + expect + .that(response.getHeaders().getFieldNamesCollection()) + .contains(entry.getKey()); + } else if ("-".equals(entry.getValue())) { + expect + .that(response.getHeaders().getFieldNamesCollection()) + .doesNotContain(entry.getKey()); + } else { + expect + .that(response.getHeaders().getValuesList(entry.getKey())) + .contains(entry.getValue()); + } + } + }); + testCase + .expectedResponseText() + .ifPresent(text -> expect.that(response.getContentAsString()).isEqualTo(text)); + testCase + .expectedContentType() + .ifPresent(type -> expect.that(response.getMediaType()).isEqualTo(type)); + if (testCase.snoopFile().isPresent()) { + checkSnoopFile(testCase); + } + } + } finally { + serverProcess.close(); + httpClient.stop(); + } + for (TestCase testCase : testCases) { + testCase + .expectedOutput() + .ifPresent( + (output) -> { + expect.that(serverProcess.output()).contains(output); + parseLogJson(serverProcess.output()); + }); + } + // Wait for the output monitor task to terminate. If it threw an exception, we will get an + // ExecutionException here. + serverProcess.outputMonitorResult().get(); + } + + private enum SignatureType { + HTTP("http"), + BACKGROUND("event"), + CLOUD_EVENT("cloudevent"), + TYPED("typed"); + + private final String name; + + SignatureType(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + private static class ServerProcess implements AutoCloseable { + private final Process process; + private final Future outputMonitorResult; + private final StringBuilder output; + + ServerProcess(Process process, Future outputMonitorResult, StringBuilder output) { + this.process = process; + this.outputMonitorResult = outputMonitorResult; + this.output = output; + } + + Process process() { + return process; + } + + Future outputMonitorResult() { + return outputMonitorResult; + } + + String output() { + synchronized (output) { + return output.toString(); + } + } + + @Override + public void close() { + process().destroy(); + try { + process().waitFor(); + } catch (InterruptedException e) { + // Should not happen. + } + } + } + + private ServerProcess startServer( + SignatureType signatureType, + String target, + ImmutableList extraArgs, + Map environmentVariables) + throws IOException, InterruptedException { + File javaHome = new File(System.getProperty("java.home")); + assertThat(javaHome.exists()).isTrue(); + File javaBin = new File(javaHome, "bin"); + File javaCommand = new File(javaBin, "java"); + assertThat(javaCommand.exists()).isTrue(); + String myClassPath = System.getProperty("java.class.path"); + assertThat(myClassPath).isNotNull(); + ImmutableList command = + ImmutableList.builder() + .add(javaCommand.toString(), "-classpath", myClassPath, Invoker.class.getName()) + .addAll(extraArgs) + .build(); + ProcessBuilder processBuilder = new ProcessBuilder().command(command).redirectErrorStream(true); + Map environment = + ImmutableMap.of( + "PORT", + String.valueOf(serverPort), + "K_SERVICE", + "test-function", + "FUNCTION_SIGNATURE_TYPE", + signatureType.toString(), + "FUNCTION_TARGET", + target, + "LOG_EXECUTION_ID", + "true"); + processBuilder.environment().putAll(environment); + processBuilder.environment().putAll(environmentVariables); + Process serverProcess = processBuilder.start(); + CountDownLatch ready = new CountDownLatch(1); + StringBuilder output = new StringBuilder(); + Future outputMonitorResult = + EXECUTOR.submit(() -> monitorOutput(serverProcess.getInputStream(), ready, output)); + boolean serverReady = ready.await(5, TimeUnit.SECONDS); + if (!serverReady) { + serverProcess.destroy(); + throw new AssertionError("Server never became ready"); + } + return new ServerProcess(serverProcess, outputMonitorResult, output); + } + + private void monitorOutput( + InputStream processOutput, CountDownLatch ready, StringBuilder output) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(processOutput))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(SERVER_READY_STRING)) { + ready.countDown(); + } + System.out.println(line); + synchronized (output) { + output.append(line).append('\n'); + } + if (line.contains("WARNING")) { + throw new AssertionError("Found warning in server output:\n" + line); + } + } + } catch (IOException e) { + e.printStackTrace(); + throw new UncheckedIOException(e); + } + } + + // Attempt to parse Json object, throws on parse failure + private void parseLogJson(String json) throws RuntimeException { + System.out.println("trying to parse the following object "); + System.out.println(json); + JsonReader reader = new JsonReader(new StringReader(json)); + JsonParser.parseReader(reader); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java new file mode 100644 index 00000000..668d60c8 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java @@ -0,0 +1,36 @@ +package com.google.cloud.functions.invoker; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.functions.TypedFunction; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class TypedFunctionExecutorTest { + private static class NameConcatRequest { + String firstName; + String lastName; + } + + private static class NameConcatResponse { + String fullName; + } + + private static class NameConcatFunction + implements TypedFunction { + @Override + public NameConcatResponse apply(NameConcatRequest arg) throws Exception { + NameConcatResponse resp = new NameConcatResponse(); + resp.fullName = arg.firstName + arg.lastName; + return resp; + } + } + + @Test + public void canDetermineTypeArgument() { + assertThat(TypedFunctionExecutor.handlerTypeArgument(NameConcatFunction.class)) + .hasValue(NameConcatRequest.class); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java new file mode 100644 index 00000000..a9794cd2 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java @@ -0,0 +1,528 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpRequest.HttpPart; +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import org.eclipse.jetty.client.ByteBufferRequestContent; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpStatus.Code; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.EagerContentHandler; +import org.eclipse.jetty.util.Callback; +import org.junit.BeforeClass; +import org.junit.Test; + +public class HttpTest { + + private static final String TEST_BODY = + "In the reign of James the Second\n" + + "It was generally reckoned\n" + + "As a rather serious crime\n" + + "To marry two wives at a time.\n"; + + private static final byte[] RANDOM_BYTES = new byte[1024]; + + static { + new Random().nextBytes(RANDOM_BYTES); + } + + private static int serverPort; + + /** + * Each test method will start up a server on the same port, make one or more HTTP requests to + * that port, then kill the server. So the port should be free when the next test method runs. + */ + @BeforeClass + public static void allocateServerPort() throws IOException { + ServerSocket serverSocket = new ServerSocket(0); + serverPort = serverSocket.getLocalPort(); + serverSocket.close(); + } + + /** + * Wrapper class that allows us to start a Jetty server with a single handler for {@code /*} + * within a try-with-resources statement. The handler will be configured to support multipart + * requests. + */ + private static class SimpleServer implements AutoCloseable { + private final Server server; + + SimpleServer(Handler handler) throws Exception { + this.server = new Server(serverPort); + server.setHandler(handler); + + MultiPartConfig config = new MultiPartConfig.Builder().maxMemoryPartSize(-1).build(); + EagerContentHandler.MultiPartContentLoaderFactory factory = + new EagerContentHandler.MultiPartContentLoaderFactory(config); + server.insertHandler(new EagerContentHandler(factory)); + + server.start(); + } + + @Override + public void close() throws Exception { + server.stop(); + } + } + + @FunctionalInterface + private interface HttpRequestTest { + void test(HttpRequest request) throws Exception; + } + + /** + * Tests methods on the {@link HttpRequest} object while the request is being serviced. We are not + * guaranteed that the underlying {@link Request} object will still be valid when the request + * completes, and in fact in Jetty it isn't. So we perform the checks in the context of the + * handler, and report any exception back to the test method. + */ + @Test + public void httpRequestMethods() throws Exception { + AtomicReference testReference = new AtomicReference<>(); + AtomicReference exceptionReference = new AtomicReference<>(); + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { + httpRequestMethods(testReference, exceptionReference); + } + } + + private void httpRequestMethods( + AtomicReference testReference, AtomicReference exceptionReference) + throws Exception { + HttpClient httpClient = new HttpClient(); + httpClient.start(); + String uri = "http://localhost:" + serverPort + "/foo/bar?baz=buh&baz=xxx&blim=blam&baz=what"; + HttpRequestTest[] tests = { + request -> assertThat(request.getMethod()).isEqualTo("POST"), + request -> assertThat(request.getMethod()).isEqualTo("POST"), + request -> assertThat(request.getUri()).isEqualTo(uri), + request -> assertThat(request.getPath()).isEqualTo("/foo/bar"), + request -> assertThat(request.getQuery()).hasValue("baz=buh&baz=xxx&blim=blam&baz=what"), + request -> { + Map> expectedQueryParameters = new TreeMap<>(); + expectedQueryParameters.put("baz", Arrays.asList("buh", "xxx", "what")); + expectedQueryParameters.put("blim", Arrays.asList("blam")); + assertThat(request.getQueryParameters()).isEqualTo(expectedQueryParameters); + }, + request -> assertThat(request.getFirstQueryParameter("baz")).hasValue("buh"), + request -> assertThat(request.getFirstQueryParameter("something")).isEmpty(), + request -> + assertThat(request.getContentType().get()) + .ignoringCase() + .isEqualTo("text/plain; charset=utf-8"), + request -> assertThat(request.getContentLength()).isEqualTo(TEST_BODY.length()), + request -> assertThat(request.getCharacterEncoding()).isPresent(), + request -> assertThat(request.getCharacterEncoding().get()).ignoringCase().isEqualTo("utf-8"), + request -> { + try (BufferedReader reader = request.getReader()) { + validateReader(reader); + assertThat(request.getReader()).isSameInstanceAs(reader); + } + try { + request.getInputStream(); + fail("Did not get expected exception"); + } catch (IllegalStateException expected) { + } + }, + request -> { + try (InputStream inputStream = request.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + validateReader(reader); + assertThat(request.getInputStream()).isSameInstanceAs(inputStream); + } + }, + request -> { + Map> expectedHeaders = new TreeMap<>(); + expectedHeaders.put( + HttpHeader.CONTENT_LENGTH.asString(), + Arrays.asList(String.valueOf(TEST_BODY.length()))); + expectedHeaders.put("foo", Arrays.asList("bar", "baz")); + assertThat(request.getHeaders()).containsAtLeastEntriesIn(expectedHeaders); + }, + request -> assertThat(request.getFirstHeader("foo")).hasValue("bar"), + request -> assertThat(request.getFirstHeader("CaSe-SeNsItIvE")).hasValue("VaLuE"), + request -> assertThat(request.getFirstHeader("case-sensitive")).hasValue("VaLuE"), + request -> { + try { + request.getParts(); + fail("Did not get expected exception"); + } catch (IllegalStateException expected) { + } + } + }; + for (HttpRequestTest test : tests) { + testReference.set(test); + org.eclipse.jetty.client.Request request = + httpClient + .POST(uri) + .headers( + m -> { + m.add(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + m.add("foo", "bar"); + m.add("foo", "baz"); + m.add("CaSe-SeNsItIvE", "VaLuE"); + }) + .body(new StringRequestContent(TEST_BODY)); + ContentResponse response = request.send(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); + throwIfNotNull(exceptionReference.get()); + } + } + + @Test + public void emptyRequest() throws Exception { + HttpClient httpClient = new HttpClient(); + httpClient.start(); + String uri = "http://localhost:" + serverPort; + HttpRequestTest test = + request -> { + assertThat(request.getUri()).isEqualTo(uri + "/"); + assertThat(request.getPath()).isEqualTo("/"); + assertThat(request.getQuery()).isEmpty(); + assertThat(request.getQueryParameters()).isEmpty(); + assertThat(request.getContentType()).isEmpty(); + assertThat(request.getContentLength()).isEqualTo(0L); + }; + AtomicReference exceptionReference = new AtomicReference<>(); + AtomicReference testReference = new AtomicReference<>(test); + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { + ContentResponse response = httpClient.POST(uri).send(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); + throwIfNotNull(exceptionReference.get()); + } + } + + private void validateReader(BufferedReader reader) { + String text = reader.lines().collect(Collectors.joining("\n", "", "\n")); + assertThat(text).isEqualTo(TEST_BODY); + } + + @Test + public void multiPartRequest() throws Exception { + AtomicReference testReference = new AtomicReference<>(); + AtomicReference exceptionReference = new AtomicReference<>(); + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + HttpClient httpClient = new HttpClient(); + httpClient.start(); + String uri = "http://localhost:" + serverPort + "/"; + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + HttpFields textHttpFields = HttpFields.build().add("foo", "bar"); + multiPart.addPart( + new MultiPart.ContentSourcePart( + "text", null, textHttpFields, new StringRequestContent(TEST_BODY))); + HttpFields.Mutable bytesHttpFields = HttpFields.build().add("foo", "baz").add("foo", "buh"); + assertThat(bytesHttpFields.getValuesList("foo")).containsExactly("baz", "buh"); + multiPart.addPart( + new MultiPart.ContentSourcePart( + "binary", + "/tmp/binary.x", + bytesHttpFields, + new ByteBufferRequestContent(ByteBuffer.wrap(RANDOM_BYTES)))); + multiPart.close(); + HttpRequestTest test = + request -> { + // The Content-Type header will also have a boundary=something attribute. + assertThat(request.getContentType().get()).startsWith("multipart/form-data"); + assertThat(request.getParts().keySet()).containsExactly("text", "binary"); + HttpPart textPart = request.getParts().get("text"); + assertThat(textPart.getFileName()).isEmpty(); + assertThat(textPart.getContentLength()).isEqualTo(TEST_BODY.length()); + assertThat(textPart.getContentType().get()).startsWith("text/plain"); + assertThat(textPart.getCharacterEncoding()).isPresent(); + assertThat(textPart.getCharacterEncoding().get()).ignoringCase().isEqualTo("utf-8"); + assertThat(textPart.getHeaders()).containsAtLeast("foo", Arrays.asList("bar")); + assertThat(textPart.getFirstHeader("foo")).hasValue("bar"); + validateReader(textPart.getReader()); + HttpPart bytesPart = request.getParts().get("binary"); + assertThat(bytesPart.getFileName()).hasValue("/tmp/binary.x"); + assertThat(bytesPart.getContentLength()).isEqualTo(RANDOM_BYTES.length); + assertThat(bytesPart.getContentType()).hasValue("application/octet-stream"); + List foos = bytesPart.getHeaders().get("foo"); + assertThat(foos).containsExactly("baz", "buh"); + + byte[] bytes = new byte[RANDOM_BYTES.length]; + try (InputStream inputStream = bytesPart.getInputStream()) { + assertThat(inputStream.read(bytes)).isEqualTo(bytes.length); + assertThat(inputStream.read()).isEqualTo(-1); + assertThat(bytes).isEqualTo(RANDOM_BYTES); + } + }; + try (SimpleServer server = new SimpleServer(testHandler)) { + testReference.set(test); + org.eclipse.jetty.client.Request request = + httpClient.POST(uri).headers(m -> m.put("foo", "oof")).body(multiPart); + ContentResponse response = request.send(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); + throwIfNotNull(exceptionReference.get()); + } + } + + private static class HttpRequestHandler extends Handler.Abstract { + private final AtomicReference testReference; + private final AtomicReference exceptionReference; + + private HttpRequestHandler( + AtomicReference testReference, + AtomicReference exceptionReference) { + this.testReference = testReference; + this.exceptionReference = exceptionReference; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + try { + testReference.get().test(new HttpRequestImpl(request)); + } catch (Throwable t) { + exceptionReference.set(t); + Response.writeError(request, response, callback, t); + } + callback.succeeded(); + return true; + } + } + + @FunctionalInterface + private interface HttpResponseTest { + void test(HttpResponse response) throws Exception; + } + + /** + * Tests interactions with the {@link HttpResponse} object while the request is still ongoing. For + * example, if we append a header then we should see that header in {@link + * HttpResponse#getHeaders()}. + */ + @Test + public void httpResponseSetAndGet() throws Exception { + AtomicReference testReference = new AtomicReference<>(); + AtomicReference exceptionReference = new AtomicReference<>(); + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { + httpResponseSetAndGet(testReference, exceptionReference); + } + } + + private void httpResponseSetAndGet( + AtomicReference testReference, + AtomicReference exceptionReference) + throws Exception { + HttpResponseTest[] tests = { + response -> assertThat(response.getContentType()).isEmpty(), + response -> { + response.setContentType("text/plain; charset=utf-8"); + assertThat(response.getContentType().get()).matches("(?i)text/plain;\\s*charset=utf-8"); + }, + response -> { + response.appendHeader("Content-Type", "application/octet-stream"); + assertThat(response.getContentType()).hasValue("application/octet-stream"); + assertThat(response.getHeaders()) + .containsAtLeast("Content-Type", Arrays.asList("application/octet-stream")); + }, + response -> { + // The fields are initialized with a Date header as per the HTTP RFCs. + // So we just check that we can add our own headers. + response.appendHeader("foo", "bar"); + response.appendHeader("wibbly", "wobbly"); + response.appendHeader("FoO", "baz"); + var updatedHeaders = response.getHeaders(); + assertThat(updatedHeaders) + .containsAtLeast("foo", Arrays.asList("bar", "baz"), "wibbly", Arrays.asList("wobbly")); + }, + }; + for (HttpResponseTest test : tests) { + testReference.set(test); + HttpClient httpClient = new HttpClient(); + httpClient.start(); + String uri = "http://localhost:" + serverPort; + org.eclipse.jetty.client.Request request = httpClient.POST(uri); + ContentResponse response = request.send(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); + throwIfNotNull(exceptionReference.get()); + } + } + + private static class HttpResponseHandler extends Handler.Abstract { + private final AtomicReference testReference; + private final AtomicReference exceptionReference; + + private HttpResponseHandler( + AtomicReference testReference, + AtomicReference exceptionReference) { + this.testReference = testReference; + this.exceptionReference = exceptionReference; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + try { + testReference.get().test(new HttpResponseImpl(response)); + callback.succeeded(); + } catch (Throwable t) { + exceptionReference.set(t); + Response.writeError(request, response, callback, t); + } + return true; + } + } + + @FunctionalInterface + private interface ResponseCheck { + void test(ContentResponse response); + } + + private static class ResponseTest { + final HttpResponseTest responseOperation; + final ResponseCheck responseCheck; + + private ResponseTest(HttpResponseTest responseOperation, ResponseCheck responseCheck) { + this.responseOperation = responseOperation; + this.responseCheck = responseCheck; + } + } + + private static ResponseTest responseTest( + HttpResponseTest responseOperation, ResponseCheck responseCheck) { + return new ResponseTest(responseOperation, responseCheck); + } + + /** + * Tests that operations on the {@link HttpResponse} have the appropriate effect on the HTTP + * response that ends up being sent. Here, for each check, we have two operations: the operation + * on the {@link HttpResponse}, which happens inside the handler, and the operation to check the + * HTTP result, which happens in the client thread. + */ + @Test + public void httpResponseEffects() throws Exception { + AtomicReference testReference = new AtomicReference<>(); + AtomicReference exceptionReference = new AtomicReference<>(); + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { + httpResponseEffects(testReference, exceptionReference); + } + } + + private void httpResponseEffects( + AtomicReference testReference, + AtomicReference exceptionReference) + throws Exception { + ResponseTest[] tests = { + responseTest( + response -> {}, + response -> assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200)), + responseTest( + response -> response.setStatusCode(HttpStatus.OK_200), + response -> assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200)), + responseTest( + response -> response.setStatusCode(HttpStatus.IM_A_TEAPOT_418), + response -> assertThat(response.getStatus()).isEqualTo(HttpStatus.IM_A_TEAPOT_418)), + responseTest( + response -> response.setStatusCode(HttpStatus.IM_A_TEAPOT_418, "Je suis une théière"), + response -> { + assertThat(response.getStatus()).isEqualTo(HttpStatus.IM_A_TEAPOT_418); + // Reason string cannot be set by the application. + assertThat(response.getReason()).isEqualTo(Code.IM_A_TEAPOT.getMessage()); + }), + responseTest( + response -> response.setContentType("application/noddy"), + response -> assertThat(response.getMediaType()).isEqualTo("application/noddy")), + responseTest( + response -> { + response.appendHeader("foo", "bar"); + response.appendHeader("blim", "blam"); + response.appendHeader("foo", "baz"); + }, + response -> { + assertThat(response.getHeaders().getValuesList("foo")).containsExactly("bar", "baz"); + assertThat(response.getHeaders().getValuesList("blim")).containsExactly("blam"); + }), + responseTest( + response -> { + response.setContentType("text/plain"); + try (BufferedWriter writer = response.getWriter()) { + writer.write(TEST_BODY); + } + }, + response -> { + assertThat(response.getMediaType()).isEqualTo("text/plain"); + assertThat(response.getContentAsString()).isEqualTo(TEST_BODY); + }), + responseTest( + response -> { + response.setContentType("application/octet-stream"); + try (OutputStream outputStream = response.getOutputStream()) { + outputStream.write(RANDOM_BYTES); + } + }, + response -> { + assertThat(response.getMediaType()).isEqualTo("application/octet-stream"); + assertThat(response.getContent()).isEqualTo(RANDOM_BYTES); + }), + }; + for (ResponseTest test : tests) { + testReference.set(test.responseOperation); + HttpClient httpClient = new HttpClient(); + httpClient.start(); + String uri = "http://localhost:" + serverPort; + org.eclipse.jetty.client.Request request = httpClient.POST(uri); + ContentResponse response = request.send(); + throwIfNotNull(exceptionReference.get()); + test.responseCheck.test(response); + } + } + + private static void throwIfNotNull(Throwable t) throws Exception { + if (t != null) { + if (t instanceof Error) { + throw (Error) t; + } else if (t instanceof Exception) { + throw (Exception) t; + } else { + // Some kind of mutant Throwable that is neither an Exception nor an Error. + throw new AssertionError(t); + } + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java new file mode 100644 index 00000000..c1a7ca29 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java @@ -0,0 +1,133 @@ +package com.google.cloud.functions.invoker.runner; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.util.stream.Collectors.joining; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class InvokerTest { + @Test + public void help() throws IOException { + String help = + captureOutput( + () -> { + Optional invoker = Invoker.makeInvoker("--help"); + assertThat(invoker).isEmpty(); + }); + assertThat(help).contains("Usage:"); + assertThat(help).contains("--target"); + assertThat(help).containsMatch("separated\\s+by\\s+'" + File.pathSeparator + "'"); + } + + @Test + public void defaultPort() { + Optional invoker = Invoker.makeInvoker(); + assertThat(invoker.get().getPort()).isEqualTo(8080); + } + + @Test + public void explicitPort() { + Optional invoker = Invoker.makeInvoker("--port", "1234"); + assertThat(invoker.get().getPort()).isEqualTo(1234); + } + + @Test + public void defaultTarget() { + Optional invoker = Invoker.makeInvoker(); + assertThat(invoker.get().getFunctionTarget()).isEqualTo("Function"); + } + + @Test + public void explicitTarget() { + Optional invoker = Invoker.makeInvoker("--target", "com.example.MyFunction"); + assertThat(invoker.get().getFunctionTarget()).isEqualTo("com.example.MyFunction"); + } + + @Test + public void defaultSignatureType() { + Optional invoker = Invoker.makeInvoker(); + assertThat(invoker.get().getFunctionSignatureType()).isNull(); + } + + @Test + public void explicitSignatureType() { + Map env = Collections.singletonMap("FUNCTION_SIGNATURE_TYPE", "http"); + Optional invoker = Invoker.makeInvoker(env); + assertThat(invoker.get().getFunctionSignatureType()).isEqualTo("http"); + } + + @Test + public void defaultClasspath() { + Optional invoker = Invoker.makeInvoker(); + assertThat(invoker.get().getClass().getClassLoader()) + .isSameInstanceAs(Invoker.class.getClassLoader()); + } + + private static final String FAKE_CLASSPATH = + "/foo/bar/baz.jar" + File.pathSeparator + "/some/directory"; + + @Test + public void explicitClasspathViaEnvironment() { + Map env = Collections.singletonMap("FUNCTION_CLASSPATH", FAKE_CLASSPATH); + Optional invoker = Invoker.makeInvoker(env); + assertThat(invokerClasspath(invoker.get())).isEqualTo(FAKE_CLASSPATH); + } + + @Test + public void explicitClasspathViaOption() { + Optional invoker = Invoker.makeInvoker("--classpath", FAKE_CLASSPATH); + assertThat(invokerClasspath(invoker.get())).isEqualTo(FAKE_CLASSPATH); + } + + private static String invokerClasspath(Invoker invoker) { + URLClassLoader urlClassLoader = (URLClassLoader) invoker.getFunctionClassLoader(); + return Arrays.stream(urlClassLoader.getURLs()) + .map(URL::getPath) + .collect(joining(File.pathSeparator)); + } + + @Test + public void classpathToUrls() throws Exception { + String classpath = + "../testfunction/target/test-classes" + File.pathSeparator + "../testfunction/target/lib/*"; + URL[] urls = Invoker.classpathToUrls(classpath); + assertWithMessage(Arrays.toString(urls)).that(urls.length).isGreaterThan(2); + File classesDir = new File(urls[0].toURI()); + assertWithMessage(classesDir.toString()).that(classesDir.isDirectory()).isTrue(); + for (int i = 1; i < urls.length; i++) { + URL url = urls[i]; + assertThat(url.toString()).endsWith(".jar"); + assertWithMessage(url.toString()).that(new File(url.toURI()).isFile()).isTrue(); + } + } + + private static String captureOutput(Runnable operation) throws IOException { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream byteCapture = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(byteCapture)) { + System.setOut(capture); + System.setErr(capture); + operation.run(); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + return new String(byteCapture.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BackgroundSnoop.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BackgroundSnoop.java new file mode 100644 index 00000000..0a6dba42 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BackgroundSnoop.java @@ -0,0 +1,44 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.UncheckedIOException; + +/** + * Extract the targetFile property from the data of the JSON payload, and write to it a JSON + * encoding of this payload and the context. The JSON format is chosen to be identical to the + * EventFlow format that we currently use in GCF, and the file that we write should in fact be + * identical to the JSON payload that the Functions Framework received from the client in the test. + * This will need to be rewritten when we switch to CloudEvents. + */ +public class BackgroundSnoop implements RawBackgroundFunction { + @Override + public void accept(String json, Context context) { + Gson gson = new Gson(); + JsonObject jsonObject = gson.fromJson(json, JsonObject.class); + String targetFile = jsonObject.get("targetFile").getAsString(); + if (targetFile == null) { + throw new IllegalArgumentException("Expected targetFile in JSON payload"); + } + JsonObject resourceJson = gson.fromJson(context.resource(), JsonObject.class); + JsonObject contextJson = new JsonObject(); + contextJson.addProperty("eventId", context.eventId()); + contextJson.addProperty("timestamp", context.timestamp()); + contextJson.addProperty("eventType", context.eventType()); + contextJson.add("resource", resourceJson); + JsonObject contextAndPayloadJson = new JsonObject(); + contextAndPayloadJson.add("data", jsonObject); + contextAndPayloadJson.add("context", contextJson); + try (FileWriter fileWriter = new FileWriter(targetFile); + PrintWriter writer = new PrintWriter(fileWriter)) { + writer.println(contextAndPayloadJson); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java new file mode 100644 index 00000000..a7989a74 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java @@ -0,0 +1,27 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedWriter; +import java.util.List; +import java.util.Map; + +public class BufferedWrites implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + Map> queryParameters = request.getQueryParameters(); + int writes = Integer.parseInt(request.getFirstQueryParameter("writes").orElse("0")); + boolean flush = Boolean.parseBoolean(request.getFirstQueryParameter("flush").orElse("false")); + + BufferedWriter writer = response.getWriter(); + for (int i = 0; i < writes; i++) { + response.appendHeader("x-write-" + i, "true"); + writer.write("write " + i + "\n"); + } + if (flush) { + writer.flush(); + } + response.appendHeader("x-written", "true"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/CloudEventSnoop.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/CloudEventSnoop.java new file mode 100644 index 00000000..439e712d --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/CloudEventSnoop.java @@ -0,0 +1,31 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.cloud.functions.CloudEventsFunction; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import io.cloudevents.jackson.JsonFormat; +import java.io.FileOutputStream; + +public class CloudEventSnoop implements CloudEventsFunction { + @Override + public void accept(CloudEvent event) throws Exception { + String payloadJson = new String(event.getData().toBytes(), UTF_8); + Gson gson = new Gson(); + JsonObject jsonObject = gson.fromJson(payloadJson, JsonObject.class); + String targetFile = jsonObject.get("targetFile").getAsString(); + if (targetFile == null) { + throw new IllegalArgumentException("Expected targetFile in JSON payload"); + } + EventFormat jsonFormat = + EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + byte[] bytes = jsonFormat.serialize(event); + try (FileOutputStream out = new FileOutputStream(targetFile)) { + out.write(bytes); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Echo.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Echo.java new file mode 100644 index 00000000..6cde3152 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Echo.java @@ -0,0 +1,29 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.stream.Collectors; + +public class Echo implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + boolean binary = "application/octet-stream".equals(request.getContentType().orElse(null)); + if (binary) { + response.setContentType("application/octet-stream"); + byte[] buf = new byte[1024]; + InputStream in = request.getInputStream(); + OutputStream out = response.getOutputStream(); + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } else { + String body = request.getReader().lines().collect(Collectors.joining("\n")) + "\n"; + response.setContentType("text/plain"); + response.getWriter().write(body); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/EchoUrl.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/EchoUrl.java new file mode 100644 index 00000000..7b446a59 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/EchoUrl.java @@ -0,0 +1,15 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class EchoUrl implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + StringBuilder url = new StringBuilder(request.getPath()); + request.getQuery().ifPresent(q -> url.append("?").append(q)); + url.append("\n"); + response.getWriter().write(url.toString()); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionBackground.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionBackground.java new file mode 100644 index 00000000..d087b395 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionBackground.java @@ -0,0 +1,11 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; + +public class ExceptionBackground implements RawBackgroundFunction { + @Override + public void accept(String json, Context context) { + throw new RuntimeException("exception thrown for test"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionHttp.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionHttp.java new file mode 100644 index 00000000..b6dee04d --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/ExceptionHttp.java @@ -0,0 +1,12 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class ExceptionHttp implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + throw new RuntimeException("exception thrown for test"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/HelloWorld.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/HelloWorld.java new file mode 100644 index 00000000..2caaad40 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/HelloWorld.java @@ -0,0 +1,12 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class HelloWorld implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + response.getWriter().write("hello\n"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Log.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Log.java new file mode 100644 index 00000000..4b1e94fe --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Log.java @@ -0,0 +1,28 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Emit log messages with configurable level, message, and exception. */ +public class Log implements HttpFunction { + private static final Logger logger = Logger.getLogger(Log.class.getName()); + + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + String message = request.getFirstQueryParameter("message").orElse("Default message"); + String levelString = request.getFirstQueryParameter("level").orElse("info"); + Optional exceptionString = request.getFirstQueryParameter("exception"); + Field levelField = Level.class.getField(levelString.toUpperCase()); + Level level = (Level) levelField.get(null); + if (exceptionString.isPresent()) { + logger.log(level, message, new Exception(exceptionString.get())); + } else { + logger.log(level, message); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Multipart.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Multipart.java new file mode 100644 index 00000000..37102bbc --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Multipart.java @@ -0,0 +1,35 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpRequest.HttpPart; +import com.google.cloud.functions.HttpResponse; +import java.io.PrintWriter; +import java.util.NavigableMap; +import java.util.TreeMap; + +/** + * A simple proof-of-concept function for multipart handling. + * + *

{@code HttpTest} contains more detailed testing, but this function is part of the integration + * test that shows that we can indeed access the multipart API from a function. + */ +public class Multipart implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + response.setContentType("text/plain"); + String contentType = request.getContentType().orElse(""); + if (!contentType.startsWith("multipart/form-data")) { + response.getWriter().write("Content-Type is " + contentType + " not multipart/form-data"); + return; + } + PrintWriter writer = new PrintWriter(response.getWriter()); + NavigableMap parts = new TreeMap<>(request.getParts()); + parts.forEach( + (name, contents) -> { + writer.printf( + "part %s type %s length %d\n", + name, contents.getContentType().get(), contents.getContentLength()); + }); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Nested.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Nested.java new file mode 100644 index 00000000..62710891 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Nested.java @@ -0,0 +1,18 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.util.stream.Collectors; + +public class Nested { + public static class Echo implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + String body = request.getReader().lines().collect(Collectors.joining("\n")); + response.setContentType("text/plain"); + response.getWriter().write(body); + response.getWriter().flush(); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java new file mode 100644 index 00000000..c73e52d2 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java @@ -0,0 +1,18 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class TimeoutHttp implements HttpFunction { + + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + response.getWriter().close(); + } + response.getWriter().write("finished\n"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java new file mode 100644 index 00000000..af1c9356 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java @@ -0,0 +1,25 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.TypedFunction; + +public class Typed implements TypedFunction { + + @Override + public NameConcatResponse apply(NameConcatRequest arg) throws Exception { + return new NameConcatResponse().setFullName(arg.firstName + arg.lastName); + } +} + +class NameConcatRequest { + String firstName; + String lastName; +} + +class NameConcatResponse { + String fullName; + + NameConcatResponse setFullName(String fullName) { + this.fullName = fullName; + return this; + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedBackgroundSnoop.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedBackgroundSnoop.java new file mode 100644 index 00000000..c1e489b4 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedBackgroundSnoop.java @@ -0,0 +1,49 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.BackgroundFunction; +import com.google.cloud.functions.Context; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.UncheckedIOException; + +/** + * Extract the targetFile property from the data of the JSON payload, and write to it a JSON + * encoding of this payload and the context. The JSON format is chosen to be identical to the + * EventFlow format that we currently use in GCF, and the file that we write should in fact be + * identical to the JSON payload that the Functions Framework received from the client in the test. + * This will need to be rewritten when we switch to CloudEvents. + */ +public class TypedBackgroundSnoop implements BackgroundFunction { + public static class Payload { + public int a; + public int b; + public String targetFile; + } + + @Override + public void accept(Payload payload, Context context) { + Gson gson = new Gson(); + String targetFile = payload.targetFile; + if (targetFile == null) { + throw new IllegalArgumentException("Expected targetFile in JSON payload"); + } + JsonObject resourceJson = gson.fromJson(context.resource(), JsonObject.class); + JsonObject contextJson = new JsonObject(); + contextJson.addProperty("eventId", context.eventId()); + contextJson.addProperty("timestamp", context.timestamp()); + contextJson.addProperty("eventType", context.eventType()); + contextJson.add("resource", resourceJson); + JsonObject contextAndPayloadJson = new JsonObject(); + contextAndPayloadJson.add("data", gson.toJsonTree(payload)); + contextAndPayloadJson.add("context", contextJson); + try (FileWriter fileWriter = new FileWriter(targetFile); + PrintWriter writer = new PrintWriter(fileWriter)) { + writer.println(contextAndPayloadJson); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java new file mode 100644 index 00000000..4597f216 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java @@ -0,0 +1,38 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import com.google.cloud.functions.TypedFunction; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +public class TypedCustomFormat implements TypedFunction, String> { + + @Override + public String apply(List arg) throws Exception { + return String.join("", arg); + } + + @Override + public WireFormat getWireFormat() { + return new CustomFormat(); + } +} + +class CustomFormat implements TypedFunction.WireFormat { + @Override + public Object deserialize(HttpRequest request, Type type) throws Exception { + List req = new ArrayList<>(); + String line; + while ((line = request.getReader().readLine()) != null) { + req.add(line); + } + return req; + } + + @Override + public void serialize(Object object, HttpResponse response) throws Exception { + response.getWriter().write((String) object); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java new file mode 100644 index 00000000..8e0cd00a --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java @@ -0,0 +1,12 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.TypedFunction; + +public class TypedVoid implements TypedFunction { + @Override + public Void apply(Request arg) throws Exception { + return null; + } +} + +class Request {} diff --git a/invoker/core/src/test/resources/adder_gcf_ga_event.json b/invoker/core/src/test/resources/adder_gcf_ga_event.json new file mode 100644 index 00000000..4762f613 --- /dev/null +++ b/invoker/core/src/test/resources/adder_gcf_ga_event.json @@ -0,0 +1,16 @@ +{ + "data": { + "a": 2, + "b": 3 + }, + "context": { + "eventId": "B234-1234-1234", + "timestamp": "2018-04-05T17:31:00Z", + "eventType": "com.example.someevent.new", + "resource": { + "service":"test-service", + "name":"test-name", + "type":"test-type" + } + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-auth-cloudevent-input.json b/invoker/core/src/test/resources/firebase-auth-cloudevent-input.json new file mode 100644 index 00000000..285878e9 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-auth-cloudevent-input.json @@ -0,0 +1,24 @@ +{ + "specversion": "1.0", + "type": "google.firebase.auth.user.v1.created", + "source": "//firebaseauth.googleapis.com/projects/my-project-id", + "subject": "users/UUpby3s4spZre6kHsgVSPetzQ8l2", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "email": "test@nowhere.com", + "metadata": { + "createTime": "2020-05-26T10:42:27Z", + "lastSignInTime": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-auth-legacy-output.json b/invoker/core/src/test/resources/firebase-auth-legacy-output.json new file mode 100644 index 00000000..cf0e572f --- /dev/null +++ b/invoker/core/src/test/resources/firebase-auth-legacy-output.json @@ -0,0 +1,23 @@ +{ + "data": { + "email": "test@nowhere.com", + "metadata": { + "createdAt": "2020-05-26T10:42:27Z", + "lastSignedInAt": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + "context": { + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "eventType": "providers/firebase.auth/eventTypes/user.create", + "resource": "projects/my-project-id", + "timestamp": "2020-09-29T11:32:00.123Z" + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-auth1.json b/invoker/core/src/test/resources/firebase-auth1.json new file mode 100644 index 00000000..bb623341 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-auth1.json @@ -0,0 +1,22 @@ +{ + "data": { + "email": "test@nowhere.com", + "metadata": { + "createdAt": "2020-05-26T10:42:27Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + "eventId": "4423b4fa-c39b-4f79-b338-977a018e9b55", + "eventType": "providers/firebase.auth/eventTypes/user.create", + "notSupported": { + }, + "resource": "projects/my-project-id", + "timestamp": "2020-05-26T10:42:27.088Z" +} diff --git a/invoker/core/src/test/resources/firebase-auth2.json b/invoker/core/src/test/resources/firebase-auth2.json new file mode 100644 index 00000000..0a702902 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-auth2.json @@ -0,0 +1,22 @@ +{ + "data": { + "email": "test@nowhere.com", + "metadata": { + "createdAt": "2020-05-26T10:42:27Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + "eventId": "5fd71bdc-4955-421f-9fc3-552ac3abead8", + "eventType": "providers/firebase.auth/eventTypes/user.delete", + "notSupported": { + }, + "resource": "projects/my-project-id", + "timestamp": "2020-05-26T10:47:14.205Z" +} diff --git a/invoker/core/src/test/resources/firebase-db1-cloudevent-input.json b/invoker/core/src/test/resources/firebase-db1-cloudevent-input.json new file mode 100644 index 00000000..0d9783c3 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db1-cloudevent-input.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "type": "google.firebase.database.ref.v1.written", + "source": "//firebasedatabase.googleapis.com/projects/_/locations/us-central1/instances/my-project-id", + "subject": "refs/gcf-test/xyz", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "data": null, + "delta": { + "grandchild": "other" + } + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-db1-legacy-output.json b/invoker/core/src/test/resources/firebase-db1-legacy-output.json new file mode 100644 index 00000000..50190855 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db1-legacy-output.json @@ -0,0 +1,14 @@ +{ + "data": { + "data": null, + "delta": { + "grandchild": "other" + } + }, + "context": { + "resource": "projects/_/instances/my-project-id/refs/gcf-test/xyz", + "timestamp": "2020-09-29T11:32:00.123Z", + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "eventType": "providers/google.firebase.database/eventTypes/ref.write" + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-db1.json b/invoker/core/src/test/resources/firebase-db1.json new file mode 100644 index 00000000..d6d6a015 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db1.json @@ -0,0 +1,19 @@ +{ + "eventType": "providers/google.firebase.database/eventTypes/ref.write", + "params": { + "child": "xyz" + }, + "auth": { + "admin": true + }, + "domain": "firebaseio.com", + "data": { + "data": null, + "delta": { + "grandchild": "other" + } + }, + "resource": "projects/_/instances/my-project-id/refs/gcf-test/xyz", + "timestamp": "2020-05-21T11:15:34.178Z", + "eventId": "/SnHth9OSlzK1Puj85kk4tDbF90=" +} diff --git a/invoker/core/src/test/resources/firebase-db2-cloudevent-input.json b/invoker/core/src/test/resources/firebase-db2-cloudevent-input.json new file mode 100644 index 00000000..71974d42 --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db2-cloudevent-input.json @@ -0,0 +1,17 @@ +{ + "specversion": "1.0", + "type": "google.firebase.database.ref.v1.written", + "source": "//firebasedatabase.googleapis.com/projects/_/locations/europe-west1/instances/my-project-id", + "subject": "refs/gcf-test/xyz", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "data": { + "grandchild": "other" + }, + "delta": { + "grandchild": "other changed" + } + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firebase-db2-legacy-output.json b/invoker/core/src/test/resources/firebase-db2-legacy-output.json new file mode 100644 index 00000000..402868fa --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db2-legacy-output.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "grandchild": "other" + }, + "delta": { + "grandchild": "other changed" + } + }, + "context": { + "resource": "projects/_/instances/my-project-id/refs/gcf-test/xyz", + "timestamp": "2020-09-29T11:32:00.123Z", + "eventType": "providers/google.firebase.database/eventTypes/ref.write", + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc" + } +} diff --git a/invoker/core/src/test/resources/firebase-db2.json b/invoker/core/src/test/resources/firebase-db2.json new file mode 100644 index 00000000..371ea00e --- /dev/null +++ b/invoker/core/src/test/resources/firebase-db2.json @@ -0,0 +1,21 @@ +{ + "eventType": "providers/google.firebase.database/eventTypes/ref.write", + "params": { + "child": "xyz" + }, + "auth": { + "admin": true + }, + "domain":"europe-west1.firebasedatabase.app", + "data": { + "data": { + "grandchild": "other" + }, + "delta": { + "grandchild": "other changed" + } + }, + "resource": "projects/_/instances/my-project-id/refs/gcf-test/xyz", + "timestamp": "2020-09-29T11:32:00.000Z", + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc" +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firestore_complex-cloudevent-input.json b/invoker/core/src/test/resources/firestore_complex-cloudevent-input.json new file mode 100644 index 00000000..fd5aadfb --- /dev/null +++ b/invoker/core/src/test/resources/firestore_complex-cloudevent-input.json @@ -0,0 +1,80 @@ +{ + "specversion": "1.0", + "type": "google.cloud.firestore.document.v1.written", + "source": "//firestore.googleapis.com/projects/project-id/databases/(default)", + "subject": "documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "oldValue": {}, + "updateMask": {}, + "value": { + "createTime": "2020-04-23T14:25:05.349632Z", + "fields": { + "arrayValue": { + "arrayValue": { + "values": [ + { + "integerValue": "1" + }, + { + "integerValue": "2" + } + ] + } + }, + "booleanValue": { + "booleanValue": true + }, + "doubleValue": { + "doubleValue": 5.5 + }, + "geoPointValue": { + "geoPointValue": { + "latitude": 51.4543, + "longitude": -0.9781 + } + }, + "intValue": { + "integerValue": "50" + }, + "mapValue": { + "mapValue": { + "fields": { + "field1": { + "stringValue": "x" + }, + "field2": { + "arrayValue": { + "values": [ + { + "stringValue": "x" + }, + { + "integerValue": "1" + } + ] + } + } + } + } + }, + "nullValue": { + "nullValue": null + }, + "referenceValue": { + "referenceValue": "projects/project-id/databases/(default)/documents/foo/bar/baz/qux" + }, + "stringValue": { + "stringValue": "text" + }, + "timestampValue": { + "timestampValue": "2020-04-23T14:23:53.241Z" + } + }, + "name": "projects/project-id/databases/(default)/documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "updateTime": "2020-04-23T14:25:05.349632Z" + } + } +} diff --git a/invoker/core/src/test/resources/firestore_complex-legacy-output.json b/invoker/core/src/test/resources/firestore_complex-legacy-output.json new file mode 100644 index 00000000..dc7a26b4 --- /dev/null +++ b/invoker/core/src/test/resources/firestore_complex-legacy-output.json @@ -0,0 +1,79 @@ +{ + "data": { + "oldValue": {}, + "updateMask": {}, + "value": { + "createTime": "2020-04-23T14:25:05.349632Z", + "fields": { + "arrayValue": { + "arrayValue": { + "values": [ + { + "integerValue": "1" + }, + { + "integerValue": "2" + } + ] + } + }, + "booleanValue": { + "booleanValue": true + }, + "doubleValue": { + "doubleValue": 5.5 + }, + "geoPointValue": { + "geoPointValue": { + "latitude": 51.4543, + "longitude": -0.9781 + } + }, + "intValue": { + "integerValue": "50" + }, + "mapValue": { + "mapValue": { + "fields": { + "field1": { + "stringValue": "x" + }, + "field2": { + "arrayValue": { + "values": [ + { + "stringValue": "x" + }, + { + "integerValue": "1" + } + ] + } + } + } + } + }, + "nullValue": { + "nullValue": null + }, + "referenceValue": { + "referenceValue": "projects/project-id/databases/(default)/documents/foo/bar/baz/qux" + }, + "stringValue": { + "stringValue": "text" + }, + "timestampValue": { + "timestampValue": "2020-04-23T14:23:53.241Z" + } + }, + "name": "projects/project-id/databases/(default)/documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "updateTime": "2020-04-23T14:25:05.349632Z" + } + }, + "context": { + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "eventType": "providers/cloud.firestore/eventTypes/document.write", + "resource": "projects/project-id/databases/(default)/documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "timestamp": "2020-09-29T11:32:00.123Z" + } +} diff --git a/invoker/core/src/test/resources/firestore_complex.json b/invoker/core/src/test/resources/firestore_complex.json new file mode 100644 index 00000000..231e2a22 --- /dev/null +++ b/invoker/core/src/test/resources/firestore_complex.json @@ -0,0 +1,81 @@ +{ + "data": { + "oldValue": {}, + "updateMask": {}, + "value": { + "createTime": "2020-04-23T14:25:05.349632Z", + "fields": { + "arrayValue": { + "arrayValue": { + "values": [ + { + "integerValue": "1" + }, + { + "integerValue": "2" + } + ] + } + }, + "booleanValue": { + "booleanValue": true + }, + "doubleValue": { + "doubleValue": 5.5 + }, + "geoPointValue": { + "geoPointValue": { + "latitude": 51.4543, + "longitude": -0.9781 + } + }, + "intValue": { + "integerValue": "50" + }, + "mapValue": { + "mapValue": { + "fields": { + "field1": { + "stringValue": "x" + }, + "field2": { + "arrayValue": { + "values": [ + { + "stringValue": "x" + }, + { + "integerValue": "1" + } + ] + } + } + } + } + }, + "nullValue": { + "nullValue": null + }, + "referenceValue": { + "referenceValue": "projects/project-id/databases/(default)/documents/foo/bar/baz/qux" + }, + "stringValue": { + "stringValue": "text" + }, + "timestampValue": { + "timestampValue": "2020-04-23T14:23:53.241Z" + } + }, + "name": "projects/project-id/databases/(default)/documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "updateTime": "2020-04-23T14:25:05.349632Z" + } + }, + "eventId": "9babded5-e5f2-41af-a46a-06ba6bd84739-0", + "eventType": "providers/cloud.firestore/eventTypes/document.write", + "notSupported": {}, + "params": { + "doc": "IH75dRdeYJKd4uuQiqch" + }, + "resource": "projects/project-id/databases/(default)/documents/gcf-test/IH75dRdeYJKd4uuQiqch", + "timestamp": "2020-04-23T14:25:05.349632Z" +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/firestore_simple.json b/invoker/core/src/test/resources/firestore_simple.json new file mode 100644 index 00000000..14d7de48 --- /dev/null +++ b/invoker/core/src/test/resources/firestore_simple.json @@ -0,0 +1,51 @@ +{ + "data":{ + "oldValue":{ + "createTime":"2020-04-23T09:58:53.211035Z", + "fields":{ + "another test":{ + "stringValue":"asd" + }, + "count":{ + "integerValue":"3" + }, + "foo":{ + "stringValue":"bar" + } + }, + "name":"projects/project-id/databases/(default)/documents/gcf-test/2Vm2mI1d0wIaK2Waj5to", + "updateTime":"2020-04-23T12:00:27.247187Z" + }, + "updateMask":{ + "fieldPaths":[ + "count" + ] + }, + "value":{ + "createTime":"2020-04-23T09:58:53.211035Z", + "fields":{ + "another test":{ + "stringValue":"asd" + }, + "count":{ + "integerValue":"4" + }, + "foo":{ + "stringValue":"bar" + } + }, + "name":"projects/project-id/databases/(default)/documents/gcf-test/2Vm2mI1d0wIaK2Waj5to", + "updateTime":"2020-04-23T12:00:27.247187Z" + } + }, + "eventId":"7b8f1804-d38b-4b68-b37d-e2fb5d12d5a0-0", + "eventType":"providers/cloud.firestore/eventTypes/document.write", + "notSupported":{ + + }, + "params":{ + "doc":"2Vm2mI1d0wIaK2Waj5to" + }, + "resource":"projects/project-id/databases/(default)/documents/gcf-test/2Vm2mI1d0wIaK2Waj5to", + "timestamp":"2020-04-23T12:00:27.247187Z" +} diff --git a/invoker/core/src/test/resources/legacy_pubsub.json b/invoker/core/src/test/resources/legacy_pubsub.json new file mode 100644 index 00000000..b03bfd59 --- /dev/null +++ b/invoker/core/src/test/resources/legacy_pubsub.json @@ -0,0 +1,13 @@ +{ + "eventId": "1215011316659232", + "timestamp": "2020-05-18T12:13:19.209Z", + "eventType": "providers/cloud.pubsub/eventTypes/topic.publish", + "resource": "projects/sample-project/topics/gcf-test", + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attribute1": "value1" + }, + "data": "VGhpcyBpcyBhIHNhbXBsZSBtZXNzYWdl" + } +} diff --git a/invoker/core/src/test/resources/legacy_storage_change.json b/invoker/core/src/test/resources/legacy_storage_change.json new file mode 100644 index 00000000..8a1e92ed --- /dev/null +++ b/invoker/core/src/test/resources/legacy_storage_change.json @@ -0,0 +1,26 @@ +{ + "data": { + "bucket": "sample-bucket", + "crc32c": "AAAAAA==", + "etag": "COu8mb3Dn+kCEAE=", + "generation": "1588778055917163", + "id": "sample-bucket/MyFile/1588778055917163", + "kind": "storage#object", + "md5Hash": "ZDQxZDhjZDk4ZjAwYjIwNGU5ODAwOTk4ZWNmODQyN2U=", + "mediaLink": "https://www.googleapis.com/download/storage/v1/b/projectid-sample-bucket/o/MyFile?generation=1588778055917163\u0026alt=media", + "metageneration": "1", + "name": "MyFile", + "resourceState": "not_exists", + "selfLink": "https://www.googleapis.com/storage/v1/b/projectid-sample-bucket/o/MyFile", + "size": "0", + "storageClass": "MULTI_REGIONAL", + "timeCreated": "2020-05-06T15:14:15.917Z", + "timeDeleted": "2020-05-18T09:07:51.799Z", + "timeStorageClassUpdated": "2020-05-06T15:14:15.917Z", + "updated": "2020-05-06T15:14:15.917Z" + }, + "eventId": "1200401551653202", + "eventType": "providers/cloud.storage/eventTypes/object.change", + "resource": "projects/_/buckets/sample-bucket/objects/MyFile#1588778055917163", + "timestamp": "2020-05-18T09:07:51.799Z" +} diff --git a/invoker/core/src/test/resources/pubsub_background.json b/invoker/core/src/test/resources/pubsub_background.json new file mode 100644 index 00000000..5f9927cb --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_background.json @@ -0,0 +1,19 @@ +{ + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "data": "eyJmb28iOiJiYXIifQ==", + "attributes": { + "test": "123" + } + }, + "context": { + "eventId": "1", + "eventType": "google.pubsub.topic.publish", + "resource": { + "name": "projects/FOO/topics/BAR_TOPIC", + "service": "pubsub.googleapis.com", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage" + }, + "timestamp": "2021-06-28T05:46:32.390Z" + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/pubsub_binary.json b/invoker/core/src/test/resources/pubsub_binary.json new file mode 100644 index 00000000..d7cbb125 --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_binary.json @@ -0,0 +1,16 @@ +{ + "context": { + "eventId":"1144231683168617", + "timestamp":"2020-05-06T07:33:34.556Z", + "eventType":"google.pubsub.topic.publish", + "resource":{ + "service":"pubsub.googleapis.com", + "name":"projects/sample-project/topics/gcf-test", + "type":"type.googleapis.com/google.pubsub.v1.PubsubMessage" + } + }, + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "data": "AQIDBA==" + } +} diff --git a/invoker/core/src/test/resources/pubsub_emulator.json b/invoker/core/src/test/resources/pubsub_emulator.json new file mode 100644 index 00000000..cdfe340a --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_emulator.json @@ -0,0 +1,10 @@ +{ + "subscription": "projects/FOO/subscriptions/BAR_SUB", + "message": { + "data": "eyJmb28iOiJiYXIifQ==", + "messageId": "1", + "attributes": { + "test": "123" + } + } +} \ No newline at end of file diff --git a/invoker/core/src/test/resources/pubsub_text-cloudevent-input.json b/invoker/core/src/test/resources/pubsub_text-cloudevent-input.json new file mode 100644 index 00000000..f4994ee8 --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_text-cloudevent-input.json @@ -0,0 +1,20 @@ +{ + "specversion": "1.0", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "source": "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "subscription": "projects/sample-project/subscriptions/sample-subscription", + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "messageId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "publishTime": "2020-09-29T11:32:00.123Z", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } + } +} diff --git a/invoker/core/src/test/resources/pubsub_text-legacy-output.json b/invoker/core/src/test/resources/pubsub_text-legacy-output.json new file mode 100644 index 00000000..e1b8b8f9 --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_text-legacy-output.json @@ -0,0 +1,19 @@ +{ + "context": { + "eventId":"aaaaaa-1111-bbbb-2222-cccccccccccc", + "timestamp":"2020-09-29T11:32:00.123Z", + "eventType":"google.pubsub.topic.publish", + "resource":{ + "service":"pubsub.googleapis.com", + "name":"projects/sample-project/topics/gcf-test", + "type":"type.googleapis.com/google.pubsub.v1.PubsubMessage" + } + }, + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } +} diff --git a/invoker/core/src/test/resources/pubsub_text.json b/invoker/core/src/test/resources/pubsub_text.json new file mode 100644 index 00000000..9d7ed53c --- /dev/null +++ b/invoker/core/src/test/resources/pubsub_text.json @@ -0,0 +1,19 @@ +{ + "context": { + "eventId":"1144231683168617", + "timestamp":"2020-05-06T07:33:34.556Z", + "eventType":"google.pubsub.topic.publish", + "resource":{ + "service":"pubsub.googleapis.com", + "name":"projects/sample-project/topics/gcf-test", + "type":"type.googleapis.com/google.pubsub.v1.PubsubMessage" + } + }, + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } +} diff --git a/invoker/core/src/test/resources/storage-cloudevent-input.json b/invoker/core/src/test/resources/storage-cloudevent-input.json new file mode 100644 index 00000000..2948b99f --- /dev/null +++ b/invoker/core/src/test/resources/storage-cloudevent-input.json @@ -0,0 +1,28 @@ +{ + "specversion": "1.0", + "type": "google.cloud.storage.object.v1.finalized", + "source": "//storage.googleapis.com/projects/_/buckets/some-bucket", + "subject": "objects/folder/Test.cs", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.123Z", + "datacontenttype": "application/json", + "data": { + "bucket": "some-bucket", + "contentType": "text/plain", + "crc32c": "rTVTeQ==", + "etag": "CNHZkbuF/ugCEAE=", + "generation": "1587627537231057", + "id": "some-bucket/folder/Test.cs/1587627537231057", + "kind": "storage#object", + "md5Hash": "kF8MuJ5+CTJxvyhHS1xzRg==", + "mediaLink": "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", + "metageneration": "1", + "name": "folder/Test.cs", + "selfLink": "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", + "size": "352", + "storageClass": "MULTI_REGIONAL", + "timeCreated": "2020-04-23T07:38:57.230Z", + "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", + "updated": "2020-04-23T07:38:57.230Z" + } +} diff --git a/invoker/core/src/test/resources/storage-legacy-output.json b/invoker/core/src/test/resources/storage-legacy-output.json new file mode 100644 index 00000000..88a9fd57 --- /dev/null +++ b/invoker/core/src/test/resources/storage-legacy-output.json @@ -0,0 +1,31 @@ +{ + "context": { + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "timestamp": "2020-09-29T11:32:00.123Z", + "eventType": "google.storage.object.finalize", + "resource": { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object" + } + }, + "data": { + "bucket": "some-bucket", + "contentType": "text/plain", + "crc32c": "rTVTeQ==", + "etag": "CNHZkbuF/ugCEAE=", + "generation": "1587627537231057", + "id": "some-bucket/folder/Test.cs/1587627537231057", + "kind": "storage#object", + "md5Hash": "kF8MuJ5+CTJxvyhHS1xzRg==", + "mediaLink": "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", + "metageneration": "1", + "name": "folder/Test.cs", + "selfLink": "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", + "size": "352", + "storageClass": "MULTI_REGIONAL", + "timeCreated": "2020-04-23T07:38:57.230Z", + "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", + "updated": "2020-04-23T07:38:57.230Z" + } +} diff --git a/invoker/core/src/test/resources/storage.json b/invoker/core/src/test/resources/storage.json new file mode 100644 index 00000000..baf92557 --- /dev/null +++ b/invoker/core/src/test/resources/storage.json @@ -0,0 +1,31 @@ +{ + "context": { + "eventId": "1147091835525187", + "timestamp": "2020-04-23T07:38:57.772Z", + "eventType": "google.storage.object.finalize", + "resource": { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object" + } + }, + "data": { + "bucket": "some-bucket", + "contentType": "text/plain", + "crc32c": "rTVTeQ==", + "etag": "CNHZkbuF/ugCEAE=", + "generation": "1587627537231057", + "id": "some-bucket/folder/Test.cs/1587627537231057", + "kind": "storage#object", + "md5Hash": "kF8MuJ5+CTJxvyhHS1xzRg==", + "mediaLink": "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", + "metageneration": "1", + "name": "folder/Test.cs", + "selfLink": "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", + "size": "352", + "storageClass": "MULTI_REGIONAL", + "timeCreated": "2020-04-23T07:38:57.230Z", + "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", + "updated": "2020-04-23T07:38:57.230Z" + } +} diff --git a/invoker/core/src/test/resources/typed_nameconcat_request.json b/invoker/core/src/test/resources/typed_nameconcat_request.json new file mode 100644 index 00000000..f6b4d425 --- /dev/null +++ b/invoker/core/src/test/resources/typed_nameconcat_request.json @@ -0,0 +1,4 @@ +{ + "firstName": "John", + "lastName": "Doe" +} \ No newline at end of file diff --git a/invoker/pom.xml b/invoker/pom.xml new file mode 100644 index 00000000..33cf5383 --- /dev/null +++ b/invoker/pom.xml @@ -0,0 +1,144 @@ + + 4.0.0 + + org.sonatype.oss + oss-parent + 9 + + + com.google.cloud.functions.invoker + java-function-invoker-parent + 2.0.2-SNAPSHOT + pom + GCF Java Invoker Parent + + Parent POM for the GCF Java Invoker. The project is structured like this so + that we can have modules that build jar files for use in tests. + + https://github.com/GoogleCloudPlatform/functions-framework-java + + + http://github.com/GoogleCloudPlatform/functions-framework-java + scm:git:git://github.com/GoogleCloudPlatform/functions-framework-java.git + scm:git:ssh://git@github.com/GoogleCloudPlatform/functions-framework-java.git + HEAD + + + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + core + testfunction + conformance + + + + UTF-8 + 3.8.1 + 17 + 17 + + + + + + com.google.cloud.functions + functions-framework-api + 2.0.1 + + + + + + + sonatype-nexus-snapshots + Sonatype Nexus Snapshots + https://oss.sonatype.org/content/repositories/snapshots/ + + + sonatype-nexus-staging + Nexus Release Repository + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + sonatype-oss-release + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 + true + + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ + + + + + + + diff --git a/invoker/testfunction/pom.xml b/invoker/testfunction/pom.xml new file mode 100644 index 00000000..541eb9a8 --- /dev/null +++ b/invoker/testfunction/pom.xml @@ -0,0 +1,97 @@ + + 4.0.0 + + + com.google.cloud.functions.invoker + java-function-invoker-parent + 2.0.2-SNAPSHOT + + + com.google.cloud.functions.invoker + java-function-invoker-testfunction + 2.0.2-SNAPSHOT + Example GCF Function Jar + + An example of a GCF function packaged into a jar. We use this in tests. + + https://github.com/GoogleCloudPlatform/functions-framework-java + + + + com.google.cloud.functions + functions-framework-api + 2.0.0 + + + + com.google.escapevelocity + escapevelocity + 1.1 + + + com.google.guava + guava + 33.5.0-jre + + + com.google.code.gson + gson + 2.13.2 + + + + + + + maven-jar-plugin + 3.5.0 + + + + true + lib + + + + + + + test-jar + + test-compile + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + test-compile + + copy-dependencies + + + ${project.build.directory}/lib + + + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + true + + + + + diff --git a/invoker/testfunction/src/test/java/com/example/functionjar/Background.java b/invoker/testfunction/src/test/java/com/example/functionjar/Background.java new file mode 100644 index 00000000..6b18b54c --- /dev/null +++ b/invoker/testfunction/src/test/java/com/example/functionjar/Background.java @@ -0,0 +1,44 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.functionjar; + +import com.google.cloud.functions.Context; +import com.google.cloud.functions.RawBackgroundFunction; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +/** + * @author emcmanus@google.com (Éamonn McManus) + */ +public class Background implements RawBackgroundFunction { + @Override + public void accept(String json, Context context) { + try { + test(json); + } catch (Throwable e) { + e.printStackTrace(); + throw e; + } + } + + private void test(String jsonString) { + Gson gson = new Gson(); + JsonObject json = gson.fromJson(jsonString, JsonObject.class); + JsonPrimitive jsonRuntimeClassName = json.getAsJsonPrimitive("class"); + String runtimeClassName = jsonRuntimeClassName.getAsString(); + new Checker().serviceOrAssert(runtimeClassName); + } +} diff --git a/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java b/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java new file mode 100644 index 00000000..2a4d3920 --- /dev/null +++ b/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java @@ -0,0 +1,42 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.example.functionjar; + +import com.google.escapevelocity.Template; + +class Checker { + void serviceOrAssert(String runtimeClassName) { + // Check that the context class loader is the loader that loaded this class. + if (getClass().getClassLoader() != Thread.currentThread().getContextClassLoader()) { + throw new AssertionError( + String.format( + "ClassLoader mismatch: mine %s; context %s", + getClass().getClassLoader(), Thread.currentThread().getContextClassLoader())); + } + + ClassLoader myLoader = getClass().getClassLoader(); + Class