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 index cc31b736..2c88f621 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -2,17 +2,30 @@ name: Java Lint CI on: push: branches: - - master + - main pull_request: + workflow_dispatch: +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - 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@v1 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - java-version: 11.x + java-version: 17.x + distribution: temurin - name: Build API with Maven run: (cd functions-framework-api/ && mvn install) - name: Lint Functions Framework API @@ -20,4 +33,26 @@ jobs: - name: Build Invoker with Maven run: (cd functions-framework-api/ && mvn install) - name: Lint Invoker - run: (cd invoker/ && mvn clean verify -DskipTests -P lint) \ No newline at end of file + 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 index f93c38fb..fd3ffc97 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -1,22 +1,39 @@ name: Java Unit CI -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest strategy: matrix: java: [ - 11.x - # 12.x, - # 13.x + 17.x, + 21.x ] steps: - - uses: actions/checkout@v2 + - 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@v1 + 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) \ No newline at end of file + run: (cd invoker/ && mvn test) diff --git a/.gitignore b/.gitignore index ca0f542f..4cd4e4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ *.tar.gz *.rar +# Maven +target/ +dependency-reduced-pom.xml + # Gradle .gradle .idea/ @@ -44,3 +48,8 @@ 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 74010b53..850b806c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,22 @@ -# Functions Framework for Java [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2FGoogleCloudPlatform%2Ffunctions-framework-java%2Fbadge&style=flat)](https://actions-badge.atrox.dev/GoogleCloudPlatform/functions-framework-java/goto) [![Maven Central](https://img.shields.io/maven-central/v/com.google.cloud.functions/functions-framework-api.svg)](https://search.maven.org/artifact/com.google.cloud.functions/functions-framework-api) +# Functions Framework for Java + +[![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) + +[![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) An open source FaaS (Function as a service) framework for writing portable -Java functions -- brought to you by the Google Cloud Functions team. +Java functions. The Functions Framework lets you write lightweight functions that run in many different environments, including: -* [Google Cloud Functions](https://cloud.google.com/functions/) +* [Google Cloud Run functions](https://cloud.google.com/functions/) * Your local development machine -* [Cloud Run and Cloud Run on GKE](https://cloud.google.com/run/) * [Knative](https://github.com/knative/)-based environments ## Installation @@ -32,7 +40,7 @@ that supports Maven to create the Maven project. Add this dependency in the com.google.cloud.functions functions-framework-api - 1.0.1 + 1.1.2 provided ``` @@ -42,7 +50,7 @@ Framework dependency in your `build.gradle` project file as follows: ```groovy dependencies { - implementation 'com.google.cloud.functions:functions-framework-api:1.0.1' + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' } ``` @@ -112,26 +120,26 @@ contents: ```java package com.example; -import com.example.PubSubBackground.PubSubMessage; 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 " + pubSubMessage.messageId); - } - - public static class PubSubMessage { - public String data; - public Map attributes; - public String messageId; - public String publishTime; + logger.info("Received message with id " + context.eventId()); } } ``` @@ -150,9 +158,9 @@ You can configure the plugin in `pom.xml`: com.google.cloud.functions function-maven-plugin - 0.9.2 + 0.10.1 - com.example.function.Echo + com.example.HelloWorld ``` @@ -168,8 +176,8 @@ mvn function:run You can alternatively configure the plugin with properties on the command line: ```sh - mvn com.google.cloud.functions:function-maven-plugin:0.9.2:run \ - -Drun.functionTarget=com.example.function.Echo + mvn com.google.cloud.functions:function-maven-plugin:0.10.1:run \ + -Drun.functionTarget=com.example.HelloWorld ``` ### Running the Functions Framework directly @@ -179,7 +187,7 @@ 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.0.0-alpha-2-rc5' \ + -Dartifact='com.google.cloud.functions.invoker:java-function-invoker:1.3.2' \ -DoutputDirectory=. ``` @@ -187,7 +195,7 @@ 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.0.0-alpha-2-rc5.jar \ +java -jar java-function-invoker-1.3.2 \ --classpath myfunction.jar \ --target com.example.HelloWorld ``` @@ -206,8 +214,8 @@ configurations { } dependencies { - implementation 'com.google.cloud.functions:functions-framework-api:1.0.1' - invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.0.0-alpha-2-rc5' + 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) { @@ -215,8 +223,8 @@ tasks.register("runFunction", JavaExec) { classpath(configurations.invoker) inputs.files(configurations.runtimeClasspath, sourceSets.main.output) args( - '--target', project.findProperty('runFunction.target'), - '--port', project.findProperty('runFunction.port') ?: 8080 + '--target', project.findProperty('run.functionTarget'), + '--port', project.findProperty('run.port') ?: 8080 ) doFirst { args('--classpath', files(configurations.runtimeClasspath, sourceSets.main.output).asPath) @@ -227,15 +235,15 @@ tasks.register("runFunction", JavaExec) { Then in your terminal or IDE, you will be able to run the function locally with: ```sh -gradle runFunction -PrunFunction.target=com.example.HelloWorld \ - -PrunFunction.port=8080 +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 -PrunFunction.target=com.example.HelloWorld \ - -PrunFunction.port=8080 +./gradlew runFunction -Prun.functionTarget=com.example.HelloWorld \ + -Prun.port=8080 ``` ## Functions Framework configuration @@ -280,7 +288,7 @@ Framework directly, you must use `--classpath` to indicate how to find the code and its dependencies. For example: ``` -java -jar java-function-invoker-1.0.0-alpha-2-rc5.jar \ +java -jar java-function-invoker-1.3.2 \ --classpath 'myfunction.jar:/some/directory:/some/library/*' \ --target com.example.HelloWorld ``` @@ -297,7 +305,7 @@ It is a list of entries separated by `:` (`;` on Windows), where each entry is: in that directory (file called `foo.jar`) is treated the same way as if it had been named explicitly. -#### Simplifying the claspath +#### 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 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/invoker/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 similarity index 87% rename from invoker/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java rename to function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java index 74e0b73f..08bff714 100644 --- a/invoker/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 @@ -63,9 +63,8 @@ public class DeployFunction extends CloudSdkMojo { */ @Parameter( alias = "deploy.allowunauthenticated", - property = "function.deploy.allowunauthenticated", - defaultValue = "false") - boolean allowUnauthenticated; + property = "function.deploy.allowunauthenticated") + Boolean allowUnauthenticated; /** * Name of a Google Cloud Function (as defined in source code) that will be executed. Defaults to @@ -99,13 +98,13 @@ public class DeployFunction extends CloudSdkMojo { * Runtime in which to run the function. * *

Required when deploying a new function; optional when updating an existing function. Default - * to Java11. + * to Java17. */ @Parameter( alias = "deploy.runtime", - defaultValue = "java11", + defaultValue = "java17", property = "function.deploy.runtime") - String runtime = "java11"; + String runtime = "java17"; /** * The email address of the IAM service account associated with the function at runtime. The @@ -182,18 +181,21 @@ public class DeployFunction extends CloudSdkMojo { */ @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. @@ -201,10 +203,32 @@ public class DeployFunction extends CloudSdkMojo { @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 -> { @@ -271,6 +295,9 @@ public List getCommands() { commands.add("functions"); commands.add("deploy"); commands.add(name); + if (gen2) { + commands.add("--gen2"); + } if (region != null) { commands.add("--region=" + region); } @@ -286,8 +313,12 @@ public List getCommands() { if (triggerEvent != null) { commands.add("--trigger-event=" + triggerEvent); } - if (allowUnauthenticated) { - commands.add("--allow-unauthenticated"); + if (allowUnauthenticated != null) { + if (allowUnauthenticated) { + commands.add("--allow-unauthenticated"); + } else { + commands.add("--no-allow-unauthenticated"); + } } if (functionTarget != null) { commands.add("--entry-point=" + functionTarget); @@ -331,11 +362,20 @@ public List getCommands() { 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); } @@ -347,7 +387,9 @@ public void execute() throws MojoExecutionException { 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, null, ex); + Logger.getLogger(DeployFunction.class.getName()) + .log(Level.SEVERE, "Function deployment failed", ex); + throw new MojoExecutionException("Function deployment failed", ex); } } } diff --git a/invoker/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 similarity index 79% rename from invoker/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/RunFunction.java rename to function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/RunFunction.java index c4c9fed4..c79fcd05 100644 --- a/invoker/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 @@ -14,37 +14,36 @@ 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... + * 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
- *    
- *  
+ * 
+ *   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}
- * 
- * + * mvn com.google.cloud.functions:function:1.0.0-alpha-2-rc3:run \ + * -Drun.functionTarget=com.example.function.Echo + * } */ -@Mojo(name = "run", +@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}. @@ -52,9 +51,7 @@ public class RunFunction extends AbstractMojo { @Parameter(property = "run.functionTarget") private String functionTarget; - /** - * The port on which the HTTP server wrapping the function should listen. - */ + /** The port on which the HTTP server wrapping the function should listen. */ @Parameter(property = "run.port", defaultValue = "8080") private Integer port; diff --git a/invoker/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 similarity index 86% rename from invoker/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java rename to function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java index f1730b96..6f107cff 100644 --- a/invoker/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 @@ -16,6 +16,7 @@ public class DeployFunctionTest { public void testDeployFunctionCommandLine() { DeployFunction mojo = new DeployFunction(); mojo.envVarsFile = "myfile"; + mojo.buildEnvVarsFile = "myfile2"; mojo.functionTarget = "function"; mojo.ignoreFile = "ff"; mojo.maxInstances = new Integer(3); @@ -30,6 +31,7 @@ public void testDeployFunctionCommandLine() { 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", @@ -49,7 +51,10 @@ public void testDeployFunctionCommandLine() { "--max-instances=3", "--set-env-vars=env1=a,env2=b", "--env-vars-file=myfile", - "--runtime=java11"); + "--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 e0d1f18f..250d5c2b 100644 --- a/functions-framework-api/pom.xml +++ b/functions-framework-api/pom.xml @@ -24,12 +24,15 @@ com.google.cloud.functions functions-framework-api - 1.0.4-SNAPSHOT + 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.1.0 + 3.15.0 + 3.12.0 5.3.2 @@ -41,6 +44,21 @@ + + + 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 @@ -52,7 +70,7 @@ io.cloudevents cloudevents-api - 2.0.0-milestone4 + 4.0.2 @@ -62,8 +80,8 @@ maven-compiler-plugin ${maven-compiler-plugin.version} - 11 - 11 + 17 + 17 @@ -86,7 +104,7 @@ org.apache.maven.plugins maven-release-plugin - 2.5.3 + 3.3.1 default @@ -122,25 +140,15 @@ attach-docs post-integration-test - jar + + jar + - - - 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 @@ -175,7 +183,7 @@ org.apache.maven.plugins maven-gpg-plugin - 1.6 + 3.2.8 sign-artifacts @@ -186,6 +194,16 @@ + + 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 index 890a5ade..5dc0a97e 100644 --- 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 @@ -17,14 +17,14 @@ /** * 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: + * 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());
@@ -49,9 +49,9 @@
 @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.
+   * 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,
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 36e857b5..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
@@ -21,44 +21,44 @@
 public interface Context {
   /**
    * Returns event ID.
-   * 
+   *
    * @return event ID
    */
   String eventId();
 
   /**
    * Returns event timestamp.
-   * 
+   *
    * @return event timestamp
    */
   String timestamp();
 
   /**
    * Returns event type.
-   * 
+   *
    * @return event type
    */
   String eventType();
 
   /**
    * 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
-   * 
+   * 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.

- * + *

The map returned by this method may be empty but is never null. + * * @return additional attributes form this event. */ default Map attributes() { diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/ExperimentalCloudEventsFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/ExperimentalCloudEventsFunction.java deleted file mode 100644 index 516a0456..00000000 --- a/functions-framework-api/src/main/java/com/google/cloud/functions/ExperimentalCloudEventsFunction.java +++ /dev/null @@ -1,22 +0,0 @@ -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. - * Because the {@link CloudEvent} API is not yet stable, a function implemented using this class may not - * build or work correctly with later versions of that API. Once the API is stable, this interface will - * become {@code CloudEventsFunction} and will also be stable. - */ -@FunctionalInterface -public interface ExperimentalCloudEventsFunction { - /** - * 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/HttpFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/HttpFunction.java index 59065ef6..6357724d 100644 --- 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 @@ -14,9 +14,7 @@ package com.google.cloud.functions; -/** - * Represents a Cloud Function that is activated by an HTTP request. - */ +/** Represents a Cloud Function that is activated by an HTTP request. */ @FunctionalInterface public interface HttpFunction { /** 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 index 43b56da3..24a70c9b 100644 --- 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 @@ -21,9 +21,7 @@ import java.util.Map; import java.util.Optional; -/** - * Represents an HTTP message, either an HTTP request or a part of a multipart HTTP request. - */ +/** 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. @@ -40,19 +38,19 @@ public interface HttpMessage { 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. + * 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. + * 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. @@ -72,8 +70,8 @@ public interface HttpMessage { 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... + * 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
@@ -82,11 +80,11 @@ public interface HttpMessage {
    * 
* * ...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"}. + * {@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. + * @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(); @@ -104,7 +102,6 @@ public interface HttpMessage { * 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) { 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 index fb1999cf..d50f1cf7 100644 --- 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 @@ -18,9 +18,7 @@ import java.util.Map; import java.util.Optional; -/** - * Represents the contents of an HTTP request that is being serviced by a Cloud Function. - */ +/** 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"}. @@ -37,39 +35,39 @@ public interface HttpRequest extends HttpMessage { 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}. + * 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. + * 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"}. + * 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. + * @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}. + * 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. @@ -102,8 +100,8 @@ interface HttpPart extends HttpMessage { * value. * * @return a map from part names to part contents. - * @throws IllegalStateException if the {@link #getContentType() content type} is not - * {@code multipart/form-data}. + * @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 index 9098315d..c3f87ea2 100644 --- 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 @@ -22,30 +22,29 @@ 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. + * 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. + * 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 + * 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. + * {@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. @@ -53,8 +52,8 @@ public interface HttpResponse { 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")}. + * 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. */ @@ -90,9 +89,9 @@ public interface HttpResponse { 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. + * 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. @@ -101,11 +100,10 @@ public interface HttpResponse { 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. + * 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. 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 index 6eff26d2..5f9706ba 100644 --- 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 @@ -16,14 +16,14 @@ /** * 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. + * 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());
@@ -38,8 +38,8 @@
  * }
  * 
* - *

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

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 {
@@ -64,9 +64,9 @@
 @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.
+   * 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,
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 index 73f17440..cd8984ba 100644 --- a/invoker/core/pom.xml +++ b/invoker/core/pom.xml @@ -4,24 +4,27 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.0.2-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker - 1.0.2-SNAPSHOT + 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 - 11 - 11 + 17 + 17 + 4.0.1 + 12.1.8 @@ -43,36 +46,32 @@ com.google.cloud.functions functions-framework-api - - - javax.servlet - javax.servlet-api - 3.1.0 + 2.0.0 io.cloudevents cloudevents-core - 2.0.0-milestone4 + ${cloudevents.sdk.version} io.cloudevents cloudevents-http-basic - 2.0.0-milestone4 + ${cloudevents.sdk.version} io.cloudevents cloudevents-json-jackson - 2.0.0-milestone4 + ${cloudevents.sdk.version} com.google.code.gson gson - 2.8.6 + 2.13.2 com.ryanharter.auto.value auto-value-gson - 1.3.0 + 1.3.1 provided @@ -84,80 +83,105 @@ com.google.auto.value auto-value - 1.7 + 1.11.1 provided com.google.auto.value auto-value-annotations - 1.7 + 1.11.1 provided org.eclipse.jetty - jetty-servlet - 9.4.26.v20200117 + jetty-server + ${jetty.version} - org.eclipse.jetty - jetty-server - 9.4.26.v20200117 + org.slf4j + slf4j-jdk14 + 2.0.17 com.beust jcommander - 1.78 + 1.82 com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.0.2-SNAPSHOT + 2.0.2-SNAPSHOT test-jar test org.mockito mockito-core - 3.2.4 + 5.23.0 test junit junit - 4.13.1 + 4.13.2 test + + com.google.re2j + re2j + 1.8 + com.google.truth truth - 1.0.1 + 1.4.5 test com.google.truth.extensions truth-java8-extension - 1.0.1 + 1.4.5 test org.eclipse.jetty jetty-client - 9.4.26.v20200117 + ${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.1.2 + 3.5.0 com.google.cloud.functions.invoker.runner.Invoker + true + true @@ -165,7 +189,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.2.1 + 3.6.2 package 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 index 2725ce84..097b9a67 100644 --- 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 @@ -14,14 +14,14 @@ package com.google.cloud.functions.invoker; -import static java.nio.charset.StandardCharsets.UTF_8; 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.ExperimentalCloudEventsFunction; 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; @@ -30,28 +30,36 @@ 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.Collections; 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 javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +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 HttpServlet { +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; @@ -60,7 +68,7 @@ private BackgroundFunctionExecutor(FunctionExecutor functionExecutor) { private enum FunctionKind { BACKGROUND(BackgroundFunction.class), RAW_BACKGROUND(RawBackgroundFunction.class), - CLOUD_EVENTS(ExperimentalCloudEventsFunction.class); + CLOUD_EVENTS(CloudEventsFunction.class); static final List VALUES = Arrays.asList(values()); @@ -72,18 +80,20 @@ private enum FunctionKind { /** 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(); + 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 ExperimentalCloudEventsFunction}. Otherwise returns {@link Optional#empty()}. + * 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. + * @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); @@ -96,10 +106,9 @@ public static Optional maybeForClass(Class functi /** * 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 ExperimentalCloudEventsFunction}; or we are unable to construct an instance using its no-arg - * constructor. + * @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); @@ -107,13 +116,16 @@ public static BackgroundFunctionExecutor forClass(Class functionClass) { List classNames = FunctionKind.VALUES.stream().map(v -> v.functionClass.getName()).collect(toList()); throw new RuntimeException( - "Class " + functionClass.getName() + " must implement one of these interfaces: " + "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) { + private static BackgroundFunctionExecutor forClass( + Class functionClass, FunctionKind functionKind) { Object instance; try { instance = functionClass.getConstructor().newInstance(); @@ -143,7 +155,7 @@ private static BackgroundFunctionExecutor forClass(Class functionClass, Funct executor = new TypedFunctionExecutor<>(maybeTargetType.get(), backgroundFunction); break; case CLOUD_EVENTS: - executor = new CloudEventFunctionExecutor((ExperimentalCloudEventsFunction) instance); + executor = new CloudEventFunctionExecutor((CloudEventsFunction) instance); break; default: // can't happen, we've listed all the FunctionKind values already. throw new AssertionError(functionKind); @@ -152,9 +164,8 @@ private static BackgroundFunctionExecutor forClass(Class functionClass, Funct } /** - * 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. + * 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) { @@ -163,33 +174,42 @@ static Optional backgroundFunctionTypeArgument( // 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) + .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(HttpServletRequest req) throws IOException { - try (BufferedReader bodyReader = req.getReader()) { + 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(); + 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()); + 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. @@ -214,10 +234,10 @@ private static Context contextFromCloudEvent(CloudEvent cloudEvent) { * *

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. + * for the various triggers. CloudEvents are ones that follow the standards defined by cloudevents.io. * - * @param the type to be used in the {@link Unmarshallers} call when + * @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 { @@ -241,6 +261,7 @@ final ClassLoader functionClassLoader() { } private static class RawFunctionExecutor extends FunctionExecutor> { + private static Gson gson = new GsonBuilder().serializeNulls().create(); private final RawBackgroundFunction function; RawFunctionExecutor(RawBackgroundFunction function) { @@ -250,16 +271,12 @@ private static class RawFunctionExecutor extends FunctionExecutor> { @Override void serviceLegacyEvent(Event legacyEvent) throws Exception { - function.accept(new Gson().toJson(legacyEvent.getData()), legacyEvent.getContext()); + function.accept(gson.toJson(legacyEvent.getData()), legacyEvent.getContext()); } @Override void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { - Context context = contextFromCloudEvent(cloudEvent); - String jsonData = (cloudEvent.getData() == null) - ? "{}" - : new String(cloudEvent.getData().toBytes(), UTF_8); - function.accept(jsonData, context); + serviceLegacyEvent(CloudEvents.convertToLegacyEvent(cloudEvent)); } } @@ -288,10 +305,7 @@ void serviceLegacyEvent(Event legacyEvent) throws Exception { @Override void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { if (cloudEvent.getData() != null) { - String data = new String(cloudEvent.getData().toBytes(), UTF_8); - T payload = new Gson().fromJson(data, type); - Context context = contextFromCloudEvent(cloudEvent); - function.accept(payload, context); + serviceLegacyEvent(CloudEvents.convertToLegacyEvent(cloudEvent)); } else { throw new IllegalStateException("Event has no \"data\" component"); } @@ -299,9 +313,9 @@ void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { } private static class CloudEventFunctionExecutor extends FunctionExecutor { - private final ExperimentalCloudEventsFunction function; + private final CloudEventsFunction function; - CloudEventFunctionExecutor(ExperimentalCloudEventsFunction function) { + CloudEventFunctionExecutor(CloudEventsFunction function) { super(function.getClass()); this.function = function; } @@ -320,39 +334,52 @@ void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { /** Executes the user's background function. This can handle all HTTP methods. */ @Override - public void service(HttpServletRequest req, HttpServletResponse res) throws IOException { - String contentType = req.getContentType(); + 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.getHeader("ce-specversion") != null) { + || req.getHeaders().get("ce-specversion") != null) { serviceCloudEvent(req); } else { serviceLegacyEvent(req); } - res.setStatus(HttpServletResponse.SC_OK); + res.setStatus(HttpStatus.OK_200); + callback.succeeded(); } catch (Throwable t) { - res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - logger.log(Level.WARNING, "Failed to execute " + functionExecutor.functionName(), 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} + private enum CloudEventKind { + BINARY, + STRUCTURED + } /** * Service a CloudEvent. * - * @param a fake type parameter, which corresponds to the type parameter of - * {@link FunctionExecutor}. + * @param a fake type parameter, which corresponds to the type parameter of {@link + * FunctionExecutor}. */ - private void serviceCloudEvent(HttpServletRequest req) throws Exception { + private void serviceCloudEvent(Request req) throws Exception { @SuppressWarnings("unchecked") FunctionExecutor executor = (FunctionExecutor) functionExecutor; - byte[] body = req.getInputStream().readAllBytes(); + + // 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 + // 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))); @@ -360,17 +387,17 @@ private void serviceCloudEvent(HttpServletRequest req) throws Exce // https://github.com/cloudevents/sdk-java/pull/259. } - private static Map> headerMap(HttpServletRequest req) { + private static Map> headerMap(Request req) { Map> headerMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (String header : Collections.list(req.getHeaderNames())) { - for (String value : Collections.list(req.getHeaders(header))) { - headerMap.computeIfAbsent(header, unused -> new ArrayList<>()).add(value); - } + for (HttpField field : req.getHeaders()) { + headerMap + .computeIfAbsent(field.getName(), unused -> new ArrayList<>()) + .addAll(field.getValueList()); } return headerMap; } - private void serviceLegacyEvent(HttpServletRequest req) throws Exception { + private void serviceLegacyEvent(Request req) throws Exception { Event event = parseLegacyEvent(req); runWithContextClassLoader(() -> functionExecutor.serviceLegacyEvent(event)); } 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 index c9fc06d9..65df5411 100644 --- 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 @@ -50,6 +50,9 @@ abstract class CloudFunctionsContext implements Context { // TODO: expose this in the Context interface (as a default method). abstract Map params(); + @Nullable + abstract String domain(); + @Override public abstract Map attributes(); @@ -66,24 +69,33 @@ static Builder builder() { @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. + * 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) { @@ -91,17 +103,12 @@ static TypeAdapter typeAdapter(Gson gson) { } static Resource from(String s) { - Gson baseGson = new Gson(); - if (s.startsWith("\"") && s.endsWith("\"")) { - String name = baseGson.fromJson(s, String.class); - return builder().setName(name).build(); - } if (s.startsWith("{") && (s.endsWith("}") || s.endsWith("}\n"))) { - TypeAdapter typeAdapter = typeAdapter(baseGson); + TypeAdapter typeAdapter = typeAdapter(new Gson()); Gson gson = new GsonBuilder().registerTypeAdapter(Resource.class, typeAdapter).create(); return gson.fromJson(s, Resource.class); } - throw new IllegalArgumentException("Unexpected resource syntax: " + s); + return builder().setName(s).build(); } static Builder builder() { @@ -111,8 +118,11 @@ static Builder 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 index 6f52f20b..642e5118 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -53,6 +55,34 @@ public Event deserialize( 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"); @@ -63,16 +93,26 @@ public Event deserialize( 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) { - String resourceValue = - contextObject.has("resource") ? contextObject.get("resource").toString() : ""; - contextObject.remove("resource"); - contextObject.addProperty("resource", resourceValue); + 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 index b4cea041..d78365dc 100644 --- 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 @@ -21,84 +21,91 @@ 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.Arrays; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * Conversions from GCF events to CloudEvents. - */ +/** 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 FirestoreFirebaseEventAdapter("google.firebase.auth.user.v1.created", FIREBASE_SERVICE)), - entry("providers/firebase.auth/eventTypes/user.delete", - new FirestoreFirebaseEventAdapter("google.firebase.auth.user.v1.deleted", FIREBASE_SERVICE)), - - 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 FirestoreFirebaseEventAdapter("google.firebase.database.document.v1.created", - FIREBASE_SERVICE)), - entry("providers/google.firebase.database/eventTypes/ref.write", - new FirestoreFirebaseEventAdapter("google.firebase.database.document.v1.written", - FIREBASE_SERVICE)), - entry("providers/google.firebase.database/eventTypes/ref.update", - new FirestoreFirebaseEventAdapter("google.firebase.database.document.v1.updated", - FIREBASE_SERVICE)), - entry("providers/google.firebase.database/eventTypes/ref.delete", - new FirestoreFirebaseEventAdapter("google.firebase.database.document.v1.deleted", - FIREBASE_SERVICE)), - - 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 Gson(); + 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(); @@ -113,6 +120,7 @@ static CloudEvent convertToCloudEvent(Event legacyEvent) { 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) { @@ -135,7 +143,8 @@ final CloudEvent convertToCloudEvent(Event legacyEvent) { Resource resource = Resource.from(legacyEvent.getContext().resource()); String service = Optional.ofNullable(resource.service()).orElse(defaultService); String resourceName = resource.name(); - SourceAndSubject sourceAndSubject = convertResourceToSourceAndSubject(resourceName); + SourceAndSubject sourceAndSubject = + convertResourceToSourceAndSubject(resourceName, legacyEvent); URI source = URI.create("//" + service + "/" + sourceAndSubject.source()); OffsetDateTime timestamp = Optional.ofNullable(legacyEvent.getContext().timestamp()) @@ -156,7 +165,7 @@ String maybeReshapeData(Event legacyEvent, String jsonData) { return jsonData; } - SourceAndSubject convertResourceToSourceAndSubject(String resourceName) { + SourceAndSubject convertResourceToSourceAndSubject(String resourceName, Event legacyEvent) { return SourceAndSubject.of(resourceName, null); } } @@ -169,6 +178,8 @@ private static class PubSubEventAdapter extends EventAdapter { @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); @@ -184,37 +195,40 @@ private static class StorageEventAdapter extends EventAdapter { } @Override - SourceAndSubject convertResourceToSourceAndSubject(String resourceName) { + 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); + 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) { - List resourceSegments = Arrays.asList(resourceName.split("/")); - int documentsIndex = resourceSegments.indexOf("documents"); - if (documentsIndex < 0) { - return super.convertResourceToSourceAndSubject(resourceName); + 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); } - String sourcePath = String.join("/", resourceSegments.subList(0, documentsIndex)); - String subject = String.join("/", resourceSegments.subList(documentsIndex, resourceSegments.size())); - return SourceAndSubject.of(sourcePath, 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" + // 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; @@ -226,4 +240,76 @@ String maybeReshapeData(Event legacyEvent, String jsonData) { 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 index d2a46afd..b414f110 100644 --- 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 @@ -15,20 +15,23 @@ 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.io.IOException; import java.util.logging.Level; import java.util.logging.Logger; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +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 HttpServlet { +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; @@ -37,55 +40,50 @@ private HttpFunctionExecutor(HttpFunction 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. + * @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 " + "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 void service(HttpServletRequest req, HttpServletResponse res) { - HttpRequestImpl reqImpl = new HttpRequestImpl(req); - HttpResponseImpl respImpl = new HttpResponseImpl(res); + 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) { - // TODO(b/146510646): this should be logged properly as an exception, but that currently - // causes integration tests to fail. - // logger.log(Level.WARNING, "Failed to execute " + function.getClass().getName(), t); - logger.log(Level.WARNING, "Failed to execute {0}", function.getClass().getName()); - t.printStackTrace(); - res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + 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); - try { - // We can't use HttpServletResponse.flushBuffer() because we wrap the PrintWriter - // returned by HttpServletResponse in our own BufferedWriter to match our API. - // So we have to flush whichever of getWriter() or getOutputStream() works. - try { - respImpl.getOutputStream().flush(); - } catch (IllegalStateException e) { - respImpl.getWriter().flush(); - } - } catch (IOException e) { - // Too bad, can't flush. - } + 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 index 181354c1..51aad21a 100644 --- 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 @@ -5,17 +5,19 @@ 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. + * 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"; @@ -25,6 +27,14 @@ public final class JsonLogHandler extends Handler { 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; @@ -39,6 +49,7 @@ public void publish(LogRecord record) { 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) @@ -50,8 +61,7 @@ private static void appendMessage(StringBuilder json, LogRecord record) { // 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("\\n").append(escapeString(getStackTraceAsString(record.getThrown()))); } json.append("\""); } @@ -98,6 +108,14 @@ private static void appendSourceLocation(StringBuilder json, LogRecord record) { 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"); } @@ -119,4 +137,16 @@ public void close() throws SecurityException { 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 index db53936e..31ee4ac6 100644 --- 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 @@ -14,32 +14,33 @@ package com.google.cloud.functions.invoker.http; -import static java.util.stream.Collectors.toMap; - import com.google.cloud.functions.HttpRequest; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.UncheckedIOException; -import java.util.AbstractMap.SimpleEntry; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; +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 java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.Part; +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 HttpServletRequest request; + private final Request request; + private InputStream inputStream; + private BufferedReader reader; - public HttpRequestImpl(HttpServletRequest request) { + public HttpRequestImpl(Request request) { this.request = request; } @@ -50,129 +51,155 @@ public String getMethod() { @Override public String getUri() { - String url = request.getRequestURL().toString(); - if (request.getQueryString() != null) { - url += "?" + request.getQueryString(); - } - return url; + return request.getHttpURI().asString(); } @Override public String getPath() { - return request.getRequestURI(); + return request.getHttpURI().getCanonicalPath(); } @Override public Optional getQuery() { - return Optional.ofNullable(request.getQueryString()); + return Optional.ofNullable(request.getHttpURI().getQuery()); } @Override public Map> getQueryParameters() { - return request.getParameterMap().entrySet().stream() - .collect(toMap(Map.Entry::getKey, e -> Arrays.asList(e.getValue()))); + 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.getContentType(); - if (contentType == null || !request.getContentType().startsWith("multipart/form-data")) { + 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); } - try { - return request.getParts().stream().collect(toMap(Part::getName, HttpPartImpl::new)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (ServletException e) { - throw new RuntimeException(e.getMessage(), e); + + // 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.getContentType()); + return Optional.ofNullable(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); } @Override public long getContentLength() { - return request.getContentLength(); + return request.getLength(); } @Override public Optional getCharacterEncoding() { - return Optional.ofNullable(request.getCharacterEncoding()); + Charset charset = Request.getCharset(request); + return Optional.ofNullable(charset == null ? null : charset.name()); } @Override public InputStream getInputStream() throws IOException { - return request.getInputStream(); + 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 { - return request.getReader(); + 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 Collections.list(request.getHeaderNames()).stream() - .map(name -> new SimpleEntry<>(name, Collections.list(request.getHeaders(name)))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + 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.getSubmittedFileName()); + return Optional.ofNullable(part.getFileName()); } @Override public Optional getContentType() { - return Optional.ofNullable(part.getContentType()); + return Optional.ofNullable(contentType); } @Override public long getContentLength() { - return part.getSize(); + return part.getLength(); } @Override public Optional getCharacterEncoding() { - String contentType = getContentType().orElse(null); - if (contentType == null) { - return Optional.empty(); - } - Pattern charsetPattern = Pattern.compile("(?i).*;\\s*charset\\s*=([^;\\s]*)\\s*(;|$)"); - Matcher matcher = charsetPattern.matcher(contentType); - return matcher.matches() ? Optional.of(matcher.group(1)) : Optional.empty(); + return Optional.ofNullable(MimeTypes.getCharsetFromContentType(contentType)); } @Override public InputStream getInputStream() throws IOException { - return part.getInputStream(); + Content.Source contentSource = part.createContentSource(); + return Content.Source.asInputStream(contentSource); } @Override public BufferedReader getReader() throws IOException { - String encoding = getCharacterEncoding().orElse("utf-8"); - return new BufferedReader(new InputStreamReader(getInputStream(), encoding)); + return new BufferedReader( + new InputStreamReader( + getInputStream(), + Objects.requireNonNullElse( + MimeTypes.DEFAULTS.getCharset(contentType), StandardCharsets.UTF_8))); } @Override public Map> getHeaders() { - return part.getHeaderNames().stream() - .map(name -> new SimpleEntry<>(name, list(part.getHeaders(name)))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + return HttpUtil.toStringListMap(part.getHeaders()); } - private static List list(Collection collection) { - return (collection instanceof List) ? (List) collection : new ArrayList<>(collection); + @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 index 41a96e36..5773de4b 100644 --- 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 @@ -14,24 +14,33 @@ package com.google.cloud.functions.invoker.http; -import static java.util.stream.Collectors.toMap; - import com.google.cloud.functions.HttpResponse; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStream; -import java.util.AbstractMap.SimpleEntry; -import java.util.ArrayList; -import java.util.Collection; +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 javax.servlet.http.HttpServletResponse; +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 HttpServletResponse response; + private final Response response; + private ContentSinkOutputStream outputStream; + private BufferedWriter writer; + private Charset charset; - public HttpResponseImpl(HttpServletResponse response) { + public HttpResponseImpl(Response response) { this.response = response; } @@ -43,56 +52,173 @@ public void setStatusCode(int code) { @Override @SuppressWarnings("deprecation") public void setStatusCode(int code, String message) { - response.setStatus(code, message); + response.setStatus(code); } @Override public void setContentType(String contentType) { - response.setContentType(contentType); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, contentType); + charset = response.getRequest().getContext().getMimeTypes().getCharset(contentType); } @Override public Optional getContentType() { - return Optional.ofNullable(response.getContentType()); + return Optional.ofNullable(response.getHeaders().get(HttpHeader.CONTENT_TYPE)); } @Override public void appendHeader(String key, String value) { - response.addHeader(key, value); + if (HttpHeader.CONTENT_TYPE.is(key)) { + setContentType(value); + } else { + response.getHeaders().add(key, value); + } } @Override public Map> getHeaders() { - return response.getHeaderNames().stream() - .map(header -> new SimpleEntry<>(header, list(response.getHeaders(header)))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - private static List list(Collection collection) { - return (collection instanceof List) ? (List) collection : new ArrayList<>(collection); + return HttpUtil.toStringListMap(response.getHeaders()); } @Override - public OutputStream getOutputStream() throws IOException { - return response.getOutputStream(); - } + 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); - private BufferedWriter writer; + // 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) { - // Unfortunately this means that we get two intermediate objects between the object we return - // and the underlying Writer that response.getWriter() wraps. We could try accessing the - // PrintWriter.out field via reflection, but that sort of access to non-public fields of - // platform classes is now frowned on and may draw warnings or even fail in subsequent - // versions. - // We could instead wrap the OutputStream, but that would require us to deduce the appropriate - // Charset, using logic like this: - // https://github.com/eclipse/jetty.project/blob/923ec38adf/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java#L731 - // We may end up doing that if performance is an issue. - writer = new BufferedWriter(response.getWriter()); + 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 index 588d6aec..20c3f248 100644 --- 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 @@ -19,13 +19,13 @@ import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; import com.beust.jcommander.ParameterException; -import com.google.cloud.functions.BackgroundFunction; -import com.google.cloud.functions.ExperimentalCloudEventsFunction; import com.google.cloud.functions.HttpFunction; -import com.google.cloud.functions.RawBackgroundFunction; +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; @@ -45,21 +45,20 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; -import javax.servlet.MultipartConfigElement; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; 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.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +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 @@ -81,10 +80,11 @@ public class Invoker { 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 (Handler handler : rootLogger.getHandlers()) { + // 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)); @@ -92,35 +92,29 @@ public class Invoker { } private static class Options { - @Parameter( - description = "Port on which to listen for HTTP requests.", - names = "--port" - ) + @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" - ) + 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" - ) + 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 - ) + @Parameter(names = "--help", help = true) private boolean help = false; } @@ -137,9 +131,7 @@ static Optional makeInvoker(String... args) { static Optional makeInvoker(Map environment, String... args) { Options options = new Options(); - JCommander jCommander = JCommander.newBuilder() - .addObject(options) - .build(); + JCommander jCommander = JCommander.newBuilder().addObject(options).build(); try { jCommander.parse(args); } catch (ParameterException e) { @@ -164,28 +156,27 @@ static Optional makeInvoker(Map environment, String... Path standardFunctionJarPath = Paths.get("function/function.jar"); Optional functionClasspath = Arrays.asList( - options.classPath, - environment.get("FUNCTION_CLASSPATH"), - Files.exists(standardFunctionJarPath) ? standardFunctionJarPath.toString() : null) + 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); + 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); + String usage = + usageBuilder + .toString() + .replace("${file.separator}", File.separator) + .replace("${path.separator}", File.pathSeparator); jCommander.getConsole().println(usage); } @@ -210,6 +201,8 @@ private static class FunctionClassLoader extends URLClassLoader { private final String functionSignatureType; private final ClassLoader functionClassLoader; + private Server server; + public Invoker( Integer port, String functionTarget, @@ -237,35 +230,141 @@ 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 { - Server server = new Server(port); + 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); + } - ServletContextHandler servletContextHandler = new ServletContextHandler(); - servletContextHandler.setContextPath("/"); - server.setHandler(NotFoundHandler.forServlet(servletContextHandler)); + 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(); - HttpServlet servlet; - if ("http".equals(functionSignatureType)) { - servlet = HttpFunctionExecutor.forClass(functionClass); - } else if ("event".equals(functionSignatureType)) { - servlet = BackgroundFunctionExecutor.forClass(functionClass); - } else if (functionSignatureType == null) { - servlet = servletForDeducedSignatureType(functionClass); + Handler handler; + if (functionSignatureType == null) { + handler = handlerForDeducedSignatureType(functionClass); } else { - String error = String.format( - "Function signature type %s is unknown; should be \"http\" or \"event\"", - functionSignatureType); - throw new RuntimeException(error); + 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); + } } - ServletHolder servletHolder = new ServletHolder(servlet); - servletHolder.getRegistration().setMultipartConfig(new MultipartConfigElement("")); - servletContextHandler.addServlet(servletHolder, "/*"); + + // 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(); - server.join(); + 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 { @@ -278,9 +377,9 @@ private Class loadFunctionClass() throws ClassNotFoundException { 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. + // 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; @@ -290,24 +389,37 @@ private Class loadFunctionClass() throws ClassNotFoundException { } } - private HttpServlet servletForDeducedSignatureType(Class functionClass) { + 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); + 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<>(); @@ -339,13 +451,14 @@ private static List jarsIn(String dir) { } return stream .filter(p -> p.getFileName().toString().endsWith(".jar")) - .map(p -> { - try { - return p.toUri().toURL(); - } catch (MalformedURLException e) { - throw new UncheckedIOException(e); - } - }) + .map( + p -> { + try { + return p.toUri().toURL(); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + }) .collect(toList()); } @@ -358,49 +471,46 @@ private void logServerInfo() { } 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. + // 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). + * 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 HandlerWrapper { - static NotFoundHandler forServlet(ServletContextHandler servletHandler) { - NotFoundHandler handler = new NotFoundHandler(); - handler.setHandler(servletHandler); - return handler; - } + private static class NotFoundHandler extends Handler.Wrapper { private static final Set NOT_FOUND_PATHS = new HashSet<>(Arrays.asList("/favicon.ico", "/robots.txt")); @Override - public void handle(String target, Request baseRequest, HttpServletRequest request, - HttpServletResponse response) throws IOException, ServletException { - if (NOT_FOUND_PATHS.contains(request.getRequestURI())) { - response.sendError(HttpStatus.NOT_FOUND_404, "Not Found"); + 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; } - super.handle(target, baseRequest, request, response); + + 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}. + * 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. + *

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. + * 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; @@ -414,8 +524,7 @@ private static class OnlyApiClassLoader extends ClassLoader { protected Class findClass(String name) throws ClassNotFoundException { String prefix = "com.google.cloud.functions."; if ((name.startsWith(prefix) && Character.isUpperCase(name.charAt(prefix.length()))) - || name.startsWith("javax.servlet.") - || isCloudEventsApiClass(name)) { + || isCloudEventsApiClass(name)) { return runtimeClassLoader.loadClass(name); } return super.findClass(name); // should throw ClassNotFoundException @@ -431,7 +540,8 @@ private static boolean isCloudEventsApiClass(String name) { private static ClassLoader getSystemOrBootstrapClassLoader() { try { - // We're still building against the Java 8 API, so we have to use reflection for now. + // 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) { 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 index 02c5d630..87b9bd31 100644 --- 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 @@ -1,10 +1,14 @@ package com.google.cloud.functions.invoker; import static com.google.cloud.functions.invoker.BackgroundFunctionExecutor.backgroundFunctionTypeArgument; -import static com.google.common.truth.Truth8.assertThat; +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; @@ -20,7 +24,8 @@ private static class PubSubMessage { } private static class PubSubFunction implements BackgroundFunction { - @Override public void accept(PubSubMessage payload, Context context) {} + @Override + public void accept(PubSubMessage payload, Context context) {} } @Test @@ -31,7 +36,8 @@ public void backgroundFunctionTypeArgument_simple() { private abstract static class Parent implements BackgroundFunction {} private static class Child extends Parent { - @Override public void accept(PubSubMessage payload, Context context) {} + @Override + public void accept(PubSubMessage payload, Context context) {} } @Test @@ -42,7 +48,8 @@ public void backgroundFunctionTypeArgument_superclass() { private interface GenericParent extends BackgroundFunction {} private static class GenericChild implements GenericParent { - @Override public void accept(PubSubMessage payload, Context context) {} + @Override + public void accept(PubSubMessage payload, Context context) {} } @Test @@ -52,7 +59,8 @@ public void backgroundFunctionTypeArgument_genericInterface() { @SuppressWarnings("rawtypes") private static class ForgotTypeParameter implements BackgroundFunction { - @Override public void accept(Object payload, Context context) {} + @Override + public void accept(Object payload, Context context) {} } @Test @@ -62,4 +70,40 @@ public void backgroundFunctionTypeArgument_raw() { (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 index bf320c39..24939fff 100644 --- 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 @@ -29,6 +29,7 @@ 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; @@ -39,23 +40,60 @@ 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", + { + "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.document.v1.written", - "//firebase.googleapis.com/projects/_/instances/my-project-id/refs/gcf-test/xyz", null}, - {"firebase-auth1.json", "google.firebase.auth.user.v1.created", - "//firebase.googleapis.com/projects/my-project-id", null}, - {"firebase-auth2.json", "google.firebase.auth.user.v1.deleted", - "//firebase.googleapis.com/projects/my-project-id", null}, + "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 @@ -66,7 +104,8 @@ public void convertGcfEvent() throws IOException { } } - private void convertGcfEvent(Event legacyEvent, String expectedType, String expectedSource, String expectedSubject) { + 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); @@ -90,11 +129,15 @@ public void checkAllProperties() throws IOException { 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, + // 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 + // 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 @@ -102,12 +145,13 @@ 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"); + assertThat(data) + .containsAtLeast( + "bucket", "some-bucket", + "timeCreated", "2020-04-23T07:38:57.230Z", + "generation", "1587627537231057", + "metageneration", "1", + "size", "352"); } @Test @@ -115,26 +159,33 @@ 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"))); + 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 @@ -144,26 +195,42 @@ public void firestoreComplexData() throws IOException { 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", Map.of(), - "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"))))))) - ); + 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); } @@ -208,14 +275,19 @@ 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\"}}"); + .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 + // 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. + // This test is currently ignored because the final representation of the "params" field is in + // flux. @Test @Ignore public void firestoreWildcards() throws IOException { 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 index 9ab37f38..d6e3b14a 100644 --- 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 @@ -28,6 +28,8 @@ 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; @@ -39,11 +41,13 @@ 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; @@ -51,6 +55,7 @@ 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; @@ -62,16 +67,17 @@ 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.api.ContentProvider; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.client.util.MultiPartContentProvider; -import org.eclipse.jetty.client.util.StringContentProvider; +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; @@ -87,7 +93,9 @@ public class IntegrationTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); @Rule public final TestName testName = new TestName(); - private static final String SERVER_READY_STRING = "Started ServerConnector"; + 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(); @@ -96,16 +104,18 @@ private static String sampleLegacyEvent(File snoopFile) { + " \"data\": {\n" + " \"a\": 2,\n" + " \"b\": 3,\n" - + " \"targetFile\": \"" + snoopFile + "\"" + + " \"targetFile\": \"" + + snoopFile + + "\"" + " },\n" + " \"context\": {\n" + " \"eventId\": \"B234-1234-1234\",\n" + " \"timestamp\": \"2018-04-05T17:31:00Z\",\n" - + " \"eventType\": \"com.example.someevent.new\",\n" + + " \"eventType\": \"google.pubsub.topic.publish\",\n" + " \"resource\": {\n" - + " \"service\":\"test-service\",\n" - + " \"name\":\"test-name\",\n" - + " \"type\":\"test-type\"\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" + "}"; @@ -114,8 +124,9 @@ private static String sampleLegacyEvent(File snoopFile) { private static CloudEvent sampleCloudEvent(File snoopFile) { return CloudEventBuilder.v1() .withId("B234-1234-1234") - .withSource(URI.create("/source")) - .withType("com.example.someevent.new") + .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)) @@ -158,10 +169,18 @@ abstract static class TestCase { abstract String url(); - abstract ContentProvider requestContent(); + 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(); @@ -181,6 +200,7 @@ static Builder builder() { .setUrl("/") .setRequestText("") .setExpectedResponseCode(HttpStatus.OK_200) + .setExpectedResponseHeaders(ImmutableMap.of()) .setExpectedResponseText("") .setHttpContentType("text/plain") .setHttpHeaders(ImmutableMap.of()); @@ -191,14 +211,16 @@ abstract static class Builder { abstract Builder setUrl(String x); - abstract Builder setRequestContent(ContentProvider x); + abstract Builder setRequestContent(Request.Content x); Builder setRequestText(String text) { - return setRequestContent(new StringContentProvider(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); @@ -241,13 +263,126 @@ private static String fullTarget(String nameWithoutPackage) { @Test public void helloWorld() throws Exception { - testHttpFunction(fullTarget("HelloWorld"), + testHttpFunction( + fullTarget("HelloWorld"), ImmutableList.of( - TestCase.builder().setExpectedResponseText("hello\n").build(), + 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"; @@ -270,9 +405,10 @@ public void echo() throws Exception { @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()); + List testCases = + Arrays.stream(testUrls) + .map(url -> TestCase.builder().setUrl(url).setExpectedResponseText(url + "\n").build()) + .collect(toList()); testHttpFunction(fullTarget("EchoUrl"), testCases); } @@ -283,16 +419,21 @@ public void stackDriverLogging() throws Exception { + "\"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 = @@ -300,54 +441,124 @@ public void stackDriverLogging() throws Exception { + "\"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 { - backgroundTest("BackgroundSnoop"); + // TODO: Only enable background tests for < 17 + if (getJavaVersion() < 17) { + backgroundTest("BackgroundSnoop"); + } } @Test public void typedBackground() throws Exception { - backgroundTest("TypedBackgroundSnoop"); + // 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); - // We don't currently put anything in the attributes() map for legacy events. - expectedJson.add("attributes", new JsonObject()); - TestCase gcfTestCase = TestCase.builder() - .setRequestText(gcfRequestText) - .setSnoopFile(snoopFile) - .setExpectedJson(expectedJson) - .build(); + 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); + 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); - cloudEventExpectedJson.getAsJsonObject("context").add("resource", new JsonObject()); - cloudEventExpectedJson.add("attributes", expectedCloudEventAttributes()); - TestCase cloudEventsStructuredTestCase = TestCase.builder() - .setSnoopFile(snoopFile) - .setRequestText(cloudEventRequestText) - .setHttpContentType("application/cloudevents+json; charset=utf-8") - .setExpectedJson(cloudEventExpectedJson) - .build(); + 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. @@ -355,36 +566,42 @@ private void backgroundTest(String target) throws Exception { 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(); + 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). */ + /** + * 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); + 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(); + 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. @@ -392,19 +609,58 @@ public void nativeCloudEvent() throws Exception { 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(); + 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"; @@ -416,25 +672,32 @@ public void nested() throws Exception { @Test public void packageless() throws Exception { - testHttpFunction("PackagelessHelloWorld", + testHttpFunction( + "PackagelessHelloWorld", ImmutableList.of(TestCase.builder().setExpectedResponseText("hello, world\n").build())); } @Test public void multipart() throws Exception { - MultiPartContentProvider multiPartProvider = new MultiPartContentProvider(); + MultiPartRequestContent multiPartRequestContent = new MultiPartRequestContent(); byte[] bytes = new byte[17]; - multiPartProvider.addFieldPart("bytes", new BytesContentProvider(bytes), new HttpFields()); - String string = "1234567890"; - multiPartProvider.addFieldPart("string", new StringContentProvider(string), new HttpFields()); - String expectedResponse = "part bytes type application/octet-stream length 17\n" - + "part string type text/plain;charset=UTF-8 length 10\n"; + 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(Optional.empty()) - .setRequestContent(multiPartProvider) + .setHttpContentType(multiPartRequestContent.getContentType()) + .setRequestContent(multiPartRequestContent) .setExpectedResponseText(expectedResponse) .build())); } @@ -450,13 +713,15 @@ 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()); + 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); + .that(functionJars) + .hasSize(1); return Iterables.getOnlyElement(functionJars).toString(); } @@ -468,13 +733,17 @@ private String functionJarString() throws IOException { */ @Test public void classpathOptionHttp() throws Exception { - TestCase testCase = TestCase.builder() - .setUrl("/?class=" + INTERNAL_CLASS.getName()) - .setExpectedResponseText("OK") - .build(); - testHttpFunction("com.example.functionjar.Foreground", + 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)); + ImmutableList.of(testCase), + Collections.emptyMap()); } /** Like {@link #classpathOptionHttp} but for background functions. */ @@ -487,9 +756,30 @@ public void classpathOptionBackground() throws Exception { JsonObject json = gson.fromJson(originalJson, JsonObject.class); JsonObject jsonData = json.getAsJsonObject("data"); jsonData.addProperty("class", INTERNAL_CLASS.getName()); - testBackgroundFunction("com.example.functionjar.Background", + 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(json.toString()).build())); + 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 @@ -497,18 +787,28 @@ public void classpathOptionBackground() throws Exception { // 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(String functionTarget, List testCases) throws Exception { + private void backgroundTest( + SignatureType signatureType, String functionTarget, List testCases) + throws Exception { for (TestCase testCase : testCases) { File snoopFile = testCase.snoopFile().get(); snoopFile.delete(); - testBackgroundFunction(functionTarget, ImmutableList.of(testCase)); + 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); + expect + .withMessage( + "Testing %s with %s\nGOT %s\nNOT %s", + functionTarget, testCase, snoopedJson, expectedJson) + .that(snoopedJson) + .isEqualTo(expectedJson); } } @@ -522,49 +822,64 @@ private void checkSnoopFile(TestCase testCase) throws IOException { } private void testHttpFunction(String target, List testCases) throws Exception { - testHttpFunction(target, ImmutableList.of(), testCases); - } - - private void testHttpFunction( - String target, ImmutableList extraArgs, List testCases) throws Exception { - testFunction(SignatureType.HTTP, target, extraArgs, testCases); - } - - private void testBackgroundFunction(String target, List testCases) - throws Exception { - testBackgroundFunction(target, ImmutableList.of(), testCases); - } - - private void testBackgroundFunction( - String target, ImmutableList extraArgs, List testCases) - throws Exception { - testFunction(SignatureType.BACKGROUND, target, extraArgs, testCases); + testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases, Collections.emptyMap()); } private void testFunction( SignatureType signatureType, String target, ImmutableList extraArgs, - List testCases) throws Exception { - ServerProcess serverProcess = startServer(signatureType, target, extraArgs); + List testCases, + Map environmentVariables) + throws Exception { + ServerProcess serverProcess = + startServer(signatureType, target, extraArgs, environmentVariables); + HttpClient httpClient = new HttpClient(); try { - HttpClient httpClient = new HttpClient(); httpClient.start(); for (TestCase testCase : testCases) { testCase.snoopFile().ifPresent(File::delete); String uri = "http://localhost:" + serverPort + testCase.url(); Request request = httpClient.POST(uri); - testCase.httpContentType().ifPresent( - contentType -> request.header(HttpHeader.CONTENT_TYPE, contentType)); - testCase.httpHeaders().forEach((header, value) -> request.header(header, value)); - request.content(testCase.requestContent()); + + 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.expectedResponseText() + .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() + testCase + .expectedContentType() .ifPresent(type -> expect.that(response.getMediaType()).isEqualTo(type)); if (testCase.snoopFile().isPresent()) { checkSnoopFile(testCase); @@ -572,10 +887,16 @@ private void testFunction( } } finally { serverProcess.close(); + httpClient.stop(); } for (TestCase testCase : testCases) { - testCase.expectedOutput() - .ifPresent(output -> expect.that(serverProcess.output()).contains(output)); + 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. @@ -584,7 +905,9 @@ private void testFunction( private enum SignatureType { HTTP("http"), - BACKGROUND("event"); + BACKGROUND("event"), + CLOUD_EVENT("cloudevent"), + TYPED("typed"); private final String name; @@ -635,7 +958,10 @@ public void close() { } private ServerProcess startServer( - SignatureType signatureType, String target, ImmutableList extraArgs) + SignatureType signatureType, + String target, + ImmutableList extraArgs, + Map environmentVariables) throws IOException, InterruptedException { File javaHome = new File(System.getProperty("java.home")); assertThat(javaHome.exists()).isTrue(); @@ -644,23 +970,31 @@ private ServerProcess startServer( 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); + 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)); + Future outputMonitorResult = + EXECUTOR.submit(() -> monitorOutput(serverProcess.getInputStream(), ready, output)); boolean serverReady = ready.await(5, TimeUnit.SECONDS); if (!serverReady) { serverProcess.destroy(); @@ -690,4 +1024,12 @@ private void monitorOutput( 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 index 12d98090..a9794cd2 100644 --- 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 @@ -15,7 +15,6 @@ package com.google.cloud.functions.invoker.http; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth8.assertThat; import static org.junit.Assert.fail; import com.google.cloud.functions.HttpRequest; @@ -28,6 +27,7 @@ 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; @@ -35,22 +35,23 @@ import java.util.TreeMap; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import javax.servlet.MultipartConfigElement; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.client.ByteBufferRequestContent; +import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.client.util.MultiPartContentProvider; -import org.eclipse.jetty.client.util.StringContentProvider; +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.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.server.handler.EagerContentHandler; +import org.eclipse.jetty.util.Callback; import org.junit.BeforeClass; import org.junit.Test; @@ -63,6 +64,7 @@ public class HttpTest { + "To marry two wives at a time.\n"; private static final byte[] RANDOM_BYTES = new byte[1024]; + static { new Random().nextBytes(RANDOM_BYTES); } @@ -81,21 +83,22 @@ public static void allocateServerPort() throws IOException { } /** - * Wrapper class that allows us to start a Jetty server with a single servlet for {@code /*} - * within a try-with-resources statement. The servlet will be configured to support multipart + * 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(HttpServlet servlet) throws Exception { + SimpleServer(Handler handler) throws Exception { this.server = new Server(serverPort); - ServletContextHandler context = new ServletContextHandler(); - context.setContextPath("/"); - server.setHandler(context); - ServletHolder servletHolder = new ServletHolder(servlet); - servletHolder.getRegistration().setMultipartConfig(new MultipartConfigElement("tiddly")); - context.addServlet(servletHolder, "/*"); + 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(); } @@ -111,17 +114,17 @@ private interface HttpRequestTest { } /** - * Tests methods on the {@link HttpRequest} object while the request is being serviced. We are - * not guaranteed that the underlying {@link HttpServletRequest} 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 servlet, and report any exception back to the test method. + * 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<>(); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpRequestMethods(testReference, exceptionReference); } } @@ -133,66 +136,76 @@ private void httpRequestMethods( 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 -> { - try { - request.getParts(); - fail("Did not get expected exception"); - } catch (IllegalStateException expected) { - } + 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); - Request request = httpClient.POST(uri) - .header(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8") - .header("foo", "bar") - .header("foo", "baz") - .content(new StringContentProvider(TEST_BODY)); + 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()); @@ -204,18 +217,19 @@ 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); - }; + 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); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + 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()); @@ -231,64 +245,67 @@ private void validateReader(BufferedReader reader) { public void multiPartRequest() throws Exception { AtomicReference testReference = new AtomicReference<>(); AtomicReference exceptionReference = new AtomicReference<>(); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); HttpClient httpClient = new HttpClient(); httpClient.start(); String uri = "http://localhost:" + serverPort + "/"; - MultiPartContentProvider multiPart = new MultiPartContentProvider(); - HttpFields textHttpFields = new HttpFields(); - textHttpFields.add("foo", "bar"); - multiPart.addFieldPart("text", new StringContentProvider(TEST_BODY), textHttpFields); - HttpFields bytesHttpFields = new HttpFields(); - bytesHttpFields.add("foo", "baz"); - bytesHttpFields.add("foo", "buh"); + 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.addFilePart( - "binary", "/tmp/binary.x", new BytesContentProvider(RANDOM_BYTES), bytesHttpFields); - 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"); - // We only see ["buh"] here, not ["baz", "buh"], apparently due to a Jetty bug. - // Repeated headers on multi-part content are not a big problem anyway. - List foos = bytesPart.getHeaders().get("foo"); - assertThat(foos).contains("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(testServlet)) { + 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); - Request request = httpClient.POST(uri) - .header("foo", "oof") - .content(multiPart); + 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 HttpRequestServlet extends HttpServlet { + private static class HttpRequestHandler extends Handler.Abstract { private final AtomicReference testReference; private final AtomicReference exceptionReference; - private HttpRequestServlet( + private HttpRequestHandler( AtomicReference testReference, AtomicReference exceptionReference) { this.testReference = testReference; @@ -296,12 +313,15 @@ private HttpRequestServlet( } @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + public boolean handle(Request request, Response response, Callback callback) { try { - testReference.get().test(new HttpRequestImpl(req)); + testReference.get().test(new HttpRequestImpl(request)); } catch (Throwable t) { exceptionReference.set(t); + Response.writeError(request, response, callback, t); } + callback.succeeded(); + return true; } } @@ -311,65 +331,64 @@ private interface HttpResponseTest { } /** - * 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()}. + * 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<>(); - HttpResponseServlet testServlet = new HttpResponseServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpResponseSetAndGet(testReference, exceptionReference); } } private void httpResponseSetAndGet( AtomicReference testReference, - AtomicReference exceptionReference) throws Exception { + 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 -> { - Map> initialHeaders = response.getHeaders(); - // The servlet spec says this should be empty, but actually we get a Date header here. - // So we just check that we can add our own headers. - response.appendHeader("foo", "bar"); - response.appendHeader("wibbly", "wobbly"); - response.appendHeader("foo", "baz"); - Map> updatedHeaders = new TreeMap<>(response.getHeaders()); - updatedHeaders.keySet().removeAll(initialHeaders.keySet()); - assertThat(updatedHeaders).containsExactly( - "foo", Arrays.asList("bar", "baz"), "wibbly", Arrays.asList("wobbly")); - }, + 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; - Request request = httpClient.POST(uri); + 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 HttpResponseServlet extends HttpServlet { + private static class HttpResponseHandler extends Handler.Abstract { private final AtomicReference testReference; private final AtomicReference exceptionReference; - private HttpResponseServlet( + private HttpResponseHandler( AtomicReference testReference, AtomicReference exceptionReference) { this.testReference = testReference; @@ -377,12 +396,15 @@ private HttpResponseServlet( } @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + public boolean handle(Request request, Response response, Callback callback) { try { - testReference.get().test(new HttpResponseImpl(resp)); + testReference.get().test(new HttpResponseImpl(response)); + callback.succeeded(); } catch (Throwable t) { exceptionReference.set(t); + Response.writeError(request, response, callback, t); } + return true; } } @@ -395,9 +417,7 @@ private static class ResponseTest { final HttpResponseTest responseOperation; final ResponseCheck responseCheck; - private ResponseTest( - HttpResponseTest responseOperation, - ResponseCheck responseCheck) { + private ResponseTest(HttpResponseTest responseOperation, ResponseCheck responseCheck) { this.responseOperation = responseOperation; this.responseCheck = responseCheck; } @@ -411,80 +431,82 @@ private static ResponseTest responseTest( /** * 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 servlet, and the operation to check the + * 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<>(); - HttpResponseServlet testServlet = new HttpResponseServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpResponseEffects(testReference, exceptionReference); } } private void httpResponseEffects( AtomicReference testReference, - AtomicReference exceptionReference) throws Exception { + 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); - assertThat(response.getReason()).isEqualTo("Je suis une théière"); - }), - 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); - }), + 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; - Request request = httpClient.POST(uri); + org.eclipse.jetty.client.Request request = httpClient.POST(uri); ContentResponse response = request.send(); throwIfNotNull(exceptionReference.get()); test.responseCheck.test(response); 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 index 6c90963d..c1a7ca29 100644 --- 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 @@ -2,7 +2,6 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.common.truth.Truth8.assertThat; import static java.util.stream.Collectors.joining; import java.io.ByteArrayOutputStream; @@ -16,7 +15,6 @@ import java.util.Collections; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -25,10 +23,12 @@ public class InvokerTest { @Test public void help() throws IOException { - String help = captureOutput(() -> { - Optional invoker = Invoker.makeInvoker("--help"); - assertThat(invoker).isEmpty(); - }); + 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 + "'"); 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 index 825de1f0..0a6dba42 100644 --- 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 @@ -34,7 +34,6 @@ public void accept(String json, Context context) { JsonObject contextAndPayloadJson = new JsonObject(); contextAndPayloadJson.add("data", jsonObject); contextAndPayloadJson.add("context", contextJson); - contextAndPayloadJson.add("attributes", gson.toJsonTree(context.attributes())); try (FileWriter fileWriter = new FileWriter(targetFile); PrintWriter writer = new PrintWriter(fileWriter)) { writer.println(contextAndPayloadJson); 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 index 87835bd2..439e712d 100644 --- 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 @@ -2,7 +2,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; -import com.google.cloud.functions.ExperimentalCloudEventsFunction; +import com.google.cloud.functions.CloudEventsFunction; import com.google.gson.Gson; import com.google.gson.JsonObject; import io.cloudevents.CloudEvent; @@ -11,7 +11,7 @@ import io.cloudevents.jackson.JsonFormat; import java.io.FileOutputStream; -public class CloudEventSnoop implements ExperimentalCloudEventsFunction { +public class CloudEventSnoop implements CloudEventsFunction { @Override public void accept(CloudEvent event) throws Exception { String payloadJson = new String(event.getData().toBytes(), UTF_8); @@ -21,7 +21,8 @@ public void accept(CloudEvent event) throws Exception { if (targetFile == null) { throw new IllegalArgumentException("Expected targetFile in JSON payload"); } - EventFormat jsonFormat = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + 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/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/Multipart.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Multipart.java index aa766663..37102bbc 100644 --- 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 @@ -11,8 +11,8 @@ /** * 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. + *

{@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 @@ -30,7 +30,6 @@ public void service(HttpRequest request, HttpResponse response) throws Exception 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/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 index 7335f5a3..c1e489b4 100644 --- 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 @@ -16,8 +16,7 @@ * 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 class TypedBackgroundSnoop implements BackgroundFunction { public static class Payload { public int a; public int b; @@ -40,7 +39,6 @@ public void accept(Payload payload, Context context) { JsonObject contextAndPayloadJson = new JsonObject(); contextAndPayloadJson.add("data", gson.toJsonTree(payload)); contextAndPayloadJson.add("context", contextJson); - contextAndPayloadJson.add("attributes", gson.toJsonTree(context.attributes())); try (FileWriter fileWriter = new FileWriter(targetFile); PrintWriter writer = new PrintWriter(fileWriter)) { writer.println(contextAndPayloadJson); 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/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-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 index b7321a06..d6d6a015 100644 --- a/invoker/core/src/test/resources/firebase-db1.json +++ b/invoker/core/src/test/resources/firebase-db1.json @@ -6,6 +6,7 @@ "auth": { "admin": true }, + "domain": "firebaseio.com", "data": { "data": null, "delta": { 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/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_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/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/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/function-maven-plugin/pom.xml b/invoker/function-maven-plugin/pom.xml deleted file mode 100644 index 05eaf1be..00000000 --- a/invoker/function-maven-plugin/pom.xml +++ /dev/null @@ -1,88 +0,0 @@ - - 4.0.0 - - - com.google.cloud.functions.invoker - java-function-invoker-parent - 1.0.2-SNAPSHOT - - - com.google.cloud.functions - function-maven-plugin - maven-plugin - 0.9.7-SNAPSHOT - Functions Framework Plugin - A Maven plugin that allows functions to be deployed, and to be run locally - using the Java Functions Framework. - http://maven.apache.org - - - 8 - 8 - 8 - - - - - - org.apache.maven - maven-plugin-api - 3.6.3 - - - org.apache.maven - maven-core - 3.6.3 - - - org.apache.maven.plugin-tools - maven-plugin-annotations - 3.6.0 - provided - - - - com.google.cloud.functions.invoker - java-function-invoker - 1.0.2-SNAPSHOT - - - - com.google.cloud.tools - appengine-maven-plugin - 2.2.0 - jar - - - - com.google.truth - truth - 1.0.1 - test - - - junit - junit - 4.13.1 - test - - - - - - - org.apache.maven.plugins - maven-plugin-plugin - 3.6.0 - - - help-goal - - helpmojo - - - - - - - diff --git a/invoker/pom.xml b/invoker/pom.xml index cd5b8805..33cf5383 100644 --- a/invoker/pom.xml +++ b/invoker/pom.xml @@ -8,14 +8,14 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.0.2-SNAPSHOT + 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/tree/master/invoker + https://github.com/GoogleCloudPlatform/functions-framework-java http://github.com/GoogleCloudPlatform/functions-framework-java @@ -24,17 +24,40 @@ 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 - function-maven-plugin + conformance UTF-8 3.8.1 - 11 - 11 + 17 + 17 @@ -42,8 +65,80 @@ com.google.cloud.functions functions-framework-api - 1.0.3 + 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 index c57cf0f5..541eb9a8 100644 --- a/invoker/testfunction/pom.xml +++ b/invoker/testfunction/pom.xml @@ -4,38 +4,40 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.0.2-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.0.2-SNAPSHOT + 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 - 0.9.1 + 1.1 com.google.guava guava - 28.1-jre + 33.5.0-jre com.google.code.gson gson - 2.8.6 + 2.13.2 @@ -43,7 +45,7 @@ maven-jar-plugin - 3.1.2 + 3.5.0 @@ -85,7 +87,7 @@ org.apache.maven.plugins maven-deploy-plugin - 3.0.0-M1 + 3.1.4 true diff --git a/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java b/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java index 19c78cd2..2a4d3920 100644 --- a/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java +++ b/invoker/testfunction/src/test/java/com/example/functionjar/Checker.java @@ -23,8 +23,7 @@ void serviceOrAssert(String runtimeClassName) { throw new AssertionError( String.format( "ClassLoader mismatch: mine %s; context %s", - getClass().getClassLoader(), - Thread.currentThread().getContextClassLoader())); + getClass().getClassLoader(), Thread.currentThread().getContextClassLoader())); } ClassLoader myLoader = getClass().getClassLoader(); diff --git a/invoker/testfunction/src/test/java/com/example/functionjar/Typed.java b/invoker/testfunction/src/test/java/com/example/functionjar/Typed.java new file mode 100644 index 00000000..fe4560bf --- /dev/null +++ b/invoker/testfunction/src/test/java/com/example/functionjar/Typed.java @@ -0,0 +1,25 @@ +package com.example.functionjar; + +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/run_conformance_tests.sh b/run_conformance_tests.sh new file mode 100755 index 00000000..9f344caa --- /dev/null +++ b/run_conformance_tests.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Runs the conformance tests locally from https://github.com/GoogleCloudPlatform/functions-framework-conformance +# +# Servers may fail to shutdown between tests on error, leaving port 8080 bound. +# You can see what's running on port 8080 by running `lsof -i :8080`. You can +# run `kill -9 ` to terminate a process. +# +# USAGE: +# ./run_conformance_tests.sh [client_version] +# +# client_version (optional): +# The version of the conformance tests client to use, formatted as "vX.X.X". +# Defaults to the latest version of the repo, which may be ahead of the +# latest release. + +CLIENT_VERSION=$1 +if [ $CLIENT_VERSION ]; then + CLIENT_VERSION="@$CLIENT_VERSION" +else + echo "Defaulting to v1.2.0 client." + echo "Use './run_conformance_tests vX.X.X' to specify a specific release version." + CLIENT_VERSION="@v1.2.0" +fi + +function print_header() { + echo + echo "========== $1 ==========" +} + +# Fail if any command fails +set -e + +print_header "INSTALLING CLIENT$CLIENT_VERSION" +echo "Note: only works with Go 1.16+ by default, see run_conformance_tests.sh for more information." +# Go install @version only works on go 1.16+, if using a lower Go version +# replace command with: +# go get github.com/GoogleCloudPlatform/functions-framework-conformance/client$CLIENT_VERSION && go install github.com/GoogleCloudPlatform/functions-framework-conformance/client +go install github.com/GoogleCloudPlatform/functions-framework-conformance/client$CLIENT_VERSION +echo "Done installing client$CLIENT_VERSION" + +print_header "HTTP CONFORMANCE TESTS" +client -buildpacks=false -type=http -cmd='mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.HttpConformanceFunction' -start-delay 5 + +print_header "BACKGROUND EVENT CONFORMANCE TESTS" +client -buildpacks=false -type=legacyevent -cmd='mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.BackgroundEventConformanceFunction' -start-delay 5 -validate-mapping=false + +print_header "CLOUDEVENT CONFORMANCE TESTS" +client -buildpacks=false -type=cloudevent -cmd='mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.CloudEventsConformanceFunction' -start-delay 5 -validate-mapping=false + +print_header "HTTP CONCURRENCY TESTS" +client -buildpacks=false -type=http -cmd='mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.ConcurrentHttpConformanceFunction' -start-delay 5 -validate-concurrency=true