diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 485ec31c..bece83ba 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1 +1 @@ -{"functions-framework-api":"1.0.4","invoker":"1.2.0","function-maven-plugin":"0.10.1"} \ No newline at end of file +{"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 index afc739a2..cfe9f42a 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -70,4 +70,4 @@ ] } } -} \ No newline at end of file +} diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml index 7fe36225..a97dad2f 100644 --- a/.github/release-trigger.yml +++ b/.github/release-trigger.yml @@ -1 +1,2 @@ -enabled: true \ No newline at end of file +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 index 6d290f64..c97377e7 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -3,24 +3,36 @@ name: Buildpack Integration Test on: push: branches: - - master + - 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: - java11-buildpack-test: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + 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: 'java11' + 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@v1.8.0 + 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' \ No newline at end of file + 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 index 5c56a303..091a77a4 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -2,30 +2,48 @@ name: Java Conformance CI on: push: branches: - - master + - main pull_request: + +# Declare default permissions as read only. +permissions: read-all + 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: > + 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@v1 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: ${{ matrix.java }} + distribution: temurin - name: Setup Go - uses: actions/setup-go@v2 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.16' + go-version: '1.26' - name: Build API with Maven run: (cd functions-framework-api/ && mvn install) @@ -33,19 +51,29 @@ jobs: - 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@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: - version: 'v1.6.0' 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@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: - version: 'v1.6.0' functionType: 'legacyevent' useBuildpacks: false validateMapping: true @@ -53,9 +81,8 @@ jobs: startDelay: 10 - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: - version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true @@ -63,11 +90,10 @@ jobs: startDelay: 10 - name: Run HTTP concurrency conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: - version: 'v1.6.0' 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 \ No newline at end of file + startDelay: 10 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 82a20cfe..2c88f621 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -2,18 +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 @@ -25,16 +37,22 @@ jobs: formatting: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 # v2 minimum required + - 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@v3 + uses: axel-op/googlejavaformat-action@dbff853fb823671ec5781365233bf86543b13215 # v3 with: - args: "--dry-run --set-exit-if-changed" - continue-on-error: true - - name: Check for failure - if: steps.formatter.outcome != 'success' - run: | - echo "Java format check failed, see 'Run formatter' step for more information." - echo "See https://github.com/google/google-java-format for options on running the formatter locally." - exit 1 \ No newline at end of file + 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 1938fc01..fd3ffc97 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -2,25 +2,38 @@ name: Java Unit CI on: push: branches: - - master + - main pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest strategy: matrix: java: [ - 11.x, - 17.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@v2 + 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 cb8a3b7e..4cd4e4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # Maven target/ +dependency-reduced-pom.xml # Gradle .gradle 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 index f2ee4665..c617e165 100644 --- a/.kokoro/release.cfg +++ b/.kokoro/release.cfg @@ -1,22 +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" +} -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 75669 - keyname: "functions-framework-java-release-bot-sonatype-password" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-pubkeyring" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-keyring" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-passphrase" +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 old mode 100644 new mode 100755 index 2e3a96d3..58b865a6 --- a/.kokoro/release.sh +++ b/.kokoro/release.sh @@ -1,70 +1,10 @@ #!/bin/bash +set -euo pipefail -# Stop execution when any command fails. -set -e +cd "${KOKORO_ARTIFACTS_DIR}" -# Get secrets from keystore and set and environment variables. -setup_environment_secrets() { - export SONATYPE_USERNAME=functions-framework-release-bot - export SONATYPE_PASSWORD=$(cat ${KOKORO_KEYSTORE_DIR}/75669_functions-framework-java-release-bot-sonatype-password) - export GPG_PASSPHRASE=$(cat ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-passphrase) - - # Add the key ring files to $GNUPGHOME to verify the GPG credentials. - export GNUPGHOME=/tmp/gpg - mkdir $GNUPGHOME - mv ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-pubkeyring $GNUPGHOME/pubring.gpg - mv ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-keyring $GNUPGHOME/secring.gpg - gpg -k -} - -create_settings_xml_file() { - echo " - - - - true - - - ${GPG_PASSPHRASE} - - - - - - sonatype-nexus-staging - ${SONATYPE_USERNAME} - ${SONATYPE_PASSWORD} - - - sonatype-nexus-snapshots - ${SONATYPE_USERNAME} - ${SONATYPE_PASSWORD} - - -" > $1 +cat > manifest.json <<'EOF' +{ + "publish_all": true } - -setup_environment_secrets - -# Pick the right package to release based on the Kokoro job name. -cd ${KOKORO_ARTIFACTS_DIR}/github/functions-framework-java -create_settings_xml_file "settings.xml" -echo "KOKORO_JOB_NAME=${KOKORO_JOB_NAME}" -if [[ $KOKORO_JOB_NAME == *"function-maven-plugin"* ]]; then - cd function-maven-plugin -elif [[ $KOKORO_JOB_NAME == *"functions-framework-api"* ]]; then - cd functions-framework-api -else - cd invoker -fi -echo "pwd=$(pwd)" - -# Make sure `JAVA_HOME` is set and using jdk11. -export JAVA_HOME=/usr/lib/jvm/java-1.11.0-openjdk-amd64 -echo "JAVA_HOME=$JAVA_HOME" -mvn clean deploy -B \ - -P sonatype-oss-release \ - --settings=../settings.xml \ - -Dgpg.executable=gpg \ - -Dgpg.passphrase=${GPG_PASSPHRASE} \ - -Dgpg.homedir=${GNUPGHOME} +EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7e4114c..5d18935a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,15 +38,15 @@ This project is divided into multiple packages, primarily: - `function-maven-plugin` - The Maven plugin for building functions - `conformance` - A set of functions used for conformance testing -### Setup JDK 11 / 17 +### Setup JDK 17 / 21 -Install JDK 11 and 17. One way to install these is through [SDK man](https://sdkman.io/). +Install JDK 17 and 21. One way to install these is through [SDK man](https://sdkman.io/). ```sh -sdk install java 11.0.2-open sdk install java 17-open +sdk install java 21-open sdk use java 17-open -sdk use java 11.0.2-open +sdk use java 21-open ``` Verify Java version with: diff --git a/README.md b/README.md index 9edaee1e..850b806c 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ [![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](https://cloud.google.com/run/) and [Cloud Run for Anthos](https://cloud.google.com/anthos/run/) * [Knative](https://github.com/knative/)-based environments ## Installation @@ -40,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.4 + 1.1.2 provided ``` @@ -50,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.4' + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' } ``` @@ -160,7 +160,7 @@ You can configure the plugin in `pom.xml`: function-maven-plugin 0.10.1 - com.example.function.Echo + com.example.HelloWorld ``` @@ -177,7 +177,7 @@ You can alternatively configure the plugin with properties on the command line: ```sh mvn com.google.cloud.functions:function-maven-plugin:0.10.1:run \ - -Drun.functionTarget=com.example.function.Echo + -Drun.functionTarget=com.example.HelloWorld ``` ### Running the Functions Framework directly @@ -187,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.1.1' \ + -Dartifact='com.google.cloud.functions.invoker:java-function-invoker:1.3.2' \ -DoutputDirectory=. ``` @@ -195,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.1.1 \ +java -jar java-function-invoker-1.3.2 \ --classpath myfunction.jar \ --target com.example.HelloWorld ``` @@ -214,8 +214,8 @@ configurations { } dependencies { - implementation 'com.google.cloud.functions:functions-framework-api:1.0.4' - invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.1.1' + 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) { @@ -288,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.1.1 \ +java -jar java-function-invoker-1.3.2 \ --classpath 'myfunction.jar:/some/directory:/some/library/*' \ --target com.example.HelloWorld ``` 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 index 61e69484..6dca952c 100644 --- a/function-maven-plugin/pom.xml +++ b/function-maven-plugin/pom.xml @@ -1,14 +1,20 @@ 4.0.0 + + org.sonatype.oss + oss-parent + 9 + + com.google.cloud.functions function-maven-plugin maven-plugin - 0.10.2-SNAPSHOT + 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. - http://maven.apache.org + https://github.com/GoogleCloudPlatform/functions-framework-java http://github.com/GoogleCloudPlatform/functions-framework-java @@ -17,9 +23,32 @@ 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 + + + - 11 - 11 + 17 + 17 @@ -27,43 +56,45 @@ org.apache.maven maven-plugin-api - 3.6.3 + 3.9.14 + provided org.apache.maven maven-core - 3.6.3 + 3.9.14 + provided org.apache.maven.plugin-tools maven-plugin-annotations - 3.6.0 + 3.15.2 provided com.google.cloud.functions.invoker java-function-invoker - 1.1.1 + 2.0.1 com.google.cloud.tools appengine-maven-plugin - 2.4.1 + 2.8.7 jar com.google.truth truth - 1.0.1 + 1.4.5 test junit junit - 4.13.1 + 4.13.2 test @@ -73,7 +104,7 @@ org.apache.maven.plugins maven-plugin-plugin - 3.6.0 + 3.15.2 help-goal @@ -85,18 +116,6 @@ - - - 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 @@ -118,7 +137,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.1.0 + 3.12.0 attach-javadocs @@ -131,7 +150,7 @@ org.apache.maven.plugins maven-gpg-plugin - 1.6 + 3.2.8 sign-artifacts @@ -143,14 +162,13 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - true + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ diff --git a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java index 821467e0..08bff714 100644 --- a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java +++ b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java @@ -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,42 +181,46 @@ 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. */ @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. - */ + + /** 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()); } @@ -310,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); @@ -367,6 +374,8 @@ public List getCommands() { if (projectId != null) { commands.add("--project=" + projectId); } + + commands.add("--quiet"); return Collections.unmodifiableList(commands); } @@ -378,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/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 index e5a2913d..6f107cff 100644 --- a/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java +++ b/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java @@ -53,7 +53,8 @@ public void testDeployFunctionCommandLine() { "--env-vars-file=myfile", "--set-build-env-vars=env1=a,env2=b", "--build-env-vars-file=myfile2", - "--runtime=java11"); + "--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 565aa357..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.5-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.RC2 + 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 @@ -187,14 +195,13 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - 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 5052b7b6..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 @@ -18,7 +18,7 @@ * 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. + * href="https://github.com/google/gson/blob/main/UserGuide.md#TOC-Object-Examples">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: 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 e27624ed..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 @@ -17,7 +17,7 @@ /** * 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. + * href="https://github.com/google/gson/blob/main/UserGuide.md">GSON. * *

Here is an example of an implementation that parses the JSON payload using Gson, to access its * {@code messageId} property: 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 index 21d264fc..87db009f 100644 --- a/invoker/CHANGELOG.md +++ b/invoker/CHANGELOG.md @@ -1,5 +1,102 @@ # 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) diff --git a/invoker/conformance/buildpack_pom.xml b/invoker/conformance/buildpack_pom.xml index 40fa9e78..cd769638 100644 --- a/invoker/conformance/buildpack_pom.xml +++ b/invoker/conformance/buildpack_pom.xml @@ -26,12 +26,12 @@ com.google.cloud.functions functions-framework-api - 0.0.0-SNAPSHOT + FRAMEWORK-API-VERSION com.google.cloud.functions.invoker java-function-invoker - 0.0.0-SNAPSHOT + INVOKER-VERSION com.google.code.gson diff --git a/invoker/conformance/pom.xml b/invoker/conformance/pom.xml index 4531c93c..331f08d1 100644 --- a/invoker/conformance/pom.xml +++ b/invoker/conformance/pom.xml @@ -4,45 +4,46 @@ java-function-invoker-parent com.google.cloud.functions.invoker - 1.2.1-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker conformance - 1.2.1-SNAPSHOT + 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-conformance + https://github.com/GoogleCloudPlatform/functions-framework-java UTF-8 - 11 - 11 + 17 + 17 com.google.cloud.functions functions-framework-api + 2.0.0 com.google.code.gson gson - 2.8.9 + 2.13.2 io.cloudevents cloudevents-core - 2.2.0 + 4.0.1 io.cloudevents cloudevents-json-jackson - 2.2.0 + 4.0.1 @@ -52,9 +53,9 @@ com.google.cloud.functions function-maven-plugin - 0.10.1 + 1.0.0 - \ No newline at end of file + diff --git a/invoker/conformance/prerun.sh b/invoker/conformance/prerun.sh index 669d5f49..504e3aa8 100755 --- a/invoker/conformance/prerun.sh +++ b/invoker/conformance/prerun.sh @@ -14,11 +14,10 @@ # - 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 +# - 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) @@ -27,13 +26,28 @@ mkdir /tmp/tests cp -r $REPO_ROOT/invoker/conformance /tmp/tests -cd $REPO_ROOT/invoker -mvn versions:set -DnewVersion=0.0.0-SNAPSHOT -DprocessAllModules=true +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) -cd $REPO_ROOT/functions-framework-api -mvn versions:set -DnewVersion=0.0.0-SNAPSHOT -DprocessAllModules=true +# 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/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 6688f2b9..cd8984ba 100644 --- a/invoker/core/pom.xml +++ b/invoker/core/pom.xml @@ -4,25 +4,27 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.2.1-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker - 1.2.1-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 - 2.0.0.RC2 + 17 + 17 + 4.0.1 + 12.1.8 @@ -44,11 +46,7 @@ com.google.cloud.functions functions-framework-api - - - javax.servlet - javax.servlet-api - 3.1.0 + 2.0.0 io.cloudevents @@ -68,12 +66,12 @@ com.google.code.gson gson - 2.8.9 + 2.13.2 com.ryanharter.auto.value auto-value-gson - 1.3.0 + 1.3.1 provided @@ -85,24 +83,24 @@ 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.49.v20220914 + jetty-server + ${jetty.version} - org.eclipse.jetty - jetty-server - 9.4.49.v20220914 + org.slf4j + slf4j-jdk14 + 2.0.17 com.beust @@ -114,56 +112,76 @@ com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.2.1-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.6 + 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 @@ -171,7 +189,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.2.1 + 3.6.2 package @@ -183,4 +201,4 @@ - \ No newline at end of file + 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 98b9bc8a..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 @@ -21,6 +21,7 @@ import com.google.cloud.functions.CloudEventsFunction; import com.google.cloud.functions.Context; import com.google.cloud.functions.RawBackgroundFunction; +import com.google.cloud.functions.invoker.gcf.ExecutionIdUtil; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; @@ -29,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; @@ -175,8 +184,13 @@ static Optional backgroundFunctionTypeArgument( .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); } } @@ -223,7 +237,7 @@ private static Context contextFromCloudEvent(CloudEvent cloudEvent) { * 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 { @@ -320,20 +334,25 @@ 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.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 { @@ -347,10 +366,14 @@ private enum CloudEventKind { * @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 @@ -364,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/HttpFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java index 7a66fefd..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; @@ -49,41 +52,38 @@ public static HttpFunctionExecutor forClass(Class functionClass) { + 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) { logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); - res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + 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 9c94b92a..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,6 +5,8 @@ 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; @@ -15,6 +17,7 @@ */ 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"; @@ -24,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; @@ -38,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) @@ -96,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"); } @@ -117,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 d35e21ea..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 @@ -20,9 +20,12 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.ParameterException; import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.TypedFunction; import com.google.cloud.functions.invoker.BackgroundFunctionExecutor; import com.google.cloud.functions.invoker.HttpFunctionExecutor; +import com.google.cloud.functions.invoker.TypedFunctionExecutor; import com.google.cloud.functions.invoker.gcf.JsonLogHandler; +import com.google.cloud.functions.invoker.http.TimeoutHandler; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; @@ -42,23 +45,19 @@ 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.server.Connector; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +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; /** @@ -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)); @@ -235,7 +235,7 @@ ClassLoader getFunctionClassLoader() { * unit or integration test, use {@link #startTestServer()} instead. * * @see #stopServer() - * @throws Exception + * @throws Exception If there was a problem starting the server */ public void startServer() throws Exception { startServer(true); @@ -252,11 +252,10 @@ public void startServer() throws Exception { *

{@code
    * // Create an invoker
    * Invoker invoker = new Invoker(
-   *         8081,
-   *         "org.example.MyHttpFunction",
-   *         "http",
-   *         Thread.currentThread().getContextClassLoader()
-   * );
+   *     8081,
+   *     "org.example.MyHttpFunction",
+   *     "http",
+   *     Thread.currentThread().getContextClassLoader());
    *
    * // Start the test server
    * invoker.startTestServer();
@@ -268,7 +267,7 @@ public void startServer() throws Exception {
    * }
* * @see #stopServer() - * @throws Exception + * @throws Exception If there was a problem starting the server */ public void startTestServer() throws Exception { startServer(false); @@ -281,27 +280,46 @@ private void startServer(boolean join) throws Exception { 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); - server.setConnectors(new Connector[] {connector}); - - ServletContextHandler servletContextHandler = new ServletContextHandler(); - servletContextHandler.setContextPath("/"); - server.setHandler(NotFoundHandler.forServlet(servletContextHandler)); + connector.setReuseAddress(true); + connector.setReusePort(true); + server.addConnector(connector); Class functionClass = loadFunctionClass(); - HttpServlet servlet; + Handler handler; if (functionSignatureType == null) { - servlet = servletForDeducedSignatureType(functionClass); + handler = handlerForDeducedSignatureType(functionClass); } else { switch (functionSignatureType) { case "http": - servlet = HttpFunctionExecutor.forClass(functionClass); + if (TypedFunction.class.isAssignableFrom(functionClass)) { + handler = TypedFunctionExecutor.forClass(functionClass); + } else { + handler = HttpFunctionExecutor.forClass(functionClass); + } break; case "event": case "cloudevent": - servlet = BackgroundFunctionExecutor.forClass(functionClass); + handler = BackgroundFunctionExecutor.forClass(functionClass); + break; + case "typed": + handler = TypedFunctionExecutor.forClass(functionClass); break; default: String error = @@ -312,9 +330,18 @@ private void startServer(boolean join) throws Exception { 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(); @@ -350,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; @@ -362,10 +389,13 @@ 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()) { @@ -381,6 +411,15 @@ private HttpServlet servletForDeducedSignatureType(Class functionClass) { 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<>(); @@ -432,38 +471,31 @@ 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); } } @@ -492,7 +524,6 @@ 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)) { return runtimeClassLoader.loadClass(name); } @@ -509,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 d00b0b4f..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 @@ -2,7 +2,6 @@ import static com.google.cloud.functions.invoker.BackgroundFunctionExecutor.backgroundFunctionTypeArgument; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth8.assertThat; import com.google.cloud.functions.BackgroundFunction; import com.google.cloud.functions.Context; @@ -100,7 +99,6 @@ public void parseLegacyEventPubSubEmulator() throws IOException { 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=="); 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 335cc7de..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(); @@ -161,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(); @@ -184,6 +200,7 @@ static Builder builder() { .setUrl("/") .setRequestText("") .setExpectedResponseCode(HttpStatus.OK_200) + .setExpectedResponseHeaders(ImmutableMap.of()) .setExpectedResponseText("") .setHttpContentType("text/plain") .setHttpHeaders(ImmutableMap.of()); @@ -194,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); @@ -247,17 +266,81 @@ public void helloWorld() throws Exception { 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\":" - + " \"service\"}, \"message\": \"Failed to execute" + + " \"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( @@ -265,6 +348,8 @@ public void exceptionHttp() throws Exception { ImmutableList.of( TestCase.builder() .setExpectedResponseCode(500) + .setExpectedResponseText("") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedOutput(exceptionExpectedOutput) .build())); } @@ -274,7 +359,10 @@ public void exceptionBackground() throws Exception { String exceptionExpectedOutput = "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":" - + " \"service\"}, \"message\": \"Failed to execute" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"Failed to execute" + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n" + "java.lang.RuntimeException: exception thrown for test"; @@ -288,9 +376,11 @@ public void exceptionBackground() throws Exception { ImmutableList.of( TestCase.builder() .setRequestText(gcfRequestText) + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedResponseCode(500) .setExpectedOutput(exceptionExpectedOutput) - .build())); + .build()), + Collections.emptyMap()); } @Test @@ -329,13 +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").setExpectedOutput(simpleExpectedOutput).build(); + 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 = @@ -343,11 +441,15 @@ 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( @@ -363,6 +465,10 @@ private static int getJavaVersion() { if (dot != -1) { version = version.substring(0, dot); } + int dash = version.indexOf("-"); + if (dash != -1) { + version = version.substring(0, dash); + } } return Integer.parseInt(version); } @@ -383,6 +489,49 @@ public void typedBackground() throws Exception { } } + @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); @@ -475,6 +624,43 @@ public void nativeCloudEvent() throws Exception { 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"; @@ -493,11 +679,16 @@ public void packageless() throws Exception { @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()); + 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"; @@ -505,8 +696,8 @@ public void multipart() throws Exception { fullTarget("Multipart"), ImmutableList.of( TestCase.builder() - .setHttpContentType(Optional.empty()) - .setRequestContent(multiPartProvider) + .setHttpContentType(multiPartRequestContent.getContentType()) + .setRequestContent(multiPartRequestContent) .setExpectedResponseText(expectedResponse) .build())); } @@ -551,7 +742,8 @@ public void classpathOptionHttp() throws Exception { 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. */ @@ -568,7 +760,26 @@ public void classpathOptionBackground() throws Exception { SignatureType.BACKGROUND, "com.example.functionjar.Background", ImmutableList.of("--classpath", functionJarString()), - ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build())); + ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build()), + Collections.emptyMap()); + } + + /** Like {@link #classpathOptionHttp} but for typed functions. */ + @Test + public void classpathOptionTyped() throws Exception { + URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json"); + assertThat(resourceUrl).isNotNull(); + String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + testFunction( + SignatureType.TYPED, + "com.example.functionjar.Typed", + ImmutableList.of("--classpath", functionJarString()), + ImmutableList.of( + TestCase.builder() + .setRequestText(originalJson) + .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}") + .build()), + Collections.emptyMap()); } // In these tests, we test a number of different functions that express the same functionality @@ -582,7 +793,12 @@ private void backgroundTest( for (TestCase testCase : testCases) { File snoopFile = testCase.snoopFile().get(); snoopFile.delete(); - testFunction(signatureType, functionTarget, ImmutableList.of(), 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); @@ -606,33 +822,59 @@ private void checkSnoopFile(TestCase testCase) throws IOException { } private void testHttpFunction(String target, List testCases) throws Exception { - testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases); + testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases, Collections.emptyMap()); } private void testFunction( SignatureType signatureType, String target, ImmutableList extraArgs, - List testCases) + List testCases, + Map environmentVariables) throws Exception { - ServerProcess serverProcess = startServer(signatureType, target, extraArgs); + 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 + .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)); @@ -645,11 +887,16 @@ private void testFunction( } } finally { serverProcess.close(); + httpClient.stop(); } for (TestCase testCase : testCases) { testCase .expectedOutput() - .ifPresent(output -> expect.that(serverProcess.output()).contains(output)); + .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. @@ -659,7 +906,8 @@ private void testFunction( private enum SignatureType { HTTP("http"), BACKGROUND("event"), - CLOUD_EVENT("cloudevent"); + CLOUD_EVENT("cloudevent"), + TYPED("typed"); private final String name; @@ -710,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(); @@ -734,8 +985,11 @@ private ServerProcess startServer( "FUNCTION_SIGNATURE_TYPE", signatureType.toString(), "FUNCTION_TARGET", - 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(); @@ -770,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 9ef51b2a..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; @@ -82,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(); } @@ -113,16 +115,16 @@ 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. + * 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); } } @@ -181,6 +183,8 @@ private void httpRequestMethods( 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(); @@ -191,13 +195,17 @@ private void httpRequestMethods( }; for (HttpRequestTest test : tests) { testReference.set(test); - Request request = + org.eclipse.jetty.client.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)); + .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()); @@ -220,8 +228,8 @@ public void emptyRequest() throws Exception { }; 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()); @@ -237,20 +245,24 @@ 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); + 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. @@ -269,10 +281,9 @@ public void multiPartRequest() throws Exception { 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"); + assertThat(foos).containsExactly("baz", "buh"); + byte[] bytes = new byte[RANDOM_BYTES.length]; try (InputStream inputStream = bytesPart.getInputStream()) { assertThat(inputStream.read(bytes)).isEqualTo(bytes.length); @@ -280,20 +291,21 @@ public void multiPartRequest() throws Exception { assertThat(bytes).isEqualTo(RANDOM_BYTES); } }; - try (SimpleServer server = new SimpleServer(testServlet)) { + 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; @@ -301,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; } } @@ -324,8 +339,8 @@ private interface HttpResponseTest { 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); } } @@ -347,16 +362,14 @@ private void httpResponseSetAndGet( .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. + // 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"); - Map> updatedHeaders = new TreeMap<>(response.getHeaders()); - updatedHeaders.keySet().removeAll(initialHeaders.keySet()); + response.appendHeader("FoO", "baz"); + var updatedHeaders = response.getHeaders(); assertThat(updatedHeaders) - .containsExactly("foo", Arrays.asList("bar", "baz"), "wibbly", Arrays.asList("wobbly")); + .containsAtLeast("foo", Arrays.asList("bar", "baz"), "wibbly", Arrays.asList("wobbly")); }, }; for (HttpResponseTest test : tests) { @@ -364,18 +377,18 @@ private void httpResponseSetAndGet( 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; @@ -383,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; } } @@ -415,15 +431,15 @@ 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); } } @@ -446,7 +462,8 @@ private void httpResponseEffects( 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"); + // Reason string cannot be set by the application. + assertThat(response.getReason()).isEqualTo(Code.IM_A_TEAPOT.getMessage()); }), responseTest( response -> response.setContentType("application/noddy"), @@ -489,7 +506,7 @@ private void httpResponseEffects( 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 b3569e4e..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; 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/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/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/typed_nameconcat_request.json b/invoker/core/src/test/resources/typed_nameconcat_request.json new file mode 100644 index 00000000..f6b4d425 --- /dev/null +++ b/invoker/core/src/test/resources/typed_nameconcat_request.json @@ -0,0 +1,4 @@ +{ + "firstName": "John", + "lastName": "Doe" +} \ No newline at end of file diff --git a/invoker/pom.xml b/invoker/pom.xml index 44640217..33cf5383 100644 --- a/invoker/pom.xml +++ b/invoker/pom.xml @@ -8,14 +8,14 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.2.1-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,6 +24,29 @@ 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 @@ -33,8 +56,8 @@ UTF-8 3.8.1 - 11 - 11 + 17 + 17 @@ -42,7 +65,7 @@ com.google.cloud.functions functions-framework-api - 1.0.4 + 2.0.1 @@ -80,7 +103,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.1.0 + 3.12.0 attach-javadocs @@ -93,7 +116,7 @@ org.apache.maven.plugins maven-gpg-plugin - 1.6 + 3.2.8 sign-artifacts @@ -105,18 +128,17 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - true + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/
-
\ No newline at end of file + diff --git a/invoker/testfunction/pom.xml b/invoker/testfunction/pom.xml index d89eef20..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.2.1-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.2.1-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 - 29.0-jre + 33.5.0-jre com.google.code.gson gson - 2.8.9 + 2.13.2 @@ -43,7 +45,7 @@ maven-jar-plugin - 3.1.2 + 3.5.0 @@ -85,11 +87,11 @@ org.apache.maven.plugins maven-deploy-plugin - 3.0.0-M1 + 3.1.4 true - \ No newline at end of file + 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; + } +}