diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..7d352b8c8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "docs/src/test/bats/test_helper/bats-assert"] + path = docs/src/test/bats/test_helper/bats-assert + url = https://github.com/ztombol/bats-assert +[submodule "docs/src/test/bats/test_helper/bats-support"] + path = docs/src/test/bats/test_helper/bats-support + url = https://github.com/ztombol/bats-support diff --git a/docs/pom.xml b/docs/pom.xml index 7989e137c..74e62a78a 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -29,6 +29,22 @@ maven-deploy-plugin 2.8.2 + + exec-maven-plugin + org.codehaus.mojo + + + Run tests + test + + exec + + + ${basedir}/src/test/bash/run-bats.sh + + + + diff --git a/docs/src/main/asciidoc/ghpages.sh b/docs/src/main/asciidoc/ghpages.sh index 19682a365..31ea6a2d0 100755 --- a/docs/src/main/asciidoc/ghpages.sh +++ b/docs/src/main/asciidoc/ghpages.sh @@ -1,15 +1,20 @@ -#!/bin/bash -x +#!/bin/bash # Usage: (cd ; ghpages.sh -v -b -c) set -e +export GIT_BIN ROOT_FOLDER COMMIT_CHANGES MAVEN_PATH MAVEN_EXEC REPO_NAME SPRING_CLOUD_STATIC_REPO +export CURRENT_BRANCH PREVIOUS_BRANCH + +GIT_BIN="${GIT_BIN:-git}" + +# The script should be executed from the root folder +ROOT_FOLDER="$( pwd )" +echo "Current folder is ${ROOT_FOLDER}" + # Set default props like MAVEN_PATH, ROOT_FOLDER etc. function set_default_props() { - # The script should be executed from the root folder - ROOT_FOLDER=`pwd` - echo "Current folder is ${ROOT_FOLDER}" - if [[ ! -e "${ROOT_FOLDER}/.git" ]]; then echo "You're not in the root folder of the project!" exit 1 @@ -24,8 +29,8 @@ function set_default_props() { MAVEN_EXEC="${MAVEN_PATH}mvn" fi echo "Path to Maven is [${MAVEN_EXEC}]" - if [ -z $REPO_NAME ]; then - REPO_NAME=$(git remote -v | grep origin | head -1 | sed -e 's!.*/!!' -e 's/ .*//' -e 's/\.git.*//') + if [ -z "${REPO_NAME}" ]; then + REPO_NAME="$("${GIT_BIN}" remote -v | grep origin | head -1 | sed -e 's!.*/!!' -e 's/ .*//' -e 's/\.git.*//')" fi echo "Repo name is [${REPO_NAME}]" SPRING_CLOUD_STATIC_REPO=${SPRING_CLOUD_STATIC_REPO:-git@github.com:spring-cloud/spring-cloud-static.git} @@ -34,22 +39,23 @@ function set_default_props() { # Adds the oauth token if present to the remote url function add_oauth_token_to_remote_url() { - remote=`git config remote.origin.url | sed -e 's/^git:/https:/'` + local remote + remote="$( "${GIT_BIN}" config remote.origin.url | sed -e 's/^git:/https:/' )" echo "Current remote [${remote}]" if [[ "${RELEASER_GIT_OAUTH_TOKEN}" != "" && ${remote} != *"@"* ]]; then echo "OAuth token found. Will reuse it to push the code" withToken=${remote/https:\/\//https://${RELEASER_GIT_OAUTH_TOKEN}@} - git remote set-url --push origin "${withToken}" + "${GIT_BIN}" remote set-url --push origin "${withToken}" else echo "No OAuth token found" - git remote set-url --push origin `git config remote.origin.url | sed -e 's/^git:/https:/'` + "${GIT_BIN}" remote set-url --push origin "$( "${GIT_BIN}" config remote.origin.url | sed -e 's/^git:/https:/' )" fi } # Check if gh-pages exists and docs have been built function check_if_anything_to_sync() { add_oauth_token_to_remote_url - if ! (git remote set-branches --add origin gh-pages && git fetch -q) && [[ "${RELEASE_TRAIN}" != "yes" ]] ; then + if ! ("${GIT_BIN}" remote set-branches --add origin gh-pages && "${GIT_BIN}" fetch -q) && [[ "${RELEASE_TRAIN}" != "yes" ]] ; then echo "No gh-pages, so not syncing" exit 0 fi @@ -66,19 +72,20 @@ function retrieve_current_branch() { # If there is a branch already passed will reuse it - otherwise will try to find it CURRENT_BRANCH=${BRANCH} if [[ -z "${CURRENT_BRANCH}" ]] ; then - CURRENT_BRANCH=$(git symbolic-ref -q HEAD) + CURRENT_BRANCH=$("${GIT_BIN}" symbolic-ref -q HEAD) CURRENT_BRANCH=${CURRENT_BRANCH##refs/heads/} CURRENT_BRANCH=${CURRENT_BRANCH:-HEAD} fi echo "Current branch is [${CURRENT_BRANCH}]" - git checkout ${CURRENT_BRANCH} || echo "Failed to check the branch... continuing with the script" - PREVIOUS_BRANCH=${CURRENT_BRANCH} + "${GIT_BIN}" checkout "${CURRENT_BRANCH}" || echo "Failed to check the branch... continuing with the script" + PREVIOUS_BRANCH="${CURRENT_BRANCH}" + echo "Previous branch was [${PREVIOUS_BRANCH}]" } # Switches to the provided value of the release version. We always prefix it with `v` function switch_to_tag() { if [[ "${RELEASE_TRAIN}" != "yes" ]] ; then - git checkout v${VERSION} + "${GIT_BIN}" checkout v"${VERSION}" fi } @@ -91,6 +98,7 @@ function build_docs_if_applicable() { # Get the name of the `docs.main` property # Get whitelisted branches - assumes that a `docs` module is available under `docs` profile +# shellcheck disable=SC2016 function retrieve_doc_properties() { MAIN_ADOC_VALUE=$("${MAVEN_EXEC}" -q \ -Dexec.executable="echo" \ @@ -113,47 +121,52 @@ function retrieve_doc_properties() { # Stash any outstanding changes function stash_changes() { - git diff-index --quiet HEAD && dirty=$? || (echo "Failed to check if the current repo is dirty. Assuming that it is." && dirty="1") - if [ "$dirty" != "0" ]; then git stash; fi + local success="false" + "${GIT_BIN}" diff-index --quiet HEAD && dirty=$? && success="true" + if [[ "${success}" == "false" ]]; then + echo "Failed to check if the current repo is dirty. Assuming that it is." && dirty="1" + fi + echo "The repo is dirty [${dirty}]" + if [ "$dirty" != "0" ]; then "${GIT_BIN}" stash; fi } # Switch to gh-pages branch to sync it with current branch function add_docs_from_target() { local DESTINATION_REPO_FOLDER if [[ -z "${DESTINATION}" && -z "${CLONE}" ]] ; then - DESTINATION_REPO_FOLDER=${ROOT_FOLDER} + DESTINATION_REPO_FOLDER="${ROOT_FOLDER}" elif [[ "${CLONE}" == "yes" ]]; then - mkdir -p ${ROOT_FOLDER}/target - local clonedStatic=${ROOT_FOLDER}/target/spring-cloud-static + mkdir -p "${ROOT_FOLDER}"/target + local clonedStatic="${ROOT_FOLDER}"/target/spring-cloud-static if [[ ! -e "${clonedStatic}/.git" ]]; then echo "Cloning Spring Cloud Static to target" - git clone ${SPRING_CLOUD_STATIC_REPO} ${clonedStatic} && cd ${clonedStatic} && git checkout gh-pages + "${GIT_BIN}" clone "${SPRING_CLOUD_STATIC_REPO}" "${clonedStatic}" && cd "${clonedStatic}" && "${GIT_BIN}" checkout gh-pages else echo "Spring Cloud Static already cloned - will pull changes" - cd ${clonedStatic} && git checkout gh-pages && git pull origin gh-pages + cd "${clonedStatic}" && "${GIT_BIN}" checkout gh-pages && "${GIT_BIN}" pull origin gh-pages fi if [[ -z "${RELEASE_TRAIN}" ]] ; then - DESTINATION_REPO_FOLDER=${clonedStatic}/${REPO_NAME} + DESTINATION_REPO_FOLDER="${clonedStatic}/${REPO_NAME}" else - DESTINATION_REPO_FOLDER=${clonedStatic} + DESTINATION_REPO_FOLDER="${clonedStatic}" fi - mkdir -p ${DESTINATION_REPO_FOLDER} + mkdir -p "${DESTINATION_REPO_FOLDER}" else if [[ ! -e "${DESTINATION}/.git" ]]; then echo "[${DESTINATION}] is not a git repository" exit 1 fi if [[ -z "${RELEASE_TRAIN}" ]] ; then - DESTINATION_REPO_FOLDER=${DESTINATION}/${REPO_NAME} + DESTINATION_REPO_FOLDER="${DESTINATION}/${REPO_NAME}" else - DESTINATION_REPO_FOLDER=${DESTINATION} + DESTINATION_REPO_FOLDER="${DESTINATION}" fi - mkdir -p ${DESTINATION_REPO_FOLDER} + mkdir -p "${DESTINATION_REPO_FOLDER}" echo "Destination was provided [${DESTINATION}]" fi - cd ${DESTINATION_REPO_FOLDER} - git checkout gh-pages - git pull origin gh-pages + cd "${DESTINATION_REPO_FOLDER}" + "${GIT_BIN}" checkout gh-pages + "${GIT_BIN}" pull origin gh-pages # Add git branches ################################################################### @@ -172,32 +185,33 @@ function copy_docs_for_current_version() { echo -e "Current branch is master - will copy the current docs only to the root folder" for f in docs/target/generated-docs/*; do file=${f#docs/target/generated-docs/*} - if ! git ls-files -i -o --exclude-standard --directory | grep -q ^$file$; then + if ! "${GIT_BIN}" ls-files -i -o --exclude-standard --directory | grep -q ^"${file}"$; then # Not ignored... - cp -rf $f ${ROOT_FOLDER}/ + cp -rf "${f}" "${ROOT_FOLDER}"/ fi done - git add -A ${ROOT_FOLDER} + "${GIT_BIN}" add -A "${ROOT_FOLDER}" COMMIT_CHANGES="yes" else echo -e "Current branch is [${CURRENT_BRANCH}]" # https://stackoverflow.com/questions/29300806/a-bash-script-to-check-if-a-string-is-present-in-a-comma-separated-list-of-strin if [[ ",${WHITELISTED_BRANCHES_VALUE}," = *",${CURRENT_BRANCH},"* ]] ; then - mkdir -p ${ROOT_FOLDER}/${CURRENT_BRANCH} + mkdir -p "${ROOT_FOLDER}/${CURRENT_BRANCH}" echo -e "Branch [${CURRENT_BRANCH}] is whitelisted! Will copy the current docs to the [${CURRENT_BRANCH}] folder" for f in docs/target/generated-docs/*; do - file=${f#docs/target/generated-docs/*} - if ! git ls-files -i -o --exclude-standard --directory | grep -q ^$file$; then + file="${f#docs/target/generated-docs/*}" + if ! "${GIT_BIN}" ls-files -i -o --exclude-standard --directory | grep -q ^"${file}"$; then + echo "The file [${file}] shouldn't be ignored" # Not ignored... # We want users to access 1.0.0.RELEASE/ instead of 1.0.0.RELEASE/spring-cloud.sleuth.html if [[ "${file}" == "${MAIN_ADOC_VALUE}.html" ]] ; then # We don't want to copy the spring-cloud-sleuth.html # we want it to be converted to index.html - cp -rf $f ${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html - git add -A ${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html + cp -rf "${f}" "${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html" + "${GIT_BIN}" add -A "${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html" else - cp -rf $f ${ROOT_FOLDER}/${CURRENT_BRANCH} - git add -A ${ROOT_FOLDER}/${CURRENT_BRANCH}/$file || echo "Failed to add the file [$file]" + cp -rf "${f}" "${ROOT_FOLDER}/${CURRENT_BRANCH}" + "${GIT_BIN}" add -A "${ROOT_FOLDER}/${CURRENT_BRANCH}/${file}" || echo "Failed to add the file [${file}]" fi fi done @@ -211,12 +225,12 @@ function copy_docs_for_current_version() { # Copies the docs by using the explicitly provided version function copy_docs_for_provided_version() { - local FOLDER=${DESTINATION_REPO_FOLDER}/${VERSION} - mkdir -p ${FOLDER} + local FOLDER="${DESTINATION_REPO_FOLDER}/${VERSION}" + mkdir -p "${FOLDER}" echo -e "Current tag is [v${VERSION}] Will copy the current docs to the [${FOLDER}] folder" - for f in ${ROOT_FOLDER}/docs/target/generated-docs/*; do - file=${f#${ROOT_FOLDER}/docs/target/generated-docs/*} - copy_docs_for_branch ${file} ${FOLDER} + for f in "${ROOT_FOLDER}"/docs/target/generated-docs/*; do + file="${f#${ROOT_FOLDER}/docs/target/generated-docs/*}" + copy_docs_for_branch "${file}" "${FOLDER}" done COMMIT_CHANGES="yes" CURRENT_BRANCH="v${VERSION}" @@ -227,26 +241,27 @@ function copy_docs_for_provided_version() { # $1 - file from target # $2 - destination to which copy the files function copy_docs_for_branch() { - local file=$1 - local destination=$2 - if ! git ls-files -i -o --exclude-standard --directory | grep -q ^${file}$; then + local file="$1" + local destination="$2" + echo "Copying file [${file}] to destination [${destination}]" + if ! "${GIT_BIN}" ls-files -i -o --exclude-standard --directory | grep -q ^"${file}"$; then # Not ignored... # We want users to access 1.0.0.RELEASE/ instead of 1.0.0.RELEASE/spring-cloud.sleuth.html if [[ ("${file}" == "${MAIN_ADOC_VALUE}.html") || ("${file}" == "${REPO_NAME}.html") ]] ; then # We don't want to copy the spring-cloud-sleuth.html # we want it to be converted to index.html - cp -rf $f ${destination}/index.html + cp -rf "${f}" "${destination}"/index.html else - cp -rf $f ${destination} + cp -rf "${f}" "${destination}" fi - git add -A ${destination} + "${GIT_BIN}" add -A "${destination}" fi } function commit_changes_if_applicable() { if [[ "${COMMIT_CHANGES}" == "yes" ]] ; then COMMIT_SUCCESSFUL="no" - git commit -a -m "Sync docs from ${CURRENT_BRANCH} to gh-pages" && COMMIT_SUCCESSFUL="yes" || echo "Failed to commit changes" + "${GIT_BIN}" commit -a -m "Sync docs from ${CURRENT_BRANCH} to gh-pages" && COMMIT_SUCCESSFUL="yes" || echo "Failed to commit changes" # Uncomment the following push if you want to auto push to # the gh-pages branch whenever you commit to master locally. @@ -254,7 +269,7 @@ function commit_changes_if_applicable() { ################################################################### if [[ "${COMMIT_SUCCESSFUL}" == "yes" ]] ; then add_oauth_token_to_remote_url - git push origin gh-pages + "${GIT_BIN}" push origin gh-pages fi fi } @@ -262,10 +277,10 @@ function commit_changes_if_applicable() { # Switch back to the previous branch and exit block function checkout_previous_branch() { # If -version was provided we need to come back to root project - cd ${ROOT_FOLDER} - git checkout ${PREVIOUS_BRANCH} || echo "Failed to check the branch... continuing with the script" - if [ "$dirty" != "0" ]; then git stash pop; fi - exit 0 + cd "${ROOT_FOLDER}" + "${GIT_BIN}" checkout "${PREVIOUS_BRANCH}" || echo "Failed to check the branch... continuing with the script" + if [ "$dirty" != "0" ]; then "${GIT_BIN}" stash pop; fi + return 0 } # Assert if properties have been properly passed @@ -322,53 +337,58 @@ EOF # # ========================================== -while [[ $# > 0 ]] -do -key="$1" -case ${key} in - -v|--version) - VERSION="$2" - shift # past argument - ;; - -r|--releasetrain) - RELEASE_TRAIN="yes" - ;; - -d|--destination) - DESTINATION="$2" - shift # past argument - ;; - -b|--build) - BUILD="yes" - ;; - -c|--clone) - CLONE="yes" - ;; - -h|--help) - print_usage - exit 0 - ;; - *) - echo "Invalid option: [$1]" - print_usage - exit 1 - ;; -esac -shift # past argument or value -done - -assert_properties -set_default_props -check_if_anything_to_sync -retrieve_current_branch -if echo $VERSION | egrep -q 'SNAPSHOT' || [[ -z "${VERSION}" ]]; then - CLONE="" - VERSION="" - echo "You've provided a version variable but it's a snapshot one. Due to this will not clone spring-cloud-static and publish docs over there" + +if [[ "${SOURCE_FUNCTIONS}" == "true" ]]; then + echo "Will just source functions. Will not run any commands" else - switch_to_tag + while [[ $# -gt 0 ]] + do + key="$1" + case "${key}" in + -v|--version) + VERSION="$2" + shift # past argument + ;; + -r|--releasetrain) + RELEASE_TRAIN="yes" + ;; + -d|--destination) + DESTINATION="$2" + shift # past argument + ;; + -b|--build) + BUILD="yes" + ;; + -c|--clone) + CLONE="yes" + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "Invalid option: [$1]" + print_usage + exit 1 + ;; + esac + shift # past argument or value + done + + assert_properties + set_default_props + check_if_anything_to_sync + retrieve_current_branch + if echo "${VERSION}" | grep -q -E 'SNAPSHOT' || [[ -z "${VERSION}" ]]; then + CLONE="" + VERSION="" + echo "You've provided a version variable but it's a snapshot one. Due to this will not clone spring-cloud-static and publish docs over there" + else + switch_to_tag + fi + build_docs_if_applicable + retrieve_doc_properties + stash_changes + add_docs_from_target + checkout_previous_branch fi -build_docs_if_applicable -retrieve_doc_properties -stash_changes -add_docs_from_target -checkout_previous_branch diff --git a/docs/src/main/bash/sync_ghpages.sh b/docs/src/main/bash/sync_ghpages.sh deleted file mode 100755 index 37c9ec0d2..000000000 --- a/docs/src/main/bash/sync_ghpages.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/bin/bash - -set -e - -# Either clones or pulls the repo for given project -# Params: -# $1 organization e.g. spring-cloud -# $2 repo name e.g. spring-cloud-sleuth -function clone_or_pull() { - if [ "$#" -ne 2 ] - then - echo "You haven't provided 2 args... \$1 organization e.g. spring-cloud; \$2 repo name e.g. spring-cloud-sleuth" - exit 1 - fi - if [[ "${JUST_PUSH}" == "yes" ]] ; then - echo "Skipping cloning since the option to just push was provided" - exit 0 - fi - local ORGANIZATION=$1 - local REPO_NAME=$2 - local LOCALREPO_VC_DIR=${REPO_NAME}/.git - if [ ! -d ${LOCALREPO_VC_DIR} ] - then - echo "Repo [${REPO_NAME}] doesn't exist - will clone it!" - git clone git@github.com:${ORGANIZATION}/${REPO_NAME}.git - else - echo "Repo [${REPO_NAME}] exists - will pull the changes" - cd ${REPO_NAME} && git pull || echo "Not pulling since repo is up to date" - cd ${ROOT_FOLDER} - fi -} - -# For the given branch updates the docs/src/main/asciidoc/ghpages.sh -# with the one from spring-cloud-build. Then commits and pushes the change -# Params: -# $1 repo name e.g. spring-cloud-sleuth -# $2 branch name -function update_ghpages_script() { - if [ "$#" -ne 2 ] - then - echo "You haven't provided 2 args... \$1 repo name e.g. spring-cloud-sleuth; \$2 branch name e.g. master" - exit 1 - fi - local REPO_NAME=$1 - local BRANCH_NAME=$2 - echo "Updating ghpages script for [${REPO_NAME}] and branch [${BRANCH_NAME}]" - cd ${REPO_NAME} - echo "Checking out [${BRANCH_NAME}]" - git checkout ${BRANCH_NAME} - echo "Resetting the repo and pulling before commiting" - git reset --hard origin/${BRANCH_NAME} && git pull origin ${BRANCH_NAME} - # If the user wants to just push we will not copy / add / commit files - if [[ "${JUST_PUSH}" != "yes" ]] ; then - echo "Copying [${GHPAGES_DOWNLOAD_PATH}] to [${GHPAGES_IN_REPO_PATH}]" - cp -rf ${GHPAGES_DOWNLOAD_PATH} ${GHPAGES_IN_REPO_PATH} - echo "Adding and committing [${GHPAGES_IN_REPO_PATH}] with message [${COMMIT_MESSAGE}]" - git add ${GHPAGES_IN_REPO_PATH} - git commit -m "${COMMIT_MESSAGE}" || echo "Proceeding to the next repo" - fi - if [[ "${AUTO_PUSH}" == "yes" ]] ; then - echo "Pushing the branch [${BRANCH_NAME}]" - wait_if_manual_proceed - git push origin ${BRANCH_NAME} - fi - cd ${ROOT_FOLDER} -} - -# Downloads ghpages.sh -function download_ghpages() { - rm -rf ${GHPAGES_DOWNLOAD_PATH} - echo "Downloading ghpages.sh from [${GHPAGES_URL}] to [${GHPAGES_DOWNLOAD_PATH}]" - curl ${GHPAGES_URL} -o ${GHPAGES_DOWNLOAD_PATH} - chmod +x ${GHPAGES_DOWNLOAD_PATH} -} - -# Either clones or pulls the repo for given project and then updates gh-pages for the given project -# Params: -# $1 organization e.g. spring-cloud -# $2 repo name e.g. spring-cloud-sleuth -# $3 branch name e.g. master -function clone_and_update_ghpages() { - if [ "$#" -ne 3 ] - then - echo "You haven't provided 3 args... \$1 organization e.g. spring-cloud; \$2 repo name e.g. spring-cloud-sleuth; \$3 branch name e.g. master" - exit 1 - fi - local ORGANIZATION=$1 - local REPO_NAME=$2 - local BRANCH_NAME=$3 - local VAR - echo -e "\n\nWill clone the repo and update scripts for org [${ORGANIZATION}], repo [${REPO_NAME}] and branch [${BRANCH_NAME}]\n\n" - clone_or_pull ${ORGANIZATION} ${REPO_NAME} - update_ghpages_script ${REPO_NAME} ${BRANCH_NAME} - echo "Proceeding to next project" - wait_if_manual_proceed -} - -function wait_if_manual_proceed() { - if [[ "${AUTO_PROCEED}" != "yes" ]] ; then - echo -n "Press [ENTER] to continue..." - read VAR - fi -} - -# Prints the provided parameters -function print_parameters() { -cat < 0 ]] -do -key="$1" -case ${key} in - -p|--nopush) - AUTO_PUSH="no" - ;; - -m|--manualproceed) - AUTO_PROCEED="no" - ;; - -x|--justpush) - JUST_PUSH="yes" - ;; - -h|--help) - print_usage - exit 0 - ;; - *) - echo "Invalid option: [$1]" - print_usage - exit 1 - ;; -esac -shift # past argument or value -done - -export GHPAGES_URL=${GHPAGES_URL:-https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/master/docs/src/main/asciidoc/ghpages.sh} -export GHPAGES_DOWNLOAD_PATH=${GHPAGES_DOWNLOAD_PATH:-/tmp/ghpages.sh} -export COMMIT_MESSAGE=${COMMIT_MESSAGE:-Updating ghpages for all projects} -export GHPAGES_IN_REPO_PATH=${GHPAGES_IN_REPO_PATH:-docs/src/main/asciidoc/ghpages.sh} -export AUTO_PROCEED=${AUTO_PROCEED:-yes} -export AUTO_PUSH=${AUTO_PUSH:-yes} -export JUST_PUSH=${JUST_PUSH:-no} -export ROOT_FOLDER=`pwd` - - -print_parameters -download_ghpages -clone_and_update_ghpages spring-cloud spring-cloud-aws master -clone_and_update_ghpages spring-cloud spring-cloud-aws 1.0.x -clone_and_update_ghpages spring-cloud spring-cloud-aws 1.2.x -clone_and_update_ghpages spring-cloud spring-cloud-bus master -clone_and_update_ghpages spring-cloud spring-cloud-cli 1.0.x -clone_and_update_ghpages spring-cloud spring-cloud-cli 1.1.x -clone_and_update_ghpages spring-cloud spring-cloud-cli master -clone_and_update_ghpages spring-cloud spring-cloud-cloudfoundry master -clone_and_update_ghpages spring-cloud spring-cloud-cluster master -clone_and_update_ghpages spring-cloud spring-cloud-commons master -clone_and_update_ghpages spring-cloud spring-cloud-config master -clone_and_update_ghpages spring-cloud spring-cloud-config 1.1.x -clone_and_update_ghpages spring-cloud spring-cloud-consul 1.0.x -clone_and_update_ghpages spring-cloud spring-cloud-consul master -clone_and_update_ghpages spring-cloud spring-cloud-contract master -clone_and_update_ghpages spring-cloud spring-cloud-netflix 1.0.x -clone_and_update_ghpages spring-cloud spring-cloud-netflix 1.1.x -clone_and_update_ghpages spring-cloud spring-cloud-netflix master -clone_and_update_ghpages spring-cloud spring-cloud-security master -clone_and_update_ghpages spring-cloud spring-cloud-sleuth 1.0.x -clone_and_update_ghpages spring-cloud spring-cloud-sleuth master -clone_and_update_ghpages spring-cloud spring-cloud-starters Brixton -clone_and_update_ghpages spring-cloud spring-cloud-starters master -clone_and_update_ghpages spring-cloud-incubator spring-cloud-vault-config master -clone_and_update_ghpages spring-cloud spring-cloud-zookeeper master \ No newline at end of file diff --git a/docs/src/test/bash/build-helper.sh b/docs/src/test/bash/build-helper.sh new file mode 100755 index 000000000..c61747ed3 --- /dev/null +++ b/docs/src/test/bash/build-helper.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +[[ -z $DEBUG ]] || set -o xtrace + +set -o errexit +set -o errtrace +set -o nounset +set -o pipefail + +ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT_DIR="${ROOT_DIR}/../../.." + +function usage { + echo "usage: $0: " + exit 1 +} + +if [[ $# -ne 1 ]]; then + usage +fi + +SHELLCHECK_VERSION="v0.4.6" +SHELLCHECK_BIN="${ROOT_DIR}/../target/shellcheck-${SHELLCHECK_VERSION}/shellcheck" + +case $1 in + download-shellcheck) + if [[ "${OSTYPE}" == linux* && ! -z "${SHELLCHECK_BIN}" ]]; then + SHELLCHECK_ARCHIVE="shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + SHELLCHECK_ARCHIVE_SHA512SUM="d9ac3e4fb2383b2d6862415e8052459ce24fd5402806b9ce739990d5c1cccebe4121288df29de32dcef5daa115874ddf7f9730de256bf134ee11cd9704aaa64c" + if [[ -x "${ROOT_DIR}/../target/shellcheck-${SHELLCHECK_VERSION}/shellcheck" ]]; then + echo "shellcheck already downloaded - skipping..." + exit 0 + fi + wget -P "${ROOT_DIR}/../target/" \ + "https://storage.googleapis.com/shellcheck/${SHELLCHECK_ARCHIVE}" + pushd "${ROOT_DIR}/../target/" + echo "${SHELLCHECK_ARCHIVE_SHA512SUM} ${SHELLCHECK_ARCHIVE}" | sha512sum -c - + tar xvf "${SHELLCHECK_ARCHIVE}" + rm -vf -- "${SHELLCHECK_ARCHIVE}" + popd + else + echo "It seems that automatic installation is not supported on your platform." + echo "Please install shellcheck manually:" + echo " https://github.com/koalaman/shellcheck#installing" + exit 1 + fi + ;; + run-shellcheck) + echo "Running shellcheck" + "${SHELLCHECK_BIN}" "${ROOT_DIR}"/src/main/asciidoc/*.sh + echo "Shellcheck passed sucesfully!" + ;; + download-bats) + if [[ -x "${ROOT_DIR}/../target/bats/bin/bats" ]]; then + echo "bats already downloaded - skipping..." + exit 0 + fi + git clone https://github.com/bats-core/bats-core.git "${ROOT_DIR}/../target/bats" + ;; + run-bats) + echo "Running bats" + SHELLCHECK_BIN="${ROOT_DIR}/../target/bats/bin/bats" + "${SHELLCHECK_BIN}" "${ROOT_DIR}"/src/test/bats + echo "Bats passed sucesfully!" + ;; + initialize-submodules) + files="$( ls "${ROOT_DIR}/src/test/bats/test_helper/bats-assert/" || echo "" )" + if [ ! -z "${files}" ]; then + echo "Submodules already initialized"; + git submodule foreach git pull origin master || echo "Failed to pull - continuing the script" + else + echo "Initilizing submodules" + git submodule init + git submodule update + git submodule foreach git pull origin master || echo "Failed to pull - continuing the script" + fi + ;; + *) + usage + ;; +esac \ No newline at end of file diff --git a/docs/src/test/bash/run-bats.sh b/docs/src/test/bash/run-bats.sh new file mode 100755 index 000000000..238e28742 --- /dev/null +++ b/docs/src/test/bash/run-bats.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +[[ -z $DEBUG ]] || set -o xtrace + +set -o errexit +set -o errtrace +set -o nounset +set -o pipefail + +ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +"${ROOT_DIR}"/build-helper.sh "initialize-submodules" +"${ROOT_DIR}"/build-helper.sh "download-shellcheck" +"${ROOT_DIR}"/build-helper.sh "run-shellcheck" +"${ROOT_DIR}"/build-helper.sh "download-bats" +"${ROOT_DIR}"/build-helper.sh "run-bats" \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/README b/docs/src/test/bats/fixtures/spring-cloud-static/README new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/COMMIT_EDITMSG b/docs/src/test/bats/fixtures/spring-cloud-static/git/COMMIT_EDITMSG new file mode 100644 index 000000000..bc56c4d89 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/COMMIT_EDITMSG @@ -0,0 +1 @@ +Foo diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/HEAD b/docs/src/test/bats/fixtures/spring-cloud-static/git/HEAD new file mode 100644 index 000000000..cb089cd89 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/config b/docs/src/test/bats/fixtures/spring-cloud-static/git/config new file mode 100644 index 000000000..65cacc92b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/config @@ -0,0 +1,11 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = file://. + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "master"] + remote = origin + merge = refs/heads/master diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/description b/docs/src/test/bats/fixtures/spring-cloud-static/git/description new file mode 100644 index 000000000..498b267a8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/applypatch-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/applypatch-msg.sample new file mode 100755 index 000000000..a5d7b84a6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/commit-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/commit-msg.sample new file mode 100755 index 000000000..b58d1184a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/fsmonitor-watchman.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/fsmonitor-watchman.sample new file mode 100755 index 000000000..e673bb398 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,114 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 1) and a time in nanoseconds +# formatted as a string and outputs to stdout all files that have been +# modified since the given time. Paths must be relative to the root of +# the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $time) = @ARGV; + +# Check the hook interface version + +if ($version == 1) { + # convert nanoseconds to seconds + $time = int $time / 1000000000; +} else { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree; +if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $git_work_tree = Win32::GetCwd(); + $git_work_tree =~ tr/\\/\//; +} else { + require Cwd; + $git_work_tree = Cwd::cwd(); +} + +my $retry = 1; + +launch_watchman(); + +sub launch_watchman { + + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $time but were not transient (ie created after + # $time but no longer exist). + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + # + # The category of transient files that we want to ignore will have a + # creation clock (cclock) newer than $time_t value and will also not + # currently exist. + + my $query = <<" END"; + ["query", "$git_work_tree", { + "since": $time, + "fields": ["name"], + "expression": ["not", ["allof", ["since", $time, "cclock"], ["not", "exists"]]] + }] + END + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + my $json_pkg; + eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; + } or do { + require JSON::PP; + $json_pkg = "JSON::PP"; + }; + + my $o = $json_pkg->new->utf8->decode($response); + + if ($retry > 0 and $o->{error} and $o->{error} =~ m/unable to resolve root .* directory (.*) is not watched/) { + print STDERR "Adding '$git_work_tree' to watchman's watch list.\n"; + $retry--; + qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + print "/\0"; + eval { launch_watchman() }; + exit 0; + } + + die "Watchman: $o->{error}.\n" . + "Falling back to scanning...\n" if $o->{error}; + + binmode STDOUT, ":utf8"; + local $, = "\0"; + print @{$o->{files}}; +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/post-update.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/post-update.sample new file mode 100755 index 000000000..ec17ec193 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-applypatch.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-applypatch.sample new file mode 100755 index 000000000..4142082bc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-commit.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-commit.sample new file mode 100755 index 000000000..6a7564163 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-push.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-push.sample new file mode 100755 index 000000000..6187dbf43 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-rebase.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-rebase.sample new file mode 100755 index 000000000..6cbef5c37 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-receive.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-receive.sample new file mode 100755 index 000000000..a1fd29ec1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/prepare-commit-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/prepare-commit-msg.sample new file mode 100755 index 000000000..10fa14c5a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/update.sample b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/update.sample new file mode 100755 index 000000000..80ba94135 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/index b/docs/src/test/bats/fixtures/spring-cloud-static/git/index new file mode 100644 index 000000000..d1d20a239 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/index differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/info/exclude b/docs/src/test/bats/fixtures/spring-cloud-static/git/info/exclude new file mode 100644 index 000000000..a5196d1be --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/HEAD b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/HEAD new file mode 100644 index 000000000..876fd71ed --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/HEAD @@ -0,0 +1,4 @@ +0000000000000000000000000000000000000000 d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 Marcin Grzejszczak 1553619277 +0100 commit (initial): Initial commit +d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 Marcin Grzejszczak 1553619297 +0100 checkout: moving from master to gh-pages +d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 722a79af4a158c0076b4aaab7273750a35034738 Marcin Grzejszczak 1553619310 +0100 commit: Foo +722a79af4a158c0076b4aaab7273750a35034738 d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 Marcin Grzejszczak 1553619315 +0100 checkout: moving from gh-pages to master diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/gh-pages new file mode 100644 index 000000000..b9390f3f0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/gh-pages @@ -0,0 +1,2 @@ +0000000000000000000000000000000000000000 d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 Marcin Grzejszczak 1553619297 +0100 branch: Created from HEAD +d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 722a79af4a158c0076b4aaab7273750a35034738 Marcin Grzejszczak 1553619310 +0100 commit: Foo diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/master b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/master new file mode 100644 index 000000000..6072f54bf --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/logs/refs/heads/master @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 Marcin Grzejszczak 1553619277 +0100 commit (initial): Initial commit diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/21/a7d70e0b6d7db6a57159723500c1d74a79273f b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/21/a7d70e0b6d7db6a57159723500c1d74a79273f new file mode 100644 index 000000000..928e2411a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/21/a7d70e0b6d7db6a57159723500c1d74a79273f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/54/3b9bebdc6bd5c4b22136034a95dd097a57d3dd b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/54/3b9bebdc6bd5c4b22136034a95dd097a57d3dd new file mode 100644 index 000000000..b57931d75 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/54/3b9bebdc6bd5c4b22136034a95dd097a57d3dd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/72/2a79af4a158c0076b4aaab7273750a35034738 b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/72/2a79af4a158c0076b4aaab7273750a35034738 new file mode 100644 index 000000000..ade2d9585 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/72/2a79af4a158c0076b4aaab7273750a35034738 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/d9/99af9d9ffde37f31f69dc42e9c9c1be55c0b72 b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/d9/99af9d9ffde37f31f69dc42e9c9c1be55c0b72 new file mode 100644 index 000000000..3d403e397 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/d9/99af9d9ffde37f31f69dc42e9c9c1be55c0b72 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 000000000..711223894 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-static/git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/gh-pages new file mode 100644 index 000000000..65ad16707 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/gh-pages @@ -0,0 +1 @@ +722a79af4a158c0076b4aaab7273750a35034738 diff --git a/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/master b/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/master new file mode 100644 index 000000000..ad2f06e50 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-static/git/refs/heads/master @@ -0,0 +1 @@ +d999af9d9ffde37f31f69dc42e9c9c1be55c0b72 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.editorconfig b/docs/src/test/bats/fixtures/spring-cloud-stream/.editorconfig new file mode 100644 index 000000000..0679d88a9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +insert_final_newline = true + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.gitignore b/docs/src/test/bats/fixtures/spring-cloud-stream/.gitignore new file mode 100644 index 000000000..214f0bcb3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.gitignore @@ -0,0 +1,27 @@ +/application.yml +/application.properties +asciidoctor.css +*~ +.#* +*# +target/ +build/ +bin/ +_site/ +.classpath +.project +.settings +.springBeans +.sts4-cache/ +.attach_pid* +.DS_Store +*.sw* +*.iml +*.ipr +*.iws +.idea/* +.factorypath +dump.rdb +.apt_generated +artifacts +**/dependency-reduced-pom.xml diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/jvm.config b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/jvm.config new file mode 100644 index 000000000..0e7dabeff --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/jvm.config @@ -0,0 +1 @@ +-Xmx1024m -XX:CICompilerCount=1 -XX:TieredStopAtLevel=1 -Djava.security.egd=file:/dev/./urandom \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/maven.config b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/maven.config new file mode 100644 index 000000000..3b8cf46e1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/maven.config @@ -0,0 +1 @@ +-DaltSnapshotDeploymentRepository=repo.spring.io::default::https://repo.spring.io/libs-snapshot-local -P spring diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/MavenWrapperDownloader.java b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100755 index 000000000..2e394d5b3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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 + + https://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. +*/ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = + "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: : " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.jar b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.jar new file mode 100755 index 000000000..01e679973 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.jar differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.properties new file mode 100755 index 000000000..00d32aab1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.4/apache-maven-3.5.4-bin.zip \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.settings.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/.settings.xml new file mode 100644 index 000000000..03645e8ce --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.settings.xml @@ -0,0 +1,68 @@ + + + + + repo.spring.io + ${env.CI_DEPLOY_USERNAME} + ${env.CI_DEPLOY_PASSWORD} + + + + + + spring + + true + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/release + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.springformat b/docs/src/test/bats/fixtures/spring-cloud-stream/.springformat new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/.travis.yml b/docs/src/test/bats/fixtures/spring-cloud-stream/.travis.yml new file mode 100644 index 000000000..a8f5dd8a0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/.travis.yml @@ -0,0 +1,16 @@ +sudo: required +cache: + directories: + - $HOME/.m2 +language: java +jdk: + - oraclejdk8 +install: true +# The environment variable ${TRAVIS_PULL_REQUEST} is set to "false" when the build +# is for a normal branch commit. When the build is for a pull request, it will +# contain the pull request’s number. +script: + - '[ "${TRAVIS_PULL_REQUEST}" != "false" ] || ./mvnw package -Pspring,full -U -Dmaven.test.redirectTestOutputToFile=false' + - '[ "${TRAVIS_PULL_REQUEST}" = "false" ] || ./mvnw package -Pspring -U -Dmaven.test.redirectTestOutputToFile=false' +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/CODE_OF_CONDUCT.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/CODE_OF_CONDUCT.adoc new file mode 100644 index 000000000..f013d6f36 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/CODE_OF_CONDUCT.adoc @@ -0,0 +1,44 @@ += Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open +and welcoming community, we pledge to respect all people who contribute through reporting +issues, posting feature requests, updating documentation, submitting pull requests or +patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for +everyone, regardless of level of experience, gender, gender identity and expression, +sexual orientation, disability, personal appearance, body size, race, ethnicity, age, +religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic addresses, + without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned to this +Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors +that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to fairly and +consistently applying these principles to every aspect of managing this project. Project +maintainers who do not follow or enforce the Code of Conduct may be permanently removed +from the project team. + +This Code of Conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting a project maintainer at spring-code-of-conduct@pivotal.io . All complaints will +be reviewed and investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. Maintainers are obligated to maintain confidentiality +with regard to the reporter of an incident. + +This Code of Conduct is adapted from the +http://contributor-covenant.org[Contributor Covenant], version 1.3.0, available at +http://contributor-covenant.org/version/1/3/0/[contributor-covenant.org/version/1/3/0/] diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/LICENSE b/docs/src/test/bats/fixtures/spring-cloud-stream/LICENSE new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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. + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/README.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/README.adoc new file mode 100644 index 000000000..967946c6a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/README.adoc @@ -0,0 +1,334 @@ +//// +DO NOT EDIT THIS FILE. IT WAS GENERATED. +Manual changes to this file will be lost when it is generated again. +Edit the files in the src/main/asciidoc/ directory instead. +//// + +:jdkversion: 1.8 +:github-tag: master +:github-repo: spring-cloud/spring-cloud-stream + +:github-raw: https://raw.githubusercontent.com/{github-repo}/{github-tag} +:github-code: https://github.com/{github-repo}/tree/{github-tag} + +image::https://circleci.com/gh/spring-cloud/spring-cloud-stream.svg?style=svg["CircleCI", link="https://circleci.com/gh/spring-cloud/spring-cloud-stream"] +image::https://codecov.io/gh/spring-cloud/spring-cloud-stream/branch/{github-tag}/graph/badge.svg["codecov", link="https://codecov.io/gh/spring-cloud/spring-cloud-stream"] +image::https://badges.gitter.im/spring-cloud/spring-cloud-stream.svg[Gitter, link="https://gitter.im/spring-cloud/spring-cloud-stream?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] + +// ====================================================================================== + += Preface +=== A Brief History of Spring's Data Integration Journey + +Spring's journey on Data Integration started with https://projects.spring.io/spring-integration/[Spring Integration]. With its programming model, it provided a consistent developer experience to build applications that can embrace http://www.enterpriseintegrationpatterns.com/[Enterprise Integration Patterns] to connect with external systems such as, databases, message brokers, and among others. + +Fast forward to the cloud-era, where microservices have become prominent in the enterprise setting. https://projects.spring.io/spring-boot/[Spring Boot] transformed the way how developers built Applications. With Spring's programming model and the runtime responsibilities handled by Spring Boot, it became seamless to develop stand-alone, production-grade Spring-based microservices. + +To extend this to Data Integration workloads, Spring Integration and Spring Boot were put together into a new project. Spring Cloud Stream was born. + +[%hardbreaks] +With Spring Cloud Stream, developers can: +* Build, test, iterate, and deploy data-centric applications in isolation. +* Apply modern microservices architecture patterns, including composition through messaging. +* Decouple application responsibilities with event-centric thinking. An event can represent something that has happened in time, to which the downstream consumer applications can react without knowing where it originated or the producer's identity. +* Port the business logic onto message brokers (such as RabbitMQ, Apache Kafka, Amazon Kinesis). +* Interoperate between channel-based and non-channel-based application binding scenarios to support stateless and stateful computations by using Project Reactor's Flux and Kafka Streams APIs. +* Rely on the framework's automatic content-type support for common use-cases. Extending to different data conversion types is possible. + +=== Quick Start + +You can try Spring Cloud Stream in less then 5 min even before you jump into any details by following this three-step guide. + +We show you how to create a Spring Cloud Stream application that receives messages coming from the messaging middleware of your choice (more on this later) and logs received messages to the console. +We call it `LoggingConsumer`. +While not very practical, it provides a good introduction to some of the main concepts +and abstractions, making it easier to digest the rest of this user guide. + +The three steps are as follows: + +. <> +. <> +. <> + +[[spring-cloud-stream-preface-creating-sample-application]] +==== Creating a Sample Application by Using Spring Initializr +To get started, visit the https://start.spring.io[Spring Initializr]. From there, you can generate our `LoggingConsumer` application. To do so: + +. In the *Dependencies* section, start typing `stream`. +When the "`Cloud Stream`" option should appears, select it. +. Start typing either 'kafka' or 'rabbit'. +. Select "`Kafka`" or "`RabbitMQ`". ++ +Basically, you choose the messaging middleware to which your application binds. +We recommend using the one you have already installed or feel more comfortable with installing and running. +Also, as you can see from the Initilaizer screen, there are a few other options you can choose. +For example, you can choose Gradle as your build tool instead of Maven (the default). +. In the *Artifact* field, type 'logging-consumer'. ++ +The value of the *Artifact* field becomes the application name. +If you chose RabbitMQ for the middleware, your Spring Initializr should now be as follows: + +[%hardbreaks] +[%hardbreaks] +[%hardbreaks] +image::{github-raw}/docs/src/main/asciidoc/images/spring-initializr.png[align="center"] + +[%hardbreaks] +[%hardbreaks] + +. Click the *Generate Project* button. ++ +Doing so downloads the zipped version of the generated project to your hard drive. +. Unzip the file into the folder you want to use as your project directory. + +TIP: We encourage you to explore the many possibilities available in the Spring Initializr. +It lets you create many different kinds of Spring applications. + +[[spring-cloud-stream-preface-importing-project]] +==== Importing the Project into Your IDE + +Now you can import the project into your IDE. +Keep in mind that, depending on the IDE, you may need to follow a specific import procedure. +For example, depending on how the project was generated (Maven or Gradle), you may need to follow specific import procedure (for example, in Eclipse or STS, you need to use File -> Import -> Maven -> Existing Maven Project). + +Once imported, the project must have no errors of any kind. Also, `src/main/java` should contain `com.example.loggingconsumer.LoggingConsumerApplication`. + +Technically, at this point, you can run the application's main class. +It is already a valid Spring Boot application. +However, it does not do anything, so we want to add some code. + +[[spring-cloud-stream-preface-adding-message-handler]] +==== Adding a Message Handler, Building, and Running + +Modify the `com.example.loggingconsumer.LoggingConsumerApplication` class to look as follows: + +[source, java] +---- +@SpringBootApplication +@EnableBinding(Sink.class) +public class LoggingConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(LoggingConsumerApplication.class, args); + } + + @StreamListener(Sink.INPUT) + public void handle(Person person) { + System.out.println("Received: " + person); + } + + public static class Person { + private String name; + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + public String toString() { + return this.name; + } + } +} +---- + +As you can see from the preceding listing: + +* We have enabled `Sink` binding (input-no-output) by using `@EnableBinding(Sink.class)`. +Doing so signals to the framework to initiate binding to the messaging middleware, where it automatically creates the destination (that is, queue, topic, and others) that are bound to the `Sink.INPUT` channel. +* We have added a `handler` method to receive incoming messages of type `Person`. +Doing so lets you see one of the core features of the framework: It tries to automatically convert incoming message payloads to type `Person`. + +You now have a fully functional Spring Cloud Stream application that does listens for messages. +From here, for simplicity, we assume you selected RabbitMQ in <>. +Assuming you have RabbitMQ installed and running, you can start the application by running its `main` method in your IDE. + +You should see following output: + +[source] +---- + --- [ main] c.s.b.r.p.RabbitExchangeQueueProvisioner : declaring queue for inbound: input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg, bound to: input + --- [ main] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672] + --- [ main] o.s.a.r.c.CachingConnectionFactory : Created new connection: rabbitConnectionFactory#2a3a299:0/SimpleConnection@66c83fc8. . . + . . . + --- [ main] o.s.i.a.i.AmqpInboundChannelAdapter : started inbound.input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg + . . . + --- [ main] c.e.l.LoggingConsumerApplication : Started LoggingConsumerApplication in 2.531 seconds (JVM running for 2.897) +---- + +Go to the RabbitMQ management console or any other RabbitMQ client and send a message to `input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg`. +The `anonymous.CbMIwdkJSBO1ZoPDOtHtCg` part represents the group name and is generated, so it is bound to be different in your environment. +For something more predictable, you can use an explicit group name by setting `spring.cloud.stream.bindings.input.group=hello` (or whatever name you like). + +The contents of the message should be a JSON representation of the `Person` class, as follows: + + {"name":"Sam Spade"} + +Then, in your console, you should see: + +`Received: Sam Spade` + +You can also build and package your application into a boot jar (by using `./mvnw clean install`) and run the built JAR by using the `java -jar` command. + +Now you have a working (albeit very basic) Spring Cloud Stream application. + +== What's New in 2.2? +Spring Cloud Stream introduces a number of new features, enhancements, and changes in addition to the once already introduced in +https://docs.spring.io/spring-cloud-stream/docs/Elmhurst.SR2/reference/htmlsingle/#_what_s_new_in_2_0[version 2.0] + + +The following sections outline the most notable ones: + +* <> +* <> + +[[spring-cloud-stream-preface-new-features]] +=== New Features and Components + + +[[spring-cloud-stream-preface-notable-enhancements]] +=== Notable Enhancements + + +[[spring-cloud-stream-preface-notable-deprecations]] +=== Notable Deprecations + +As of version 2.2, the following items have been deprecated: + +- The spring-cloud-stream-reactive module is deprecated in favor of native support + via <> programming model. + +=== Notes on migrating from 1.x to 2.x? +- Due to the improvements in content-type negotiation, the `originalContentType` header is not used (ignored) since 2.x and only exists for maintaining compatibility with 1.x versions +- Introduction of `@StreamRetryTemplate` qualifier. While configuring custom instance of the `RetryTemplate` and to avoid conflicts you must qualify the instance of such `RetryTemplate` with this qualifier. See <> for more details. + += Appendices +[appendix] +[[building]] +== Building + +:jdkversion: 1.8 + +=== Basic Compile and Test + +To build the source you will need to install JDK {jdkversion}. + +The build uses the Maven wrapper so you don't have to install a specific +version of Maven. To enable the tests for Redis, Rabbit, and Kafka bindings you +should have those servers running before building. See below for more +information on running the servers. + +The main build command is + +---- +$ ./mvnw clean install +---- + +You can also add '-DskipTests' if you like, to avoid running the tests. + +NOTE: You can also install Maven (>=3.3.3) yourself and run the `mvn` command +in place of `./mvnw` in the examples below. If you do that you also +might need to add `-P spring` if your local Maven settings do not +contain repository declarations for spring pre-release artifacts. + +NOTE: Be aware that you might need to increase the amount of memory +available to Maven by setting a `MAVEN_OPTS` environment variable with +a value like `-Xmx512m -XX:MaxPermSize=128m`. We try to cover this in +the `.mvn` configuration, so if you find you have to do it to make a +build succeed, please raise a ticket to get the settings added to +source control. + + +The projects that require middleware generally include a +`docker-compose.yml`, so consider using +http://compose.docker.io/[Docker Compose] to run the middeware servers +in Docker containers. See the README in the +https://github.com/spring-cloud-samples/scripts[scripts demo +repository] for specific instructions about the common cases of mongo, +rabbit and redis. + +=== Documentation + +There is a "full" profile that will generate documentation. + +=== Working with the code +If you don't have an IDE preference we would recommend that you use +http://www.springsource.com/developer/sts[Spring Tools Suite] or +http://eclipse.org[Eclipse] when working with the code. We use the +http://eclipse.org/m2e/[m2eclipe] eclipse plugin for maven support. Other IDEs and tools +should also work without issue. + +==== Importing into eclipse with m2eclipse +We recommend the http://eclipse.org/m2e/[m2eclipe] eclipse plugin when working with +eclipse. If you don't already have m2eclipse installed it is available from the "eclipse +marketplace". + +Unfortunately m2e does not yet support Maven 3.3, so once the projects +are imported into Eclipse you will also need to tell m2eclipse to use +the `.settings.xml` file for the projects. If you do not do this you +may see many different errors related to the POMs in the +projects. Open your Eclipse preferences, expand the Maven +preferences, and select User Settings. In the User Settings field +click Browse and navigate to the Spring Cloud project you imported +selecting the `.settings.xml` file in that project. Click Apply and +then OK to save the preference changes. + +NOTE: Alternatively you can copy the repository settings from https://github.com/spring-cloud/spring-cloud-build/blob/master/.settings.xml[`.settings.xml`] into your own `~/.m2/settings.xml`. + +==== Importing into eclipse without m2eclipse +If you prefer not to use m2eclipse you can generate eclipse project metadata using the +following command: + +[indent=0] +---- + $ ./mvnw eclipse:eclipse +---- + +The generated eclipse projects can be imported by selecting `import existing projects` +from the `file` menu. + +[[contributing]] +== Contributing + +Spring Cloud is released under the non-restrictive Apache 2.0 license, +and follows a very standard Github development process, using Github +tracker for issues and merging pull requests into master. If you want +to contribute even something trivial please do not hesitate, but +follow the guidelines below. + +=== Sign the Contributor License Agreement +Before we accept a non-trivial patch or pull request we will need you to sign the +https://support.springsource.com/spring_committer_signup[contributor's agreement]. +Signing the contributor's agreement does not grant anyone commit rights to the main +repository, but it does mean that we can accept your contributions, and you will get an +author credit if we do. Active contributors might be asked to join the core team, and +given the ability to merge pull requests. + +=== Code Conventions and Housekeeping +None of these is essential for a pull request, but they will all help. They can also be +added after the original pull request but before a merge. + +* Use the Spring Framework code format conventions. If you use Eclipse + you can import formatter settings using the + `eclipse-code-formatter.xml` file from the + https://github.com/spring-cloud/build/tree/master/eclipse-coding-conventions.xml[Spring + Cloud Build] project. If using IntelliJ, you can use the + http://plugins.jetbrains.com/plugin/6546[Eclipse Code Formatter + Plugin] to import the same file. +* Make sure all new `.java` files to have a simple Javadoc class comment with at least an + `@author` tag identifying you, and preferably at least a paragraph on what the class is + for. +* Add the ASF license header comment to all new `.java` files (copy from existing files + in the project) +* Add yourself as an `@author` to the .java files that you modify substantially (more + than cosmetic changes). +* Add some Javadocs and, if you change the namespace, some XSD doc elements. +* A few unit tests would help a lot as well -- someone has to do it. +* If no-one else is using your branch, please rebase it against the current master (or + other target branch in the main project). +* When writing a commit message please follow http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], + if you are fixing an existing issue please add `Fixes gh-XXXX` at the end of the commit + message (where XXXX is the issue number). + + +// ====================================================================================== \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/pom.xml new file mode 100644 index 000000000..39d84f497 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/pom.xml @@ -0,0 +1,326 @@ + + + 4.0.0 + spring-cloud-stream-core-docs + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + pom + spring-cloud-stream-core-docs + Spring Cloud Stream Core Documentation + + home + ${basedir}/.. + 0.1.1.RELEASE + 0.1.0.RELEASE + + 1.5.0-alpha.16 + + + + docs + + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven-dependency-plugin.version} + false + + + unpack-docs + generate-resources + + unpack + + + + + org.springframework.cloud + + spring-cloud-build-docs + + ${spring-cloud-build.version} + + sources + jar + false + ${docs.resources.dir} + + + + + + + unpack-docs-resources + generate-resources + + unpack + + + + + io.spring.docresources + spring-doc-resources + ${spring-doc-resources.version} + zip + true + ${project.build.directory}/refdocs/ + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-asciidoc-resources + generate-resources + + copy-resources + + + ${project.build.directory}/refdocs/ + + + src/main/asciidoc + false + + ghpages.sh + + + + + + + + + + org.asciidoctor + asciidoctor-maven-plugin + ${asciidoctor-maven-plugin.version} + false + + + io.spring.asciidoctor + spring-asciidoctor-extensions + ${spring-asciidoctor-extensions.version} + + + org.asciidoctor + asciidoctorj-pdf + ${asciidoctorj-pdf.version} + + + + ${project.build.directory}/refdocs/ + + ${project.version} + http://cloud.spring.io/ + + + + + + + + generate-html-documentation + prepare-package + + process-asciidoc + + + html5 + highlight.js + book + + // these attributes are required to use the doc resources + shared + css/ + spring.css + true + font + js/highlight + atom-one-dark-reasonable + true + + left + 4 + ${project.version} + true + + + + + generate-docbook + none + + process-asciidoc + + + + generate-index + none + + process-asciidoc + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + ${maven-antrun-plugin.version} + + + ant-contrib + ant-contrib + 1.0b3 + + + ant + ant + + + + + org.apache.ant + ant-nodeps + 1.8.1 + + + org.tigris.antelope + antelopetasks + 3.2.10 + + + org.jruby + jruby-complete + 1.7.17 + + + org.asciidoctor + asciidoctorj + 1.5.8 + + + + + readme + process-resources + + run + + + + + + + + + + + + + assert-no-unresolved-links + prepare-package + + run + + + + + + + + + + + + + + + + setup-maven-properties + validate + + run + + + true + + + + + + + + + + + + + + + + + + copy-css + none + + run + + + + generate-documentation-index + none + + run + + + + copy-generated-html + none + + run + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + false + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/README.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/README.adoc new file mode 100644 index 000000000..235d8e2e3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/README.adoc @@ -0,0 +1,146 @@ +:jdkversion: 1.8 +:github-tag: master +:github-repo: spring-cloud/spring-cloud-stream + +:github-raw: https://raw.githubusercontent.com/{github-repo}/{github-tag} +:github-code: https://github.com/{github-repo}/tree/{github-tag} + +image::https://circleci.com/gh/spring-cloud/spring-cloud-stream.svg?style=svg["CircleCI", link="https://circleci.com/gh/spring-cloud/spring-cloud-stream"] +image::https://codecov.io/gh/spring-cloud/spring-cloud-stream/branch/{github-tag}/graph/badge.svg["codecov", link="https://codecov.io/gh/spring-cloud/spring-cloud-stream"] +image::https://badges.gitter.im/spring-cloud/spring-cloud-stream.svg[Gitter, link="https://gitter.im/spring-cloud/spring-cloud-stream?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] + +// ====================================================================================== + += Preface +include::preface.adoc[] + += Appendices +[appendix] +[[building]] +== Building + +:jdkversion: 1.8 + +=== Basic Compile and Test + +To build the source you will need to install JDK {jdkversion}. + +The build uses the Maven wrapper so you don't have to install a specific +version of Maven. To enable the tests for Redis, Rabbit, and Kafka bindings you +should have those servers running before building. See below for more +information on running the servers. + +The main build command is + +---- +$ ./mvnw clean install +---- + +You can also add '-DskipTests' if you like, to avoid running the tests. + +NOTE: You can also install Maven (>=3.3.3) yourself and run the `mvn` command +in place of `./mvnw` in the examples below. If you do that you also +might need to add `-P spring` if your local Maven settings do not +contain repository declarations for spring pre-release artifacts. + +NOTE: Be aware that you might need to increase the amount of memory +available to Maven by setting a `MAVEN_OPTS` environment variable with +a value like `-Xmx512m -XX:MaxPermSize=128m`. We try to cover this in +the `.mvn` configuration, so if you find you have to do it to make a +build succeed, please raise a ticket to get the settings added to +source control. + + +The projects that require middleware generally include a +`docker-compose.yml`, so consider using +http://compose.docker.io/[Docker Compose] to run the middeware servers +in Docker containers. See the README in the +https://github.com/spring-cloud-samples/scripts[scripts demo +repository] for specific instructions about the common cases of mongo, +rabbit and redis. + +=== Documentation + +There is a "full" profile that will generate documentation. + +=== Working with the code +If you don't have an IDE preference we would recommend that you use +http://www.springsource.com/developer/sts[Spring Tools Suite] or +http://eclipse.org[Eclipse] when working with the code. We use the +http://eclipse.org/m2e/[m2eclipe] eclipse plugin for maven support. Other IDEs and tools +should also work without issue. + +==== Importing into eclipse with m2eclipse +We recommend the http://eclipse.org/m2e/[m2eclipe] eclipse plugin when working with +eclipse. If you don't already have m2eclipse installed it is available from the "eclipse +marketplace". + +Unfortunately m2e does not yet support Maven 3.3, so once the projects +are imported into Eclipse you will also need to tell m2eclipse to use +the `.settings.xml` file for the projects. If you do not do this you +may see many different errors related to the POMs in the +projects. Open your Eclipse preferences, expand the Maven +preferences, and select User Settings. In the User Settings field +click Browse and navigate to the Spring Cloud project you imported +selecting the `.settings.xml` file in that project. Click Apply and +then OK to save the preference changes. + +NOTE: Alternatively you can copy the repository settings from https://github.com/spring-cloud/spring-cloud-build/blob/master/.settings.xml[`.settings.xml`] into your own `~/.m2/settings.xml`. + +==== Importing into eclipse without m2eclipse +If you prefer not to use m2eclipse you can generate eclipse project metadata using the +following command: + +[indent=0] +---- + $ ./mvnw eclipse:eclipse +---- + +The generated eclipse projects can be imported by selecting `import existing projects` +from the `file` menu. + +[[contributing]] +== Contributing + +Spring Cloud is released under the non-restrictive Apache 2.0 license, +and follows a very standard Github development process, using Github +tracker for issues and merging pull requests into master. If you want +to contribute even something trivial please do not hesitate, but +follow the guidelines below. + +=== Sign the Contributor License Agreement +Before we accept a non-trivial patch or pull request we will need you to sign the +https://support.springsource.com/spring_committer_signup[contributor's agreement]. +Signing the contributor's agreement does not grant anyone commit rights to the main +repository, but it does mean that we can accept your contributions, and you will get an +author credit if we do. Active contributors might be asked to join the core team, and +given the ability to merge pull requests. + +=== Code Conventions and Housekeeping +None of these is essential for a pull request, but they will all help. They can also be +added after the original pull request but before a merge. + +* Use the Spring Framework code format conventions. If you use Eclipse + you can import formatter settings using the + `eclipse-code-formatter.xml` file from the + https://github.com/spring-cloud/build/tree/master/eclipse-coding-conventions.xml[Spring + Cloud Build] project. If using IntelliJ, you can use the + http://plugins.jetbrains.com/plugin/6546[Eclipse Code Formatter + Plugin] to import the same file. +* Make sure all new `.java` files to have a simple Javadoc class comment with at least an + `@author` tag identifying you, and preferably at least a paragraph on what the class is + for. +* Add the ASF license header comment to all new `.java` files (copy from existing files + in the project) +* Add yourself as an `@author` to the .java files that you modify substantially (more + than cosmetic changes). +* Add some Javadocs and, if you change the namespace, some XSD doc elements. +* A few unit tests would help a lot as well -- someone has to do it. +* If no-one else is using your branch, please rebase it against the current master (or + other target branch in the main project). +* When writing a commit message please follow http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], + if you are fixing an existing issue please add `Fixes gh-XXXX` at the end of the commit + message (where XXXX is the issue number). + + +// ====================================================================================== diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/binders.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/binders.adoc new file mode 100644 index 000000000..db895eace --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/binders.adoc @@ -0,0 +1,13 @@ +*{spring-cloud-stream-version}* + +[[binders]] +== Binder Implementations + +The following is the list of available binder implementations + +* https://cloud.spring.io/spring-cloud-stream-binder-rabbit/[RabbitMQ] +* https://cloud.spring.io/spring-cloud-stream-binder-kafka/[Apache Kafka] +* https://github.com/spring-cloud/spring-cloud-stream-binder-aws-kinesis[Amazon Kinesis] +* https://github.com/spring-cloud/spring-cloud-gcp/tree/master/spring-cloud-gcp-pubsub-stream-binder[Google PubSub _(partner maintained)_] +* https://github.com/SolaceProducts/spring-cloud-stream-binder-solace[Solace PubSub+ _(partner maintained)_] +* https://github.com/Microsoft/spring-cloud-azure/tree/master/spring-cloud-azure-eventhub-stream-binder[Azure Event Hubs _(partner maintained)_] diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/ghpages.sh b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/ghpages.sh new file mode 100755 index 000000000..2562c7171 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/ghpages.sh @@ -0,0 +1,330 @@ +#!/bin/bash -x + +set -e + +# Set default props like MAVEN_PATH, ROOT_FOLDER etc. +function set_default_props() { + # The script should be executed from the root folder + ROOT_FOLDER=`pwd` + echo "Current folder is ${ROOT_FOLDER}" + + if [[ ! -e "${ROOT_FOLDER}/.git" ]]; then + echo "You're not in the root folder of the project!" + exit 1 + fi + + # Prop that will let commit the changes + COMMIT_CHANGES="no" + MAVEN_PATH=${MAVEN_PATH:-} + echo "Path to Maven is [${MAVEN_PATH}]" + REPO_NAME=${PWD##*/} + echo "Repo name is [${REPO_NAME}]" + SPRING_CLOUD_STATIC_REPO=${SPRING_CLOUD_STATIC_REPO:-git@github.com:spring-cloud/spring-cloud-static.git} + echo "Spring Cloud Static repo is [${SPRING_CLOUD_STATIC_REPO}" +} + +# Check if gh-pages exists and docs have been built +function check_if_anything_to_sync() { + git remote set-url --push origin `git config remote.origin.url | sed -e 's/^git:/https:/'` + + if ! (git remote set-branches --add origin gh-pages && git fetch -q); then + echo "No gh-pages, so not syncing" + exit 0 + fi + + if ! [ -d docs/target/generated-docs ] && ! [ "${BUILD}" == "yes" ]; then + echo "No gh-pages sources in docs/target/generated-docs, so not syncing" + exit 0 + fi +} + +function retrieve_current_branch() { + # Code getting the name of the current branch. For master we want to publish as we did until now + # https://stackoverflow.com/questions/1593051/how-to-programmatically-determine-the-current-checked-out-git-branch + # If there is a branch already passed will reuse it - otherwise will try to find it + CURRENT_BRANCH=${BRANCH} + if [[ -z "${CURRENT_BRANCH}" ]] ; then + CURRENT_BRANCH=$(git symbolic-ref -q HEAD) + CURRENT_BRANCH=${CURRENT_BRANCH##refs/heads/} + CURRENT_BRANCH=${CURRENT_BRANCH:-HEAD} + fi + echo "Current branch is [${CURRENT_BRANCH}]" + git checkout ${CURRENT_BRANCH} || echo "Failed to check the branch... continuing with the script" +} + +# Switches to the provided value of the release version. We always prefix it with `v` +function switch_to_tag() { + git checkout v${VERSION} +} + +# Build the docs if switch is on +function build_docs_if_applicable() { + if [[ "${BUILD}" == "yes" ]] ; then + ./mvnw clean install -P docs -pl docs -DskipTests + fi +} + +# Get the name of the `docs.main` property +# Get whitelisted branches - assumes that a `docs` module is available under `docs` profile +function retrieve_doc_properties() { + MAIN_ADOC_VALUE=$("${MAVEN_PATH}"mvn -q \ + -Dexec.executable="echo" \ + -Dexec.args='${docs.main}' \ + --non-recursive \ + org.codehaus.mojo:exec-maven-plugin:1.3.1:exec) + echo "Extracted 'main.adoc' from Maven build [${MAIN_ADOC_VALUE}]" + + + WHITELIST_PROPERTY=${WHITELIST_PROPERTY:-"docs.whitelisted.branches"} + WHITELISTED_BRANCHES_VALUE=$("${MAVEN_PATH}"mvn -q \ + -Dexec.executable="echo" \ + -Dexec.args="\${${WHITELIST_PROPERTY}}" \ + org.codehaus.mojo:exec-maven-plugin:1.3.1:exec \ + -P docs \ + -pl docs) + echo "Extracted '${WHITELIST_PROPERTY}' from Maven build [${WHITELISTED_BRANCHES_VALUE}]" +} + +# Stash any outstanding changes +function stash_changes() { + git diff-index --quiet HEAD && dirty=$? || (echo "Failed to check if the current repo is dirty. Assuming that it is." && dirty="1") + if [ "$dirty" != "0" ]; then git stash; fi +} + +# Switch to gh-pages branch to sync it with current branch +function add_docs_from_target() { + local DESTINATION_REPO_FOLDER + if [[ -z "${DESTINATION}" && -z "${CLONE}" ]] ; then + DESTINATION_REPO_FOLDER=${ROOT_FOLDER} + elif [[ "${CLONE}" == "yes" ]]; then + mkdir -p ${ROOT_FOLDER}/target + local clonedStatic=${ROOT_FOLDER}/target/spring-cloud-static + if [[ ! -e "${clonedStatic}/.git" ]]; then + echo "Cloning Spring Cloud Static to target" + git clone ${SPRING_CLOUD_STATIC_REPO} ${clonedStatic} && git checkout gh-pages + else + echo "Spring Cloud Static already cloned - will pull changes" + cd ${clonedStatic} && git checkout gh-pages && git pull origin gh-pages + fi + DESTINATION_REPO_FOLDER=${clonedStatic}/${REPO_NAME} + mkdir -p ${DESTINATION_REPO_FOLDER} + else + if [[ ! -e "${DESTINATION}/.git" ]]; then + echo "[${DESTINATION}] is not a git repository" + exit 1 + fi + DESTINATION_REPO_FOLDER=${DESTINATION}/${REPO_NAME} + mkdir -p ${DESTINATION_REPO_FOLDER} + echo "Destination was provided [${DESTINATION}]" + fi + cd ${DESTINATION_REPO_FOLDER} + git checkout gh-pages + git pull origin gh-pages + + # Add git branches + ################################################################### + if [[ -z "${VERSION}" ]] ; then + copy_docs_for_current_version + else + copy_docs_for_provided_version + fi + commit_changes_if_applicable +} + + +# Copies the docs by using the retrieved properties from Maven build +function copy_docs_for_current_version() { + if [[ "${CURRENT_BRANCH}" == "master" ]] ; then + echo -e "Current branch is master - will copy the current docs only to the root folder" + for f in docs/target/generated-docs/*; do + file=${f#docs/target/generated-docs/*} + if ! git ls-files -i -o --exclude-standard --directory | grep -q ^$file$; then + # Not ignored... + cp -rf $f ${ROOT_FOLDER}/ + git add -A ${ROOT_FOLDER}/$file + fi + done + COMMIT_CHANGES="yes" + else + echo -e "Current branch is [${CURRENT_BRANCH}]" + # https://stackoverflow.com/questions/29300806/a-bash-script-to-check-if-a-string-is-present-in-a-comma-separated-list-of-strin + if [[ ",${WHITELISTED_BRANCHES_VALUE}," = *",${CURRENT_BRANCH},"* ]] ; then + mkdir -p ${ROOT_FOLDER}/${CURRENT_BRANCH} + echo -e "Branch [${CURRENT_BRANCH}] is whitelisted! Will copy the current docs to the [${CURRENT_BRANCH}] folder" + for f in docs/target/generated-docs/*; do + file=${f#docs/target/generated-docs/*} + if ! git ls-files -i -o --exclude-standard --directory | grep -q ^$file$; then + # Not ignored... + # We want users to access 2.0.0.BUILD-SNAPSHOT/ instead of 1.0.0.RELEASE/spring-cloud.sleuth.html + if [[ "${file}" == "${MAIN_ADOC_VALUE}.html" ]] ; then + # We don't want to copy the spring-cloud-sleuth.html + # we want it to be converted to index.html + cp -rf $f ${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html + git add -A ${ROOT_FOLDER}/${CURRENT_BRANCH}/index.html + else + cp -rf $f ${ROOT_FOLDER}/${CURRENT_BRANCH} + git add -A ${ROOT_FOLDER}/${CURRENT_BRANCH}/$file + fi + fi + done + COMMIT_CHANGES="yes" + else + echo -e "Branch [${CURRENT_BRANCH}] is not on the white list! Check out the Maven [${WHITELIST_PROPERTY}] property in + [docs] module available under [docs] profile. Won't commit any changes to gh-pages for this branch." + fi + fi +} + +# Copies the docs by using the explicitly provided version +function copy_docs_for_provided_version() { + local FOLDER=${DESTINATION_REPO_FOLDER}/${VERSION} + mkdir -p ${FOLDER} + echo -e "Current tag is [v${VERSION}] Will copy the current docs to the [${FOLDER}] folder" + for f in ${ROOT_FOLDER}/docs/target/generated-docs/*; do + file=${f#${ROOT_FOLDER}/docs/target/generated-docs/*} + copy_docs_for_branch ${file} ${FOLDER} + done + COMMIT_CHANGES="yes" + CURRENT_BRANCH="v${VERSION}" +} + +# Copies the docs from target to the provided destination +# Params: +# $1 - file from target +# $2 - destination to which copy the files +function copy_docs_for_branch() { + local file=$1 + local destination=$2 + if ! git ls-files -i -o --exclude-standard --directory | grep -q ^${file}$; then + # Not ignored... + # We want users to access 2.0.0.BUILD-SNAPSHOT/ instead of 1.0.0.RELEASE/spring-cloud.sleuth.html + if [[ ("${file}" == "${MAIN_ADOC_VALUE}.html") || ("${file}" == "${REPO_NAME}.html") ]] ; then + # We don't want to copy the spring-cloud-sleuth.html + # we want it to be converted to index.html + cp -rf $f ${destination}/index.html + git add -A ${destination}/index.html + else + cp -rf $f ${destination} + git add -A ${destination}/$file + fi + fi +} + +function commit_changes_if_applicable() { + if [[ "${COMMIT_CHANGES}" == "yes" ]] ; then + COMMIT_SUCCESSFUL="no" + git commit -a -m "Sync docs from ${CURRENT_BRANCH} to gh-pages" && COMMIT_SUCCESSFUL="yes" || echo "Failed to commit changes" + + # Uncomment the following push if you want to auto push to + # the gh-pages branch whenever you commit to master locally. + # This is a little extreme. Use with care! + ################################################################### + if [[ "${COMMIT_SUCCESSFUL}" == "yes" ]] ; then + git push origin gh-pages + fi + fi +} + +# Switch back to the previous branch and exit block +function checkout_previous_branch() { + # If -version was provided we need to come back to root project + cd ${ROOT_FOLDER} + git checkout ${CURRENT_BRANCH} || echo "Failed to check the branch... continuing with the script" + if [ "$dirty" != "0" ]; then git stash pop; fi + exit 0 +} + +# Assert if properties have been properly passed +function assert_properties() { +echo "VERSION [${VERSION}], DESTINATION [${DESTINATION}], CLONE [${CLONE}]" +if [[ "${VERSION}" != "" && (-z "${DESTINATION}" && -z "${CLONE}") ]] ; then echo "Version was set but destination / clone was not!"; exit 1;fi +if [[ ("${DESTINATION}" != "" && "${CLONE}" != "") && -z "${VERSION}" ]] ; then echo "Destination / clone was set but version was not!"; exit 1;fi +if [[ "${DESTINATION}" != "" && "${CLONE}" == "yes" ]] ; then echo "Destination and clone was set. Pick one!"; exit 1;fi +} + +# Prints the usage +function print_usage() { +cat </` +- if the destination switch is passed (-d) then the script will check if the provided dir is a git repo and then will + switch to gh-pages of that repo and copy the generated docs to `docs//` + +USAGE: + +You can use the following options: + +-v|--version - the script will apply the whole procedure for a particular library version +-d|--destination - the root of destination folder where the docs should be copied. You have to use the full path. + E.g. point to spring-cloud-static folder. Can't be used with (-c) +-b|--build - will run the standard build process after checking out the branch +-c|--clone - will automatically clone the spring-cloud-static repo instead of providing the destination. + Obviously can't be used with (-d) + +EOF +} + + +# ========================================== +# ____ ____ _____ _____ _____ _______ +# / ____|/ ____| __ \|_ _| __ \__ __| +# | (___ | | | |__) | | | | |__) | | | +# \___ \| | | _ / | | | ___/ | | +# ____) | |____| | \ \ _| |_| | | | +# |_____/ \_____|_| \_\_____|_| |_| +# +# ========================================== + +while [[ $# > 0 ]] +do +key="$1" +case ${key} in + -v|--version) + VERSION="$2" + shift # past argument + ;; + -d|--destination) + DESTINATION="$2" + shift # past argument + ;; + -b|--build) + BUILD="yes" + ;; + -c|--clone) + CLONE="yes" + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "Invalid option: [$1]" + print_usage + exit 1 + ;; +esac +shift # past argument or value +done + +assert_properties +set_default_props +check_if_anything_to_sync +if [[ -z "${VERSION}" ]] ; then + retrieve_current_branch +else + switch_to_tag +fi +build_docs_if_applicable +retrieve_doc_properties +stash_changes +add_docs_from_target +checkout_previous_branch \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/home.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/home.adoc new file mode 100644 index 000000000..a1822dfcc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/home.adoc @@ -0,0 +1,24 @@ += Spring Cloud Stream Reference Documentation +Sabby Anandan; Marius Bogoevici; Eric Bottard; Mark Fisher; Ilayaperumal Gopinathan; Gunnar Hillert; Mark Pollack; Patrick Peralta; Glenn Renfro; Thomas Risberg; Dave Syer; David Turanski; Janne Valkealahti; Benjamin Klein; Vinicius Carvalho; Gary Russell; Oleg Zhurakousky; Jay Bryant; Soby Chacko + +*{spring-cloud-stream-version}* + +:docinfo: shared + +The reference documentation consists of the following sections: + +[horizontal] +<> :: History, Quick Start, Concepts, Architecture Overview, Binder Abstraction, and Core Features +{docs-url}spring-cloud-stream-binder-rabbit/{docs-version}spring-cloud-stream-binder-rabbit.html[Rabbit MQ Binder] :: Spring Cloud Stream binder reference for Rabbit MQ +{docs-url}spring-cloud-stream-binder-kafka/{docs-version}spring-cloud-stream-binder-kafka.html#_apache_kafka_binder[Apache Kafka Binder] :: Spring Cloud Stream binder reference for Apache Kafka +{docs-url}spring-cloud-stream-binder-kafka/{docs-version}spring-cloud-stream-binder-kafka.html#_kafka_streams_binder[Apache Kafka Streams Binder] :: Spring Cloud Stream binder reference for Apache Kafka Streams +<> :: A collection of Partner maintained binder implementations for Spring Cloud Stream (e.g., Azure Event Hubs, Google PubSub, Solace PubSub+) +https://github.com/spring-cloud/spring-cloud-stream-samples/[Spring Cloud Stream Samples] :: A curated collection of repeatable Spring Cloud Stream samples to walk through the features + +Relevant Links: + +[horizontal] +https://cloud.spring.io/spring-cloud-dataflow/[Spring Cloud Data Flow] :: Spring Cloud Data Flow +http://www.enterpriseintegrationpatterns.com/[Enterprise Integration Patterns] :: Patterns and Best Practices for Enterprise Integration +https://spring.io/projects/spring-integration[Spring Integration] :: Spring Integration framework + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-groups.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-groups.png new file mode 100644 index 000000000..931ac9727 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-groups.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-overview.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-overview.png new file mode 100644 index 000000000..46dd0c809 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-overview.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-partitioning.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-partitioning.png new file mode 100644 index 000000000..833d2ac66 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-partitioning.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-sensors.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-sensors.png new file mode 100644 index 000000000..6a15c8729 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-sensors.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-with-binder.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-with-binder.png new file mode 100644 index 000000000..b4d66fd5e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/SCSt-with-binder.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/custom_vs_global_error_channels.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/custom_vs_global_error_channels.png new file mode 100644 index 000000000..6363d5162 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/custom_vs_global_error_channels.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/producers-consumers.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/producers-consumers.png new file mode 100755 index 000000000..9990897dd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/producers-consumers.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/redis-binder.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/redis-binder.png new file mode 100755 index 000000000..1832bd2e3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/redis-binder.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/registration.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/registration.png new file mode 100644 index 000000000..d2c044c1e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/registration.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_reading.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_reading.png new file mode 100644 index 000000000..df9985630 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_reading.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_resolution.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_resolution.png new file mode 100644 index 000000000..2acbfac78 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/schema_resolution.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/spring-initializr.png b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/spring-initializr.png new file mode 100644 index 000000000..8ece01df0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/images/spring-initializr.png differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/preface.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/preface.adoc new file mode 100644 index 000000000..51b2574b3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/preface.adoc @@ -0,0 +1,183 @@ +=== A Brief History of Spring's Data Integration Journey + +Spring's journey on Data Integration started with https://projects.spring.io/spring-integration/[Spring Integration]. With its programming model, it provided a consistent developer experience to build applications that can embrace http://www.enterpriseintegrationpatterns.com/[Enterprise Integration Patterns] to connect with external systems such as, databases, message brokers, and among others. + +Fast forward to the cloud-era, where microservices have become prominent in the enterprise setting. https://projects.spring.io/spring-boot/[Spring Boot] transformed the way how developers built Applications. With Spring's programming model and the runtime responsibilities handled by Spring Boot, it became seamless to develop stand-alone, production-grade Spring-based microservices. + +To extend this to Data Integration workloads, Spring Integration and Spring Boot were put together into a new project. Spring Cloud Stream was born. + +[%hardbreaks] +With Spring Cloud Stream, developers can: +* Build, test, iterate, and deploy data-centric applications in isolation. +* Apply modern microservices architecture patterns, including composition through messaging. +* Decouple application responsibilities with event-centric thinking. An event can represent something that has happened in time, to which the downstream consumer applications can react without knowing where it originated or the producer's identity. +* Port the business logic onto message brokers (such as RabbitMQ, Apache Kafka, Amazon Kinesis). +* Interoperate between channel-based and non-channel-based application binding scenarios to support stateless and stateful computations by using Project Reactor's Flux and Kafka Streams APIs. +* Rely on the framework's automatic content-type support for common use-cases. Extending to different data conversion types is possible. + +=== Quick Start + +You can try Spring Cloud Stream in less then 5 min even before you jump into any details by following this three-step guide. + +We show you how to create a Spring Cloud Stream application that receives messages coming from the messaging middleware of your choice (more on this later) and logs received messages to the console. +We call it `LoggingConsumer`. +While not very practical, it provides a good introduction to some of the main concepts +and abstractions, making it easier to digest the rest of this user guide. + +The three steps are as follows: + +. <> +. <> +. <> + +[[spring-cloud-stream-preface-creating-sample-application]] +==== Creating a Sample Application by Using Spring Initializr +To get started, visit the https://start.spring.io[Spring Initializr]. From there, you can generate our `LoggingConsumer` application. To do so: + +. In the *Dependencies* section, start typing `stream`. +When the "`Cloud Stream`" option should appears, select it. +. Start typing either 'kafka' or 'rabbit'. +. Select "`Kafka`" or "`RabbitMQ`". ++ +Basically, you choose the messaging middleware to which your application binds. +We recommend using the one you have already installed or feel more comfortable with installing and running. +Also, as you can see from the Initilaizer screen, there are a few other options you can choose. +For example, you can choose Gradle as your build tool instead of Maven (the default). +. In the *Artifact* field, type 'logging-consumer'. ++ +The value of the *Artifact* field becomes the application name. +If you chose RabbitMQ for the middleware, your Spring Initializr should now be as follows: + +[%hardbreaks] +[%hardbreaks] +[%hardbreaks] +image::{github-raw}/docs/src/main/asciidoc/images/spring-initializr.png[align="center"] + +[%hardbreaks] +[%hardbreaks] + +. Click the *Generate Project* button. ++ +Doing so downloads the zipped version of the generated project to your hard drive. +. Unzip the file into the folder you want to use as your project directory. + +TIP: We encourage you to explore the many possibilities available in the Spring Initializr. +It lets you create many different kinds of Spring applications. + +[[spring-cloud-stream-preface-importing-project]] +==== Importing the Project into Your IDE + +Now you can import the project into your IDE. +Keep in mind that, depending on the IDE, you may need to follow a specific import procedure. +For example, depending on how the project was generated (Maven or Gradle), you may need to follow specific import procedure (for example, in Eclipse or STS, you need to use File -> Import -> Maven -> Existing Maven Project). + +Once imported, the project must have no errors of any kind. Also, `src/main/java` should contain `com.example.loggingconsumer.LoggingConsumerApplication`. + +Technically, at this point, you can run the application's main class. +It is already a valid Spring Boot application. +However, it does not do anything, so we want to add some code. + +[[spring-cloud-stream-preface-adding-message-handler]] +==== Adding a Message Handler, Building, and Running + +Modify the `com.example.loggingconsumer.LoggingConsumerApplication` class to look as follows: + +[source, java] +---- +@SpringBootApplication +@EnableBinding(Sink.class) +public class LoggingConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(LoggingConsumerApplication.class, args); + } + + @StreamListener(Sink.INPUT) + public void handle(Person person) { + System.out.println("Received: " + person); + } + + public static class Person { + private String name; + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + public String toString() { + return this.name; + } + } +} +---- + +As you can see from the preceding listing: + +* We have enabled `Sink` binding (input-no-output) by using `@EnableBinding(Sink.class)`. +Doing so signals to the framework to initiate binding to the messaging middleware, where it automatically creates the destination (that is, queue, topic, and others) that are bound to the `Sink.INPUT` channel. +* We have added a `handler` method to receive incoming messages of type `Person`. +Doing so lets you see one of the core features of the framework: It tries to automatically convert incoming message payloads to type `Person`. + +You now have a fully functional Spring Cloud Stream application that does listens for messages. +From here, for simplicity, we assume you selected RabbitMQ in <>. +Assuming you have RabbitMQ installed and running, you can start the application by running its `main` method in your IDE. + +You should see following output: + +[source] +---- + --- [ main] c.s.b.r.p.RabbitExchangeQueueProvisioner : declaring queue for inbound: input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg, bound to: input + --- [ main] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672] + --- [ main] o.s.a.r.c.CachingConnectionFactory : Created new connection: rabbitConnectionFactory#2a3a299:0/SimpleConnection@66c83fc8. . . + . . . + --- [ main] o.s.i.a.i.AmqpInboundChannelAdapter : started inbound.input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg + . . . + --- [ main] c.e.l.LoggingConsumerApplication : Started LoggingConsumerApplication in 2.531 seconds (JVM running for 2.897) +---- + +Go to the RabbitMQ management console or any other RabbitMQ client and send a message to `input.anonymous.CbMIwdkJSBO1ZoPDOtHtCg`. +The `anonymous.CbMIwdkJSBO1ZoPDOtHtCg` part represents the group name and is generated, so it is bound to be different in your environment. +For something more predictable, you can use an explicit group name by setting `spring.cloud.stream.bindings.input.group=hello` (or whatever name you like). + +The contents of the message should be a JSON representation of the `Person` class, as follows: + + {"name":"Sam Spade"} + +Then, in your console, you should see: + +`Received: Sam Spade` + +You can also build and package your application into a boot jar (by using `./mvnw clean install`) and run the built JAR by using the `java -jar` command. + +Now you have a working (albeit very basic) Spring Cloud Stream application. + +== What's New in 2.2? +Spring Cloud Stream introduces a number of new features, enhancements, and changes in addition to the once already introduced in +https://docs.spring.io/spring-cloud-stream/docs/Elmhurst.SR2/reference/htmlsingle/#_what_s_new_in_2_0[version 2.0] + + +The following sections outline the most notable ones: + +* <> +* <> + +[[spring-cloud-stream-preface-new-features]] +=== New Features and Components + + +[[spring-cloud-stream-preface-notable-enhancements]] +=== Notable Enhancements + + +[[spring-cloud-stream-preface-notable-deprecations]] +=== Notable Deprecations + +As of version 2.2, the following items have been deprecated: + +- The spring-cloud-stream-reactive module is deprecated in favor of native support + via <> programming model. + +=== Notes on migrating from 1.x to 2.x? +- Due to the improvements in content-type negotiation, the `originalContentType` header is not used (ignored) since 2.x and only exists for maintaining compatibility with 1.x versions +- Introduction of `@StreamRetryTemplate` qualifier. While configuring custom instance of the `RetryTemplate` and to avoid conflicts you must qualify the instance of such `RetryTemplate` with this qualifier. See <> for more details. diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/spring-cloud-stream.adoc b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/spring-cloud-stream.adoc new file mode 100644 index 000000000..8ef135672 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/asciidoc/spring-cloud-stream.adoc @@ -0,0 +1,2792 @@ +:github-tag: master +:github-repo: spring-cloud/spring-cloud-stream +:github-raw: https://raw.githubusercontent.com/{github-repo}/{github-tag} +:github-code: https://github.com/{github-repo}/tree/{github-tag} +:toc: left +:toclevels: 8 +:nofooter: +:sectlinks: true + +*{spring-cloud-stream-version}* + +[#index-link] +{docs-url}spring-cloud-stream/{docs-version}home.html + +[[spring-cloud-stream-reference]] + +:doctype: book + +// ====================================================================================== + +== Preface +include::preface.adoc[] + +[partintro] +-- +This section goes into more detail about how you can work with Spring Cloud Stream. +It covers topics such as creating and running stream applications. +-- + +[[spring-cloud-stream-overview-introducing]] +== Introducing Spring Cloud Stream + +Spring Cloud Stream is a framework for building message-driven microservice applications. +Spring Cloud Stream builds upon Spring Boot to create standalone, production-grade Spring applications and uses Spring Integration to provide connectivity to message brokers. +It provides opinionated configuration of middleware from several vendors, introducing the concepts of persistent publish-subscribe semantics, consumer groups, and partitions. + +You can add the `@EnableBinding` annotation to your application to get immediate connectivity to a message broker, and you can add `@StreamListener` to a method to cause it to receive events for stream processing. +The following example shows a sink application that receives external messages: + +[source,java] +---- +@SpringBootApplication +@EnableBinding(Sink.class) +public class VoteRecordingSinkApplication { + + public static void main(String[] args) { + SpringApplication.run(VoteRecordingSinkApplication.class, args); + } + + @StreamListener(Sink.INPUT) + public void processVote(Vote vote) { + votingService.recordVote(vote); + } +} +---- + +The `@EnableBinding` annotation takes one or more interfaces as parameters (in this case, the parameter is a single `Sink` interface). +An interface declares input and output channels. +Spring Cloud Stream provides the `Source`, `Sink`, and `Processor` interfaces. You can also define your own interfaces. + +The following listing shows the definition of the `Sink` interface: + +[source,java] +---- +public interface Sink { + String INPUT = "input"; + + @Input(Sink.INPUT) + SubscribableChannel input(); +} +---- + +The `@Input` annotation identifies an input channel, through which received messages enter the application. +The `@Output` annotation identifies an output channel, through which published messages leave the application. +The `@Input` and `@Output` annotations can take a channel name as a parameter. +If a name is not provided, the name of the annotated method is used. + +Spring Cloud Stream creates an implementation of the interface for you. +You can use this in the application by autowiring it, as shown in the following example (from a test case): + +[source,java] +---- +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = VoteRecordingSinkApplication.class) +@WebAppConfiguration +@DirtiesContext +public class StreamApplicationTests { + + @Autowired + private Sink sink; + + @Test + public void contextLoads() { + assertThat(this.sink.input()).isNotNull(); + } +} +---- + +== Main Concepts + +Spring Cloud Stream provides a number of abstractions and primitives that simplify the writing of message-driven microservice applications. +This section gives an overview of the following: + +* <> +* <> +* <> +* <> +* <> +* <> + +[[spring-cloud-stream-overview-application-model]] +=== Application Model + +A Spring Cloud Stream application consists of a middleware-neutral core. +The application communicates with the outside world through input and output channels injected into it by Spring Cloud Stream. +Channels are connected to external brokers through middleware-specific Binder implementations. + +.Spring Cloud Stream Application +image::{github-raw}/docs/src/main/asciidoc/images/SCSt-with-binder.png[width=800,scaledwidth="75%",align="center"] + +==== Fat JAR + +Spring Cloud Stream applications can be run in stand-alone mode from your IDE for testing. +To run a Spring Cloud Stream application in production, you can create an executable (or "`fat`") JAR by using the standard Spring Boot tooling provided for Maven or Gradle. See the https://docs.spring.io/spring-boot/docs/current/reference/html/howto-build.html#howto-create-an-executable-jar-with-maven[Spring Boot Reference Guide] for more details. + +[[spring-cloud-stream-overview-binder-abstraction]] +=== The Binder Abstraction + +Spring Cloud Stream provides Binder implementations for https://github.com/spring-cloud/spring-cloud-stream-binder-kafka[Kafka] and https://github.com/spring-cloud/spring-cloud-stream-binder-rabbit[Rabbit MQ]. +Spring Cloud Stream also includes a https://github.com/spring-cloud/spring-cloud-stream/blob/master/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java[TestSupportBinder], which leaves a channel unmodified so that tests can interact with channels directly and reliably assert on what is received. +You can also use the extensible API to write your own Binder. + +Spring Cloud Stream uses Spring Boot for configuration, and the Binder abstraction makes it possible for a Spring Cloud Stream application to be flexible in how it connects to middleware. +For example, deployers can dynamically choose, at runtime, the destinations (such as the Kafka topics or RabbitMQ exchanges) to which channels connect. +Such configuration can be provided through external configuration properties and in any form supported by Spring Boot (including application arguments, environment variables, and `application.yml` or `application.properties` files). +In the sink example from the <> section, setting the `spring.cloud.stream.bindings.input.destination` application property to `raw-sensor-data` causes it to read from the `raw-sensor-data` Kafka topic or from a queue bound to the `raw-sensor-data` RabbitMQ exchange. + +Spring Cloud Stream automatically detects and uses a binder found on the classpath. +You can use different types of middleware with the same code. +To do so, include a different binder at build time. +For more complex use cases, you can also package multiple binders with your application and have it choose the binder( and even whether to use different binders for different channels) at runtime. + +[[spring-cloud-stream-overview-persistent-publish-subscribe-support]] +=== Persistent Publish-Subscribe Support + +Communication between applications follows a publish-subscribe model, where data is broadcast through shared topics. +This can be seen in the following figure, which shows a typical deployment for a set of interacting Spring Cloud Stream applications. + +.Spring Cloud Stream Publish-Subscribe +image::{github-raw}/docs/src/main/asciidoc/images/SCSt-sensors.png[width=800,scaledwidth="75%",align="center"] + +Data reported by sensors to an HTTP endpoint is sent to a common destination named `raw-sensor-data`. +From the destination, it is independently processed by a microservice application that computes time-windowed averages and by another microservice application that ingests the raw data into HDFS (Hadoop Distributed File System). +In order to process the data, both applications declare the topic as their input at runtime. + +The publish-subscribe communication model reduces the complexity of both the producer and the consumer and lets new applications be added to the topology without disruption of the existing flow. +For example, downstream from the average-calculating application, you can add an application that calculates the highest temperature values for display and monitoring. +You can then add another application that interprets the same flow of averages for fault detection. +Doing all communication through shared topics rather than point-to-point queues reduces coupling between microservices. + +While the concept of publish-subscribe messaging is not new, Spring Cloud Stream takes the extra step of making it an opinionated choice for its application model. +By using native middleware support, Spring Cloud Stream also simplifies use of the publish-subscribe model across different platforms. + +[[consumer-groups]] +=== Consumer Groups +While the publish-subscribe model makes it easy to connect applications through shared topics, the ability to scale up by creating multiple instances of a given application is equally important. +When doing so, different instances of an application are placed in a competing consumer relationship, where only one of the instances is expected to handle a given message. + +Spring Cloud Stream models this behavior through the concept of a consumer group. +(Spring Cloud Stream consumer groups are similar to and inspired by Kafka consumer groups.) +Each consumer binding can use the `spring.cloud.stream.bindings..group` property to specify a group name. +For the consumers shown in the following figure, this property would be set as `spring.cloud.stream.bindings..group=hdfsWrite` or `spring.cloud.stream.bindings..group=average`. + +.Spring Cloud Stream Consumer Groups +image::{github-raw}/docs/src/main/asciidoc/images/SCSt-groups.png[width=800,scaledwidth="75%",align="center"] + +All groups that subscribe to a given destination receive a copy of published data, but only one member of each group receives a given message from that destination. +By default, when a group is not specified, Spring Cloud Stream assigns the application to an anonymous and independent single-member consumer group that is in a publish-subscribe relationship with all other consumer groups. + +[[consumer-types]] +=== Consumer Types + +Two types of consumer are supported: + +* Message-driven (sometimes referred to as Asynchronous) +* Polled (sometimes referred to as Synchronous) + +Prior to version 2.0, only asynchronous consumers were supported. A message is delivered as soon as it is available and a thread is available to process it. + +When you wish to control the rate at which messages are processed, you might want to use a synchronous consumer. +// TODO This needs more description. A sentence parallel to the last sentence of the preceding paragraph would help. + +[[durability]] +==== Durability + +Consistent with the opinionated application model of Spring Cloud Stream, consumer group subscriptions are durable. +That is, a binder implementation ensures that group subscriptions are persistent and that, once at least one subscription for a group has been created, the group receives messages, even if they are sent while all applications in the group are stopped. + +[NOTE] +==== +Anonymous subscriptions are non-durable by nature. +For some binder implementations (such as RabbitMQ), it is possible to have non-durable group subscriptions. +==== + +In general, it is preferable to always specify a consumer group when binding an application to a given destination. +When scaling up a Spring Cloud Stream application, you must specify a consumer group for each of its input bindings. +Doing so prevents the application's instances from receiving duplicate messages (unless that behavior is desired, which is unusual). + +[[partitioning]] +=== Partitioning Support + +Spring Cloud Stream provides support for partitioning data between multiple instances of a given application. +In a partitioned scenario, the physical communication medium (such as the broker topic) is viewed as being structured into multiple partitions. +One or more producer application instances send data to multiple consumer application instances and ensure that data identified by common characteristics are processed by the same consumer instance. + +Spring Cloud Stream provides a common abstraction for implementing partitioned processing use cases in a uniform fashion. +Partitioning can thus be used whether the broker itself is naturally partitioned (for example, Kafka) or not (for example, RabbitMQ). + +.Spring Cloud Stream Partitioning +image::{github-raw}/docs/src/main/asciidoc/images/SCSt-partitioning.png[width=800,scaledwidth="75%",align="center"] + +Partitioning is a critical concept in stateful processing, where it is critical (for either performance or consistency reasons) to ensure that all related data is processed together. +For example, in the time-windowed average calculation example, it is important that all measurements from any given sensor are processed by the same application instance. + +NOTE: To set up a partitioned processing scenario, you must configure both the data-producing and the data-consuming ends. + +== Programming Model + +To understand the programming model, you should be familiar with the following core concepts: + +* *Destination Binders:* Components responsible to provide integration with the external messaging systems. +* *Destination Bindings:* Bridge between the external messaging systems and application provided _Producers_ and _Consumers_ of messages (created by the Destination Binders). +* *Message:* The canonical data structure used by producers and consumers to communicate with Destination Binders (and thus other applications via external messaging systems). + +image::{github-raw}/docs/src/main/asciidoc/images/SCSt-overview.png[width=800,scaledwidth="75%",align="center"] + +=== Destination Binders + +Destination Binders are extension components of Spring Cloud Stream responsible for providing the necessary configuration and implementation to facilitate +integration with external messaging systems. +This integration is responsible for connectivity, delegation, and routing of messages to and from producers and consumers, data type conversion, +invocation of the user code, and more. + +Binders handle a lot of the boiler plate responsibilities that would otherwise fall on your shoulders. However, to accomplish that, the binder still needs +some help in the form of minimalistic yet required set of instructions from the user, which typically come in the form of some type of configuration. + +While it is out of scope of this section to discuss all of the available binder and binding configuration options (the rest of the manual covers them extensively), +_Destination Binding_ does require special attention. The next section discusses it in detail. + +=== Destination Bindings + +As stated earlier, _Destination Bindings_ provide a bridge between the external messaging system and application-provided _Producers_ and _Consumers_. + +Applying the @EnableBinding annotation to one of the application’s configuration classes defines a destination binding. +The `@EnableBinding` annotation itself is meta-annotated with `@Configuration` and triggers the configuration of the Spring Cloud Stream infrastructure. + +The following example shows a fully configured and functioning Spring Cloud Stream application that receives the payload of the message from the `INPUT` +destination as a `String` type (see <> section), logs it to the console and sends it to the `OUTPUT` destination after converting it to upper case. + +[source, java] +---- +@SpringBootApplication +@EnableBinding(Processor.class) +public class MyApplication { + + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String handle(String value) { + System.out.println("Received: " + value); + return value.toUpperCase(); + } +} +---- + +As you can see the `@EnableBinding` annotation can take one or more interface classes as parameters. The parameters are referred to as _bindings_, +and they contain methods representing _bindable components_. +These components are typically message channels (see https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-messaging.html[Spring Messaging]) +for channel-based binders (such as Rabbit, Kafka, and others). However other types of bindings can +provide support for the native features of the corresponding technology. For example Kafka Streams binder (formerly known as KStream) allows native bindings directly to Kafka Streams +(see http://cloud.spring.io/spring-cloud-static/spring-cloud-stream-binder-kafka/{spring-cloud-stream-version}/[Kafka Binder] for more details). + +Spring Cloud Stream already provides _binding_ interfaces for typical message exchange contracts, which include: + +* *Sink:* Identifies the contract for the message consumer by providing the destination from which the message is consumed. +* *Source:* Identifies the contract for the message producer by providing the destination to which the produced message is sent. +* *Processor:* Encapsulates both the sink and the source contracts by exposing two destinations that allow consumption and production of messages. + +[source, java] +---- +public interface Sink { + + String INPUT = "input"; + + @Input(Sink.INPUT) + SubscribableChannel input(); +} +---- + +[source, java] +---- +public interface Source { + + String OUTPUT = "output"; + + @Output(Source.OUTPUT) + MessageChannel output(); +} +---- + +[source, java] +---- +public interface Processor extends Source, Sink {} +---- + +While the preceding example satisfies the majority of cases, you can also define your own contracts by defining your own bindings interfaces and use `@Input` and `@Output` +annotations to identify the actual _bindable components_. + +For example: + +[source, java] +---- +public interface Barista { + + @Input + SubscribableChannel orders(); + + @Output + MessageChannel hotDrinks(); + + @Output + MessageChannel coldDrinks(); +} +---- + +Using the interface shown in the preceding example as a parameter to `@EnableBinding` triggers the creation of the three bound channels named `orders`, `hotDrinks`, and `coldDrinks`, +respectively. + +You can provide as many binding interfaces as you need, as arguments to the `@EnableBinding` annotation, as shown in the following example: + +[source, java] +---- +@EnableBinding(value = { Orders.class, Payment.class }) +---- + +In Spring Cloud Stream, the bindable `MessageChannel` components are the Spring Messaging `MessageChannel` (for outbound) and its extension, `SubscribableChannel`, +(for inbound). + +*Pollable Destination Binding* + +While the previously described bindings support event-based message consumption, sometimes you need more control, such as rate of consumption. + +Starting with version 2.0, you can now bind a pollable consumer: + +The following example shows how to bind a pollable consumer: + +[source, java] +---- +public interface PolledBarista { + + @Input + PollableMessageSource orders(); + . . . +} +---- + +In this case, an implementation of `PollableMessageSource` is bound to the `orders` “channelâ€. See <> for more details. + +*Customizing Channel Names* + +By using the `@Input` and `@Output` annotations, you can specify a customized channel name for the channel, as shown in the following example: + +[source, java] +---- +public interface Barista { + @Input("inboundOrders") + SubscribableChannel orders(); +} +---- + +In the preceding example, the created bound channel is named `inboundOrders`. + +Normally, you need not access individual channels or bindings directly (other then configuring them via `@EnableBinding` annotation). However there may be +times, such as testing or other corner cases, when you do. + +Aside from generating channels for each binding and registering them as Spring beans, for each bound interface, Spring Cloud Stream generates a bean that implements the interface. +That means you can have access to the interfaces representing the bindings or individual channels by auto-wiring either in your application, as shown in the following two examples: + +_Autowire Binding interface_ + +[source, java] +---- +@Autowire +private Source source + +public void sayHello(String name) { + source.output().send(MessageBuilder.withPayload(name).build()); +} +---- + +_Autowire individual channel_ + +[source, java] +---- +@Autowire +private MessageChannel output; + +public void sayHello(String name) { + output.send(MessageBuilder.withPayload(name).build()); +} +---- + +You can also use standard Spring's `@Qualifier` annotation for cases when channel names are customized or in multiple-channel scenarios that require specifically named channels. + +The following example shows how to use the @Qualifier annotation in this way: + +[source, java] +---- +@Autowire +@Qualifier("myChannel") +private MessageChannel output; +---- + +[[spring-cloud-stream-overview-producing-consuming-messages]] +=== Producing and Consuming Messages + +You can write a Spring Cloud Stream application by using either Spring Integration annotations or Spring Cloud Stream native annotation. + +==== Spring Integration Support + +Spring Cloud Stream is built on the concepts and patterns defined by http://www.enterpriseintegrationpatterns.com/[Enterprise Integration Patterns] and relies +in its internal implementation on an already established and popular implementation of Enterprise Integration Patterns within the Spring portfolio of projects: +https://projects.spring.io/spring-integration/[Spring Integration] framework. + +So its only natural for it to support the foundation, semantics, and configuration options that are already established by Spring Integration + +For example, you can attach the output channel of a `Source` to a `MessageSource` and use the familiar `@InboundChannelAdapter` annotation, as follows: + +[source, java] +---- +@EnableBinding(Source.class) +public class TimerSource { + + @Bean + @InboundChannelAdapter(value = Source.OUTPUT, poller = @Poller(fixedDelay = "10", maxMessagesPerPoll = "1")) + public MessageSource timerMessageSource() { + return () -> new GenericMessage<>("Hello Spring Cloud Stream"); + } +} +---- + +Similarly, you can use @Transformer or @ServiceActivator while providing an implementation of a message handler method for a _Processor_ binding contract, as shown in the following example: + +[source,java] +---- +@EnableBinding(Processor.class) +public class TransformProcessor { + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Object transform(String message) { + return message.toUpperCase(); + } +} +---- + +NOTE: While this may be skipping ahead a bit, it is important to understand that, when you consume from the same binding using `@StreamListener` annotation, a pub-sub model is used. +Each method annotated with `@StreamListener` receives its own copy of a message, and each one has its own consumer group. +However, if you consume from the same binding by using one of the Spring Integration annotation (such as `@Aggregator`, `@Transformer`, or `@ServiceActivator`), those consume in a competing model. +No individual consumer group is created for each subscription. + +==== Using @StreamListener Annotation + +Complementary to its Spring Integration support, Spring Cloud Stream provides its own `@StreamListener` annotation, modeled after other Spring Messaging annotations +(`@MessageMapping`, `@JmsListener`, `@RabbitListener`, and others) and provides conviniences, such as content-based routing and others. + +[source,java] +---- +@EnableBinding(Sink.class) +public class VoteHandler { + + @Autowired + VotingService votingService; + + @StreamListener(Sink.INPUT) + public void handle(Vote vote) { + votingService.record(vote); + } +} +---- + +As with other Spring Messaging methods, method arguments can be annotated with `@Payload`, `@Headers`, and `@Header`. + + +For methods that return data, you must use the `@SendTo` annotation to specify the output binding destination for data returned by the method, as shown in the following example: + +[source,java] +---- +@EnableBinding(Processor.class) +public class TransformProcessor { + + @Autowired + VotingService votingService; + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public VoteResult handle(Vote vote) { + return votingService.record(vote); + } +} +---- + + +==== Using @StreamListener for Content-based routing + +Spring Cloud Stream supports dispatching messages to multiple handler methods annotated with `@StreamListener` based on conditions. + +In order to be eligible to support conditional dispatching, a method must satisfy the follow conditions: + +* It must not return a value. +* It must be an individual message handling method (reactive API methods are not supported). + +The condition is specified by a SpEL expression in the `condition` argument of the annotation and is evaluated for each message. +All the handlers that match the condition are invoked in the same thread, and no assumption must be made about the order in which the invocations take place. + +In the following example of a `@StreamListener` with dispatching conditions, all the messages bearing a header `type` with the value `bogey` are dispatched to the +`receiveBogey` method, and all the messages bearing a header `type` with the value `bacall` are dispatched to the `receiveBacall` method. + +[source,java] +---- +@EnableBinding(Sink.class) +@EnableAutoConfiguration +public static class TestPojoWithAnnotatedArguments { + + @StreamListener(target = Sink.INPUT, condition = "headers['type']=='bogey'") + public void receiveBogey(@Payload BogeyPojo bogeyPojo) { + // handle the message + } + + @StreamListener(target = Sink.INPUT, condition = "headers['type']=='bacall'") + public void receiveBacall(@Payload BacallPojo bacallPojo) { + // handle the message + } +} +---- + +*Content Type Negotiation in the Context of `condition`* + +It is important to understand some of the mechanics behind content-based routing using the `condition` argument of `@StreamListener`, especially in the context of the type of the message as a whole. +It may also help if you familiarize yourself with the <> before you proceed. + +Consider the following scenario: + +[source,java] +---- +@EnableBinding(Sink.class) +@EnableAutoConfiguration +public static class CatsAndDogs { + + @StreamListener(target = Sink.INPUT, condition = "payload.class.simpleName=='Dog'") + public void bark(Dog dog) { + // handle the message + } + + @StreamListener(target = Sink.INPUT, condition = "payload.class.simpleName=='Cat'") + public void purr(Cat cat) { + // handle the message + } +} +---- + +The preceding code is perfectly valid. It compiles and deploys without any issues, yet it never produces the result you expect. + +That is because you are testing something that does not yet exist in a state you expect. That is because the payload of the message is not yet converted from the +wire format (`byte[]`) to the desired type. +In other words, it has not yet gone through the type conversion process described in the <>. + +So, unless you use a SPeL expression that evaluates raw data (for example, the value of the first byte in the byte array), use message header-based expressions +(such as `condition = "headers['type']=='dog'"`). + + +NOTE: At the moment, dispatching through `@StreamListener` conditions is supported only for channel-based binders (not for reactive programming) +support. + + +[[spring_cloud_function]] +==== Spring Cloud Function support + +Since Spring Cloud Stream v2.1, another alternative for defining _stream handlers_ and _sources_ is to use build-in +support for https://cloud.spring.io/spring-cloud-function/[Spring Cloud Function] where they can be expressed as beans of + type `java.util.function.[Supplier/Function/Consumer]`. + +To specify which functional bean to bind to the external destination(s) exposed by the bindings, you must provide `spring.cloud.stream.function.definition` property. + +Here is the example of the Processor application exposing message handler as `java.util.function.Function` +[source,java] +---- +@SpringBootApplication +@EnableBinding(Processor.class) +public class MyFunctionBootApp { + + public static void main(String[] args) { + SpringApplication.run(MyFunctionBootApp.class, "--spring.cloud.stream.function.definition=toUpperCase"); + } + + @Bean + public Function toUpperCase() { + return s -> s.toUpperCase(); + } +} +---- +In the above you we simply define a bean of type `java.util.function.Function` called _toUpperCase_ and identify it as a bean to be used as message handler +whose 'input' and 'output' must be bound to the external destinations exposed by the Processor binding. + +Below are the examples of simple functional applications to support Source, Processor and Sink. + +Here is the example of a Source application defined as `java.util.function.Supplier` +[source,java] +---- +@SpringBootApplication +@EnableBinding(Source.class) +public static class SourceFromSupplier { + public static void main(String[] args) { + SpringApplication.run(SourceFromSupplier.class, "--spring.cloud.stream.function.definition=date"); + } + @Bean + public Supplier date() { + return () -> new Date(12345L); + } +} +---- + +Here is the example of a Processor application defined as `java.util.function.Function` +[source,java] +---- +@SpringBootApplication +@EnableBinding(Processor.class) +public static class ProcessorFromFunction { + public static void main(String[] args) { + SpringApplication.run(ProcessorFromFunction.class, "--spring.cloud.stream.function.definition=toUpperCase"); + } + @Bean + public Function toUpperCase() { + return s -> s.toUpperCase(); + } +} +---- + +Here is the example of a Sink application defined as `java.util.function.Consumer` +[source,java] +---- +@EnableAutoConfiguration +@EnableBinding(Sink.class) +public static class SinkFromConsumer { + public static void main(String[] args) { + SpringApplication.run(SinkFromConsumer.class, "--spring.cloud.stream.function.definition=sink"); + } + @Bean + public Consumer sink() { + return System.out::println; + } +} +---- +===== Reactive Functions support + +Since _Spring Cloud Function_ is build on top of https://projectreactor.io/[Project Reactor] there isn't much you need to do +to benefit from reactive programming model while implementing `Supplier`, `Function` or `Consumer`. + +For example: + +[source,java] +---- +@EnableAutoConfiguration +@EnableBinding(Processor.class) +public static class SinkFromConsumer { + public static void main(String[] args) { + SpringApplication.run(SinkFromConsumer.class, "--spring.cloud.stream.function.definition=reactiveUpperCase"); + } + @Bean + public Function, Flux> reactiveUpperCase() { + return flux -> flux.map(val -> val.toUpperCase()); + } +} +---- +===== Functional Composition + +Using this programming model you can also benefit from functional composition where you can dynamically compose complex handlers from a set of simple functions. +As an example let's add the following function bean to the application defined above +[source,java] +---- +@Bean +public Function wrapInQuotes() { + return s -> "\"" + s + "\""; +} +---- +and modify the `spring.cloud.stream.function.definition` property to reflect your intention to compose a new function from both ‘toUpperCase’ and ‘wrapInQuotes’. +To do that Spring Cloud Function allows you to use `|` (pipe) symbol. So to finish our example our property will now look like this: + +[source,java] +---- +--spring.cloud.stream.function.definition=toUpperCase|wrapInQuotes +---- + +NOTE: One of the great benefits of functional composition support provided by _Spring Cloud Function_ is +the fact that you can compose _reactive_ and _imperative_ functions. + +For example, the above composition could be defined as such (if both functions present): + +[source,java] +---- +--spring.cloud.stream.function.definition=reactiveUpperCase|wrapInQuotes +---- + + + +[[spring-cloud-streams-overview-using-polled-consumers]] +==== Using Polled Consumers + +===== Overview + +When using polled consumers, you poll the `PollableMessageSource` on demand. +Consider the following example of a polled consumer: + +[source,java] +---- +public interface PolledConsumer { + + @Input + PollableMessageSource destIn(); + + @Output + MessageChannel destOut(); + +} +---- + +Given the polled consumer in the preceding example, you might use it as follows: + +[source,java] +---- +@Bean +public ApplicationRunner poller(PollableMessageSource destIn, MessageChannel destOut) { + return args -> { + while (someCondition()) { + try { + if (!destIn.poll(m -> { + String newPayload = ((String) m.getPayload()).toUpperCase(); + destOut.send(new GenericMessage<>(newPayload)); + })) { + Thread.sleep(1000); + } + } + catch (Exception e) { + // handle failure + } + } + }; +} +---- + +A less manual and more Spring-like alternative would be to configure a scheduled task bean. For example, + +[source,java] +---- + +@Scheduled(fixedDelay = 5_000) +public void poll() { + System.out.println("Polling..."); + this.source.poll(m -> { + System.out.println(m.getPayload()); + + }, new ParameterizedTypeReference() { }); +} +---- + + +The `PollableMessageSource.poll()` method takes a `MessageHandler` argument (often a lambda expression, as shown here). +It returns `true` if the message was received and successfully processed. + +As with message-driven consumers, if the `MessageHandler` throws an exception, messages are published to error channels, +as discussed in `<>`. + +Normally, the `poll()` method acknowledges the message when the `MessageHandler` exits. +If the method exits abnormally, the message is rejected (not re-queued), but see <>. +You can override that behavior by taking responsibility for the acknowledgment, as shown in the following example: + +[source,java] +---- +@Bean +public ApplicationRunner poller(PollableMessageSource dest1In, MessageChannel dest2Out) { + return args -> { + while (someCondition()) { + if (!dest1In.poll(m -> { + StaticMessageHeaderAccessor.getAcknowledgmentCallback(m).noAutoAck(); + // e.g. hand off to another thread which can perform the ack + // or acknowledge(Status.REQUEUE) + + })) { + Thread.sleep(1000); + } + } + }; +} +---- + +IMPORTANT: You must `ack` (or `nack`) the message at some point, to avoid resource leaks. + +IMPORTANT: Some messaging systems (such as Apache Kafka) maintain a simple offset in a log. If a delivery fails and is re-queued with `StaticMessageHeaderAccessor.getAcknowledgmentCallback(m).acknowledge(Status.REQUEUE);`, any later successfully ack'd messages are redelivered. + +There is also an overloaded `poll` method, for which the definition is as follows: + +[source,java] +---- +poll(MessageHandler handler, ParameterizedTypeReference type) +---- + +The `type` is a conversion hint that allows the incoming message payload to be converted, as shown in the following example: + +[source,java] +---- +boolean result = pollableSource.poll(received -> { + Map payload = (Map) received.getPayload(); + ... + + }, new ParameterizedTypeReference>() {}); +---- + +[[polled-errors]] +===== Handling Errors + +By default, an error channel is configured for the pollable source; if the callback throws an exception, an `ErrorMessage` is sent to the error channel (`..errors`); this error channel is also bridged to the global Spring Integration `errorChannel`. + +You can subscribe to either error channel with a `@ServiceActivator` to handle errors; without a subscription, the error will simply be logged and the message will be acknowledged as successful. +If the error channel service activator throws an exception, the message will be rejected (by default) and won't be redelivered. +If the service activator throws a `RequeueCurrentMessageException`, the message will be requeued at the broker and will be again retrieved on a subsequent poll. + +If the listener throws a `RequeueCurrentMessageException` directly, the message will be requeued, as discussed above, and will not be sent to the error channels. + +[[spring-cloud-stream-overview-error-handling]] +=== Error Handling + +Errors happen, and Spring Cloud Stream provides several flexible mechanisms to handle them. +The error handling comes in two flavors: + + * *application:* The error handling is done within the application (custom error handler). + + * *system:* The error handling is delegated to the binder (re-queue, DL, and others). Note that the techniques are dependent on binder implementation and the + capability of the underlying messaging middleware. + +Spring Cloud Stream uses the https://github.com/spring-projects/spring-retry[Spring Retry] library to facilitate successful message processing. See <> for more details. +However, when all fails, the exceptions thrown by the message handlers are propagated back to the binder. At that point, binder invokes custom error handler or communicates +the error back to the messaging system (re-queue, DLQ, and others). + +==== Application Error Handling + +There are two types of application-level error handling. Errors can be handled at each binding subscription or a global handler can handle all the binding subscription errors. Let's review the details. + +.A Spring Cloud Stream Sink Application with Custom and Global Error Handlers +image::{github-raw}/docs/src/main/asciidoc/images/custom_vs_global_error_channels.png[width=800,scaledwidth="75%",align="center"] + +For each input binding, Spring Cloud Stream creates a dedicated error channel with the following semantics `.errors`. + +NOTE: The `` consists of the name of the binding (such as `input`) and the name of the group (such as `myGroup`). + +Consider the following: + +[source,text] +---- +spring.cloud.stream.bindings.input.group=myGroup +---- + +[source,java] +---- +@StreamListener(Sink.INPUT) // destination name 'input.myGroup' +public void handle(Person value) { + throw new RuntimeException("BOOM!"); +} + +@ServiceActivator(inputChannel = Processor.INPUT + ".myGroup.errors") //channel name 'input.myGroup.errors' +public void error(Message message) { + System.out.println("Handling ERROR: " + message); +} +---- + +In the preceding example the destination name is `input.myGroup` and the dedicated error channel name is `input.myGroup.errors`. + +NOTE: The use of @StreamListener annotation is intended specifically to define bindings that bridge internal channels and external destinations. Given that the destination +specific error channel does NOT have an associated external destination, such channel is a prerogative of Spring Integration (SI). This means that the handler +for such destination must be defined using one of the SI handler annotations (i.e., @ServiceActivator, @Transformer etc.). + +NOTE: If `group` is not specified anonymous group is used (something like `input.anonymous.2K37rb06Q6m2r51-SPIDDQ`), which is not suitable for error +handling scenarious, since you don't know what it's going to be until the destination is created. + +Also, in the event you are binding to the existing destination such as: + +[source,text] +---- +spring.cloud.stream.bindings.input.destination=myFooDestination +spring.cloud.stream.bindings.input.group=myGroup +---- + +the full destination name is `myFooDestination.myGroup` and then the dedicated error channel name is `myFooDestination.myGroup.errors`. + +Back to the example... + +The `handle(..)` method, which subscribes to the channel named `input`, throws an exception. Given there is also a subscriber to the error channel `input.myGroup.errors` +all error messages are handled by this subscriber. + +If you have multiple bindings, you may want to have a single error handler. Spring Cloud Stream automatically provides support for +a _global error channel_ by bridging each individual error channel to the channel named `errorChannel`, allowing a single subscriber to handle all errors, +as shown in the following example: + +[source,java] +---- +@StreamListener("errorChannel") +public void error(Message message) { + System.out.println("Handling ERROR: " + message); +} +---- + +This may be a convenient option if error handling logic is the same regardless of which handler produced the error. + +==== System Error Handling + +System-level error handling implies that the errors are communicated back to the messaging system and, given that not every messaging system +is the same, the capabilities may differ from binder to binder. + +That said, in this section we explain the general idea behind system level error handling and use Rabbit binder as an example. NOTE: Kafka binder provides similar +support, although some configuration properties do differ. Also, for more details and configuration options, see the individual binder's documentation. + +If no internal error handlers are configured, the errors propagate to the binders, and the binders subsequently propagate those errors back to the messaging system. +Depending on the capabilities of the messaging system such a system may _drop_ the message, _re-queue_ the message for re-processing or _send the failed message to DLQ_. +Both Rabbit and Kafka support these concepts. However, other binders may not, so refer to your individual binder’s documentation for details on supported system-level +error-handling options. + +===== Drop Failed Messages + +By default, if no additional system-level configuration is provided, the messaging system drops the failed message. +While acceptable in some cases, for most cases, it is not, and we need some recovery mechanism to avoid message loss. + +===== DLQ - Dead Letter Queue + +DLQ allows failed messages to be sent to a special destination: - _Dead Letter Queue_. + +When configured, failed messages are sent to this destination for subsequent re-processing or auditing and reconciliation. + +For example, continuing on the previous example and to set up the DLQ with Rabbit binder, you need to set the following property: + +[source,text] +---- +spring.cloud.stream.rabbit.bindings.input.consumer.auto-bind-dlq=true +---- + +Keep in mind that, in the above property, `input` corresponds to the name of the input destination binding. +The `consumer` indicates that it is a consumer property and `auto-bind-dlq` instructs the binder to configure DLQ for `input` +destination, which results in an additional Rabbit queue named `input.myGroup.dlq`. + +Once configured, all failed messages are routed to this queue with an error message similar to the following: + +[source,text] +---- +delivery_mode: 1 +headers: +x-death: +count: 1 +reason: rejected +queue: input.hello +time: 1522328151 +exchange: +routing-keys: input.myGroup +Payload {"nameâ€:"Bob"} +---- + +As you can see from the above, your original message is preserved for further actions. + +However, one thing you may have noticed is that there is limited information on the original issue with the message processing. For example, you do not see a stack +trace corresponding to the original error. +To get more relevant information about the original error, you must set an additional property: + +[source,text] +---- +spring.cloud.stream.rabbit.bindings.input.consumer.republish-to-dlq=true +---- + +Doing so forces the internal error handler to intercept the error message and add additional information to it before publishing it to DLQ. +Once configured, you can see that the error message contains more information relevant to the original error, as follows: + +[source,text] +---- +delivery_mode: 2 +headers: +x-original-exchange: +x-exception-message: has an error +x-original-routingKey: input.myGroup +x-exception-stacktrace: org.springframework.messaging.MessageHandlingException: nested exception is + org.springframework.messaging.MessagingException: has an error, failedMessage=GenericMessage [payload=byte[15], + headers={amqp_receivedDeliveryMode=NON_PERSISTENT, amqp_receivedRoutingKey=input.hello, amqp_deliveryTag=1, + deliveryAttempt=3, amqp_consumerQueue=input.hello, amqp_redelivered=false, id=a15231e6-3f80-677b-5ad7-d4b1e61e486e, + amqp_consumerTag=amq.ctag-skBFapilvtZhDsn0k3ZmQg, contentType=application/json, timestamp=1522327846136}] + at org.spring...integ...han...MethodInvokingMessageProcessor.processMessage(MethodInvokingMessageProcessor.java:107) + at. . . . . +Payload {"nameâ€:"Bob"} +---- + +This effectively combines application-level and system-level error handling to further assist with downstream troubleshooting mechanics. + +===== Re-queue Failed Messages + +As mentioned earlier, the currently supported binders (Rabbit and Kafka) rely on `RetryTemplate` to facilitate successful message processing. See <> for details. +However, for cases when `max-attempts` property is set to 1, internal reprocessing of the message is disabled. At this point, you can facilitate message re-processing (re-tries) +by instructing the messaging system to re-queue the failed message. Once re-queued, the failed message is sent back to the original handler, essentially creating a retry loop. + +This option may be feasible for cases where the nature of the error is related to some sporadic yet short-term unavailability of some resource. + +To accomplish that, you must set the following properties: + +[source,text] +---- +spring.cloud.stream.bindings.input.consumer.max-attempts=1 +spring.cloud.stream.rabbit.bindings.input.consumer.requeue-rejected=true +---- + +In the preceding example, the `max-attempts` set to 1 essentially disabling internal re-tries and `requeue-rejected` (short for _requeue rejected messages_) is set to `true`. +Once set, the failed message is resubmitted to the same handler and loops continuously or until the handler throws `AmqpRejectAndDontRequeueException` +essentially allowing you to build your own re-try logic within the handler itself. + +==== Retry Template + +The `RetryTemplate` is part of the https://github.com/spring-projects/spring-retry[Spring Retry] library. +While it is out of scope of this document to cover all of the capabilities of the `RetryTemplate`, we will mention the following consumer properties that are specifically related to +the `RetryTemplate`: + +maxAttempts:: +The number of attempts to process the message. ++ +Default: 3. +backOffInitialInterval:: +The backoff initial interval on retry. ++ +Default 1000 milliseconds. +backOffMaxInterval:: +The maximum backoff interval. ++ +Default 10000 milliseconds. +backOffMultiplier:: +The backoff multiplier. ++ +Default 2.0. +defaultRetryable:: +Whether exceptions thrown by the listener that are not listed in the `retryableExceptions` are retryable. ++ +Default: `true`. +retryableExceptions:: +A map of Throwable class names in the key and a boolean in the value. +Specify those exceptions (and subclasses) that will or won't be retried. +Also see `defaultRetriable`. +Example: `spring.cloud.stream.bindings.input.consumer.retryable-exceptions.java.lang.IllegalStateException=false`. ++ +Default: empty. + +While the preceding settings are sufficient for majority of the customization requirements, they may not satisfy certain complex requirements at, which +point you may want to provide your own instance of the `RetryTemplate`. To do so configure it as a bean in your application configuration. The application provided +instance will override the one provided by the framework. Also, to avoid conflicts you must qualify the instance of the `RetryTemplate` you want to be used by the binder +as `@StreamRetryTemplate`. For example, + +[source,java] +---- +@StreamRetryTemplate +public RetryTemplate myRetryTemplate() { + return new RetryTemplate(); +} +---- +As you can see from the above example you don't need to annotate it with `@Bean` since `@StreamRetryTemplate` is a qualified `@Bean`. + +If you need to be more precise with your `RetryTemplate`, you can specify the bean by name in your `ConsumerProperties` to associate +the specific retry bean per binding. + +[source] +---- +spring.cloud.stream.bindings..consumer.retry-template-name= +---- + + +[[spring-cloud-stream-overview-binders]] +== Binders + +Spring Cloud Stream provides a Binder abstraction for use in connecting to physical destinations at the external middleware. +This section provides information about the main concepts behind the Binder SPI, its main components, and implementation-specific details. + +=== Producers and Consumers + +The following image shows the general relationship of producers and consumers: + +.Producers and Consumers +image::{github-raw}/docs/src/main/asciidoc/images/producers-consumers.png[width=800,scaledwidth="75%",align="center"] + +A producer is any component that sends messages to a channel. +The channel can be bound to an external message broker with a `Binder` implementation for that broker. +When invoking the `bindProducer()` method, the first parameter is the name of the destination within the broker, the second parameter is the local channel instance to which the producer sends messages, and the third parameter contains properties (such as a partition key expression) to be used within the adapter that is created for that channel. + +A consumer is any component that receives messages from a channel. +As with a producer, the consumer's channel can be bound to an external message broker. +When invoking the `bindConsumer()` method, the first parameter is the destination name, and a second parameter provides the name of a logical group of consumers. +Each group that is represented by consumer bindings for a given destination receives a copy of each message that a producer sends to that destination (that is, it follows normal publish-subscribe semantics). +If there are multiple consumer instances bound with the same group name, then messages are load-balanced across those consumer instances so that each message sent by a producer is consumed by only a single consumer instance within each group (that is, it follows normal queueing semantics). + +[[spring-cloud-stream-overview-binder-api]] +=== Binder SPI + +The Binder SPI consists of a number of interfaces, out-of-the box utility classes, and discovery strategies that provide a pluggable mechanism for connecting to external middleware. + +The key point of the SPI is the `Binder` interface, which is a strategy for connecting inputs and outputs to external middleware. The following listing shows the definnition of the `Binder` interface: + +[source,java] +---- +public interface Binder { + Binding bindConsumer(String name, String group, T inboundBindTarget, C consumerProperties); + + Binding bindProducer(String name, T outboundBindTarget, P producerProperties); +} +---- + +The interface is parameterized, offering a number of extension points: + +* Input and output bind targets. As of version 1.0, only `MessageChannel` is supported, but this is intended to be used as an extension point in the future. +* Extended consumer and producer properties, allowing specific Binder implementations to add supplemental properties that can be supported in a type-safe manner. + +A typical binder implementation consists of the following: + +* A class that implements the `Binder` interface; +* A Spring `@Configuration` class that creates a bean of type `Binder` along with the middleware connection infrastructure. +* A `META-INF/spring.binders` file found on the classpath containing one or more binder definitions, as shown in the following example: ++ +[source] +---- +kafka:\ +org.springframework.cloud.stream.binder.kafka.config.KafkaBinderConfiguration +---- + +=== Binder Detection + +Spring Cloud Stream relies on implementations of the Binder SPI to perform the task of connecting channels to message brokers. +Each Binder implementation typically connects to one type of messaging system. + +==== Classpath Detection + +By default, Spring Cloud Stream relies on Spring Boot's auto-configuration to configure the binding process. +If a single Binder implementation is found on the classpath, Spring Cloud Stream automatically uses it. +For example, a Spring Cloud Stream project that aims to bind only to RabbitMQ can add the following dependency: + +[source,xml] +---- + + org.springframework.cloud + spring-cloud-stream-binder-rabbit + +---- + +For the specific Maven coordinates of other binder dependencies, see the documentation of that binder implementation. + +[[multiple-binders]] +=== Multiple Binders on the Classpath + +When multiple binders are present on the classpath, the application must indicate which binder is to be used for each channel binding. +Each binder configuration contains a `META-INF/spring.binders` file, which is a simple properties file, as shown in the following example: + +[source] +---- +rabbit:\ +org.springframework.cloud.stream.binder.rabbit.config.RabbitServiceAutoConfiguration +---- + +Similar files exist for the other provided binder implementations (such as Kafka), and custom binder implementations are expected to provide them as well. +The key represents an identifying name for the binder implementation, whereas the value is a comma-separated list of configuration classes that each contain one and only one bean definition of type `org.springframework.cloud.stream.binder.Binder`. + +Binder selection can either be performed globally, using the `spring.cloud.stream.defaultBinder` property (for example, `spring.cloud.stream.defaultBinder=rabbit`) or individually, by configuring the binder on each channel binding. +For instance, a processor application (that has channels named `input` and `output` for read and write respectively) that reads from Kafka and writes to RabbitMQ can specify the following configuration: + +[source] +---- +spring.cloud.stream.bindings.input.binder=kafka +spring.cloud.stream.bindings.output.binder=rabbit +---- + +[[multiple-systems]] +=== Connecting to Multiple Systems + +By default, binders share the application's Spring Boot auto-configuration, so that one instance of each binder found on the classpath is created. +If your application should connect to more than one broker of the same type, you can specify multiple binder configurations, each with different environment settings. + +NOTE: Turning on explicit binder configuration disables the default binder configuration process altogether. +If you do so, all binders in use must be included in the configuration. +Frameworks that intend to use Spring Cloud Stream transparently may create binder configurations that can be referenced by name, but they do not affect the default binder configuration. +In order to do so, a binder configuration may have its `defaultCandidate` flag set to false (for example, `spring.cloud.stream.binders..defaultCandidate=false`). +This denotes a configuration that exists independently of the default binder configuration process. + +The following example shows a typical configuration for a processor application that connects to two RabbitMQ broker instances: + +[source,yml] +---- +spring: + cloud: + stream: + bindings: + input: + destination: thing1 + binder: rabbit1 + output: + destination: thing2 + binder: rabbit2 + binders: + rabbit1: + type: rabbit + environment: + spring: + rabbitmq: + host: + rabbit2: + type: rabbit + environment: + spring: + rabbitmq: + host: +---- +NOTE: The `environment` property of the particular binder can also be used for any Spring Boot property, +including this `spring.main.sources` which can be useful for adding additional configurations for the +particular binders, e.g. overriding auto-configured beans. + +For example; +[source, yaml] +---- +environment: + spring: + main: + sources: com.acme.config.MyCustomBinderConfiguration +---- + +To activate a specific profile for the particular binder environment, you should use a `spring.profiles.active` property: + +[source, yaml] +---- +environment: + spring: + profiles: + active: myBinderProfile +---- +[[binding_visualization_control]] +=== Binding visualization and control +Since version 2.0, Spring Cloud Stream supports visualization and control of the Bindings through Actuator endpoints. + +Starting with version 2.0 actuator and web are optional, you must first add one of the web dependencies as well as add the actuator dependency manually. +The following example shows how to add the dependency for the Web framework: + +[source,xml] +---- + + org.springframework.boot + spring-boot-starter-web + +---- + +The following example shows how to add the dependency for the WebFlux framework: + +[source,xml] +---- + + org.springframework.boot + spring-boot-starter-webflux + +---- + +You can add the Actuator dependency as follows: +[source,xml] +---- + + org.springframework.boot + spring-boot-starter-actuator + +---- + +NOTE: To run Spring Cloud Stream 2.0 apps in Cloud Foundry, you must add `spring-boot-starter-web` and `spring-boot-starter-actuator` to the classpath. Otherwise, the +application will not start due to health check failures. + +You must also enable the `bindings` actuator endpoints by setting the following property: `--management.endpoints.web.exposure.include=bindings`. + +Once those prerequisites are satisfied. you should see the following in the logs when application start: + + : Mapped "{[/actuator/bindings/{name}],methods=[POST]. . . + : Mapped "{[/actuator/bindings],methods=[GET]. . . + : Mapped "{[/actuator/bindings/{name}],methods=[GET]. . . + +To visualize the current bindings, access the following URL: +`http://:/actuator/bindings` + +Alternative, to see a single binding, access one of the URLs similar to the following: +`http://:/actuator/bindings/` + +You can also stop, start, pause, and resume individual bindings by posting to the same URL while providing a `state` argument as JSON, as shown in the following examples: + + curl -d '{"state":"STOPPED"}' -H "Content-Type: application/json" -X POST http://:/actuator/bindings/myBindingName + curl -d '{"state":"STARTED"}' -H "Content-Type: application/json" -X POST http://:/actuator/bindings/myBindingName + curl -d '{"state":"PAUSED"}' -H "Content-Type: application/json" -X POST http://:/actuator/bindings/myBindingName + curl -d '{"state":"RESUMED"}' -H "Content-Type: application/json" -X POST http://:/actuator/bindings/myBindingName + +NOTE: `PAUSED` and `RESUMED` work only when the corresponding binder and its underlying technology supports it. Otherwise, you see the warning message in the logs. +Currently, only Kafka binder supports the `PAUSED` and `RESUMED` states. + +=== Binder Configuration Properties + +The following properties are available when customizing binder configurations. These properties exposed via `org.springframework.cloud.stream.config.BinderProperties` + +They must be prefixed with `spring.cloud.stream.binders.`. + +type:: +The binder type. +It typically references one of the binders found on the classpath -- in particular, a key in a `META-INF/spring.binders` file. ++ +By default, it has the same value as the configuration name. +inheritEnvironment:: +Whether the configuration inherits the environment of the application itself. ++ +Default: `true`. +environment:: +Root for a set of properties that can be used to customize the environment of the binder. +When this property is set, the context in which the binder is being created is not a child of the application context. +This setting allows for complete separation between the binder components and the application components. ++ +Default: `empty`. +defaultCandidate:: +Whether the binder configuration is a candidate for being considered a default binder or can be used only when explicitly referenced. +This setting allows adding binder configurations without interfering with the default processing. ++ +Default: `true`. + +== Configuration Options + +Spring Cloud Stream supports general configuration options as well as configuration for bindings and binders. +Some binders let additional binding properties support middleware-specific features. + +Configuration options can be provided to Spring Cloud Stream applications through any mechanism supported by Spring Boot. +This includes application arguments, environment variables, and YAML or .properties files. + +=== Binding Service Properties + +These properties are exposed via `org.springframework.cloud.stream.config.BindingServiceProperties` + +spring.cloud.stream.instanceCount:: +The number of deployed instances of an application. +Must be set for partitioning on the producer side. Must be set on the consumer side when using RabbitMQ and with Kafka if `autoRebalanceEnabled=false`. ++ +Default: `1`. + +spring.cloud.stream.instanceIndex:: +The instance index of the application: A number from `0` to `instanceCount - 1`. +Used for partitioning with RabbitMQ and with Kafka if `autoRebalanceEnabled=false`. +Automatically set in Cloud Foundry to match the application's instance index. + +spring.cloud.stream.dynamicDestinations:: +A list of destinations that can be bound dynamically (for example, in a dynamic routing scenario). +If set, only listed destinations can be bound. ++ +Default: empty (letting any destination be bound). + +spring.cloud.stream.defaultBinder:: +The default binder to use, if multiple binders are configured. +See <>. ++ +Default: empty. + +spring.cloud.stream.overrideCloudConnectors:: +This property is only applicable when the `cloud` profile is active and Spring Cloud Connectors are provided with the application. +If the property is `false` (the default), the binder detects a suitable bound service (for example, a RabbitMQ service bound in Cloud Foundry for the RabbitMQ binder) and uses it for creating connections (usually through Spring Cloud Connectors). +When set to `true`, this property instructs binders to completely ignore the bound services and rely on Spring Boot properties (for example, relying on the `spring.rabbitmq.*` properties provided in the environment for the RabbitMQ binder). +The typical usage of this property is to be nested in a customized environment <>. ++ +Default: `false`. + +spring.cloud.stream.bindingRetryInterval:: +The interval (in seconds) between retrying binding creation when, for example, the binder does not support late binding and the broker (for example, Apache Kafka) is down. +Set it to zero to treat such conditions as fatal, preventing the application from starting. ++ +Default: `30` + +[[binding-properties]] +=== Binding Properties + +Binding properties are supplied by using the format of `spring.cloud.stream.bindings..=`. +The `` represents the name of the channel being configured (for example, `output` for a `Source`). + +To avoid repetition, Spring Cloud Stream supports setting values for all channels, in the format of `spring.cloud.stream.default.=`. + +When it comes to avoiding repetitions for extended binding properties, this format should be used - `spring.cloud.stream..default..=`. + +In what follows, we indicate where we have omitted the `spring.cloud.stream.bindings..` prefix and focus just on the property name, with the understanding that the prefix ise included at runtime. + +==== Common Binding Properties + +These properties are exposed via `org.springframework.cloud.stream.config.BindingProperties` + +The following binding properties are available for both input and output bindings and must be prefixed with `spring.cloud.stream.bindings..` (for example, `spring.cloud.stream.bindings.input.destination=ticktock`). + +Default values can be set by using the `spring.cloud.stream.default` prefix (for example`spring.cloud.stream.default.contentType=application/json`). + +destination:: +The target destination of a channel on the bound middleware (for example, the RabbitMQ exchange or Kafka topic). +If the channel is bound as a consumer, it could be bound to multiple destinations, and the destination names can be specified as comma-separated `String` values. +If not set, the channel name is used instead. +The default value of this property cannot be overridden. +group:: +The consumer group of the channel. +Applies only to inbound bindings. +See <>. ++ +Default: `null` (indicating an anonymous consumer). +contentType:: +The content type of the channel. +See `<>`. ++ +Default: `application/json`. +binder:: +The binder used by this binding. +See `<>` for details. ++ +Default: `null` (the default binder is used, if it exists). + +==== Consumer Properties + +These properties are exposed via `org.springframework.cloud.stream.binder.ConsumerProperties` + +The following binding properties are available for input bindings only and must be prefixed with `spring.cloud.stream.bindings..consumer.` (for example, `spring.cloud.stream.bindings.input.consumer.concurrency=3`). + +Default values can be set by using the `spring.cloud.stream.default.consumer` prefix (for example, `spring.cloud.stream.default.consumer.headerMode=none`). + +autoStartup:: +Signals if this consumer needs to be started automatically ++ +Default: `true`. +concurrency:: +The concurrency of the inbound consumer. ++ +Default: `1`. +partitioned:: +Whether the consumer receives data from a partitioned producer. ++ +Default: `false`. +headerMode:: +When set to `none`, disables header parsing on input. +Effective only for messaging middleware that does not support message headers natively and requires header embedding. +This option is useful when consuming data from non-Spring Cloud Stream applications when native headers are not supported. +When set to `headers`, it uses the middleware's native header mechanism. +When set to `embeddedHeaders`, it embeds headers into the message payload. ++ +Default: depends on the binder implementation. +maxAttempts:: +If processing fails, the number of attempts to process the message (including the first). +Set to `1` to disable retry. ++ +Default: `3`. +backOffInitialInterval:: +The backoff initial interval on retry. ++ +Default: `1000`. +backOffMaxInterval:: +The maximum backoff interval. ++ +Default: `10000`. +backOffMultiplier:: +The backoff multiplier. ++ +Default: `2.0`. +defaultRetryable:: +Whether exceptions thrown by the listener that are not listed in the `retryableExceptions` are retryable. ++ +Default: `true`. +instanceIndex:: +When set to a value greater than equal to zero, it allows customizing the instance index of this consumer (if different from `spring.cloud.stream.instanceIndex`). +When set to a negative value, it defaults to `spring.cloud.stream.instanceIndex`. +See `<>` for more information. ++ +Default: `-1`. +instanceCount:: +When set to a value greater than equal to zero, it allows customizing the instance count of this consumer (if different from `spring.cloud.stream.instanceCount`). +When set to a negative value, it defaults to `spring.cloud.stream.instanceCount`. +See `<>` for more information. ++ +Default: `-1`. +retryableExceptions:: +A map of Throwable class names in the key and a boolean in the value. +Specify those exceptions (and subclasses) that will or won't be retried. +Also see `defaultRetriable`. +Example: `spring.cloud.stream.bindings.input.consumer.retryable-exceptions.java.lang.IllegalStateException=false`. ++ +Default: empty. +useNativeDecoding:: +When set to `true`, the inbound message is deserialized directly by the client library, which must be configured correspondingly (for example, setting an appropriate Kafka producer value deserializer). +When this configuration is being used, the inbound message unmarshalling is not based on the `contentType` of the binding. +When native decoding is used, it is the responsibility of the producer to use an appropriate encoder (for example, the Kafka producer value serializer) to serialize the outbound message. +Also, when native encoding and decoding is used, the `headerMode=embeddedHeaders` property is ignored and headers are not embedded in the message. +See the producer property `useNativeEncoding`. ++ +Default: `false`. + +==== Advanced Consumer Configuration + +For advanced configuration of the underlying message listener container for message-driven consumers, add a single `ListenerContainerCustomizer` bean to the application context. +It will be invoked after the above properties have been applied and can be used to set additional properties. +Similarly, for polled consumers, add a `MessageSourceCustomizer` bean. + +The following is an example for the RabbitMQ binder: + +==== +[source, java] +---- +@Bean +public ListenerContainerCustomizer containerCustomizer() { + return (container, dest, group) -> container.setAdviceChain(advice1, advice2); +} + +@Bean +public MessageSourceCustomizer sourceCustomizer() { + return (source, dest, group) -> source.setPropertiesConverter(customPropertiesConverter); +} +---- +==== + +==== Producer Properties + +These properties are exposed via `org.springframework.cloud.stream.binder.ProducerProperties` + +The following binding properties are available for output bindings only and must be prefixed with `spring.cloud.stream.bindings..producer.` (for example, `spring.cloud.stream.bindings.input.producer.partitionKeyExpression=payload.id`). + +Default values can be set by using the prefix `spring.cloud.stream.default.producer` (for example, `spring.cloud.stream.default.producer.partitionKeyExpression=payload.id`). + +autoStartup:: +Signals if this consumer needs to be started automatically ++ +Default: `true`. +partitionKeyExpression:: +A SpEL expression that determines how to partition outbound data. +If set, or if `partitionKeyExtractorClass` is set, outbound data on this channel is partitioned. `partitionCount` must be set to a value greater than 1 to be effective. +Mutually exclusive with `partitionKeyExtractorClass`. +See `<>`. ++ +Default: null. +partitionKeyExtractorClass:: +A `PartitionKeyExtractorStrategy` implementation. +If set, or if `partitionKeyExpression` is set, outbound data on this channel is partitioned. `partitionCount` must be set to a value greater than 1 to be effective. +Mutually exclusive with `partitionKeyExpression`. +See `<>`. ++ +Default: `null`. +partitionSelectorClass:: + A `PartitionSelectorStrategy` implementation. +Mutually exclusive with `partitionSelectorExpression`. +If neither is set, the partition is selected as the `hashCode(key) % partitionCount`, where `key` is computed through either `partitionKeyExpression` or `partitionKeyExtractorClass`. ++ +Default: `null`. +partitionSelectorExpression:: +A SpEL expression for customizing partition selection. +Mutually exclusive with `partitionSelectorClass`. +If neither is set, the partition is selected as the `hashCode(key) % partitionCount`, where `key` is computed through either `partitionKeyExpression` or `partitionKeyExtractorClass`. ++ +Default: `null`. +partitionCount:: +The number of target partitions for the data, if partitioning is enabled. +Must be set to a value greater than 1 if the producer is partitioned. +On Kafka, it is interpreted as a hint. The larger of this and the partition count of the target topic is used instead. ++ +Default: `1`. +requiredGroups:: +A comma-separated list of groups to which the producer must ensure message delivery even if they start after it has been created (for example, by pre-creating durable queues in RabbitMQ). +headerMode:: +When set to `none`, it disables header embedding on output. +It is effective only for messaging middleware that does not support message headers natively and requires header embedding. +This option is useful when producing data for non-Spring Cloud Stream applications when native headers are not supported. +When set to `headers`, it uses the middleware's native header mechanism. +When set to `embeddedHeaders`, it embeds headers into the message payload. ++ +Default: Depends on the binder implementation. +useNativeEncoding:: +When set to `true`, the outbound message is serialized directly by the client library, which must be configured correspondingly (for example, setting an appropriate Kafka producer value serializer). +When this configuration is being used, the outbound message marshalling is not based on the `contentType` of the binding. +When native encoding is used, it is the responsibility of the consumer to use an appropriate decoder (for example, the Kafka consumer value de-serializer) to deserialize the inbound message. +Also, when native encoding and decoding is used, the `headerMode=embeddedHeaders` property is ignored and headers are not embedded in the message. +See the consumer property `useNativeDecoding`. ++ +Default: `false`. +errorChannelEnabled:: +When set to `true`, if the binder supports asynchroous send results, send failures are sent to an error channel for the destination. +See `<>` for more information. ++ +Default: `false`. + +[[dynamicdestination]] +=== Using Dynamically Bound Destinations + +Besides the channels defined by using `@EnableBinding`, Spring Cloud Stream lets applications send messages to dynamically bound destinations. +This is useful, for example, when the target destination needs to be determined at runtime. +Applications can do so by using the `BinderAwareChannelResolver` bean, registered automatically by the `@EnableBinding` annotation. + +The 'spring.cloud.stream.dynamicDestinations' property can be used for restricting the dynamic destination names to a known set (whitelisting). +If this property is not set, any destination can be bound dynamically. + +The `BinderAwareChannelResolver` can be used directly, as shown in the following example of a REST controller using a path variable to decide the target channel: + +[source,java] +---- +@EnableBinding +@Controller +public class SourceWithDynamicDestination { + + @Autowired + private BinderAwareChannelResolver resolver; + + @RequestMapping(path = "/{target}", method = POST, consumes = "*/*") + @ResponseStatus(HttpStatus.ACCEPTED) + public void handleRequest(@RequestBody String body, @PathVariable("target") target, + @RequestHeader(HttpHeaders.CONTENT_TYPE) Object contentType) { + sendMessage(body, target, contentType); + } + + private void sendMessage(String body, String target, Object contentType) { + resolver.resolveDestination(target).send(MessageBuilder.createMessage(body, + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, contentType)))); + } +} +---- + +Now consider what happens when we start the application on the default port (8080) and make the following requests with CURL: + +---- +curl -H "Content-Type: application/json" -X POST -d "customer-1" http://localhost:8080/customers + +curl -H "Content-Type: application/json" -X POST -d "order-1" http://localhost:8080/orders +---- + +The destinations, 'customers' and 'orders', are created in the broker (in the exchange for Rabbit or in the topic for Kafka) with names of 'customers' and 'orders', and the data is published to the appropriate destinations. + +The `BinderAwareChannelResolver` is a general-purpose Spring Integration `DestinationResolver` and can be injected in other components -- for example, in a router using a SpEL expression based on the `target` field of an incoming JSON message. The following example includes a router that reads SpEL expressions: + +[source,java] +---- +@EnableBinding +@Controller +public class SourceWithDynamicDestination { + + @Autowired + private BinderAwareChannelResolver resolver; + + + @RequestMapping(path = "/", method = POST, consumes = "application/json") + @ResponseStatus(HttpStatus.ACCEPTED) + public void handleRequest(@RequestBody String body, @RequestHeader(HttpHeaders.CONTENT_TYPE) Object contentType) { + sendMessage(body, contentType); + } + + private void sendMessage(Object body, Object contentType) { + routerChannel().send(MessageBuilder.createMessage(body, + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, contentType)))); + } + + @Bean(name = "routerChannel") + public MessageChannel routerChannel() { + return new DirectChannel(); + } + + @Bean + @ServiceActivator(inputChannel = "routerChannel") + public ExpressionEvaluatingRouter router() { + ExpressionEvaluatingRouter router = + new ExpressionEvaluatingRouter(new SpelExpressionParser().parseExpression("payload.target")); + router.setDefaultOutputChannelName("default-output"); + router.setChannelResolver(resolver); + return router; + } +} +---- + +The https://github.com/spring-cloud-stream-app-starters/router[Router Sink Application] uses this technique to create the destinations on-demand. + +If the channel names are known in advance, you can configure the producer properties as with any other destination. +Alternatively, if you register a `NewDestinationBindingCallback<>` bean, it is invoked just before the binding is created. +The callback takes the generic type of the extended producer properties used by the binder. +It has one method: + +[source, java] +---- +void configure(String channelName, MessageChannel channel, ProducerProperties producerProperties, + T extendedProducerProperties); +---- + +The following example shows how to use the RabbitMQ binder: + +[source, java] +---- +@Bean +public NewDestinationBindingCallback dynamicConfigurer() { + return (name, channel, props, extended) -> { + props.setRequiredGroups("bindThisQueue"); + extended.setQueueNameGroupOnly(true); + extended.setAutoBindDlq(true); + extended.setDeadLetterQueueName("myDLQ"); + }; +} +---- + +NOTE: If you need to support dynamic destinations with multiple binder types, use `Object` for the generic type and cast the `extended` argument as needed. + +[[content-type-management]] +== Content Type Negotiation + +Data transformation is one of the core features of any message-driven microservice architecture. Given that, in Spring Cloud Stream, such data +is represented as a Spring `Message`, a message may have to be transformed to a desired shape or size before reaching its destination. This is required for two reasons: + +. To convert the contents of the incoming message to match the signature of the application-provided handler. + +. To convert the contents of the outgoing message to the wire format. + +The wire format is typically `byte[]` (that is true for the Kafka and Rabbit binders), but it is governed by the binder implementation. + +In Spring Cloud Stream, message transformation is accomplished with an `org.springframework.messaging.converter.MessageConverter`. + +NOTE: As a supplement to the details to follow, you may also want to read the following https://spring.io/blog/2018/02/26/spring-cloud-stream-2-0-content-type-negotiation-and-transformation[blog post]. + +=== Mechanics + +To better understand the mechanics and the necessity behind content-type negotiation, we take a look at a very simple use case by using the following message handler as an example: + +[source, java] +---- +@StreamListener(Processor.INPUT) +@SendTo(Processor.OUTPUT) +public String handle(Person person) {..} +---- + +NOTE: For simplicity, we assume that this is the only handler in the application (we assume there is no internal pipeline). + +The handler shown in the preceding example expects a `Person` object as an argument and produces a `String` type as an output. +In order for the framework to succeed in passing the incoming `Message` as an argument to this handler, it has to somehow transform the payload of the `Message` type from the wire format to a `Person` type. +In other words, the framework must locate and apply the appropriate `MessageConverter`. +To accomplish that, the framework needs some instructions from the user. +One of these instructions is already provided by the signature of the handler method itself (`Person` type). +Consequently, in theory, that should be (and, in some cases, is) enough. +However, for the majority of use cases, in order to select the appropriate `MessageConverter`, the framework needs an additional piece of information. +That missing piece is `contentType`. + +Spring Cloud Stream provides three mechanisms to define `contentType` (in order of precedence): + +. *HEADER*: The `contentType` can be communicated through the Message itself. By providing a `contentType` header, you declare the content type to use to locate and apply the appropriate `MessageConverter`. + +. *BINDING*: The `contentType` can be set per destination binding by setting the `spring.cloud.stream.bindings.input.content-type` property. ++ +NOTE: The `input` segment in the property name corresponds to the actual name of the destination (which is “input†in our case). This approach lets you declare, on a per-binding basis, the content type to use to locate and apply the appropriate `MessageConverter`. + +. *DEFAULT*: If `contentType` is not present in the `Message` header or the binding, the default `application/json` content type is used to +locate and apply the appropriate `MessageConverter`. + +As mentioned earlier, the preceding list also demonstrates the order of precedence in case of a tie. For example, a header-provided content type takes precedence over any other content type. +The same applies for a content type set on a per-binding basis, which essentially lets you override the default content type. +However, it also provides a sensible default (which was determined from community feedback). + +Another reason for making `application/json` the default stems from the interoperability requirements driven by distributed microservices architectures, where producer and consumer not only run in different JVMs but can also run on different non-JVM platforms. + +When the non-void handler method returns, if the the return value is already a `Message`, that `Message` becomes the payload. However, when the return value is not a `Message`, the new `Message` is constructed with the return value as the payload while inheriting +headers from the input `Message` minus the headers defined or filtered by `SpringIntegrationProperties.messageHandlerNotPropagatedHeaders`. +By default, there is only one header set there: `contentType`. This means that the new `Message` does not have `contentType` header set, thus ensuring that the `contentType` can evolve. +You can always opt out of returning a `Message` from the handler method where you can inject any header you wish. + +If there is an internal pipeline, the `Message` is sent to the next handler by going through the same process of conversion. However, if there is no internal pipeline or you have reached the end of it, the `Message` is sent back to the output destination. + +==== Content Type versus Argument Type + +As mentioned earlier, for the framework to select the appropriate `MessageConverter`, it requires argument type and, optionally, content type information. +The logic for selecting the appropriate `MessageConverter` resides with the argument resolvers (`HandlerMethodArgumentResolvers`), which trigger right before the invocation of the user-defined handler method (which is when the actual argument type is known to the framework). +If the argument type does not match the type of the current payload, the framework delegates to the stack of the +pre-configured `MessageConverters` to see if any one of them can convert the payload. +As you can see, the `Object fromMessage(Message message, Class targetClass);` +operation of the MessageConverter takes `targetClass` as one of its arguments. +The framework also ensures that the provided `Message` always contains a `contentType` header. +When no contentType header was already present, it injects either the per-binding `contentType` header or the default `contentType` header. +The combination of `contentType` argument type is the mechanism by which framework determines if message can be converted to a target type. +If no appropriate `MessageConverter` is found, an exception is thrown, which you can handle by adding a custom `MessageConverter` (see `<>`). + +But what if the payload type matches the target type declared by the handler method? In this case, there is nothing to convert, and the +payload is passed unmodified. While this sounds pretty straightforward and logical, keep in mind handler methods that take a `Message` or `Object` as an argument. +By declaring the target type to be `Object` (which is an `instanceof` everything in Java), you essentially forfeit the conversion process. + +NOTE: Do not expect `Message` to be converted into some other type based only on the `contentType`. +Remember that the `contentType` is complementary to the target type. +If you wish, you can provide a hint, which `MessageConverter` may or may not take into consideration. + +==== Message Converters + +`MessageConverters` define two methods: + +[source, java] +---- +Object fromMessage(Message message, Class targetClass); + +Message toMessage(Object payload, @Nullable MessageHeaders headers); +---- + +It is important to understand the contract of these methods and their usage, specifically in the context of Spring Cloud Stream. + +The `fromMessage` method converts an incoming `Message` to an argument type. +The payload of the `Message` could be any type, and it is +up to the actual implementation of the `MessageConverter` to support multiple types. +For example, some JSON converter may support the payload type as `byte[]`, `String`, and others. +This is important when the application contains an internal pipeline (that is, input -> handler1 -> handler2 ->. . . -> output) and the output of the upstream handler results in a `Message` which may not be in the initial wire format. + +However, the `toMessage` method has a more strict contract and must always convert `Message` to the wire format: `byte[]`. + +So, for all intents and purposes (and especially when implementing your own converter) you regard the two methods as having the following signatures: + +[source, java] +---- +Object fromMessage(Message message, Class targetClass); + +Message toMessage(Object payload, @Nullable MessageHeaders headers); +---- + +=== Provided MessageConverters + +As mentioned earlier, the framework already provides a stack of `MessageConverters` to handle most common use cases. +The following list describes the provided `MessageConverters`, in order of precedence (the first `MessageConverter` that works is used): + +. `ApplicationJsonMessageMarshallingConverter`: Variation of the `org.springframework.messaging.converter.MappingJackson2MessageConverter`. Supports conversion of the payload of the `Message` to/from POJO for cases when `contentType` is `application/json` (DEFAULT). +. `TupleJsonMessageConverter`: *DEPRECATED* Supports conversion of the payload of the `Message` to/from `org.springframework.tuple.Tuple`. +. `ByteArrayMessageConverter`: Supports conversion of the payload of the `Message` from `byte[]` to `byte[]` for cases when `contentType` is `application/octet-stream`. It is essentially a pass through and exists primarily for backward compatibility. +. `ObjectStringMessageConverter`: Supports conversion of any type to a `String` when `contentType` is `text/plain`. +It invokes Object’s `toString()` method or, if the payload is `byte[]`, a new `String(byte[])`. +. `JavaSerializationMessageConverter`: *DEPRECATED* Supports conversion based on java serialization when `contentType` is `application/x-java-serialized-object`. +. `KryoMessageConverter`: *DEPRECATED* Supports conversion based on Kryo serialization when `contentType` is `application/x-java-object`. +. `JsonUnmarshallingConverter`: Similar to the `ApplicationJsonMessageMarshallingConverter`. It supports conversion of any type when `contentType` is `application/x-java-object`. +It expects the actual type information to be embedded in the `contentType` as an attribute (for example, `application/x-java-object;type=foo.bar.Cat`). + +When no appropriate converter is found, the framework throws an exception. When that happens, you should check your code and configuration and ensure you did not miss anything (that is, ensure that you provided a `contentType` by using a binding or a header). +However, most likely, you found some uncommon case (such as a custom `contentType` perhaps) and the current stack of provided `MessageConverters` +does not know how to convert. If that is the case, you can add custom `MessageConverter`. See <>. + +[[spring-cloud-stream-overview-user-defined-message-converters]] +=== User-defined Message Converters + +Spring Cloud Stream exposes a mechanism to define and register additional `MessageConverters`. +To use it, implement `org.springframework.messaging.converter.MessageConverter`, configure it as a `@Bean`, and annotate it with `@StreamMessageConverter`. +It is then apended to the existing stack of `MessageConverter`s. + +NOTE: It is important to understand that custom `MessageConverter` implementations are added to the head of the existing stack. +Consequently, custom `MessageConverter` implementations take precedence over the existing ones, which lets you override as well as add to the existing converters. + +The following example shows how to create a message converter bean to support a new content type called `application/bar`: + +[source,java] +---- +@EnableBinding(Sink.class) +@SpringBootApplication +public static class SinkApplication { + + ... + + @Bean + @StreamMessageConverter + public MessageConverter customMessageConverter() { + return new MyCustomMessageConverter(); + } +} + +public class MyCustomMessageConverter extends AbstractMessageConverter { + + public MyCustomMessageConverter() { + super(new MimeType("application", "bar")); + } + + @Override + protected boolean supports(Class clazz) { + return (Bar.class.equals(clazz)); + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, Object conversionHint) { + Object payload = message.getPayload(); + return (payload instanceof Bar ? payload : new Bar((byte[]) payload)); + } +} +---- + +Spring Cloud Stream also provides support for Avro-based converters and schema evolution. +See `<>` for details. + +[[schema-evolution]] +== Schema Evolution Support + +Spring Cloud Stream provides support for schema evolution so that the data can be evolved over time and still work with older or newer producers and consumers and vice versa. +Most serialization models, especially the ones that aim for portability across different platforms and languages, rely on a schema that describes how the data is serialized in the binary payload. +In order to serialize the data and then to interpret it, both the sending and receiving sides must have access to a schema that describes the binary format. +In certain cases, the schema can be inferred from the payload type on serialization or from the target type on deserialization. +However, many applications benefit from having access to an explicit schema that describes the binary data format. +A schema registry lets you store schema information in a textual format (typically JSON) and makes that information accessible to various applications that need it to receive and send data in binary format. +A schema is referenceable as a tuple consisting of: + +* A subject that is the logical name of the schema +* The schema version +* The schema format, which describes the binary format of the data + +This following sections goes through the details of various components involved in schema evolution process. + +=== Schema Registry Client + +The client-side abstraction for interacting with schema registry servers is the `SchemaRegistryClient` interface, which has the following structure: + +[source,java] +---- +public interface SchemaRegistryClient { + + SchemaRegistrationResponse register(String subject, String format, String schema); + + String fetch(SchemaReference schemaReference); + + String fetch(Integer id); + +} +---- + +Spring Cloud Stream provides out-of-the-box implementations for interacting with its own schema server and for interacting with the Confluent Schema Registry. + +A client for the Spring Cloud Stream schema registry can be configured by using the `@EnableSchemaRegistryClient`, as follows: + +[source,java] +---- + @EnableBinding(Sink.class) + @SpringBootApplication + @EnableSchemaRegistryClient + public static class AvroSinkApplication { + ... + } +---- + +NOTE: The default converter is optimized to cache not only the schemas from the remote server but also the `parse()` and `toString()` methods, which are quite expensive. +Because of this, it uses a `DefaultSchemaRegistryClient` that does not cache responses. +If you intend to change the default behavior, you can use the client directly on your code and override it to the desired outcome. +To do so, you have to add the property `spring.cloud.stream.schemaRegistryClient.cached=true` to your application properties. + +==== Schema Registry Client Properties + +The Schema Registry Client supports the following properties: + +`spring.cloud.stream.schemaRegistryClient.endpoint`:: The location of the schema-server. +When setting this, use a full URL, including protocol (`http` or `https`) , port, and context path. ++ +Default:: `http://localhost:8990/` +`spring.cloud.stream.schemaRegistryClient.cached`:: Whether the client should cache schema server responses. +Normally set to `false`, as the caching happens in the message converter. +Clients using the schema registry client should set this to `true`. ++ +Default:: `false` + +=== Avro Schema Registry Client Message Converters + +For applications that have a SchemaRegistryClient bean registered with the application context, Spring Cloud Stream auto configures an Apache Avro message converter for schema management. +This eases schema evolution, as applications that receive messages can get easy access to a writer schema that can be reconciled with their own reader schema. + +For outbound messages, if the content type of the channel is set to `application/*+avro`, the `MessageConverter` is activated, as shown in the following example: + +[source,properties] +---- +spring.cloud.stream.bindings.output.contentType=application/*+avro +---- + +During the outbound conversion, the message converter tries to infer the schema of each outbound messages (based on its type) and register it to a subject (based on the payload type) by using the `SchemaRegistryClient`. +If an identical schema is already found, then a reference to it is retrieved. +If not, the schema is registered, and a new version number is provided. +The message is sent with a `contentType` header by using the following scheme: `application/[prefix].[subject].v[version]+avro`, where `prefix` is configurable and `subject` is deduced from the payload type. + +For example, a message of the type `User` might be sent as a binary payload with a content type of `application/vnd.user.v2+avro`, where `user` is the subject and `2` is the version number. + +When receiving messages, the converter infers the schema reference from the header of the incoming message and tries to retrieve it. The schema is used as the writer schema in the deserialization process. + +==== Avro Schema Registry Message Converter Properties + +If you have enabled Avro based schema registry client by setting `spring.cloud.stream.bindings.output.contentType=application/*+avro`, you can customize the behavior of the registration by setting the following properties. + +spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled:: Enable if you want the converter to use reflection to infer a Schema from a POJO. ++ +Default: `false` ++ +spring.cloud.stream.schema.avro.readerSchema:: Avro compares schema versions by looking at a writer schema (origin payload) and a reader schema (your application payload). See the https://avro.apache.org/docs/1.7.6/spec.html[Avro documentation] for more information. If set, this overrides any lookups at the schema server and uses the local schema as the reader schema. +Default: `null` ++ +spring.cloud.stream.schema.avro.schemaLocations:: Registers any `.avsc` files listed in this property with the Schema Server. ++ +Default: `empty` ++ +spring.cloud.stream.schema.avro.prefix:: The prefix to be used on the Content-Type header. ++ +Default: `vnd` + +=== Apache Avro Message Converters + +Spring Cloud Stream provides support for schema-based message converters through its `spring-cloud-stream-schema` module. +Currently, the only serialization format supported out of the box for schema-based message converters is Apache Avro, with more formats to be added in future versions. + +The `spring-cloud-stream-schema` module contains two types of message converters that can be used for Apache Avro serialization: + +* Converters that use the class information of the serialized or deserialized objects or a schema with a location known at startup. +* Converters that use a schema registry. They locate the schemas at runtime and dynamically register new schemas as domain objects evolve. + +=== Converters with Schema Support + +The `AvroSchemaMessageConverter` supports serializing and deserializing messages either by using a predefined schema or by using the schema information available in the class (either reflectively or contained in the `SpecificRecord`). +If you provide a custom converter, then the default AvroSchemaMessageConverter bean is not created. The following example shows a custom converter: + +To use custom converters, you can simply add it to the application context, optionally specifying one or more `MimeTypes` with which to associate it. +The default `MimeType` is `application/avro`. + +If the target type of the conversion is a `GenericRecord`, a schema must be set. + +The following example shows how to configure a converter in a sink application by registering the Apache Avro `MessageConverter` without a predefined schema. +In this example, note that the mime type value is `avro/bytes`, not the default `application/avro`. + +[source,java] +---- +@EnableBinding(Sink.class) +@SpringBootApplication +public static class SinkApplication { + + ... + + @Bean + public MessageConverter userMessageConverter() { + return new AvroSchemaMessageConverter(MimeType.valueOf("avro/bytes")); + } +} +---- + +Conversely, the following application registers a converter with a predefined schema (found on the classpath): + +[source,java] +---- +@EnableBinding(Sink.class) +@SpringBootApplication +public static class SinkApplication { + + ... + + @Bean + public MessageConverter userMessageConverter() { + AvroSchemaMessageConverter converter = new AvroSchemaMessageConverter(MimeType.valueOf("avro/bytes")); + converter.setSchemaLocation(new ClassPathResource("schemas/User.avro")); + return converter; + } +} +---- + +=== Schema Registry Server + +Spring Cloud Stream provides a schema registry server implementation. +To use it, you can add the `spring-cloud-stream-schema-server` artifact to your project and use the `@EnableSchemaRegistryServer` annotation, which adds the schema registry server REST controller to your application. +This annotation is intended to be used with Spring Boot web applications, and the listening port of the server is controlled by the `server.port` property. +The `spring.cloud.stream.schema.server.path` property can be used to control the root path of the schema server (especially when it is embedded in other applications). +The `spring.cloud.stream.schema.server.allowSchemaDeletion` boolean property enables the deletion of a schema. By default, this is disabled. + +The schema registry server uses a relational database to store the schemas. +By default, it uses an embedded database. +You can customize the schema storage by using the http://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/htmlsingle/#boot-features-sql[Spring Boot SQL database and JDBC configuration options]. + +The following example shows a Spring Boot application that enables the schema registry: + +[source,java] +---- +@SpringBootApplication +@EnableSchemaRegistryServer +public class SchemaRegistryServerApplication { + public static void main(String[] args) { + SpringApplication.run(SchemaRegistryServerApplication.class, args); + } +} +---- + +==== Schema Registry Server API + +The Schema Registry Server API consists of the following operations: + +* `POST /` -- see `<>` +* 'GET /{subject}/{format}/{version}' -- see `<>` +* `GET /{subject}/{format}` -- see `<>` +* `GET /schemas/{id}` -- see `<>` +* `DELETE /{subject}/{format}/{version}` -- see `<>` +* `DELETE /schemas/{id}` -- see `<>` +* `DELETE /{subject}` -- see `<>` + +[[spring-cloud-stream-overview-registering-new-schema]] +===== Registering a New Schema + +To register a new schema, send a `POST` request to the `/` endpoint. + +The `/` accepts a JSON payload with the following fields: + +* `subject`: The schema subject +* `format`: The schema format +* `definition`: The schema definition + +Its response is a schema object in JSON, with the following fields: + +* `id`: The schema ID +* `subject`: The schema subject +* `format`: The schema format +* `version`: The schema version +* `definition`: The schema definition + +[[spring-cloud-stream-overview-retrieve-schema-subject-format-version]] +===== Retrieving an Existing Schema by Subject, Format, and Version + +To retrieve an existing schema by subject, format, and version, send `GET` request to the `/{subject}/{format}/{version}` endpoint. + +Its response is a schema object in JSON, with the following fields: + +* `id`: The schema ID +* `subject`: The schema subject +* `format`: The schema format +* `version`: The schema version +* `definition`: The schema definition + +[[spring-cloud-stream-overview-retrieve-schema-subject-format]] +===== Retrieving an Existing Schema by Subject and Format + +To retrieve an existing schema by subject and format, send a `GET` request to the `/subject/format` endpoint. + +Its response is a list of schemas with each schema object in JSON, with the following fields: + +* `id`: The schema ID +* `subject`: The schema subject +* `format`: The schema format +* `version`: The schema version +* `definition`: The schema definition + +[[spring-cloud-stream-overview-retrieve-schema-id]] +===== Retrieving an Existing Schema by ID + +To retrieve a schema by its ID, send a `GET` request to the `/schemas/{id}` endpoint. + +Its response is a schema object in JSON, with the following fields: + +* `id`: The schema ID +* `subject`: The schema subject +* `format`: The schema format +* `version`: The schema version +* `definition`: The schema definition + +[[spring-cloud-stream-overview-deleting-schema-subject-format-version]] +===== Deleting a Schema by Subject, Format, and Version + +To delete a schema identified by its subject, format, and version, send a `DELETE` request to the `/{subject}/{format}/{version}` endpoint. + +[[spring-cloud-stream-overview-deleting-schema-id]] +===== Deleting a Schema by ID + +To delete a schema by its ID, send a `DELETE` request to the `/schemas/{id}` endpoint. + +[[spring-cloud-stream-overview-deleting-schema-subject]] +===== Deleting a Schema by Subject +`DELETE /{subject}` + +Delete existing schemas by their subject. + +NOTE: This note applies to users of Spring Cloud Stream 1.1.0.RELEASE only. +Spring Cloud Stream 1.1.0.RELEASE used the table name, `schema`, for storing `Schema` objects. `Schema` is a keyword in a number of database implementations. +To avoid any conflicts in the future, starting with 1.1.1.RELEASE, we have opted for the name `SCHEMA_REPOSITORY` for the storage table. +Any Spring Cloud Stream 1.1.0.RELEASE users who upgrade should migrate their existing schemas to the new table before upgrading. + +==== Using Confluent's Schema Registry + +The default configuration creates a `DefaultSchemaRegistryClient` bean. +If you want to use the Confluent schema registry, you need to create a bean of type `ConfluentSchemaRegistryClient`, which supersedes the one configured by default by the framework. The following example shows how to create such a bean: + +[source,java] +---- +@Bean +public SchemaRegistryClient schemaRegistryClient(@Value("${spring.cloud.stream.schemaRegistryClient.endpoint}") String endpoint){ + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient(); + client.setEndpoint(endpoint); + return client; +} +---- +NOTE: The ConfluentSchemaRegistryClient is tested against Confluent platform version 4.0.0. + +=== Schema Registration and Resolution + +To better understand how Spring Cloud Stream registers and resolves new schemas and its use of Avro schema comparison features, we provide two separate subsections: + +* `<>` +* `<>` + +[[spring-cloud-stream-overview-schema-registration-process]] +==== Schema Registration Process (Serialization) + +The first part of the registration process is extracting a schema from the payload that is being sent over a channel. +Avro types such as `SpecificRecord` or `GenericRecord` already contain a schema, which can be retrieved immediately from the instance. +In the case of POJOs, a schema is inferred if the `spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled` property is set to `true` (the default). + +.Schema Writer Resolution Process +image::{github-raw}/docs/src/main/asciidoc/images/schema_resolution.png[width=800,scaledwidth="75%",align="center"] + +Ones a schema is obtained, the converter loads its metadata (version) from the remote server. +First, it queries a local cache. If no result is found, it submits the data to the server, which replies with versioning information. +The converter always caches the results to avoid the overhead of querying the Schema Server for every new message that needs to be serialized. + +.Schema Registration Process +image::{github-raw}/docs/src/main/asciidoc/images/registration.png[width=800,scaledwidth="75%",align="center"] + +With the schema version information, the converter sets the `contentType` header of the message to carry the version information -- for example: `application/vnd.user.v1+avro`. + +[[spring-cloud-stream-overview-schema-resolution-process]] +==== Schema Resolution Process (Deserialization) + +When reading messages that contain version information (that is, a `contentType` header with a scheme like the one described under `<>`, the converter queries the Schema server to fetch the writer schema of the message. +Once it has found the correct schema of the incoming message, it retrieves the reader schema and, by using Avro's schema resolution support, reads it into the reader definition (setting defaults and any missing properties). + +.Schema Reading Resolution Process +image::{github-raw}/docs/src/main/asciidoc/images/schema_reading.png[width=800,scaledwidth="75%",align="center"] + +NOTE: You should understand the difference between a writer schema (the application that wrote the message) and a reader schema (the receiving application). +We suggest taking a moment to read https://avro.apache.org/docs/1.7.6/spec.html[the Avro terminology] and understand the process. +Spring Cloud Stream always fetches the writer schema to determine how to read a message. +If you want to get Avro's schema evolution support working, you need to make sure that a `readerSchema` was properly set for your application. + +== Inter-Application Communication + +Spring Cloud Stream enables communication between applications. Inter-application communication is a complex issue spanning several concerns, as described in the following topics: + +* `<>` +* `<>` +* `<>` + +[[spring-cloud-stream-overview-connecting-multiple-application-instances]] +=== Connecting Multiple Application Instances + +While Spring Cloud Stream makes it easy for individual Spring Boot applications to connect to messaging systems, the typical scenario for Spring Cloud Stream is the creation of multi-application pipelines, where microservice applications send data to each other. +You can achieve this scenario by correlating the input and output destinations of "`adjacent`" applications. + +Suppose a design calls for the Time Source application to send data to the Log Sink application. You could use a common destination named `ticktock` for bindings within both applications. + +Time Source (that has the channel name `output`) would set the following property: + +---- +spring.cloud.stream.bindings.output.destination=ticktock +---- + +Log Sink (that has the channel name `input`) would set the following property: + +---- +spring.cloud.stream.bindings.input.destination=ticktock +---- + +[[spring-cloud-stream-overview-instance-index-instance-count]] +=== Instance Index and Instance Count + +When scaling up Spring Cloud Stream applications, each instance can receive information about how many other instances of the same application exist and what its own instance index is. +Spring Cloud Stream does this through the `spring.cloud.stream.instanceCount` and `spring.cloud.stream.instanceIndex` properties. +For example, if there are three instances of a HDFS sink application, all three instances have `spring.cloud.stream.instanceCount` set to `3`, and the individual applications have `spring.cloud.stream.instanceIndex` set to `0`, `1`, and `2`, respectively. + +When Spring Cloud Stream applications are deployed through Spring Cloud Data Flow, these properties are configured automatically; when Spring Cloud Stream applications are launched independently, these properties must be set correctly. +By default, `spring.cloud.stream.instanceCount` is `1`, and `spring.cloud.stream.instanceIndex` is `0`. + +In a scaled-up scenario, correct configuration of these two properties is important for addressing partitioning behavior (see below) in general, and the two properties are always required by certain binders (for example, the Kafka binder) in order to ensure that data are split correctly across multiple consumer instances. + +[[spring-cloud-stream-overview-partitioning]] +=== Partitioning + +Partitioning in Spring Cloud Stream consists of two tasks: + +* `<>` +* `<>` + +[[spring-cloud-stream-overview-configuring-output-bindings-partitioning]] +==== Configuring Output Bindings for Partitioning + +You can configure an output binding to send partitioned data by setting one and only one of its `partitionKeyExpression` or `partitionKeyExtractorName` properties, as well as its `partitionCount` property. + +For example, the following is a valid and typical configuration: + +---- +spring.cloud.stream.bindings.output.producer.partitionKeyExpression=payload.id +spring.cloud.stream.bindings.output.producer.partitionCount=5 +---- + +Based on that example configuration, data is sent to the target partition by using the following logic. + +A partition key's value is calculated for each message sent to a partitioned output channel based on the `partitionKeyExpression`. +The `partitionKeyExpression` is a SpEL expression that is evaluated against the outbound message for extracting the partitioning key. + +If a SpEL expression is not sufficient for your needs, you can instead calculate the partition key value by providing an implementation of `org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy` and configuring it as a bean (by using the `@Bean` annotation). +If you have more then one bean of type `org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy` available in the Application Context, you can further filter it by specifying its name with the `partitionKeyExtractorName` property, as shown in the following example: + +[source] +---- +--spring.cloud.stream.bindings.output.producer.partitionKeyExtractorName=customPartitionKeyExtractor +--spring.cloud.stream.bindings.output.producer.partitionCount=5 +. . . +@Bean +public CustomPartitionKeyExtractorClass customPartitionKeyExtractor() { + return new CustomPartitionKeyExtractorClass(); +} +---- + +NOTE: In previous versions of Spring Cloud Stream, you could specify the implementation of `org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy` by setting the `spring.cloud.stream.bindings.output.producer.partitionKeyExtractorClass` property. +Since version 2.0, this property is deprecated, and support for it will be removed in a future version. + +Once the message key is calculated, the partition selection process determines the target partition as a value between `0` and `partitionCount - 1`. +The default calculation, applicable in most scenarios, is based on the following formula: `key.hashCode() % partitionCount`. +This can be customized on the binding, either by setting a SpEL expression to be evaluated against the 'key' (through the `partitionSelectorExpression` property) or by configuring an implementation of `org.springframework.cloud.stream.binder.PartitionSelectorStrategy` as a bean (by using the @Bean annotation). +Similar to the `PartitionKeyExtractorStrategy`, you can further filter it by using the `spring.cloud.stream.bindings.output.producer.partitionSelectorName` property when more than one bean of this type is available in the Application Context, as shown in the following example: + +[source] +---- +--spring.cloud.stream.bindings.output.producer.partitionSelectorName=customPartitionSelector +. . . +@Bean +public CustomPartitionSelectorClass customPartitionSelector() { + return new CustomPartitionSelectorClass(); +} +---- + +NOTE: In previous versions of Spring Cloud Stream you could specify the implementation of `org.springframework.cloud.stream.binder.PartitionSelectorStrategy` by setting the `spring.cloud.stream.bindings.output.producer.partitionSelectorClass` property. +Since version 2.0, this property is deprecated and support for it will be removed in a future version. + +[[spring-cloud-stream-overview-configuring-input-bindings-partitioning]] +==== Configuring Input Bindings for Partitioning + +An input binding (with the channel name `input`) is configured to receive partitioned data by setting its `partitioned` property, as well as the `instanceIndex` and `instanceCount` properties on the application itself, as shown in the following example: + +---- +spring.cloud.stream.bindings.input.consumer.partitioned=true +spring.cloud.stream.instanceIndex=3 +spring.cloud.stream.instanceCount=5 +---- + +The `instanceCount` value represents the total number of application instances between which the data should be partitioned. +The `instanceIndex` must be a unique value across the multiple instances, with a value between `0` and `instanceCount - 1`. +The instance index helps each application instance to identify the unique partition(s) from which it receives data. +It is required by binders using technology that does not support partitioning natively. +For example, with RabbitMQ, there is a queue for each partition, with the queue name containing the instance index. +With Kafka, if `autoRebalanceEnabled` is `true` (default), Kafka takes care of distributing partitions across instances, and these properties are not required. +If `autoRebalanceEnabled` is set to false, the `instanceCount` and `instanceIndex` are used by the binder to determine which partition(s) the instance subscribes to (you must have at least as many partitions as there are instances). +The binder allocates the partitions instead of Kafka. +This might be useful if you want messages for a particular partition to always go to the same instance. +When a binder configuration requires them, it is important to set both values correctly in order to ensure that all of the data is consumed and that the application instances receive mutually exclusive datasets. + +While a scenario in which using multiple instances for partitioned data processing may be complex to set up in a standalone case, Spring Cloud Dataflow can simplify the process significantly by populating both the input and output values correctly and by letting you rely on the runtime infrastructure to provide information about the instance index and instance count. + +== Testing + +Spring Cloud Stream provides support for testing your microservice applications without connecting to a messaging system. +You can do that by using the `TestSupportBinder` provided by the `spring-cloud-stream-test-support` library, which can be added as a test dependency to the application, as shown in the following example: + +[source,xml] +---- + + org.springframework.cloud + spring-cloud-stream-test-support + test + +---- + +NOTE: The `TestSupportBinder` uses the Spring Boot autoconfiguration mechanism to supersede the other binders found on the classpath. +Therefore, when adding a binder as a dependency, you must make sure that the `test` scope is being used. + +The `TestSupportBinder` lets you interact with the bound channels and inspect any messages sent and received by the application. + +For outbound message channels, the `TestSupportBinder` registers a single subscriber and retains the messages emitted by the application in a `MessageCollector`. +They can be retrieved during tests and have assertions made against them. + +You can also send messages to inbound message channels so that the consumer application can consume the messages. +The following example shows how to test both input and output channels on a processor: + +[source,java] +---- +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ExampleTest { + + @Autowired + private Processor processor; + + @Autowired + private MessageCollector messageCollector; + + @Test + @SuppressWarnings("unchecked") + public void testWiring() { + Message message = new GenericMessage<>("hello"); + processor.input().send(message); + Message received = (Message) messageCollector.forChannel(processor.output()).poll(); + assertThat(received.getPayload(), equalTo("hello world")); + } + + + @SpringBootApplication + @EnableBinding(Processor.class) + public static class MyProcessor { + + @Autowired + private Processor channels; + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " world"; + } + } +} +---- + +In the preceding example, we create an application that has an input channel and an output channel, both bound through the `Processor` interface. +The bound interface is injected into the test so that we can have access to both channels. +We send a message on the input channel, and we use the `MessageCollector` provided by Spring Cloud Stream's test support to capture that the message has been sent to the output channel as a result. +Once we have received the message, we can validate that the component functions correctly. + + +=== Disabling the Test Binder Autoconfiguration + +The intent behind the test binder superseding all the other binders on the classpath is to make it easy to test your applications without making changes to your production dependencies. +In some cases (for example, integration tests) it is useful to use the actual production binders instead, and that requires disabling the test binder autoconfiguration. +To do so, you can exclude the `org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration` class by using one of the Spring Boot autoconfiguration exclusion mechanisms, as shown in the following example: + +[source,java] +---- + @SpringBootApplication(exclude = TestSupportBinderAutoConfiguration.class) + @EnableBinding(Processor.class) + public static class MyProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " world"; + } + } +---- + +When autoconfiguration is disabled, the test binder is available on the classpath, and its `defaultCandidate` property is set to `false` so that it does not interfere with the regular user configuration. It can be referenced under the name, `test`, as shown in the following example: + +`spring.cloud.stream.defaultBinder=test` + +[[spring_integration_test_binder]] +=== Spring Integration Test Binder +Current test binder was specifically designed to facilitate _unit testing_ of the actual messaging components and thus bypasses some of the core functionality of the binder API. +While such light-weight approach is sufficient for a lot of cases, it usually requires additional _integration testing_ with real binders (e.g., Rabbit, Kafka etc). + +To begin bridging the gap between _unit_ and _integration_ testing we've developed a new test binder which uses https://spring.io/projects/spring-integration[Spring Integration] framework +as an in-JVM Message Broker essentially giving you the best of both worlds - a real binder without the networking. + +To enable Spring Integration Test Binder all you need is: + +- Add required dependencies +- Remove the dependency for `spring-cloud-stream-test-support` + +***Add required dependencies*** + +Below is the example of the required Maven POM entries which could be easily retrofitted into Gradle. + +[source,xml] +---- + + org.springframework.cloud + spring-cloud-stream + ${spring.cloud.strea.version} + test-jar + test + test-binder + +. . . + + + org.apache.maven.plugins + maven-jar-plugin + + + + + **/integration/* + + test-binder + + + test-jar + + + + + +---- + +***Remove the dependency for `spring-cloud-stream-test-support`*** + +To avoid conflicts with the existing test binder you must eremove the following entry + +[source,xml] +---- + + org.springframework.cloud + spring-cloud-stream-test-support + test + +---- + +Now you can test your microservice as a simple unit test + + +[source,java] +---- +@SpringBootApplication +@EnableBinding(Processor.class) +public class DemoTestBinderApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoTestBinderApplication.class, args); + } + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String echo(String value) { + return value; + } +} + +. . . + +@Test +public void sampleTest() { + ApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.class, + DemoTestBinderApplication.class) + .web(WebApplicationType.NONE).run(); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + source.send(new GenericMessage("hello".getBytes())); + System.out.println("Result: " + new String(target.receive().getPayload())); +} +---- + +In the above you simply create an ApplicationContext with your configuration (your application) while additionally supplying `TestChannelBinderConfiguration` +provided by the framework. Then you access `InputDestination` and `OutputDestination` beans to send/receive messages. In the context of this binder +`InputDestination` and `OutputDestination` emulate remote destinations such as Rabbit _exchange/queue_ or Kafka _topic_. + +In the future we plan to simplify the API. + +NOTE: In its current state Spring Integration Test Binder only supports the three bindings provided by the framework (Source, Processor, Sink) specifically to promote +light-weight microservices architectures rather then general purpose messaging applications. + +==== Spring Integration Test Binder and PollableMessageSource +Spring Integration Test Binder also allows you to write tests when working with `PollableMessageSource` (see <> for more details). + +The important thing that needs to be understood though is that polling is not event-driven, and that `PollableMessageSource` is a strategy which exposes operation to produce (poll for) a Message (singular). +How often you poll or how many threads you use or where you're polling from (message queue or file system) is entirely up to you; +In other words it is your responsibility to configure Poller or Threads or the actual source of Message. Luckily Spring has plenty of abstractions to configure exactly that. + +Let's look at the example: + +[source, java] +---- +@Test +public void samplePollingTest() { + ApplicationContext context = new SpringApplicationBuilder(SamplePolledConfiguration.class) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + OutputDestination destination = context.getBean(OutputDestination.class); + System.out.println("Message 1: " + new String(destination.receive().getPayload())); + System.out.println("Message 2: " + new String(destination.receive().getPayload())); + System.out.println("Message 3: " + new String(destination.receive().getPayload())); +} + +@EnableBinding(SamplePolledConfiguration.PolledConsumer.class) +@Import(TestChannelBinderConfiguration.class) +@EnableAutoConfiguration +public static class SamplePolledConfiguration { + @Bean + public ApplicationRunner poller(PollableMessageSource polledMessageSource, MessageChannel output, TaskExecutor taskScheduler) { + return args -> { + taskScheduler.execute(() -> { + for (int i = 0; i < 3; i++) { + try { + if (!polledMessageSource.poll(m -> { + String newPayload = ((String) m.getPayload()).toUpperCase(); + output.send(new GenericMessage<>(newPayload)); + })) { + Thread.sleep(2000); + } + } + catch (Exception e) { + // handle failure + } + } + }); + }; + } + + public static interface PolledConsumer extends Source { + @Input + PollableMessageSource pollableSource(); + } +} +---- + +The above (very rudimentary) example will produce 3 messages in 2 second intervals sending them to the output destination of `Source` +which this binder sends to `OutputDestination` where we retrieve them (for any assertions). +Currently it prints the following: +[source, text] +---- +Message 1: POLLED DATA +Message 2: POLLED DATA +Message 3: POLLED DATA +---- +As you can see the data is the same. That is because this binder defines a default implementation of the actual `MessageSource` - the source +from which the Messages are polled using `poll()` operation. While sufficient for most testing scenarios, there are cases where you may want +to define your own `MessageSource`. To do so simply configure a bean of type `MessageSource` in your test configuration providing your own +implementation of Message sourcing. + +Here is the example: + +[source, java] +---- +@Bean +public MessageSource source() { + return () -> new GenericMessage<>("My Own Data " + UUID.randomUUID()); +} +---- +rendering the following output; +[source, text] +---- +Message 1: MY OWN DATA 1C180A91-E79F-494F-ABF4-BA3F993710DA +Message 2: MY OWN DATA D8F3A477-5547-41B4-9434-E69DA7616FEE +Message 3: MY OWN DATA 20BF2E64-7FF4-4CB6-A823-4053D30B5C74 +---- + +NOTE: DO NOT name this bean `messageSource` as it is going to be in conflict with the bean of the same name (different type) +provided by Spring Boot for unrelated reasons. + + +== Health Indicator + +Spring Cloud Stream provides a health indicator for binders. +It is registered under the name `binders` and can be enabled or disabled by setting the `management.health.binders.enabled` property. + +To enable health check you first need to enable both "web" and "actuator" by including its dependencies (see <>) + +If `management.health.binders.enabled` is not set explicitly by the application, then `management.health.defaults.enabled` is matched as `true` and the binder health indicators are enabled. +If you want to disable health indicator completely, then you have to set `management.health.binders.enabled` to `false`. + +You can use Spring Boot actuator health endpoint to access the health indicator - `/actuator/health`. +By default, you will only receive the top level application status when you hit the above endpoint. +In order to receive the full details from the binder specific health indicators, you need to include the property `management.endpoint.health.show-details` with the value `ALWAYS` in your application. + +Health indicators are binder-specific and certain binder implementations may not necessarily provide a health indicator. + +If you want to completely disable all health indicators available out of the box and instead provide your own health indicators, +you can do so by setting property `management.health.binders.enabled` to `false` and then provide your own `HealthIndicator` beans in your application. +In this case, the health indicator infrastructure from Spring Boot will still pick up these custom beans. +Even if you are not disabling the binder health indicators, you can still enhance the health checks by providing your own `HealthIndicator` beans in addition to the out of the box health checks. + +When you have multiple binders in the same application, health indicators are enabled by default unless the application turns them off by setting `management.health.binders.enabled` to `false`. +In this case, if the user wants to disable health check for a subset of the binders, then that should be done by setting `management.health.binders.enabled` to `false` in the multi binder configurations's environment. +See <> for details on how environment specific properties can be provided. + +If there are multiple binders present in the classpath but not all of them are used in the application, this may cause some issues in the context of health indicators. +There may be implementation specific details as to how the health checks are performed. For example, a Kafka binder may decide the status as `DOWN` if there are no destinations registered by the binder. +For this reason, if you include a binder in the classpath, it is advised to use that binder by providing at least one binding (for E.g. through `EnableBinding`). +If you don't have any bindings to provide for this binder, then that is an indication that you don't need to include that binder in the classpath. + +Lets take a concrete situation. Imagine you have both Kafka and Kafka Streams binders present in the classpath, but only use the Kafka Streams binder in the application code, i.e. only provide bindings using the Kafka Streams binder. +Since Kafka binder is not used and it has specific checks to see if any destinations are registered, the binder health heck will fail. +The top level application health check status will be reported as `DOWN`. +In this situation, you can simply remove the dependency for kafka binder from your application since you are not using it. + +[[spring-cloud-stream-overview-metrics-emitter]] +== Metrics Emitter + +Spring Boot Actuator provides dependency management and auto-configuration for https://micrometer.io/[Micrometer], an application metrics +facade that supports numerous https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/reference/htmlsingle/#production-ready-metrics[monitoring systems]. + +Spring Cloud Stream provides support for emitting any available micrometer-based metrics to a binding destination, allowing for periodic +collection of metric data from stream applications without relying on polling individual endpoints. + +Metrics Emitter is activated by defining the `spring.cloud.stream.bindings.applicationMetrics.destination` property, +which specifies the name of the binding destination used by the current binder to publish metric messages. + +For example: +[source,java] +---- +spring.cloud.stream.bindings.applicationMetrics.destination=myMetricDestination +---- +The preceding example instructs the binder to bind to `myMetricDestination` (that is, Rabbit exchange, Kafka topic, and others). + +The following properties can be used for customizing the emission of metrics: + +spring.cloud.stream.metrics.key:: +The name of the metric being emitted. Should be a unique value per application. ++ +Default: `${spring.application.name:${vcap.application.name:${spring.config.name:application}}}` ++ +spring.cloud.stream.metrics.properties:: +Allows white listing application properties that are added to the metrics payload ++ +Default: null. ++ +spring.cloud.stream.metrics.meter-filter:: +Pattern to control the 'meters' one wants to capture. +For example, specifying `spring.integration.*` captures metric information for meters whose name starts with `spring.integration.` ++ +Default: all 'meters' are captured. ++ +spring.cloud.stream.metrics.schedule-interval:: +Interval to control the rate of publishing metric data. ++ +Default: 1 min + +Consider the following: + +[source,bash] +---- +java -jar time-source.jar \ + --spring.cloud.stream.bindings.applicationMetrics.destination=someMetrics \ + --spring.cloud.stream.metrics.properties=spring.application** \ + --spring.cloud.stream.metrics.meter-filter=spring.integration.* +---- + +The following example shows the payload of the data published to the binding destination as a result of the preceding command: + +[source,javascript] +---- +{ + "name": "application", + "createdTime": "2018-03-23T14:48:12.700Z", + "properties": { + }, + "metrics": [ + { + "id": { + "name": "spring.integration.send", + "tags": [ + { + "key": "exception", + "value": "none" + }, + { + "key": "name", + "value": "input" + }, + { + "key": "result", + "value": "success" + }, + { + "key": "type", + "value": "channel" + } + ], + "type": "TIMER", + "description": "Send processing time", + "baseUnit": "milliseconds" + }, + "timestamp": "2018-03-23T14:48:12.697Z", + "sum": 130.340546, + "count": 6, + "mean": 21.72342433333333, + "upper": 116.176299, + "total": 130.340546 + } + ] +} +---- + +NOTE: Given that the format of the Metric message has slightly changed after migrating to Micrometer, the published message will also have +a `STREAM_CLOUD_STREAM_VERSION` header set to `2.x` to help distinguish between Metric messages from the older versions of the Spring Cloud Stream. + +== Samples + +For Spring Cloud Stream samples, see the https://github.com/spring-cloud/spring-cloud-stream-samples[spring-cloud-stream-samples] repository on GitHub. + +=== Deploying Stream Applications on CloudFoundry + +On CloudFoundry, services are usually exposed through a special environment variable called https://docs.cloudfoundry.org/devguide/deploy-apps/environment-variable.html#VCAP-SERVICES[VCAP_SERVICES]. + +When configuring your binder connections, you can use the values from an environment variable as explained on the http://docs.spring.io/spring-cloud-dataflow-server-cloudfoundry/docs/current-SNAPSHOT/reference/htmlsingle/#getting-started-ups[dataflow Cloud Foundry Server] docs. + +== Binder Implementations + +The following is the list of available binder implementations + +* https://cloud.spring.io/spring-cloud-stream-binder-rabbit/[RabbitMQ] +* https://cloud.spring.io/spring-cloud-stream-binder-kafka/[Apache Kafka] +* https://github.com/spring-cloud/spring-cloud-stream-binder-aws-kinesis[Amazon Kinesis] +* https://github.com/spring-cloud/spring-cloud-gcp/tree/master/spring-cloud-gcp-pubsub-stream-binder[Google PubSub _(partner maintained)_] +* https://github.com/SolaceProducts/spring-cloud-stream-binder-solace[Solace PubSub+ _(partner maintained)_] +* https://github.com/Microsoft/spring-cloud-azure/tree/master/spring-cloud-azure-eventhub-stream-binder[Azure Event Hubs _(partner maintained)_] diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/ruby/generate_readme.sh b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/ruby/generate_readme.sh new file mode 100755 index 000000000..6d0ce9dc5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/docs/src/main/ruby/generate_readme.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby + +base_dir = File.join(File.dirname(__FILE__),'../../..') +src_dir = File.join(base_dir, "/src/main/asciidoc") +require 'asciidoctor' +require 'optparse' + +options = {} +file = "#{src_dir}/README.adoc" + +OptionParser.new do |o| + o.on('-o OUTPUT_FILE', 'Output file (default is stdout)') { |file| options[:to_file] = file unless file=='-' } + o.on('-h', '--help') { puts o; exit } + o.parse! +end + +file = ARGV[0] if ARGV.length>0 + +# Copied from https://github.com/asciidoctor/asciidoctor-extensions-lab/blob/master/scripts/asciidoc-coalescer.rb +doc = Asciidoctor.load_file file, safe: :unsafe, header_only: true, attributes: options[:attributes] +header_attr_names = (doc.instance_variable_get :@attributes_modified).to_a +header_attr_names.each {|k| doc.attributes[%(#{k}!)] = '' unless doc.attr? k } +attrs = doc.attributes +attrs['allow-uri-read'] = true +puts attrs + +out = "// Do not edit this file (e.g. go instead to src/main/asciidoc)\n\n" +doc = Asciidoctor.load_file file, safe: :unsafe, parse: false, attributes: attrs +out << doc.reader.read + +unless options[:to_file] + puts out +else + File.open(options[:to_file],'w+') do |file| + file.write(out) + end +end diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/COMMIT_EDITMSG b/docs/src/test/bats/fixtures/spring-cloud-stream/git/COMMIT_EDITMSG new file mode 100644 index 000000000..f762b9164 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/COMMIT_EDITMSG @@ -0,0 +1 @@ +Added checkstyle diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/FETCH_HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/FETCH_HEAD new file mode 100644 index 000000000..0146b2e74 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/FETCH_HEAD @@ -0,0 +1 @@ +3e7f960197e196ec9a2f536711a97cc7cfc7e45d branch 'gh-pages' of github.com:spring-cloud/spring-cloud-stream diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/HEAD new file mode 100644 index 000000000..cb089cd89 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/ORIG_HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/ORIG_HEAD new file mode 100644 index 000000000..b86aeb645 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/ORIG_HEAD @@ -0,0 +1 @@ +6b970b1d0ed73ee4da171c19ee2d004c629570df diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/config b/docs/src/test/bats/fixtures/spring-cloud-stream/git/config new file mode 100644 index 000000000..e83144d82 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/config @@ -0,0 +1,14 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = git@github.com:spring-cloud/spring-cloud-stream + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "master"] + remote = origin + merge = refs/heads/master +[branch "gh-pages"] + remote = origin + merge = refs/heads/gh-pages diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/description b/docs/src/test/bats/fixtures/spring-cloud-stream/git/description new file mode 100644 index 000000000..498b267a8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/applypatch-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/applypatch-msg.sample new file mode 100755 index 000000000..a5d7b84a6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/commit-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/commit-msg.sample new file mode 100755 index 000000000..b58d1184a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/fsmonitor-watchman.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/fsmonitor-watchman.sample new file mode 100755 index 000000000..e673bb398 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,114 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 1) and a time in nanoseconds +# formatted as a string and outputs to stdout all files that have been +# modified since the given time. Paths must be relative to the root of +# the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $time) = @ARGV; + +# Check the hook interface version + +if ($version == 1) { + # convert nanoseconds to seconds + $time = int $time / 1000000000; +} else { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree; +if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $git_work_tree = Win32::GetCwd(); + $git_work_tree =~ tr/\\/\//; +} else { + require Cwd; + $git_work_tree = Cwd::cwd(); +} + +my $retry = 1; + +launch_watchman(); + +sub launch_watchman { + + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $time but were not transient (ie created after + # $time but no longer exist). + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + # + # The category of transient files that we want to ignore will have a + # creation clock (cclock) newer than $time_t value and will also not + # currently exist. + + my $query = <<" END"; + ["query", "$git_work_tree", { + "since": $time, + "fields": ["name"], + "expression": ["not", ["allof", ["since", $time, "cclock"], ["not", "exists"]]] + }] + END + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + my $json_pkg; + eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; + } or do { + require JSON::PP; + $json_pkg = "JSON::PP"; + }; + + my $o = $json_pkg->new->utf8->decode($response); + + if ($retry > 0 and $o->{error} and $o->{error} =~ m/unable to resolve root .* directory (.*) is not watched/) { + print STDERR "Adding '$git_work_tree' to watchman's watch list.\n"; + $retry--; + qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + print "/\0"; + eval { launch_watchman() }; + exit 0; + } + + die "Watchman: $o->{error}.\n" . + "Falling back to scanning...\n" if $o->{error}; + + binmode STDOUT, ":utf8"; + local $, = "\0"; + print @{$o->{files}}; +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/post-update.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/post-update.sample new file mode 100755 index 000000000..ec17ec193 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-applypatch.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-applypatch.sample new file mode 100755 index 000000000..4142082bc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-commit.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-commit.sample new file mode 100755 index 000000000..6a7564163 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-push.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-push.sample new file mode 100755 index 000000000..6187dbf43 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-rebase.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-rebase.sample new file mode 100755 index 000000000..6cbef5c37 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-receive.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-receive.sample new file mode 100755 index 000000000..a1fd29ec1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/prepare-commit-msg.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/prepare-commit-msg.sample new file mode 100755 index 000000000..10fa14c5a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/update.sample b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/update.sample new file mode 100755 index 000000000..80ba94135 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/index b/docs/src/test/bats/fixtures/spring-cloud-stream/git/index new file mode 100644 index 000000000..3736dcc5d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/index differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/info/exclude b/docs/src/test/bats/fixtures/spring-cloud-stream/git/info/exclude new file mode 100644 index 000000000..a5196d1be --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/HEAD new file mode 100644 index 000000000..4f5f474d7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/HEAD @@ -0,0 +1,9 @@ +0000000000000000000000000000000000000000 c6d238085f11a086b62813da5d08cd90310da5ea Marcin Grzejszczak 1549274693 +0100 clone: from git@github.com:spring-cloud/spring-cloud-stream +c6d238085f11a086b62813da5d08cd90310da5ea a8cbf7779458bf72b4e2439136299c33eb797697 Marcin Grzejszczak 1549292135 +0100 commit: Added checkstyle +a8cbf7779458bf72b4e2439136299c33eb797697 070ab0adfa59e54951f6b759d930f5da1e9f218b Marcin Grzejszczak 1553590477 +0100 pull --rebase origin master: Fast-forward +070ab0adfa59e54951f6b759d930f5da1e9f218b 7b980d7644c03e913963daf46d74476ee881eea6 Marcin Grzejszczak 1553590489 +0100 checkout: moving from master to gh-pages +7b980d7644c03e913963daf46d74476ee881eea6 6b970b1d0ed73ee4da171c19ee2d004c629570df Marcin Grzejszczak 1553590494 +0100 pull --rebase origin gh-pages: Fast-forward +6b970b1d0ed73ee4da171c19ee2d004c629570df 070ab0adfa59e54951f6b759d930f5da1e9f218b Marcin Grzejszczak 1553619375 +0100 checkout: moving from gh-pages to master +070ab0adfa59e54951f6b759d930f5da1e9f218b 6b970b1d0ed73ee4da171c19ee2d004c629570df Marcin Grzejszczak 1553619382 +0100 checkout: moving from master to gh-pages +6b970b1d0ed73ee4da171c19ee2d004c629570df 3e7f960197e196ec9a2f536711a97cc7cfc7e45d Marcin Grzejszczak 1553619390 +0100 pull --rebase origin gh-pages: Fast-forward +3e7f960197e196ec9a2f536711a97cc7cfc7e45d 070ab0adfa59e54951f6b759d930f5da1e9f218b Marcin Grzejszczak 1553619397 +0100 checkout: moving from gh-pages to master diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/gh-pages new file mode 100644 index 000000000..f520188f9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/gh-pages @@ -0,0 +1,3 @@ +0000000000000000000000000000000000000000 7b980d7644c03e913963daf46d74476ee881eea6 Marcin Grzejszczak 1553590489 +0100 branch: Created from refs/remotes/origin/gh-pages +7b980d7644c03e913963daf46d74476ee881eea6 6b970b1d0ed73ee4da171c19ee2d004c629570df Marcin Grzejszczak 1553590494 +0100 pull --rebase origin gh-pages: Fast-forward +6b970b1d0ed73ee4da171c19ee2d004c629570df 3e7f960197e196ec9a2f536711a97cc7cfc7e45d Marcin Grzejszczak 1553619390 +0100 pull --rebase origin gh-pages: Fast-forward diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/master b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/master new file mode 100644 index 000000000..6e320ec29 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/heads/master @@ -0,0 +1,3 @@ +0000000000000000000000000000000000000000 c6d238085f11a086b62813da5d08cd90310da5ea Marcin Grzejszczak 1549274693 +0100 clone: from git@github.com:spring-cloud/spring-cloud-stream +c6d238085f11a086b62813da5d08cd90310da5ea a8cbf7779458bf72b4e2439136299c33eb797697 Marcin Grzejszczak 1549292135 +0100 commit: Added checkstyle +a8cbf7779458bf72b4e2439136299c33eb797697 070ab0adfa59e54951f6b759d930f5da1e9f218b Marcin Grzejszczak 1553590477 +0100 pull --rebase origin master: Fast-forward diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/HEAD new file mode 100644 index 000000000..aa57a8227 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 c6d238085f11a086b62813da5d08cd90310da5ea Marcin Grzejszczak 1549274693 +0100 clone: from git@github.com:spring-cloud/spring-cloud-stream diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/gh-pages new file mode 100644 index 000000000..55093fb4c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/gh-pages @@ -0,0 +1,2 @@ +7b980d7644c03e913963daf46d74476ee881eea6 6b970b1d0ed73ee4da171c19ee2d004c629570df Marcin Grzejszczak 1553590494 +0100 pull --rebase origin gh-pages: fast-forward +6b970b1d0ed73ee4da171c19ee2d004c629570df 3e7f960197e196ec9a2f536711a97cc7cfc7e45d Marcin Grzejszczak 1553619390 +0100 pull --rebase origin gh-pages: fast-forward diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/master b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/master new file mode 100644 index 000000000..2ff83ee5e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/logs/refs/remotes/origin/master @@ -0,0 +1,2 @@ +c6d238085f11a086b62813da5d08cd90310da5ea a8cbf7779458bf72b4e2439136299c33eb797697 Marcin Grzejszczak 1549292142 +0100 update by push +a8cbf7779458bf72b4e2439136299c33eb797697 070ab0adfa59e54951f6b759d930f5da1e9f218b Marcin Grzejszczak 1553590477 +0100 pull --rebase origin master: fast-forward diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/67a1381ccbb282e009b2efc5af03d39e9fcff1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/67a1381ccbb282e009b2efc5af03d39e9fcff1 new file mode 100644 index 000000000..7d2cf363f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/67a1381ccbb282e009b2efc5af03d39e9fcff1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/d32aab1d44085ccfba1be9c5af0f321bd6ca3d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/d32aab1d44085ccfba1be9c5af0f321bd6ca3d new file mode 100644 index 000000000..e02bd7868 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/d32aab1d44085ccfba1be9c5af0f321bd6ca3d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/fb3dd34a357b6cf511cab8554c5b39459118ba b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/fb3dd34a357b6cf511cab8554c5b39459118ba new file mode 100644 index 000000000..ccf335002 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/00/fb3dd34a357b6cf511cab8554c5b39459118ba differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/8617b99b8d1ab1c5c42714a8315ae271fc3487 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/8617b99b8d1ab1c5c42714a8315ae271fc3487 new file mode 100644 index 000000000..06c153ea6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/8617b99b8d1ab1c5c42714a8315ae271fc3487 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/af9b3a722f92555818358293c9ec9af8b80aa4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/af9b3a722f92555818358293c9ec9af8b80aa4 new file mode 100644 index 000000000..df6e3f886 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/af9b3a722f92555818358293c9ec9af8b80aa4 @@ -0,0 +1,2 @@ +x+)JMU052a040031QpL*.)JL.qÊÌKI- +I-.)ÖËJ,Kd(K8aì·aÖªÇó/ÜLß[Ùå!ÇŠ¦' ?''1)'Õ9?¯¸4¢bĈ$ƒ÷MM'‚ì¿_¹å½ôv£¾GÝr4#@"kYÿTvÓ ?¬?“æÝ>ùmwÔ…kÂÔTBÔLšp7Úš3ð‹ÿžŽIbjOÿw?Ï…ªÙäÝ7ÿw¼¿Ê\ù#æ\{Šç?ŸËYÇûÝi \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/dd82ad2acd4144801234faf8de63aced0c37bf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/dd82ad2acd4144801234faf8de63aced0c37bf new file mode 100644 index 000000000..43fc83682 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/dd82ad2acd4144801234faf8de63aced0c37bf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/e67997377a393fd672c7dcde9dccbedf0cb1e9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/e67997377a393fd672c7dcde9dccbedf0cb1e9 new file mode 100644 index 000000000..50086b317 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/01/e67997377a393fd672c7dcde9dccbedf0cb1e9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/61a2dbbf6b9d37b2e38c426bd668ce6c2a7bb2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/61a2dbbf6b9d37b2e38c426bd668ce6c2a7bb2 new file mode 100644 index 000000000..a6de457e8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/61a2dbbf6b9d37b2e38c426bd668ce6c2a7bb2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/8169a6a30654d150621b6dd861788c03aa6d3d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/8169a6a30654d150621b6dd861788c03aa6d3d new file mode 100644 index 000000000..d8a104f70 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/02/8169a6a30654d150621b6dd861788c03aa6d3d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/1085da6a5ad37c075eaadbd9560b9c1a744714 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/1085da6a5ad37c075eaadbd9560b9c1a744714 new file mode 100644 index 000000000..ba5a75507 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/1085da6a5ad37c075eaadbd9560b9c1a744714 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/25a8ec45c06f20567132c2bcc0f50faf0aadd9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/25a8ec45c06f20567132c2bcc0f50faf0aadd9 new file mode 100644 index 000000000..8f76b0501 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/25a8ec45c06f20567132c2bcc0f50faf0aadd9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/57bebe0e114fff7079429e05c8de03938253af b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/57bebe0e114fff7079429e05c8de03938253af new file mode 100644 index 000000000..5bfda1bc7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/57bebe0e114fff7079429e05c8de03938253af differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/f914c42403d4e2aa6297ceddc5d82d9dc1dcd3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/f914c42403d4e2aa6297ceddc5d82d9dc1dcd3 new file mode 100644 index 000000000..9cdd6bec5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/03/f914c42403d4e2aa6297ceddc5d82d9dc1dcd3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/99ea26c8a2eb58a80c94eaff88bb734bdfb059 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/99ea26c8a2eb58a80c94eaff88bb734bdfb059 new file mode 100644 index 000000000..f627464d0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/99ea26c8a2eb58a80c94eaff88bb734bdfb059 @@ -0,0 +1,2 @@ +xŽKN!E³Šš 4ŸÄ÷à +Š¢è×QšÍ‹q÷~âཱི;8'‡{kÛ„%,wsˆ€ÓÙ‹ælCÅT½KÁÚŠšƒHdv¼Dï8ÖE4dŸ ­Ñ yB*6°(D%—„^çĆ‚sÁ8E×yéòu{/Î)žnÎËyŒm_ë &}¼=ö±>ƒA´h¢N÷ú{Šsàö?"õú¹3”Î'ÔÑü%Íëåá UNõ!ZØ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/cc03e26a517af1835a0e07ba3718e1dc712f58 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/cc03e26a517af1835a0e07ba3718e1dc712f58 new file mode 100644 index 000000000..709722ccd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/cc03e26a517af1835a0e07ba3718e1dc712f58 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/f7309fe13055ae61783bf714231a8f95b5aa91 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/f7309fe13055ae61783bf714231a8f95b5aa91 new file mode 100644 index 000000000..63388dc27 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/04/f7309fe13055ae61783bf714231a8f95b5aa91 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/05247d2272aa43d291c008386c7dbb7cb70185 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/05247d2272aa43d291c008386c7dbb7cb70185 new file mode 100644 index 000000000..f910317e5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/05247d2272aa43d291c008386c7dbb7cb70185 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/913377d3140f74b0f8073e503741c14b23385a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/913377d3140f74b0f8073e503741c14b23385a new file mode 100644 index 000000000..b86ff7ba0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/913377d3140f74b0f8073e503741c14b23385a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/d4da3401c067cf4a04bc20b207c3c6adca174f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/d4da3401c067cf4a04bc20b207c3c6adca174f new file mode 100644 index 000000000..3184cd54a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/d4da3401c067cf4a04bc20b207c3c6adca174f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/dcb1627095a0ec1b43ee352fa2473c47acbad2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/dcb1627095a0ec1b43ee352fa2473c47acbad2 new file mode 100644 index 000000000..acec96100 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/dcb1627095a0ec1b43ee352fa2473c47acbad2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/ea13509e898520191bbccb6b8fa2f055831a32 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/ea13509e898520191bbccb6b8fa2f055831a32 new file mode 100644 index 000000000..f12ea96e3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/05/ea13509e898520191bbccb6b8fa2f055831a32 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/17d865e41c5c614e0aa54db5e917958a4a2fe4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/17d865e41c5c614e0aa54db5e917958a4a2fe4 new file mode 100644 index 000000000..3bdaa0a05 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/17d865e41c5c614e0aa54db5e917958a4a2fe4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/32de232843551bafc5e8ad1b93db5e18fe69b0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/32de232843551bafc5e8ad1b93db5e18fe69b0 new file mode 100644 index 000000000..2a69a13b8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/32de232843551bafc5e8ad1b93db5e18fe69b0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/3e685f77b90a7d672a9859f51d1cb5748a0ae2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/3e685f77b90a7d672a9859f51d1cb5748a0ae2 new file mode 100644 index 000000000..e44209e8f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/3e685f77b90a7d672a9859f51d1cb5748a0ae2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/79d88a9c4cd3fda832ab0294e4cbd99c88e252 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/79d88a9c4cd3fda832ab0294e4cbd99c88e252 new file mode 100644 index 000000000..9f23b3e22 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/79d88a9c4cd3fda832ab0294e4cbd99c88e252 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/b70b3455204372ac4fdcf80fcda1519004b9b6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/b70b3455204372ac4fdcf80fcda1519004b9b6 new file mode 100644 index 000000000..07ad88a23 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/b70b3455204372ac4fdcf80fcda1519004b9b6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/ff47cfb1c46bea49c4d43a55fb78e191c77c03 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/ff47cfb1c46bea49c4d43a55fb78e191c77c03 new file mode 100644 index 000000000..9f4c66f0d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/06/ff47cfb1c46bea49c4d43a55fb78e191c77c03 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/aaa44be841a9c78b875d7d43a989e5c429ccc4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/aaa44be841a9c78b875d7d43a989e5c429ccc4 new file mode 100644 index 000000000..597c4ae0d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/aaa44be841a9c78b875d7d43a989e5c429ccc4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/be502016b467a6b0209f71f70d62a224f20da1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/be502016b467a6b0209f71f70d62a224f20da1 new file mode 100644 index 000000000..f65429361 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/07/be502016b467a6b0209f71f70d62a224f20da1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/03bb71288671d1b70d6f2e52dfe9d1323f91cd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/03bb71288671d1b70d6f2e52dfe9d1323f91cd new file mode 100644 index 000000000..68fad7d39 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/03bb71288671d1b70d6f2e52dfe9d1323f91cd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/0e18834d0be40d765462d78c8283f22e56c873 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/0e18834d0be40d765462d78c8283f22e56c873 new file mode 100644 index 000000000..965a5b87d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/0e18834d0be40d765462d78c8283f22e56c873 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/719697a1fd8d0e31318156bbff814d14d197ff b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/719697a1fd8d0e31318156bbff814d14d197ff new file mode 100644 index 000000000..75217e465 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/719697a1fd8d0e31318156bbff814d14d197ff differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/7220e6a555c02914cd7d8598d7b6246f044612 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/7220e6a555c02914cd7d8598d7b6246f044612 new file mode 100644 index 000000000..036483521 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/7220e6a555c02914cd7d8598d7b6246f044612 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ce27888f7e27cb7aeda6b0d66d1cd4b36a4f99 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ce27888f7e27cb7aeda6b0d66d1cd4b36a4f99 new file mode 100644 index 000000000..4128595f8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ce27888f7e27cb7aeda6b0d66d1cd4b36a4f99 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ec8b2bdcc0b02818311172a8e3ab6350843369 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ec8b2bdcc0b02818311172a8e3ab6350843369 new file mode 100644 index 000000000..6b3cc144b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ec8b2bdcc0b02818311172a8e3ab6350843369 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ecebf9222207dc34f16f6ec1aacd7a0a92d9ff b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ecebf9222207dc34f16f6ec1aacd7a0a92d9ff new file mode 100644 index 000000000..a18c9897d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/08/ecebf9222207dc34f16f6ec1aacd7a0a92d9ff differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/7d7811ebb7cb0f8c11279b4c20bc7711b85f0e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/7d7811ebb7cb0f8c11279b4c20bc7711b85f0e new file mode 100644 index 000000000..947b31db9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/7d7811ebb7cb0f8c11279b4c20bc7711b85f0e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/88962c6afbc30d12bc8a252ecef5762d21d99b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/88962c6afbc30d12bc8a252ecef5762d21d99b new file mode 100644 index 000000000..4a2d258d9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/88962c6afbc30d12bc8a252ecef5762d21d99b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/d24e315efd717954951d54b7acc3c33395bad8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/d24e315efd717954951d54b7acc3c33395bad8 new file mode 100644 index 000000000..d89d290f9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/d24e315efd717954951d54b7acc3c33395bad8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/e4051e99461055018f60b2b727d5a249a45a6c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/e4051e99461055018f60b2b727d5a249a45a6c new file mode 100644 index 000000000..8a67a18ee Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/09/e4051e99461055018f60b2b727d5a249a45a6c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/2ba3519aea47ed80557b1a1e8bd9da2a346a74 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/2ba3519aea47ed80557b1a1e8bd9da2a346a74 new file mode 100644 index 000000000..1a662f02b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/2ba3519aea47ed80557b1a1e8bd9da2a346a74 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/75cbc29270e3dd8dde1f6ae862a5e03a71be67 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/75cbc29270e3dd8dde1f6ae862a5e03a71be67 new file mode 100644 index 000000000..7656862c8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0a/75cbc29270e3dd8dde1f6ae862a5e03a71be67 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/17987a3780bf603fe253151182a1e3d87e08df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/17987a3780bf603fe253151182a1e3d87e08df new file mode 100644 index 000000000..d9ab387de Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/17987a3780bf603fe253151182a1e3d87e08df differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/48c3575a1eb03e59abb7660beef4e1f4d2568d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/48c3575a1eb03e59abb7660beef4e1f4d2568d new file mode 100644 index 000000000..d0cd01c81 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/48c3575a1eb03e59abb7660beef4e1f4d2568d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/80f61baa9958e3604fe8950a8c7d2381772d86 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/80f61baa9958e3604fe8950a8c7d2381772d86 new file mode 100644 index 000000000..dfc0a2754 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/80f61baa9958e3604fe8950a8c7d2381772d86 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/f31b296304f035bcb55018fee33cef1b2b3f0e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/f31b296304f035bcb55018fee33cef1b2b3f0e new file mode 100644 index 000000000..d98b77fb7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0b/f31b296304f035bcb55018fee33cef1b2b3f0e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/bc976f73f6a803a16598826d7bbf5fd93c0755 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/bc976f73f6a803a16598826d7bbf5fd93c0755 new file mode 100644 index 000000000..2477aaf64 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/bc976f73f6a803a16598826d7bbf5fd93c0755 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/c9830ccef7edcd1bbe9d5eb90d550f9e59b597 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/c9830ccef7edcd1bbe9d5eb90d550f9e59b597 new file mode 100644 index 000000000..68e5b8405 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/c9830ccef7edcd1bbe9d5eb90d550f9e59b597 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/cd67be87e9a41c88e9cc0537fe5cf32b4c8557 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/cd67be87e9a41c88e9cc0537fe5cf32b4c8557 new file mode 100644 index 000000000..40b0c23df Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/cd67be87e9a41c88e9cc0537fe5cf32b4c8557 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/ffa69ed09712b4271b985a7460bf5a307d6094 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/ffa69ed09712b4271b985a7460bf5a307d6094 new file mode 100644 index 000000000..1ae55be82 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0c/ffa69ed09712b4271b985a7460bf5a307d6094 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2874298d4b930e56e3c4eabc21d53344c9241e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2874298d4b930e56e3c4eabc21d53344c9241e new file mode 100644 index 000000000..357349ed1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2874298d4b930e56e3c4eabc21d53344c9241e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2ed9fe390ddc8c9107e2bc797a2ab89d1db5d0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2ed9fe390ddc8c9107e2bc797a2ab89d1db5d0 new file mode 100644 index 000000000..43150e27b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/2ed9fe390ddc8c9107e2bc797a2ab89d1db5d0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/690a42a06b73b264d77e74f3b2db1291a788de b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/690a42a06b73b264d77e74f3b2db1291a788de new file mode 100644 index 000000000..ae65d84c7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0d/690a42a06b73b264d77e74f3b2db1291a788de differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0e/da8b4820bdb51d4aa520572a4e35052f03d694 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0e/da8b4820bdb51d4aa520572a4e35052f03d694 new file mode 100644 index 000000000..2f76ec8d9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0e/da8b4820bdb51d4aa520572a4e35052f03d694 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/0a6d785b3d7b0d93a7e363563f31171ed7251c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/0a6d785b3d7b0d93a7e363563f31171ed7251c new file mode 100644 index 000000000..871867d93 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/0a6d785b3d7b0d93a7e363563f31171ed7251c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/16ed6c6b86eca368513c91172cb2790edbbdd5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/16ed6c6b86eca368513c91172cb2790edbbdd5 new file mode 100644 index 000000000..177f21aaa Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/16ed6c6b86eca368513c91172cb2790edbbdd5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/63987593fc826cdccab249756d98f7257f670a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/63987593fc826cdccab249756d98f7257f670a new file mode 100644 index 000000000..c1658fdbd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/63987593fc826cdccab249756d98f7257f670a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/6ebc78667aa14c9bc3fea4cb096f68b1e90ea9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/6ebc78667aa14c9bc3fea4cb096f68b1e90ea9 new file mode 100644 index 000000000..7d3452bae Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/6ebc78667aa14c9bc3fea4cb096f68b1e90ea9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/d1bdcdd2956fb8a66a16aeff7686746a94055e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/d1bdcdd2956fb8a66a16aeff7686746a94055e new file mode 100644 index 000000000..f6fcd0248 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/0f/d1bdcdd2956fb8a66a16aeff7686746a94055e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/04f90eb638532629bcb355c4a26f8d7cade08a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/04f90eb638532629bcb355c4a26f8d7cade08a new file mode 100644 index 000000000..b8d284de1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/04f90eb638532629bcb355c4a26f8d7cade08a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/108eb1db7f08d82eedd9e8550e8ac1a644b8aa b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/108eb1db7f08d82eedd9e8550e8ac1a644b8aa new file mode 100644 index 000000000..ac9c8eda8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/10/108eb1db7f08d82eedd9e8550e8ac1a644b8aa differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/00c4ceb703ed2272383d78c501769f99ead0b0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/00c4ceb703ed2272383d78c501769f99ead0b0 new file mode 100644 index 000000000..a3e2241ab Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/00c4ceb703ed2272383d78c501769f99ead0b0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/af3838f9d9c43c6419da85744c9fa9872acbed b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/af3838f9d9c43c6419da85744c9fa9872acbed new file mode 100644 index 000000000..e92fa577b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/11/af3838f9d9c43c6419da85744c9fa9872acbed differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/030512193b8a4b530e7a90a264487c85dbbd7f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/030512193b8a4b530e7a90a264487c85dbbd7f new file mode 100644 index 000000000..6395050f5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/030512193b8a4b530e7a90a264487c85dbbd7f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/40aaa0022da28e9195a8e29c36b6b5c524d922 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/40aaa0022da28e9195a8e29c36b6b5c524d922 new file mode 100644 index 000000000..32dadd39c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/40aaa0022da28e9195a8e29c36b6b5c524d922 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/44761b08c407c44222f20be3e6c070e881e24d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/44761b08c407c44222f20be3e6c070e881e24d new file mode 100644 index 000000000..0114fd803 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/12/44761b08c407c44222f20be3e6c070e881e24d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/b8698bcd9467b3d4049dc9efb13fb0c0137dae b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/b8698bcd9467b3d4049dc9efb13fb0c0137dae new file mode 100644 index 000000000..0500f0a4e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/b8698bcd9467b3d4049dc9efb13fb0c0137dae differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/c3081b01798169f7307acf7f42c75eece279f7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/c3081b01798169f7307acf7f42c75eece279f7 new file mode 100644 index 000000000..bcfa419ec Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/13/c3081b01798169f7307acf7f42c75eece279f7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/15/74fd510d6b8adf2197e4d54fb572d690ea4393 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/15/74fd510d6b8adf2197e4d54fb572d690ea4393 new file mode 100644 index 000000000..fd8fe17f9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/15/74fd510d6b8adf2197e4d54fb572d690ea4393 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/16aafe31575ae777ff13462ebfeac223cc2c93 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/16aafe31575ae777ff13462ebfeac223cc2c93 new file mode 100644 index 000000000..3043be4dc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/16aafe31575ae777ff13462ebfeac223cc2c93 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ca7d1f9d166735521d00e07adf6bd079f79fd4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ca7d1f9d166735521d00e07adf6bd079f79fd4 new file mode 100644 index 000000000..fecb03f83 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ca7d1f9d166735521d00e07adf6bd079f79fd4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ebeae3abd508d2c044426c9ae1cfd8b1585680 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ebeae3abd508d2c044426c9ae1cfd8b1585680 new file mode 100644 index 000000000..a45d10fb7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/16/ebeae3abd508d2c044426c9ae1cfd8b1585680 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/28a3696f3a95840a687b7c2ddffd869cdf7ba7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/28a3696f3a95840a687b7c2ddffd869cdf7ba7 new file mode 100644 index 000000000..f6f2b039d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/28a3696f3a95840a687b7c2ddffd869cdf7ba7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/8f497d128ebc55a29450b9c47f21e8d49f8d9e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/8f497d128ebc55a29450b9c47f21e8d49f8d9e new file mode 100644 index 000000000..5c10a7032 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/8f497d128ebc55a29450b9c47f21e8d49f8d9e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/92b5edcccc38ee789887fae7d3fdfabd944648 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/92b5edcccc38ee789887fae7d3fdfabd944648 new file mode 100644 index 000000000..16c29e069 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/18/92b5edcccc38ee789887fae7d3fdfabd944648 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/19/8b62ce4aa415920b077a9b278a6a15c9288484 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/19/8b62ce4aa415920b077a9b278a6a15c9288484 new file mode 100644 index 000000000..4826c85ce Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/19/8b62ce4aa415920b077a9b278a6a15c9288484 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/4956e64705230122da8c19d762a7f8e6971533 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/4956e64705230122da8c19d762a7f8e6971533 new file mode 100644 index 000000000..1a9a42f5a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/4956e64705230122da8c19d762a7f8e6971533 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/b4ed39e87d3b85bbf3f1d58906af2159a95bc6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/b4ed39e87d3b85bbf3f1d58906af2159a95bc6 new file mode 100644 index 000000000..5faa43d83 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/b4ed39e87d3b85bbf3f1d58906af2159a95bc6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/c5b94adb4b966ec8d2c205fc7578890179560d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/c5b94adb4b966ec8d2c205fc7578890179560d new file mode 100644 index 000000000..07b56fb85 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1a/c5b94adb4b966ec8d2c205fc7578890179560d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/a33223db3a22e8d17b77c301c2e7b102375ad8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/a33223db3a22e8d17b77c301c2e7b102375ad8 new file mode 100644 index 000000000..72135286d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/a33223db3a22e8d17b77c301c2e7b102375ad8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/b8c4130d6d9059aa02aad7c9bf25899e9008ab b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/b8c4130d6d9059aa02aad7c9bf25899e9008ab new file mode 100644 index 000000000..14d5b178b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/b8c4130d6d9059aa02aad7c9bf25899e9008ab differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/f307e9ccdd90e42479a116bc77de19cad87f9e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/f307e9ccdd90e42479a116bc77de19cad87f9e new file mode 100644 index 000000000..b4063cacb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1c/f307e9ccdd90e42479a116bc77de19cad87f9e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1d/4a3007ddc090d87f1918b1cc0088f6408054f1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1d/4a3007ddc090d87f1918b1cc0088f6408054f1 new file mode 100644 index 000000000..42625cdc2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1d/4a3007ddc090d87f1918b1cc0088f6408054f1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/1638b1ae1176934dc49cd897b3d3f48e8cc111 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/1638b1ae1176934dc49cd897b3d3f48e8cc111 new file mode 100644 index 000000000..12dbce25e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/1638b1ae1176934dc49cd897b3d3f48e8cc111 @@ -0,0 +1,4 @@ +x}T]Ó:½¯Í¯íÓ² +)TBBwÑíîÕ­€m Þœdšš¦v®í4T¨ÿýÎ8î6ýûPmìññ9gÎ8-u +ƒÁËÁ_ý›n`¤«­‘ÅÒÁàÅËWÏéç5¸%‚¦E©D ¢vKmè;üg:Æ'?Ê •Åj•£ñ‡†•Èèl؉á++µ‚Aò®õ*l]=»eˆ­®a-¶ ´ƒÚ"aH Y"௠+RA¦×U)…Êé–þž€ÂLà{ÀЩT.è@µ½è‚p4ðßÒ¹êï~¿išDxƉ6E¿lõØþÇñèa2{xN¬Ã©/ªDkÁàµ4¤8Ý‚¨ˆU&RâZŠÆÛS¤=§™uc¤“ªˆÁê…k„A¦šKëŒLkwdZ$½[@¶ WÃŒgWp7œg1ƒ|Ïÿ~™Ã·áããp2?Ì`ú£éä~<O'ôõ 'ßáÃxr’eÔüUV@]”l'æÞ»²çO ƒíó·­0“ ™‘4UÔ¢@(ô"EP¡YKËmµD0gJ¥\K'œ_:„¡Ó¤~‘Ï+"§[BZ±ÆF›U’•ºÎòÅ:I%Çé6Šˆ¨6~ŠHj'Ë䓨nÏW?M”œDKgú7!Ôj!‹ÚxN^•€—zc‡)a­Î$•(¿ß—R­àÎß>ßV¸kã&e‰™î"†âÃÕ»†Ô¯6l„5ò™\×¥“Ué3ptϨ‹º³ìí"14KäAP +3O—2ÖÅ´[ëpMÝmCoÉWp$a?©ïà FÖît¡q#3ÉÂö{Ó ø±$ÏVº¶«-íõ£ªN)ä•äU°æˆ2üŽ¢5s#ÒÜòs1£„“¡­Ûì#u处úø¦-‹ašþ$Mï êvñ¤>ÕºDš©(ÊÒ=¨4Z­Q¹sì}mŽ A®(¡2'v\Éáè±äJPô:}æC>Ø7~‹üû÷ˆ|?„© pàì:Ÿv©ë2ßk÷Px0 ´ÜÃvÒ×½æÔ‰?\’"‡ÌJš?Ò/h¾!Ûè3èqstüøÇ@@€ç>5º L;EGQ¹> F Bõz½}?ÏÍŠa¿wªðŲ×ãÞ…Ç„co; ¿Ýïwzöö(ƒáüùµ„s¾ø„wJ…ªO—¨vÇ#Ñ:\)еž1ÕëV€AWE?Òq|ü’ƒ„õùiš.`4cíí”v|6h`.ºÐQv€»?™Å `—MÚEÿ—.ü½ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/88a7149242788437ecb94f1a0e22e35c625da2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/88a7149242788437ecb94f1a0e22e35c625da2 new file mode 100644 index 000000000..d017287ef Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/88a7149242788437ecb94f1a0e22e35c625da2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/9cc813f4ddb0398a85ee3d9a16e83a94c73502 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/9cc813f4ddb0398a85ee3d9a16e83a94c73502 new file mode 100644 index 000000000..941583e05 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1e/9cc813f4ddb0398a85ee3d9a16e83a94c73502 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/36fa58b641ffc8382bcb7b22f99e2992060ee2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/36fa58b641ffc8382bcb7b22f99e2992060ee2 new file mode 100644 index 000000000..4e715290f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/36fa58b641ffc8382bcb7b22f99e2992060ee2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/604130ab15bf8c901ae8c408d9c82e99fa25f6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/604130ab15bf8c901ae8c408d9c82e99fa25f6 new file mode 100644 index 000000000..1c2dfd40f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/604130ab15bf8c901ae8c408d9c82e99fa25f6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/96debab7392c018980c7433cca6b59e0f012c7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/96debab7392c018980c7433cca6b59e0f012c7 new file mode 100644 index 000000000..aeb791d18 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/1f/96debab7392c018980c7433cca6b59e0f012c7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/bbaaeddafdef8446b60acbd9d71242a6554591 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/bbaaeddafdef8446b60acbd9d71242a6554591 new file mode 100644 index 000000000..1245654bb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/bbaaeddafdef8446b60acbd9d71242a6554591 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/c973ec212c75d149e5b587a71a87e6c64119fb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/c973ec212c75d149e5b587a71a87e6c64119fb new file mode 100644 index 000000000..1a32bca1b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/20/c973ec212c75d149e5b587a71a87e6c64119fb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/09e5243594351b5613690d02ce560708526758 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/09e5243594351b5613690d02ce560708526758 new file mode 100644 index 000000000..01abfccdc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/09e5243594351b5613690d02ce560708526758 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/1e1d50fe81ea24c00f9c754d58e3bb5d7bc93c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/1e1d50fe81ea24c00f9c754d58e3bb5d7bc93c new file mode 100644 index 000000000..3539cfdd3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/1e1d50fe81ea24c00f9c754d58e3bb5d7bc93c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/4daf04eb82d614fba16cf84ae440ca6d20a6ea b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/4daf04eb82d614fba16cf84ae440ca6d20a6ea new file mode 100644 index 000000000..95f537256 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/4daf04eb82d614fba16cf84ae440ca6d20a6ea differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/536f12673bbb424741a203afe17a9e1e805773 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/536f12673bbb424741a203afe17a9e1e805773 new file mode 100644 index 000000000..9dbc5a69a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/21/536f12673bbb424741a203afe17a9e1e805773 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/347dffe07130a4d94efdc3cb8c144ba5a97302 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/347dffe07130a4d94efdc3cb8c144ba5a97302 new file mode 100644 index 000000000..2dbe65521 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/347dffe07130a4d94efdc3cb8c144ba5a97302 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/74f584d17ad0b19ba2f218cd6a9acf743165a1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/74f584d17ad0b19ba2f218cd6a9acf743165a1 new file mode 100644 index 000000000..748f4dbfe Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/74f584d17ad0b19ba2f218cd6a9acf743165a1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/9a99e26f94221f235554e6979b171c08a5373f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/9a99e26f94221f235554e6979b171c08a5373f new file mode 100644 index 000000000..259c01293 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/9a99e26f94221f235554e6979b171c08a5373f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e25c0b3838a50e93cc9f2d847d171a227247fa b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e25c0b3838a50e93cc9f2d847d171a227247fa new file mode 100644 index 000000000..842acfd16 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e25c0b3838a50e93cc9f2d847d171a227247fa differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e730a3e9253344de2ba60d2063333ea4d7165e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e730a3e9253344de2ba60d2063333ea4d7165e new file mode 100644 index 000000000..fd52ffbd6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/e730a3e9253344de2ba60d2063333ea4d7165e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/f1f32705523672327a6538324ead1c93ea935e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/f1f32705523672327a6538324ead1c93ea935e new file mode 100644 index 000000000..bfbd47e27 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/22/f1f32705523672327a6538324ead1c93ea935e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/1b79a6c3b6f687ae0a07962156b1468d6996b1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/1b79a6c3b6f687ae0a07962156b1468d6996b1 new file mode 100644 index 000000000..3fe433907 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/1b79a6c3b6f687ae0a07962156b1468d6996b1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/53ee4972b55bb4067c433aa0c3538f79ef68a4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/53ee4972b55bb4067c433aa0c3538f79ef68a4 new file mode 100644 index 000000000..0b2a37c66 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/53ee4972b55bb4067c433aa0c3538f79ef68a4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/d02df23781c22aabb8690605e6be788cc87a23 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/d02df23781c22aabb8690605e6be788cc87a23 new file mode 100644 index 000000000..4e36d76d1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/23/d02df23781c22aabb8690605e6be788cc87a23 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/890f84c2549dc5c427ea79d4363c737cfce531 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/890f84c2549dc5c427ea79d4363c737cfce531 new file mode 100644 index 000000000..222c76572 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/890f84c2549dc5c427ea79d4363c737cfce531 @@ -0,0 +1,4 @@ +x…TMoÚ@íÿŠ'“T*• NpT« ¤˜4Ê©ZÛƒÙÆìºû±¢ü÷Î\TQÈžyžyïÍ&…LàâòÓù‡Á©§p-ËJñ|iàãùÅåý}³Dä‚À¬YJEïÍ“ö©ÌUÞò…Æ ¬ÈPÕEAÉRªmNúð•æRÀGÿzµÛuO†¢’V¬! X„Á5,x€/)–¸€T®Ê‚3‘"l¸YÖßiP\'ðÔ`ÈÄ0JgTPV íD`¦iÜoiLùe0Øl6>«;ö¥ÊÅv=¸®ÃIžQ×MÕƒ(PkPøÛrE'°’ºJYB½lSÓ“+¤3#]×Å y´\˜ SèZ͸6Š'ÖüEZ3 Ðèí¢ è1Dq®‚8Šûä1š>Ìá1˜Í‚É< +c˜Îàz:Góh:¡·&Oð-šŒû€D‰ƒ/¥rŠÜщYÍ]ŒŽó`° s÷®KLù‚§4šÈ-Ër¹F%h"(Q­¸v²jj0s-|Å 3uho†–HÏ#žŸ1íëRÒB±n¤zöÓBÚÌ'n­|&È 5ØÐó¨Y© übkæ»^Z§þX¦v…‚ÈÍ tióªÄ㉑ ªøñfhŽ†=Ž¶K»—d”êxòœ©Í~à±” Ú_°ÔHUµ‰ønYAj¡¢úÁi½›‘ÈÈœ5ÉÉŒ3’´¦´N'¤¢©?HU ¤D½s%Ûé¿—¦Y€Qs ŒÙ!®P9Ù߃wLq«áJæ×<åí³@\Á'ñ(L>íºõFÛ©{¯Ð’È¿‰Â[2n;t’åbÁd2Îñ?çO÷aßëtÚ%÷Á, ²po'Þh§Eo÷´UÅŸ=Ð +Ý…”³Sßíå•6!õ`ÄÉgŠ¸G˜n‰|õ¼Ž#»CDÄõºl©; XLJînˀѾפ… Ö»vLëTgÕ jºAêEØ6.˜-Œ_'Œ«D-Øû€Ëx˜nÒ{Í +‹½“wèvÉ,oÞïë È \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/b2a95b0b2b6de1f7721cd696621200616548e7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/b2a95b0b2b6de1f7721cd696621200616548e7 new file mode 100644 index 000000000..a1a42d7e0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/b2a95b0b2b6de1f7721cd696621200616548e7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/f0316a9d6298b2af60578b874fba72f9fd7fa4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/f0316a9d6298b2af60578b874fba72f9fd7fa4 new file mode 100644 index 000000000..d5164df40 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/24/f0316a9d6298b2af60578b874fba72f9fd7fa4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/cf65b8bf5f79e7f705d0e3029f0cf8041d50ee b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/cf65b8bf5f79e7f705d0e3029f0cf8041d50ee new file mode 100644 index 000000000..08e6cc516 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/cf65b8bf5f79e7f705d0e3029f0cf8041d50ee differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/d6f0cbf651283ae758c056e476d66364fc1222 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/d6f0cbf651283ae758c056e476d66364fc1222 new file mode 100644 index 000000000..93d4b8214 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/25/d6f0cbf651283ae758c056e476d66364fc1222 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/09cc4f932517b353b841ebb0a94d91478395d6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/09cc4f932517b353b841ebb0a94d91478395d6 new file mode 100644 index 000000000..31e93c0d0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/09cc4f932517b353b841ebb0a94d91478395d6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/0baab80b6d19d22121557fb3821226185fc82c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/0baab80b6d19d22121557fb3821226185fc82c new file mode 100644 index 000000000..7e62ea59e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/0baab80b6d19d22121557fb3821226185fc82c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/dc2bc5ef0b88c2551e31a722f85c45fc93f873 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/dc2bc5ef0b88c2551e31a722f85c45fc93f873 new file mode 100644 index 000000000..6663642c6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/26/dc2bc5ef0b88c2551e31a722f85c45fc93f873 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/32a38e65e28deafad631d51dbc40a344fce771 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/32a38e65e28deafad631d51dbc40a344fce771 new file mode 100644 index 000000000..c595e99a8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/32a38e65e28deafad631d51dbc40a344fce771 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/b3d3bc15b04f549a738017c55b02fc1d3b7616 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/b3d3bc15b04f549a738017c55b02fc1d3b7616 new file mode 100644 index 000000000..76fcaebca Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/b3d3bc15b04f549a738017c55b02fc1d3b7616 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/d0a5d8a28532fc7df15b34aa053b704652c79c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/d0a5d8a28532fc7df15b34aa053b704652c79c new file mode 100644 index 000000000..7c405a42e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/27/d0a5d8a28532fc7df15b34aa053b704652c79c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/28/64170daf66a4f45817f5f1e969d643c80e23f7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/28/64170daf66a4f45817f5f1e969d643c80e23f7 new file mode 100644 index 000000000..1246dec0f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/28/64170daf66a4f45817f5f1e969d643c80e23f7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/2f63e8a73f466371b0d155309112a796c34641 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/2f63e8a73f466371b0d155309112a796c34641 new file mode 100644 index 000000000..7afaf4c8d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/2f63e8a73f466371b0d155309112a796c34641 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/dce09cfc84114e73e20c18167472e121a21ced b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/dce09cfc84114e73e20c18167472e121a21ced new file mode 100644 index 000000000..8c7c70947 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/dce09cfc84114e73e20c18167472e121a21ced differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/ee9231d27dea0108ff07b61ff28d3b7c0e141e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/ee9231d27dea0108ff07b61ff28d3b7c0e141e new file mode 100644 index 000000000..c72a22796 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/29/ee9231d27dea0108ff07b61ff28d3b7c0e141e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/4b7321888fb50d7352a34e675a80433d44add3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/4b7321888fb50d7352a34e675a80433d44add3 new file mode 100644 index 000000000..a0e02ae3b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/4b7321888fb50d7352a34e675a80433d44add3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a92b34dd980f74d4cb873e09abdbcb29bae9e7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a92b34dd980f74d4cb873e09abdbcb29bae9e7 new file mode 100644 index 000000000..02a1ddcab Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a92b34dd980f74d4cb873e09abdbcb29bae9e7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a9b997b16fc4150595dda1197e1f165e3efc68 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a9b997b16fc4150595dda1197e1f165e3efc68 new file mode 100644 index 000000000..5c7591820 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/a9b997b16fc4150595dda1197e1f165e3efc68 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/c2b5d60aa92d970b4d8502ba7a5b5b34e93253 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/c2b5d60aa92d970b4d8502ba7a5b5b34e93253 new file mode 100644 index 000000000..a85070dc6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2a/c2b5d60aa92d970b4d8502ba7a5b5b34e93253 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2b/24fc236589ad58fdc7c69ff442b03e3c3f55c2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2b/24fc236589ad58fdc7c69ff442b03e3c3f55c2 new file mode 100644 index 000000000..8a8fe994e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2b/24fc236589ad58fdc7c69ff442b03e3c3f55c2 @@ -0,0 +1,3 @@ +x•1 +1E­sŠaj±°PHï ìÄbv3J Ù„LÙ»›dce!¾òÿùoØ›—œÈ³D5òL>:ÞÑ#Üö5àÉ“u-ÉÏX.!™Ý,;#¨/ +*EÚiî¢%c‹œ›óÓ­"”œìtÇu³”Ÿ•oE¶ÙqýÖÁ?ç<çŸkWµ¨7]GC \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/0e43e4a79beecbd67a6310eee326ecd23850f9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/0e43e4a79beecbd67a6310eee326ecd23850f9 new file mode 100644 index 000000000..12e7b2d57 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/0e43e4a79beecbd67a6310eee326ecd23850f9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/1a0899d2cbf5b073cf3b8dc70eec8214c3541c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/1a0899d2cbf5b073cf3b8dc70eec8214c3541c new file mode 100644 index 000000000..0c616e805 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/1a0899d2cbf5b073cf3b8dc70eec8214c3541c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/443f21eb31ec6d18c11c66c9e41f4c1b8bce4c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/443f21eb31ec6d18c11c66c9e41f4c1b8bce4c new file mode 100644 index 000000000..a8d7947d5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/443f21eb31ec6d18c11c66c9e41f4c1b8bce4c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/47bd7f410e11a69d01ac19f03e22f18d696794 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/47bd7f410e11a69d01ac19f03e22f18d696794 new file mode 100644 index 000000000..34ef31f1d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/47bd7f410e11a69d01ac19f03e22f18d696794 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/e332f5a2db3a6a99d35ab5eaf351c178fe0991 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/e332f5a2db3a6a99d35ab5eaf351c178fe0991 new file mode 100644 index 000000000..493624932 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2c/e332f5a2db3a6a99d35ab5eaf351c178fe0991 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2d/805c8509acbef455339c94a78f6957e322c05a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2d/805c8509acbef455339c94a78f6957e322c05a new file mode 100644 index 000000000..8a726707e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2d/805c8509acbef455339c94a78f6957e322c05a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/74f1132e458f72a076c2d68ceacbd7f48167ad b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/74f1132e458f72a076c2d68ceacbd7f48167ad new file mode 100644 index 000000000..cd2d29c9c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/74f1132e458f72a076c2d68ceacbd7f48167ad differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/afc57d60c7f35300e862bfc8c59b4302ffd0dc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/afc57d60c7f35300e862bfc8c59b4302ffd0dc new file mode 100644 index 000000000..3abe00db4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/afc57d60c7f35300e862bfc8c59b4302ffd0dc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/d033a4180d1509a98961e2b5b4d6054e8d20bb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/d033a4180d1509a98961e2b5b4d6054e8d20bb new file mode 100644 index 000000000..b716f2947 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2e/d033a4180d1509a98961e2b5b4d6054e8d20bb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/34581ff00cf76eb191d9bcd739f0139b62c2d4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/34581ff00cf76eb191d9bcd739f0139b62c2d4 new file mode 100644 index 000000000..5f64271a1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/34581ff00cf76eb191d9bcd739f0139b62c2d4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/b6a5ee6ad80abe9cb7e25bce4c7c542bc4d87c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/b6a5ee6ad80abe9cb7e25bce4c7c542bc4d87c new file mode 100644 index 000000000..eb809b861 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/2f/b6a5ee6ad80abe9cb7e25bce4c7c542bc4d87c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/8148454e177870cf981f7bb9f2d261ad74fe30 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/8148454e177870cf981f7bb9f2d261ad74fe30 new file mode 100644 index 000000000..2c27778db Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/8148454e177870cf981f7bb9f2d261ad74fe30 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/f3809b3d7a97bd3a2a3e4f6829aa997ece80d3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/f3809b3d7a97bd3a2a3e4f6829aa997ece80d3 new file mode 100644 index 000000000..481740d4b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/30/f3809b3d7a97bd3a2a3e4f6829aa997ece80d3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/1fea9232f466f9e622b045eb7b820702e9fc59 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/1fea9232f466f9e622b045eb7b820702e9fc59 new file mode 100644 index 000000000..8e4a18219 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/1fea9232f466f9e622b045eb7b820702e9fc59 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/3d22b5f3d7c61124545007ac12302da60e15f8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/3d22b5f3d7c61124545007ac12302da60e15f8 new file mode 100644 index 000000000..f208fed9b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/3d22b5f3d7c61124545007ac12302da60e15f8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/b03679391b70008979397b4207f7e664c2bf7e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/b03679391b70008979397b4207f7e664c2bf7e new file mode 100644 index 000000000..2484f3591 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/b03679391b70008979397b4207f7e664c2bf7e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/eb253ceaf0ff787df7468a6c83ededebff31cb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/eb253ceaf0ff787df7468a6c83ededebff31cb new file mode 100644 index 000000000..42df67af6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/31/eb253ceaf0ff787df7468a6c83ededebff31cb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/228cfff411c525f614292b91795c1993614023 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/228cfff411c525f614292b91795c1993614023 new file mode 100644 index 000000000..1362e2d0f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/228cfff411c525f614292b91795c1993614023 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/28b7a4cf204e0a9652285b14a91067028ad7e7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/28b7a4cf204e0a9652285b14a91067028ad7e7 new file mode 100644 index 000000000..75d952951 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/28b7a4cf204e0a9652285b14a91067028ad7e7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/ad53edef7fc6d2f8f20f8553a805e4aa641894 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/ad53edef7fc6d2f8f20f8553a805e4aa641894 new file mode 100644 index 000000000..49a659a55 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/ad53edef7fc6d2f8f20f8553a805e4aa641894 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/b2961025b851c7d536ee4c24fa1d33bc0290c5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/b2961025b851c7d536ee4c24fa1d33bc0290c5 new file mode 100644 index 000000000..b9f662867 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/32/b2961025b851c7d536ee4c24fa1d33bc0290c5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/199fd55c350ca8e461cbd53676df35ca31142b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/199fd55c350ca8e461cbd53676df35ca31142b new file mode 100644 index 000000000..e8190d4b2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/199fd55c350ca8e461cbd53676df35ca31142b @@ -0,0 +1,6 @@ +x­UÑnÚ0ÝkøŠ(ÚklèÐ4!“jk5­]+ÑN{5¶CbG¶LÓþ}×v PÒÄ‹í{Žïñ¹÷†I©'iïc¿ÿŽ\oª2] c¥V쇺Y*Ó\ªÙ0{~úšÊ®‹©^æR@+;ØX9ÌæÎÕŒ×ë5Z@ÚÌðU·ÛÃ?ïGc6Í¥²Ž*&²N’DâŽTÑ•PˆÖúøpû¨ éØÊ —Œ4£.HkÓg¦ÇËq å!‚}VtRi.ÊñéE|pjœœRæîxak¦ä¬Ô Ï­3‚V9ÓFä\3Kðx55B9È’™ÑM |pÅ;¦†Vb­Í…ËÞB<|ïž·2Æ‹_¥KH[Áâ +õà÷åùnt›¿~{x"xax§ 4²%Á›ŠZW>°ÝLÂs/Àra™‘µ/S1&¥7Þ¤tLJoÀ¤ôV³¦KB5 Þçx»Œ®X-l°Ì;Š**Õ[€¼ {Ã<M¨\šâýïvõ#åÜ…×ï'òi§²l“¶/ !’±¬°“F–q jËl R=6nÌo·Åôõn;<6mK;(7Jû”‹Z(sø+¤×õö,|÷¤ +ˤ„ç8mN&§/¸< +9šRsa¤¼˜ÒÒ +‚_¼aÿ,é +A–bAWÊÉrsRe„œx‰=ñ3ti‘¨r¦QÇ ôÿ,òŸd1§ Œ…^è“þ„NÍ碄‰:çÒÅ·¶Ææ'x7ˆÃs°[*¬ý_GÑù Y® \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/83cf05fc5bb6e5f1a5922938d2c14070f6704d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/83cf05fc5bb6e5f1a5922938d2c14070f6704d new file mode 100644 index 000000000..5a0b26883 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/83cf05fc5bb6e5f1a5922938d2c14070f6704d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/86019afcda8611049ecf42d708fefbc939ae83 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/86019afcda8611049ecf42d708fefbc939ae83 new file mode 100644 index 000000000..bfc159e9c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/33/86019afcda8611049ecf42d708fefbc939ae83 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/34/80fcf0ff566bc9ee4a1a2b42f7facdbad92def b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/34/80fcf0ff566bc9ee4a1a2b42f7facdbad92def new file mode 100644 index 000000000..2574b42cc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/34/80fcf0ff566bc9ee4a1a2b42f7facdbad92def differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/35/3f66464a9cddf6fdfd1cd03b46678315a01053 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/35/3f66464a9cddf6fdfd1cd03b46678315a01053 new file mode 100644 index 000000000..7563fe7ff --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/35/3f66464a9cddf6fdfd1cd03b46678315a01053 @@ -0,0 +1,2 @@ +x­UMoÚ@íÿŠ'ˆÈ’æVAU!„ª¨ T˜$Êq±Çf³ëî®qP•ÿÞYã¨\§©êØ;ž7oÞ|x‘¨œ¾ÿxö®{äÁ UºÑ"^Z8=yvL?À. +Éà™]*MÏåaäæ<¯D€Ò`™ QNƒ”ä[Z:p‹Ú%á”@Ë¡6KS³Ýs•ÁŠo@* ™AÂ"‘ àc€©!!P«4\¹°Ë"N‰â˜À}‰¡–ÓëœÒ ¨hÿEà¶$ îZZ›žw»yž3^0fJÇÝd›é^‡£‰?:&Ö¥×LÐÐø#š2^l€§Ä*à âšð¼'ÖH6«ë\ +dÜ£"›sŽj(ŒÕb‘ÙÑÊd€RßdãšÆ~.þØï8»ñüëôfwƒÙl0™G>Lg0œN.ÇóñtBO_`0¹‡oãÉe$£âàcª]TEáäÄ°ÐÎG§ùKÁ "»{6)"¥&ãŒÇ±Z£–”¤¨W¸²":J‰X Ëmq´k†½"u=t~p@¤43©&¤HóæJ?° QYÈHä+f¨ƒVœÎJË%#÷¿øõþýF~#Éà¥*„&Qù±xŽß\‚0Ž¼Q™,œÌ©*riCШbãJþ–Ö)SÐeòœ:Œz—κ†Ø™Š¶bG…ñT9 åh¥´$ù%“¥'UPf¶¥V¢È$ÕÊoBžˆÂLèsÄ0©Ø.PîȬŽ7’ð‘4ñÏÆûòE¿_×u"ãÄØu_7z\ÿfr=ž.ÆÏÀ:F},´tŽ¬ü§RŠÓ‰¬2‘‚«u°gm%Ö¼aÖµU^ë9³òµ°’©æÊy«ÒÊŸ˜Å¤o€m¢ ³Ñ‚&‹3z3ZL=ù4Y¾Ÿ}\Ò§Ñ|>š.'ãÍæt=›¾,'³)žÞÑhú™þœLßöHÂ2G~)-+@Û)óàÝB²ç_ F+¬ó³+e¦V*ƒ´b]‰µ¤µ¹“¶€"*¥Ý*Çeu ˜3%­¶Ê ^=4ÃQ‘úí6|¾e 8¸ÒieÅVÖÆÞ&™6UžÀ)¶Iª¸í6ˆëŸ ØB‹@{®“¿Â79ø©ÝïÁ[sŽŸHdãГP rýó0#Š"$Üž„®ÅÎqAÙL-œ'W¥.CíQ‹W–î‡Z·ô&¨[kìõF…Ôû.[püÆšš]ŽãÀ£¤V¤|À5…Þà +§rI+k¶a=µ*‡áèGf±ÆÜ ÍÐ’“QÖdë&4Á˜é®D;k@\Lcι5õm#·²hêL¹‘.Œ­•¾²ÄÈ€„¯yCá~˜ÐYKÔúÔéîÇÈ00»£yåœÔæÐ)žù‹ä +Ðøí·38éèŸJs¼á¢°šSðv«ÅMغGêÑ Ê±øZÓHºo·[hÎ;áad8þN·x”c¡#ãZâQ`jŒ–(].W¢Ò qóÌ"œä˜«éüO=Èný8i[->h“Ð1/ɉ{þ›=½ü®´Öê†3œhC sªç%Ý•Ç>‰Öwâçï¯_Ѷ™æ.:6t~³tßAìÜÃX僃J’˘á}þ툱ÿÀ8v•_52À×=þ˜zT™`4>ˆ6"_U8Ï—.®oUG†ÁŠ†»Ò=8w‚yÈ)5®V®:Ê[©õ9:G…èRQi:ªÕê,9Šoº§Óà*ÚPð}û_ÖÝœ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/17d944a3e35a4ec6dc5bc4c5f18a52bebd1b86 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/17d944a3e35a4ec6dc5bc4c5f18a52bebd1b86 new file mode 100644 index 000000000..06fb633c7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/17d944a3e35a4ec6dc5bc4c5f18a52bebd1b86 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/8a9d9f0cff8e124782d2e25a137748dce9dbbd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/8a9d9f0cff8e124782d2e25a137748dce9dbbd new file mode 100644 index 000000000..93880e409 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/8a9d9f0cff8e124782d2e25a137748dce9dbbd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/bf95b953ad8b1ef3edd2c8350de3fe9c525088 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/bf95b953ad8b1ef3edd2c8350de3fe9c525088 new file mode 100644 index 000000000..0290ee509 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3a/bf95b953ad8b1ef3edd2c8350de3fe9c525088 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/afa1d04173de03559166a3e3e67b3dbaf8d239 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/afa1d04173de03559166a3e3e67b3dbaf8d239 new file mode 100644 index 000000000..8dcaa7a5c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/afa1d04173de03559166a3e3e67b3dbaf8d239 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/e109f4e04c79e894ffc8fe08ff411ad2cccfd2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/e109f4e04c79e894ffc8fe08ff411ad2cccfd2 new file mode 100644 index 000000000..307cf41fc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3b/e109f4e04c79e894ffc8fe08ff411ad2cccfd2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/1b309f3e8b4675226aa8578f3006fce2b20f5d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/1b309f3e8b4675226aa8578f3006fce2b20f5d new file mode 100644 index 000000000..f8a50112a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/1b309f3e8b4675226aa8578f3006fce2b20f5d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/52ca09a73ee935f2f756d800c0fae3b55c2d0c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/52ca09a73ee935f2f756d800c0fae3b55c2d0c new file mode 100644 index 000000000..2b752fde4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/52ca09a73ee935f2f756d800c0fae3b55c2d0c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/639f57d31fac727dacd8fa1698d07006181b93 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/639f57d31fac727dacd8fa1698d07006181b93 new file mode 100644 index 000000000..92cc65e9d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/639f57d31fac727dacd8fa1698d07006181b93 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cdce86e397fb6983f79cea7b2538ed9748958 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cdce86e397fb6983f79cea7b2538ed9748958 new file mode 100644 index 000000000..14e9600ff Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cdce86e397fb6983f79cea7b2538ed9748958 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cf5731abd6e0c9fcd5e19c61e3947fdd7963e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cf5731abd6e0c9fcd5e19c61e3947fdd7963e new file mode 100644 index 000000000..051ba867e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/9cf5731abd6e0c9fcd5e19c61e3947fdd7963e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/fc1d9ce9a88c7a712cd4f27d641b7a7523afe9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/fc1d9ce9a88c7a712cd4f27d641b7a7523afe9 new file mode 100644 index 000000000..602e27717 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3c/fc1d9ce9a88c7a712cd4f27d641b7a7523afe9 @@ -0,0 +1 @@ +xŽ1 …û+È̓ݺcâØt¸¶TI€#@é:u’…Ë»÷¾w¤ŽUÕa÷.c Qqg°çP¡2’ïñi Êß:lŽ¤ê!‹~6ÑnyOvÕFÁåà nb,¼DÿŽ©#øS,a³ž9Î[¡ïƒËêØ „>Ë%zAúœ‹ÿEœ%ᤛrP28)2×É=.äÅ(úÔ ízküÛb)>zÏUá \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3d/9b0a317a892fcce421ec341ceebc35b4612ee0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3d/9b0a317a892fcce421ec341ceebc35b4612ee0 new file mode 100644 index 000000000..10fff90d4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3d/9b0a317a892fcce421ec341ceebc35b4612ee0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/4f8c8b60bb6a95ab4f8b0c3f70434551bb7bab b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/4f8c8b60bb6a95ab4f8b0c3f70434551bb7bab new file mode 100644 index 000000000..9a22b59ee Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/4f8c8b60bb6a95ab4f8b0c3f70434551bb7bab differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/7f960197e196ec9a2f536711a97cc7cfc7e45d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/7f960197e196ec9a2f536711a97cc7cfc7e45d new file mode 100644 index 000000000..ac55967db Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/7f960197e196ec9a2f536711a97cc7cfc7e45d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/dbf64e779af6e4a5c768dd63bf0a684b687e3b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/dbf64e779af6e4a5c768dd63bf0a684b687e3b new file mode 100644 index 000000000..dbe33fd2a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/dbf64e779af6e4a5c768dd63bf0a684b687e3b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/e708a19ceb4f3fdca83691f127ceb80d43354e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/e708a19ceb4f3fdca83691f127ceb80d43354e new file mode 100644 index 000000000..8de350009 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3e/e708a19ceb4f3fdca83691f127ceb80d43354e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/50e06d8f611cf67206346bbd135bc2df2f40b6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/50e06d8f611cf67206346bbd135bc2df2f40b6 new file mode 100644 index 000000000..1fa757314 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/50e06d8f611cf67206346bbd135bc2df2f40b6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/9b539580e0ec7c8ca88e80fee8cda401e69825 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/9b539580e0ec7c8ca88e80fee8cda401e69825 new file mode 100644 index 000000000..4c62d038f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/3f/9b539580e0ec7c8ca88e80fee8cda401e69825 @@ -0,0 +1,4 @@ +xV]sâ6í+üŠ;<™ éîl;³_-!d—61LL6³Óédd[mŒåJríìï½²L !tóà`I÷ëÜsç*†W¯~zýCÿE^ÀP•k-³……—'?¾>ÆÇÏ`.Ê‚çÀ+»Pßý/ÃÐŒ,/d" +#R¨ŠThg4(y‚¶~§Ÿ…6Rð’@@^;~«Ó}K.Öª‚%_C¡,TF i`.sâ!¥Y@¢–e.y‘XI»pq¼Ê¾x*¶s4(× æÛ[Ÿ4ÐßÂÚòM¿¿Z­w3¥³~^×cúãá(ŒFǘµ·º.ra hñW%5V¯—˜UÂcÌ5ç+O¦îYEY¯´´²Èz`ÔÜ®¸”j*Õ2®ìh¾ÀÒ· l¼€Î ‚qÔÓA4Žzääf<û4¹žÁÍàêjÎÆ£&W0œ„gãÙxâÛ9 Â/ðû8<ë@È°9â¡ÔTvQœ"uØE‚0ß4 æ¸O黎œËK+²Šg2u/tA)ôRj«ÁSJ)—Ki¹uKdØjR¿ÝFœïÈ"ÍL©ÑÓ\ó¥X)}Ç’\U)Cl_2ž!Ž·âm»¹*má+¿ç¬²2g²¸é'n—¼|ûtWÙ¨°zýh¼/`¬”e§¼(„f—*ÅP>Ò³‡oD<¨;NeÎÖåwØÄ•Ìq2XäÊÝ2?­7FÝÁDÑç9ûS­°v"„æ°…&– U1—Y¥‰±[©à2mtAÑ3íúË’¡–³3œƒÄë·ƒÖ˜ á(%‹ªØ$ÈJacÙî¿pjríÅ‘8ÿHB±ŒEšÝüœÕä¢ùnè±Ñ¡_½B]r-+§*Sâ^&’˜Ùìs¾æˆXµDEû¨JT6‹¹où, +™C¨´*|{ã7ª>óüNðœ/,9î·yŒ|剅$ç8Xƒ&­-l©$ÿ´Û­²ŠQ,ÀÐ$(p¤«JÖ7§×³ÛSÖqøñ6\Žà=tdQV¶ƒ$>dŠ*°ÏVU¶1ö÷œ;+¦(R…õ|†TÕ»_>üñ'*X¥azíV«U'‹\g¦ç+ÁiÊj•ùœìQ†EÚƒfy%âQq/×%pnš­…à))kQBçÏÌɦçJo’¬¹L¬@¸ +±‚猼iZ­†5{=0_aàÿw¦{uE£‹ó[¼fƒq8:»L§·Ó«Étt5ûR3í:›\(›:à´°•~œºýø骈dñ 9ëiçŽÉžÕå;µÂ‹@~ ‡Û6zxmÒ…ŸŠ‡šMè­3€ wðéŽùh¹E-Â;µ‰ûœªà€ÿÿ E¹=½fZ Ò[ªÀß ‰ÔNÎ I‡2ŒG:Vs-vÝS=Øäü¼Ë¾“hˆÕ&ç§õ4ÜÏÒ¢h:Žö‘f_¼µñûFèhQÙT­ŠOJÝsžÙ:4ƒPÿÖ{%SÀ¯:-ð‘žb—Q"gH-a¯œw½v½;°æy[×´OˆwM › ^šüjÚµî¬#'jÍ ÉÉž›ÌÇõ÷‰R•çt–®³À}“ìæ¶àÍÓ¸ ÕR¯#aƒn‚WßdžbÌÖ9 IÀ+ªBM  -9‡@Â8ñ¦­°°¦sÁ†*4hŒíéõÔHNíÐ×[ýF‘wÖ½8ï|-ìdønOÝFþeÀ1üøÉSHü;TÞ{¿ËMž¦/Ž@,•ì[û_xè \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/0c1a7d3d3b00774f727d6cad0285e89a0a0c67 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/0c1a7d3d3b00774f727d6cad0285e89a0a0c67 new file mode 100644 index 000000000..956fdb73c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/0c1a7d3d3b00774f727d6cad0285e89a0a0c67 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/821db3cd6cc8463449bf2340151a95a8356d63 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/821db3cd6cc8463449bf2340151a95a8356d63 new file mode 100644 index 000000000..d92f062a0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/821db3cd6cc8463449bf2340151a95a8356d63 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/91b880dd29af04ad2f0155aced50fe0fb7e08a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/91b880dd29af04ad2f0155aced50fe0fb7e08a new file mode 100644 index 000000000..aed139a94 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/91b880dd29af04ad2f0155aced50fe0fb7e08a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/b6e0cb37f59f649733f50c7ee8cc4c2864c8f2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/b6e0cb37f59f649733f50c7ee8cc4c2864c8f2 new file mode 100644 index 000000000..864691a02 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/40/b6e0cb37f59f649733f50c7ee8cc4c2864c8f2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/2400da43d801ad43731796a1cbe0cdc3a3a71e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/2400da43d801ad43731796a1cbe0cdc3a3a71e new file mode 100644 index 000000000..1a19c7a83 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/2400da43d801ad43731796a1cbe0cdc3a3a71e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/44e5f4f599cfc1d97322c774b5853628294d5c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/44e5f4f599cfc1d97322c774b5853628294d5c new file mode 100644 index 000000000..cd5e63cba Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/41/44e5f4f599cfc1d97322c774b5853628294d5c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/29f05397034992d2c5365bd78a4858cc5b94fb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/29f05397034992d2c5365bd78a4858cc5b94fb new file mode 100644 index 000000000..fd08ccba4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/29f05397034992d2c5365bd78a4858cc5b94fb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/5dae1258f40725c1c1b3aa3a4b0698c86ddb83 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/5dae1258f40725c1c1b3aa3a4b0698c86ddb83 new file mode 100644 index 000000000..24edf1f0b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/5dae1258f40725c1c1b3aa3a4b0698c86ddb83 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/fba6b8f9fbbec79bc3c61dbc93486156e06283 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/fba6b8f9fbbec79bc3c61dbc93486156e06283 new file mode 100644 index 000000000..70cf1fe29 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/43/fba6b8f9fbbec79bc3c61dbc93486156e06283 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/1b6ceedfd4a0f1d1a036009c4099db7af8e5ea b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/1b6ceedfd4a0f1d1a036009c4099db7af8e5ea new file mode 100644 index 000000000..39f27c0f2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/1b6ceedfd4a0f1d1a036009c4099db7af8e5ea differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/8598e0f7c038f7bdad618d9c2b46681ef485c4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/8598e0f7c038f7bdad618d9c2b46681ef485c4 new file mode 100644 index 000000000..2e73dc9f2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/44/8598e0f7c038f7bdad618d9c2b46681ef485c4 @@ -0,0 +1,4 @@ +xuRËnÛ0ìÕüŠ…OŽáHiAS_ª:*4ËN#%­d"ÉòÅòïYªjãª!r9ÙÙ-ZUÀ—«ëOñœÁVJh./>_Ór èPHÞ÷î  íÇ?, ïD‰Òb^VhP¢yIر²€{4V( —ÑÌët,Mϖ⨂ªO/w£hßÁ9ý5Žû¾ø 8R¦‰Ûß~l|—®ÖY¾>'Õ#j/[´ þòÂãâ\“ª’¤µåýOcjNÕ½NÈfVÕ®çƒÔJXgDáÝ?¡f€¬Ÿ^ Ø¸„i’CšOá{’§ù"<¤»›ý’í6Évé:‡ÍV›ì&Ý¥›Œv·dð3Ín€5_´ ¨‹"ĉÕ]Ž!ó¿ ƒšêao5–¢%Y“ç B£žÑHrM'lh«%UÔŠN8a8iRÌåüˆ(éÈjCLµáöÊ@)G´¸p‰>#s +)ÛI•P£å¤BsI쳡븴óu{_*C:<Ýœ—óèÛ¾j§*­¿=¶¾>ƒ Cžrñpo¿gø7÷¾aÿ#2¯Ÿ;ÃÒøí­Â_Òh°^Zå4_£0Zy \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/11ab6decd043e619abda9cffb38f481500dd7b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/11ab6decd043e619abda9cffb38f481500dd7b new file mode 100644 index 000000000..f3893dd1e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/11ab6decd043e619abda9cffb38f481500dd7b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/f02833d5b97518f772160347e2c8e1c40d3e63 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/f02833d5b97518f772160347e2c8e1c40d3e63 new file mode 100644 index 000000000..5527f3adc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4a/f02833d5b97518f772160347e2c8e1c40d3e63 @@ -0,0 +1,5 @@ +xmSÁn›@í¾bäS9Dj«6ªTê¤*jdKÁi”Þ†e€•a—î.&V•ï,A±+™b™yoÞ¼™ÍÃåŇ÷ïâ³Î`¡»‘Uíàêâòã9¿>« 4ÿ” +ÀÞÕÚðyú²Ã<òN +R– +èUAf% +ÆN‘9ü"c¥Vp]À‰gM¡Ù鵧ØéZÜÒzKÌ!-”²! gA©@è¶k$*A0HWu&¯ž&;ätd@·]&ºI4ø§v®ûÇÃ0D8*Ž´©âæµߥ‹Ûev{Ϊ'ÔƒjÈZ0ô§—†;Îw€«˜³Ö‡ÑžÊÇœöª#TÕ¬.Ý€†¼ÔBZgdÞ»ÿL›šný0mC³$ƒ4›Á·$K³¹'yL×?VkxLîï“å:½Í`u‹Õò&]§«%Ÿ¾C²|‚ŸéòfÄ–ñpè¹3¾ž¢ôvR1z—‘÷üm`PrÜŸmGB–Rpkªê±"¨ô–ŒâŽ #ÓJëÇjY`á%5²•Ýøk¿ CŠÃ}Þx"v:²a¦Ò`Kƒ6›H4º/"ö†°ré×)rdÝu²ZmÜQTË !ïhe}n맱¨Q)jŸkúuZàUCü®{ƒÝÛÍîu´qˆ9Fá@4È%Óñ†ëóþû¦ào,y‹ŽàH-o5ƒcáŠÜ¤ê䔹‚Àëb§¥Þ°Á WÙjY€Ý磛¯T‡ðe¯$Æ–ŽÌT<µ¹¾xûJG£À8æ+©»1ó%üez \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/27a03d274a9777aa8f9622ad33a954186e44b8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/27a03d274a9777aa8f9622ad33a954186e44b8 new file mode 100644 index 000000000..493b399fd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/27a03d274a9777aa8f9622ad33a954186e44b8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/2d4dc886eaecd8a3e832e708bb5421118e6f65 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/2d4dc886eaecd8a3e832e708bb5421118e6f65 new file mode 100644 index 000000000..47054ce82 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/2d4dc886eaecd8a3e832e708bb5421118e6f65 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/9fa3c7c4766e690508f5d8104097bbc1086b35 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/9fa3c7c4766e690508f5d8104097bbc1086b35 new file mode 100644 index 000000000..c6cdc0702 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4b/9fa3c7c4766e690508f5d8104097bbc1086b35 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/0136001eb1c0b1a9f945546365104f8c350d81 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/0136001eb1c0b1a9f945546365104f8c350d81 new file mode 100644 index 000000000..e34d5694b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/0136001eb1c0b1a9f945546365104f8c350d81 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/9b2d99885a0d0f595d6940a6e344584f298764 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/9b2d99885a0d0f595d6940a6e344584f298764 new file mode 100644 index 000000000..eae20da56 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4c/9b2d99885a0d0f595d6940a6e344584f298764 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/84a52f47d12f8f4bfeff4023160d176fc5f5ac b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/84a52f47d12f8f4bfeff4023160d176fc5f5ac new file mode 100644 index 000000000..2abf87c26 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/84a52f47d12f8f4bfeff4023160d176fc5f5ac differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/e6aa1d7a7d7e66088957cf9f251adf439f8b12 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/e6aa1d7a7d7e66088957cf9f251adf439f8b12 new file mode 100644 index 000000000..97e6dbea8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4d/e6aa1d7a7d7e66088957cf9f251adf439f8b12 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/831dd647b563a0d3a7fc87e7986f81cefb608a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/831dd647b563a0d3a7fc87e7986f81cefb608a new file mode 100644 index 000000000..33dad0991 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/831dd647b563a0d3a7fc87e7986f81cefb608a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/93a41b17b0f1afe2eb81ef6352227e93f9bbc9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/93a41b17b0f1afe2eb81ef6352227e93f9bbc9 new file mode 100644 index 000000000..dbb11c3e9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/93a41b17b0f1afe2eb81ef6352227e93f9bbc9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/a1fdec2dfbec7aa0bc82acee7479cba0e3846b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/a1fdec2dfbec7aa0bc82acee7479cba0e3846b new file mode 100644 index 000000000..0618b56e9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/4f/a1fdec2dfbec7aa0bc82acee7479cba0e3846b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/0da0263f14eea2b0953bd75068149c4468d9cc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/0da0263f14eea2b0953bd75068149c4468d9cc new file mode 100644 index 000000000..16661cab1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/0da0263f14eea2b0953bd75068149c4468d9cc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/2ced32101ae8ad15b1cca2b1e285b256badc8b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/2ced32101ae8ad15b1cca2b1e285b256badc8b new file mode 100644 index 000000000..ec23799b8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/50/2ced32101ae8ad15b1cca2b1e285b256badc8b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/05bed30b8d5563871fbbc50ff012f5a8f7ae27 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/05bed30b8d5563871fbbc50ff012f5a8f7ae27 new file mode 100644 index 000000000..9180f7e7f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/05bed30b8d5563871fbbc50ff012f5a8f7ae27 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/2045099ca9fea27d3970ca60a9a9238e861b8d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/2045099ca9fea27d3970ca60a9a9238e861b8d new file mode 100644 index 000000000..326d83b43 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/2045099ca9fea27d3970ca60a9a9238e861b8d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/5dd273467b9968bea90ea2e9d38c8059fb8575 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/5dd273467b9968bea90ea2e9d38c8059fb8575 new file mode 100644 index 000000000..8e0e0fc46 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/5dd273467b9968bea90ea2e9d38c8059fb8575 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/7c34a189ea0b0f4edea1046304d740ce4b7992 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/7c34a189ea0b0f4edea1046304d740ce4b7992 new file mode 100644 index 000000000..17034758a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/7c34a189ea0b0f4edea1046304d740ce4b7992 @@ -0,0 +1,5 @@ +xUR]oÔ0ä9¿buOpº&åD… /ä>%']®T}tœMnÕÄ6¶Ó4BüwÖ!‚#Q6ëÏÌnÙêÞß¼{•,#XÂV›ÑRsö°¾~{sůàÏš’-ˆÞŸµåzþr1ÃòŽ$*‡ôªB;R#$cçÎ +¾£u¤¬ãkxXskñæ6PŒº‡NŒ ´‡Þ!sƒšZ|‘h<©;Ó’Pa žî™Y‚xœ9té 0#èúò ?‹†ðœ½7“d†XLŠcm›¤ýãÇ%wÙvŸû+V=£îU‹ÎÅ=Yv\Ž «’¢d­­¦x‹Üó:¨,yRÍ +œ®ý ,©9o©ìý¡Íf€­_àØ„‚EZ@V,`“Y± +$ÙéËáþéñ˜æ§l_ÀáÛC¾ËNÙ!çê3¤ù#|ÍòÝ +#ãáà‹±ÁO‘BœXMÙ2ÿ;0¨¹jgPRM’­©¦ B£ŸÑ*vmG.ŒÕ±À*Hj©#/üôëß2\ )‰"Îù)qÒ±3–™j+:´}Še«û*ælPtqIa¸êËõm%ËiÙ>ÍkøMXêlt£ñ™$ñõIdú’g²l±`àfâXïÐ “)9ÂÏ(úýÄúá \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/a5d67cf25a655e7172b1fb208556b9dac66978 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/a5d67cf25a655e7172b1fb208556b9dac66978 new file mode 100644 index 000000000..ac09c4666 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/a5d67cf25a655e7172b1fb208556b9dac66978 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/ad2ab4e5a874ba1bd61f1465c379aa631ceac1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/ad2ab4e5a874ba1bd61f1465c379aa631ceac1 new file mode 100644 index 000000000..8a8310948 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/51/ad2ab4e5a874ba1bd61f1465c379aa631ceac1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/47b179f9c4fd2a12d39989986a10ae4ffbfd1b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/47b179f9c4fd2a12d39989986a10ae4ffbfd1b new file mode 100644 index 000000000..338c149a1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/47b179f9c4fd2a12d39989986a10ae4ffbfd1b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/4ad2491c31b6bac0c17d3059ee44166c83084f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/4ad2491c31b6bac0c17d3059ee44166c83084f new file mode 100644 index 000000000..e790c81b3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/53/4ad2491c31b6bac0c17d3059ee44166c83084f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/583435a0992351fcb52fb12dfe39f4a796d442 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/583435a0992351fcb52fb12dfe39f4a796d442 new file mode 100644 index 000000000..024a5eeba Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/583435a0992351fcb52fb12dfe39f4a796d442 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/9818f05ad2055110ceb63b999342eb33f1fef3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/9818f05ad2055110ceb63b999342eb33f1fef3 new file mode 100644 index 000000000..2ada630c6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/9818f05ad2055110ceb63b999342eb33f1fef3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/a1e655216ca7fde95fe2b40856746febeff8f7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/a1e655216ca7fde95fe2b40856746febeff8f7 new file mode 100644 index 000000000..60fb1269e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/54/a1e655216ca7fde95fe2b40856746febeff8f7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/3f1e96fd5bc4bdb3e80aa8b7e215fc4251e9a3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/3f1e96fd5bc4bdb3e80aa8b7e215fc4251e9a3 new file mode 100644 index 000000000..1e2da9398 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/3f1e96fd5bc4bdb3e80aa8b7e215fc4251e9a3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/4b69fd0a95f05840ec20c8b571dde51639b364 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/4b69fd0a95f05840ec20c8b571dde51639b364 new file mode 100644 index 000000000..c5a67d512 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/4b69fd0a95f05840ec20c8b571dde51639b364 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/51fde8e7dba1e37a1821e8b26374893e2a9e2e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/51fde8e7dba1e37a1821e8b26374893e2a9e2e new file mode 100644 index 000000000..3a60540d7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/51fde8e7dba1e37a1821e8b26374893e2a9e2e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/cbc98e519005ee43e7973f63ed8b5ad0d8ade1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/cbc98e519005ee43e7973f63ed8b5ad0d8ade1 new file mode 100644 index 000000000..939ef6372 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/55/cbc98e519005ee43e7973f63ed8b5ad0d8ade1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/8f0b40dad555c097026774b4beb7615f625fff b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/8f0b40dad555c097026774b4beb7615f625fff new file mode 100644 index 000000000..554759b71 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/8f0b40dad555c097026774b4beb7615f625fff differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/e4277d7807ca7f8f745013238323bf08951275 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/e4277d7807ca7f8f745013238323bf08951275 new file mode 100644 index 000000000..ce9334898 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/57/e4277d7807ca7f8f745013238323bf08951275 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/58/7318dc0b2c57f9ab00212d3f98ed8e520f8e8e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/58/7318dc0b2c57f9ab00212d3f98ed8e520f8e8e new file mode 100644 index 000000000..3df0b8330 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/58/7318dc0b2c57f9ab00212d3f98ed8e520f8e8e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/7fc5517d8725c7e7059f365bd837efcf282d48 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/7fc5517d8725c7e7059f365bd837efcf282d48 new file mode 100644 index 000000000..91298a1a2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/7fc5517d8725c7e7059f365bd837efcf282d48 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/b6e14902cbf2be3d5a667d9935842f35857aa8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/b6e14902cbf2be3d5a667d9935842f35857aa8 new file mode 100644 index 000000000..e39ca9ad7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/b6e14902cbf2be3d5a667d9935842f35857aa8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/ce9bb3630f389388ecee384c0bf385cf6835b1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/ce9bb3630f389388ecee384c0bf385cf6835b1 new file mode 100644 index 000000000..7203342ac Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/ce9bb3630f389388ecee384c0bf385cf6835b1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/d8fb4bf050ddb022823f70c9d2d48fa6c8978d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/d8fb4bf050ddb022823f70c9d2d48fa6c8978d new file mode 100644 index 000000000..f8902ffa6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/59/d8fb4bf050ddb022823f70c9d2d48fa6c8978d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/864aff6738c290824cf2a0fd3efda7a8f9021e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/864aff6738c290824cf2a0fd3efda7a8f9021e new file mode 100644 index 000000000..f329ba0b6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/864aff6738c290824cf2a0fd3efda7a8f9021e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/c150aab8e06986f36bbe360077a00832b978e6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/c150aab8e06986f36bbe360077a00832b978e6 new file mode 100644 index 000000000..453c0eb58 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/c150aab8e06986f36bbe360077a00832b978e6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/e8b4e72ec9a4d0dab16e5488bb0a2272d6a797 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/e8b4e72ec9a4d0dab16e5488bb0a2272d6a797 new file mode 100644 index 000000000..00366cfb1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5b/e8b4e72ec9a4d0dab16e5488bb0a2272d6a797 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/17a1f16cf31eb377ca7c22554d15e6985647e8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/17a1f16cf31eb377ca7c22554d15e6985647e8 new file mode 100644 index 000000000..d00d3d8cc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/17a1f16cf31eb377ca7c22554d15e6985647e8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/24ac2f9228b61e8fe61312a723c00cce8422df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/24ac2f9228b61e8fe61312a723c00cce8422df new file mode 100644 index 000000000..a28cba2f4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/24ac2f9228b61e8fe61312a723c00cce8422df differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/c8a75dd5328927da231e3d7f3f34d85ab7e8be b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/c8a75dd5328927da231e3d7f3f34d85ab7e8be new file mode 100644 index 000000000..a9626122d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5c/c8a75dd5328927da231e3d7f3f34d85ab7e8be @@ -0,0 +1,2 @@ +xµVao"7íWø#>mRbÒT­ÔKsbCH»jˆ…Dù„Ì2€ïví­í  *ÿ½cï&À]hs‘.RÖëϼ7ï™iª¦ðóéÙé­ã:CGå-K g§?ýzB~»DP´($Ov©4=WŸ £0y#”gPÈjæ<¡ØêMîP¡$œ±S\ÖFõªqtîRlT߀T +ƒ”C˜‹× æ„„Dey*¸LVÂ.ý9UW ¥ZS¾òð,4Ò;«\Õ+-¬‹&5·+®Ñ•:Æj1-ìhU3@­ïn ظ„FC7à2Œ£¸é’ÜG£?ûã܇ÃaØEÝúCèô{WÑ(ê÷èéÂÞüõ®š€‘ƒë\»ˆEáàÄ™Ç.F‡ù a0§÷îÙ䘈¹H¨5¹(øa¡QKêrÔ™0ŽVCÎ\I©È„åÖ/m‡a‡¤V½N8v‰ifrM™æšg¸Rú3KRUÌaƒRX6BcÏŸ—ýF¥Y¢4²¼˜¦ÂP«ì:-Öû±_7UʲØ×–,ºÒ_ò¾V¡!%¨DɹXtbW:òCZëTkÿL³×(—4ö>¦Êu)äŒpyW†HæÅš×Z8tv¿°ï =c74Õ(Q¿½îŒF‘“»,Ø@«„?Ój&‰'ƒÚ~*æ¹`¡_psÆÊw£%ßvýÆÈ9éK­;1_ŽÉ`Ó’¶ßrWk¥o}7hXÔ» o¢«IÔŒGú<îNœZ'$W·rÛ%å^Má0¼ý. »£ñ°7= º“^ß{Ù÷®&ñ Û‰®£î¡Ü:öæÝ®l=Jù†“¤‹Œ¬þ•“åÛ%—¤êVÝk*$%¬a¿ña%Ò[¤ËaædiàŸz½Övëµ*òQ òCZyÞÉGžŠ™Ö;žxOöT`™iÀ)Xº6‚#JX«Y½ñÿk_é•éB>±ÒîTŸ´Ìê…!S¡²›Ð89¡!"óbÎK.NÝÍS«ÕÜ(MqÁSÚ_d(m×_:4^`–ªHg°äSDI®¨ÕJ–ÁO” á6YB°À²ÞÚv.d ´ÕtGGîØZ†Yº‹Êß2.¾h:÷é0Æ%€C´…–£Mî¡í)dªÈ“ãÒÑqö͸–9ˆg³ìËáì-•}ÊÖ ½9Î.æ<5ØøjËkÒ2L8óø®… ‹kÛÊS‚ë­9”w³CIv¹ß¡nMàÐüð#ü¯ˆž¹xÏ lÙó1žòò²©.ˆàÅ5KRh²Ún£)V>Wjù°j¼ˆkí}±ïœç­ÍMò®Â5&HvànÞß)”Œý#}"2KâÛ¥*wÒ”†x."Fÿõ±$®QÅ¢¡WA9σ N>BƬçäXnTU±QIã»áT©àU|¶pì!ð KÛ;ÓN÷%ˆ¾ù}¸ªîµ×p‰ ;ظ³17ôûTÿÀð \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/032a5b138abdc655c0a17f67c8d2454c55f2f6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/032a5b138abdc655c0a17f67c8d2454c55f2f6 new file mode 100644 index 000000000..f6626375b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/032a5b138abdc655c0a17f67c8d2454c55f2f6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/48a4fd4badc50a671ddd2a462b1d612093b654 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/48a4fd4badc50a671ddd2a462b1d612093b654 new file mode 100644 index 000000000..f23c38ed6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/48a4fd4badc50a671ddd2a462b1d612093b654 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/738f799c6ef05672176c7577ea7f3ed53a2c7c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/738f799c6ef05672176c7577ea7f3ed53a2c7c new file mode 100644 index 000000000..f65f2f812 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5d/738f799c6ef05672176c7577ea7f3ed53a2c7c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/166673149c80bd70cf01fa56b364cbaddbd6d2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/166673149c80bd70cf01fa56b364cbaddbd6d2 new file mode 100644 index 000000000..f6669504a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/166673149c80bd70cf01fa56b364cbaddbd6d2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/3813088e87c0e5251d2796a6ab5e95544834d7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/3813088e87c0e5251d2796a6ab5e95544834d7 new file mode 100644 index 000000000..b4e02e223 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/3813088e87c0e5251d2796a6ab5e95544834d7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/5ecdf523d151f372c59314c8d9363114763a02 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/5ecdf523d151f372c59314c8d9363114763a02 new file mode 100644 index 000000000..d11cd1c11 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/5ecdf523d151f372c59314c8d9363114763a02 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fa192c90a0775d7da6e0e929c58613ecba0a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fa192c90a0775d7da6e0e929c58613ecba0a new file mode 100644 index 000000000..a759160cf Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fa192c90a0775d7da6e0e929c58613ecba0a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fdc746cc1ef3873e7b587af57aed263ffd21 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fdc746cc1ef3873e7b587af57aed263ffd21 new file mode 100644 index 000000000..d5dfc4ae4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/94fdc746cc1ef3873e7b587af57aed263ffd21 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/bc2118f55e05c88812ff7ac023a5c72f95ffe9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/bc2118f55e05c88812ff7ac023a5c72f95ffe9 new file mode 100644 index 000000000..3d07e9a9a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5e/bc2118f55e05c88812ff7ac023a5c72f95ffe9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/45e09a2e63fb6db390aa95b6bb58f2df41c138 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/45e09a2e63fb6db390aa95b6bb58f2df41c138 new file mode 100644 index 000000000..436c6d854 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/45e09a2e63fb6db390aa95b6bb58f2df41c138 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/f956815ab8dd36d8a928be4f543f260d6ebe55 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/f956815ab8dd36d8a928be4f543f260d6ebe55 new file mode 100644 index 000000000..e13ab86cf Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/5f/f956815ab8dd36d8a928be4f543f260d6ebe55 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/60/369db92312e85985d3693291b9722ccffcc869 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/60/369db92312e85985d3693291b9722ccffcc869 new file mode 100644 index 000000000..548d3acda Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/60/369db92312e85985d3693291b9722ccffcc869 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/61/639bd81162a88ef68fc13d0d0800bb71effa99 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/61/639bd81162a88ef68fc13d0d0800bb71effa99 new file mode 100644 index 000000000..94f9c7dc5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/61/639bd81162a88ef68fc13d0d0800bb71effa99 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/30ef8282c8523ff7d4da4ba5c22b812f487ea7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/30ef8282c8523ff7d4da4ba5c22b812f487ea7 new file mode 100644 index 000000000..b62218207 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/30ef8282c8523ff7d4da4ba5c22b812f487ea7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/a659a8f91d91d3fcb8bb6cffd5f4bfb450022c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/a659a8f91d91d3fcb8bb6cffd5f4bfb450022c new file mode 100644 index 000000000..692015f96 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/a659a8f91d91d3fcb8bb6cffd5f4bfb450022c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/bd799188299bf1235a8e98779987e16fd89204 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/bd799188299bf1235a8e98779987e16fd89204 new file mode 100644 index 000000000..05b441b57 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/bd799188299bf1235a8e98779987e16fd89204 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/c27a19d43871db933955a88297a89fd3ef288e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/c27a19d43871db933955a88297a89fd3ef288e new file mode 100644 index 000000000..4e40a05ba Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/c27a19d43871db933955a88297a89fd3ef288e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/fcb967a2435395b0afd8a1fb9db94e762cd71c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/fcb967a2435395b0afd8a1fb9db94e762cd71c new file mode 100644 index 000000000..4c3a876af Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/62/fcb967a2435395b0afd8a1fb9db94e762cd71c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/86342cb6ad29f7f3c0371be0bda0406c9140c0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/86342cb6ad29f7f3c0371be0bda0406c9140c0 new file mode 100644 index 000000000..44ba3a233 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/86342cb6ad29f7f3c0371be0bda0406c9140c0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/9feaf88d09e2972494b09b595229a022882458 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/9feaf88d09e2972494b09b595229a022882458 new file mode 100644 index 000000000..12e6c3325 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/63/9feaf88d09e2972494b09b595229a022882458 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/4a8f68bfd42231288364d5f345c2a35e1bc3f3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/4a8f68bfd42231288364d5f345c2a35e1bc3f3 new file mode 100644 index 000000000..57772b33a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/4a8f68bfd42231288364d5f345c2a35e1bc3f3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/96363fb4eee64c5620c8df07f95307aa4bac7e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/96363fb4eee64c5620c8df07f95307aa4bac7e new file mode 100644 index 000000000..430795cc5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/65/96363fb4eee64c5620c8df07f95307aa4bac7e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/2a6eb375270802a8a228a22859544706ce1c4a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/2a6eb375270802a8a228a22859544706ce1c4a new file mode 100644 index 000000000..c6f24ecb4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/2a6eb375270802a8a228a22859544706ce1c4a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/43938d1e4896a21cc640e4ff1f99512556ab28 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/43938d1e4896a21cc640e4ff1f99512556ab28 new file mode 100644 index 000000000..19492fdca Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/43938d1e4896a21cc640e4ff1f99512556ab28 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/bdd3b6aad634816d82e8bb437e706047edeeaa b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/bdd3b6aad634816d82e8bb437e706047edeeaa new file mode 100644 index 000000000..a753c8cbd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/bdd3b6aad634816d82e8bb437e706047edeeaa differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/dc8699719bbcd054972eec52207a9647d4b9d2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/dc8699719bbcd054972eec52207a9647d4b9d2 new file mode 100644 index 000000000..ba6fe8236 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/66/dc8699719bbcd054972eec52207a9647d4b9d2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/10183029fc644b2bbd2f0ca60084e16e421bb4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/10183029fc644b2bbd2f0ca60084e16e421bb4 new file mode 100644 index 000000000..bce10895f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/10183029fc644b2bbd2f0ca60084e16e421bb4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/2f12ab261520d041d5e52065e27ce95b0af368 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/2f12ab261520d041d5e52065e27ce95b0af368 new file mode 100644 index 000000000..095e23d98 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/2f12ab261520d041d5e52065e27ce95b0af368 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/6e54c4e19b23f49e9cd959676e4ac754e5b180 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/6e54c4e19b23f49e9cd959676e4ac754e5b180 new file mode 100644 index 000000000..c7e29a386 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/6e54c4e19b23f49e9cd959676e4ac754e5b180 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/a222cf6c4b4a4e0e5124529299f25331a5e76e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/a222cf6c4b4a4e0e5124529299f25331a5e76e new file mode 100644 index 000000000..28c1dd6dc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/68/a222cf6c4b4a4e0e5124529299f25331a5e76e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/462ca7985f5430675032aa0259052e812a829f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/462ca7985f5430675032aa0259052e812a829f new file mode 100644 index 000000000..1d6a89e07 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/462ca7985f5430675032aa0259052e812a829f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/82217ffd5f793672d52c6c3761f315b74f8c66 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/82217ffd5f793672d52c6c3761f315b74f8c66 new file mode 100644 index 000000000..e4658c2fe Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/69/82217ffd5f793672d52c6c3761f315b74f8c66 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/2bcef3a08f78893082b4ce64791b212cb38a05 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/2bcef3a08f78893082b4ce64791b212cb38a05 new file mode 100644 index 000000000..609b2f38e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/2bcef3a08f78893082b4ce64791b212cb38a05 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/50477a6a05ecdcf80801ff8d966a0207abbd6c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/50477a6a05ecdcf80801ff8d966a0207abbd6c new file mode 100644 index 000000000..5c21cf1e6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/50477a6a05ecdcf80801ff8d966a0207abbd6c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/57794a51f00d30816636fb60bc4a28fccb242a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/57794a51f00d30816636fb60bc4a28fccb242a new file mode 100644 index 000000000..96bd286d7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/57794a51f00d30816636fb60bc4a28fccb242a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/58f43369ca248c59ed2b6783932efe8a7cd26c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/58f43369ca248c59ed2b6783932efe8a7cd26c new file mode 100644 index 000000000..2957852c5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/58f43369ca248c59ed2b6783932efe8a7cd26c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/e94cc089263af51f8bb7f80f47a021b0ffafaf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/e94cc089263af51f8bb7f80f47a021b0ffafaf new file mode 100644 index 000000000..b5bd05425 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6a/e94cc089263af51f8bb7f80f47a021b0ffafaf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/970b1d0ed73ee4da171c19ee2d004c629570df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/970b1d0ed73ee4da171c19ee2d004c629570df new file mode 100644 index 000000000..f03afe534 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/970b1d0ed73ee4da171c19ee2d004c629570df differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/986a15972a95faade7610a2e5edd27baafc42e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/986a15972a95faade7610a2e5edd27baafc42e new file mode 100644 index 000000000..61c6562a6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6b/986a15972a95faade7610a2e5edd27baafc42e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/7af12542730777a0ad6c968b12eed062d71f4a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/7af12542730777a0ad6c968b12eed062d71f4a new file mode 100644 index 000000000..3c45eb194 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/7af12542730777a0ad6c968b12eed062d71f4a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/9277b74484b54a8f61b76e8f648eec39af3b52 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/9277b74484b54a8f61b76e8f648eec39af3b52 new file mode 100644 index 000000000..3f74506cc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/9277b74484b54a8f61b76e8f648eec39af3b52 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/daa529c7b7b35de21da6bd23c3b86efbbc8a7b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/daa529c7b7b35de21da6bd23c3b86efbbc8a7b new file mode 100644 index 000000000..a139d020a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6c/daa529c7b7b35de21da6bd23c3b86efbbc8a7b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/768b5a0c9d2a65ee5b487dad98e1c0ae705d3f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/768b5a0c9d2a65ee5b487dad98e1c0ae705d3f new file mode 100644 index 000000000..b03ccfee3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/768b5a0c9d2a65ee5b487dad98e1c0ae705d3f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/7f5fb0aa71f66a028de645a6aa73afd20da995 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/7f5fb0aa71f66a028de645a6aa73afd20da995 new file mode 100644 index 000000000..631ac7e56 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/7f5fb0aa71f66a028de645a6aa73afd20da995 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/e3fb80a6d6704fcd4f702826aa8163826c0442 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/e3fb80a6d6704fcd4f702826aa8163826c0442 new file mode 100644 index 000000000..153a935ac Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6d/e3fb80a6d6704fcd4f702826aa8163826c0442 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6e/9a4873385857ee530b2bed6efe1331f0300ee8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6e/9a4873385857ee530b2bed6efe1331f0300ee8 new file mode 100644 index 000000000..f1ac1fc88 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6e/9a4873385857ee530b2bed6efe1331f0300ee8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6f/505665196f90dccda2716522a85c78afe8de1c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6f/505665196f90dccda2716522a85c78afe8de1c new file mode 100644 index 000000000..4fe2d5db2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/6f/505665196f90dccda2716522a85c78afe8de1c @@ -0,0 +1,9 @@ +xVmOÛH¾¯ñ¯ù“ƒÂš"ª“@•)Õ¥-ɉ„¢~Ü8cg/ήÙBTñßov턘p\ùPØÝyyæ™gÆ•j§gOÿH"8‚ª6Z §'>Ó?‚] (º’—À](Mçæ/ÃÈÍ{~JƒsprŽ:8õ+ž‘oóÒƒ¨PNÙ $>jÜ<ÅÝ b£¬ø¤²à R a %>eXY2µªJÁe†°vò4Q<øÙÄP3ËÉœ“Cµ•ï· hð? k«ó4]¯×ŒÄLé"-ëzLú}8¸M® uãu'K44>8¡©âÙxE¨2>#¬%_z +ôf•G½Ö +YôÀ¨Ü®¹Fu.ŒÕbæl‹´¦ Ò÷ ˆ6.!îO`8‰áª?Nz>Èýpú×øn +÷ýÛÛþh:¼žÀøãÑçát8Ñé ôG?áÛpô¹H”Qsð©Ò¾ê¢ðtâ1ÍL¥)R®ù +×J/YV*7gÄ ò› /§‹(" JÛàð“²){qx­”¨Ù­“÷TlË`¥²¥°ŠÝÔ¿ÛA_£˜!—†å<³Jo—$ÊPë;«Ö¾ï­ØîJYFâ2%sQ8ìZz}x÷AsþK•2ŠcñɲI`ëŠÂðC‹È½ +j WÄ,ñþnúV„I†Ó(ØßZetPÔ—½¶¤oïe&GŒÝL”Ó¾ëý’í&äÅÁ‚aù®S‹© “³†°¯w¤™³AÉ!iB^ºo|c³ ¬­Búºp+”ö†[Ú`Ú0|ØåýsaF®|ú†K£AFs$ò I½lì ׸R…ÂG‘ ?XÛ·¯žøÁË%ò’/¬L£ËFòI­ƒ‚i®¨øn”¦pIEeKc7%ž«<.Û¢J‚%ø»N7jñ3Õü9åº@k¼ +MÆm©UÝm"ŵ<ÚÓ=Çœ»Òúˆ¨?yöãC€2ªÜŒ¶+X¿ ~EQçr7¬Q‡ææ‘[„:Ù—z°aR7'jÛ.»ÊÀ lw +æW…ezÏÃB4É/ˆ5_ÛM…&îAìd ç1 éúÇê~ ó»»y¶N¯rXÝÁb•-ÓuºÊ8º†yö7i¶œ²e<|imPÀS”ÁN¬zïr žÿl8b×b)7²diºö¢F¨Í3ZÍŠ EÛHÆê˜`()ÙHÔý]†ƒ!%QÄ>? v:v­e¤ vÆ>Å¥2¾Š¯N#bñlÍE1Mc©/fqÞ}ÌÿŽ,Šæ5Î}ñ KÊDÃ×çda½c¬ä¸_èËaÕ—âYVp#”q’å%Q©{·ðŽLó)Fo*6¨ÉÁç¯Q4úÀä^£Ñ]®Øh++ŒF­/xÅ€‰ßÉ È“½°÷«oY$oõp×H×^)Ö‰þÑî[ô“ïUÐ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/71/ed7ba98ca6b86c38e6e710026aa93461c1b4d4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/71/ed7ba98ca6b86c38e6e710026aa93461c1b4d4 new file mode 100644 index 000000000..c0c06e2eb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/71/ed7ba98ca6b86c38e6e710026aa93461c1b4d4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/350008be7f309469aa3212c261c52caa6fc15e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/350008be7f309469aa3212c261c52caa6fc15e new file mode 100644 index 000000000..b42829b7d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/350008be7f309469aa3212c261c52caa6fc15e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/446f37e0c151d00d33d35d63b23cf0f05c0c0d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/446f37e0c151d00d33d35d63b23cf0f05c0c0d new file mode 100644 index 000000000..98fe2e7e0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/72/446f37e0c151d00d33d35d63b23cf0f05c0c0d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/1a6d80461437fc52a9d4c7bc14d1f681a9e2cd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/1a6d80461437fc52a9d4c7bc14d1f681a9e2cd new file mode 100644 index 000000000..e5e4e37a4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/1a6d80461437fc52a9d4c7bc14d1f681a9e2cd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/3b43e44bcb17f9b603fcfa14069fa1aa0219d9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/3b43e44bcb17f9b603fcfa14069fa1aa0219d9 new file mode 100644 index 000000000..97b0b5636 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/3b43e44bcb17f9b603fcfa14069fa1aa0219d9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/6f331ea4175f8a80b94aac49970e5e45b4b5ea b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/6f331ea4175f8a80b94aac49970e5e45b4b5ea new file mode 100644 index 000000000..a6a697cc5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/6f331ea4175f8a80b94aac49970e5e45b4b5ea differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/b52963e046bc0f614737af331fde2a467be1e1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/b52963e046bc0f614737af331fde2a467be1e1 new file mode 100644 index 000000000..c50efe7f4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/73/b52963e046bc0f614737af331fde2a467be1e1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/021c2d24aa549885bb0bdd366fcfe8ab7b7acb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/021c2d24aa549885bb0bdd366fcfe8ab7b7acb new file mode 100644 index 000000000..e13966b40 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/021c2d24aa549885bb0bdd366fcfe8ab7b7acb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/67afa8822f4c79f4a23139dbb9b1a0b54d00df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/67afa8822f4c79f4a23139dbb9b1a0b54d00df new file mode 100644 index 000000000..c7c47d044 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/67afa8822f4c79f4a23139dbb9b1a0b54d00df differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/a254db636e4d5910e9868c7c738a297991f4bb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/a254db636e4d5910e9868c7c738a297991f4bb new file mode 100644 index 000000000..e7813c28e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/a254db636e4d5910e9868c7c738a297991f4bb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/d841c345f5cf9285d7181bea594a324a81f3eb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/d841c345f5cf9285d7181bea594a324a81f3eb new file mode 100644 index 000000000..2e02d15c3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/74/d841c345f5cf9285d7181bea594a324a81f3eb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/1313e1ce8faa4d1a006525af39af300411f45a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/1313e1ce8faa4d1a006525af39af300411f45a new file mode 100644 index 000000000..fc15ecee5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/1313e1ce8faa4d1a006525af39af300411f45a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/48912768ac035ac2c0a5d0f9a42980f6cf05fd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/48912768ac035ac2c0a5d0f9a42980f6cf05fd new file mode 100644 index 000000000..fef7b4122 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/48912768ac035ac2c0a5d0f9a42980f6cf05fd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/80df70f46bb51a748b3ddedd45f5c7809c4439 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/80df70f46bb51a748b3ddedd45f5c7809c4439 new file mode 100644 index 000000000..2b1c54e9e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/80df70f46bb51a748b3ddedd45f5c7809c4439 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/d395a66b18fc62861fd8176521d9e15cbd3519 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/d395a66b18fc62861fd8176521d9e15cbd3519 new file mode 100644 index 000000000..a438b2c48 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/75/d395a66b18fc62861fd8176521d9e15cbd3519 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/60c83b534eb09aaae39fd0d967bd798a481e05 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/60c83b534eb09aaae39fd0d967bd798a481e05 new file mode 100644 index 000000000..a7b1fad17 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/60c83b534eb09aaae39fd0d967bd798a481e05 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/8900742aab756a74027230bc2b7533e392b3cf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/8900742aab756a74027230bc2b7533e392b3cf new file mode 100644 index 000000000..1c9b687ff Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/8900742aab756a74027230bc2b7533e392b3cf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/edd5bd59b81f704433c2b25972f4296a36cf45 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/edd5bd59b81f704433c2b25972f4296a36cf45 new file mode 100644 index 000000000..6c2e9c0f4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/76/edd5bd59b81f704433c2b25972f4296a36cf45 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/5d53f91aa86bacb11a63e551c40702fb809d06 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/5d53f91aa86bacb11a63e551c40702fb809d06 new file mode 100644 index 000000000..0fec22921 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/5d53f91aa86bacb11a63e551c40702fb809d06 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/d940be6feb75ba87384140626d86666b8a51d8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/d940be6feb75ba87384140626d86666b8a51d8 new file mode 100644 index 000000000..e66084d65 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/77/d940be6feb75ba87384140626d86666b8a51d8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/78/8e4d050605396707d95b6180563a33e9421944 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/78/8e4d050605396707d95b6180563a33e9421944 new file mode 100644 index 000000000..75a5c08af Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/78/8e4d050605396707d95b6180563a33e9421944 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/1932b87eaf66880091c17590ddec8e0daaa52d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/1932b87eaf66880091c17590ddec8e0daaa52d new file mode 100644 index 000000000..1a9a52b45 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/1932b87eaf66880091c17590ddec8e0daaa52d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/3eb146b2a9ad97184983a31296258d032cef2a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/3eb146b2a9ad97184983a31296258d032cef2a new file mode 100644 index 000000000..548dfa81a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/79/3eb146b2a9ad97184983a31296258d032cef2a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/615caa6da8301fab736e83ec9b10754bd63ccc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/615caa6da8301fab736e83ec9b10754bd63ccc new file mode 100644 index 000000000..b0ea701e4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/615caa6da8301fab736e83ec9b10754bd63ccc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/9748764e7bc3d4c62c162efe710e9a459190a9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/9748764e7bc3d4c62c162efe710e9a459190a9 new file mode 100644 index 000000000..ff7808e5b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7a/9748764e7bc3d4c62c162efe710e9a459190a9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/393cbd5203b47aeab3a6672f32b5ac292062a2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/393cbd5203b47aeab3a6672f32b5ac292062a2 new file mode 100644 index 000000000..474ea82f6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/393cbd5203b47aeab3a6672f32b5ac292062a2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/5bb43818c5be85ad5de04135b666c01fdafc9d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/5bb43818c5be85ad5de04135b666c01fdafc9d new file mode 100644 index 000000000..776c186d2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/5bb43818c5be85ad5de04135b666c01fdafc9d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/94c05a36b63d67304f27ea28b3fca5e368e92d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/94c05a36b63d67304f27ea28b3fca5e368e92d new file mode 100644 index 000000000..527a3b43e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/94c05a36b63d67304f27ea28b3fca5e368e92d @@ -0,0 +1,2 @@ +xÅTQkÛ0Þ«ó+Œßm%íÆJP\6ÆX!] IÇ^ù’ªµ%!)Múïw’ÜVÉÜŽÁ`øé¤ïtß÷ÝW­Zå“OßÑó}×æ`¬PrVLªq‘ƒäªr3+n–_˳â¼QmÔp—#ZÚéÞŠYq뜞²ÛíªÝi¥Ì†œŒÇòór¾à·Ð±RHë˜äPŒ²,&>'uìdÅ4CdH½¾º$ï«1–`+¦6<2Wœ¹@­/÷zfþboÒÊP¡Â¸¨GíTí(½W”œÄŒkÆÝES[mЕ’·jÛ”Ö`]¹²S:°Ž’‹™(îžm0£¾c†’—Ð?+Y~0 Ý€åFhoD½ÄR¹Ýj­ŒË×Êä‘B.:ÝBÒ¿,%i’/©™Á[žÑQ[Š°iUTµ6Hh§Ì}äQòñðD×ñá#ù퇪>©&ø}¾¹˜)ß?]/¾]-)yºEbhMd”j@G%`ÕúƒG¾M]HôR`‡º‡•`ÏÛ­_‹@$=LŽ¸ô³ÌŒ:¬¸Ä?_rÕiÑErãÕ¡ÏU£Ü—8Ú]MýÀÉHÃ7í9¤•’꛺ƒÕ1#?Ei¿©70IV ŒÒ?-¿R +w2™ã¡ê„»Œ}Þbl‰åJCÝ÷Œ’úµø¿ÞdÔ="/ÿç)Ã&„aGxˬk&^Ç?%Éùïü9a÷p-ã?¿ýÚ± \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/fa8719e56a37f46130341f35eeeca2a66f3b89 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/fa8719e56a37f46130341f35eeeca2a66f3b89 new file mode 100644 index 000000000..b741906f5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7b/fa8719e56a37f46130341f35eeeca2a66f3b89 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7c/ef77766ea719b5068ef9df7f717ae7dd5d3d99 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7c/ef77766ea719b5068ef9df7f717ae7dd5d3d99 new file mode 100644 index 000000000..9c50dbf14 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7c/ef77766ea719b5068ef9df7f717ae7dd5d3d99 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/201668708ec1d7c9656f02e48ccd65047fc2fc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/201668708ec1d7c9656f02e48ccd65047fc2fc new file mode 100644 index 000000000..6796ed151 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/201668708ec1d7c9656f02e48ccd65047fc2fc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/4cbfd51112420bf821196df8e196049c8e732b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/4cbfd51112420bf821196df8e196049c8e732b new file mode 100644 index 000000000..6b44df652 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/4cbfd51112420bf821196df8e196049c8e732b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/64cb384865966d4006a647dfcd208515a25b99 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/64cb384865966d4006a647dfcd208515a25b99 new file mode 100644 index 000000000..e2f197e32 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/64cb384865966d4006a647dfcd208515a25b99 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/aabcb70d63f3a82d1051353e41facf24d27b9f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/aabcb70d63f3a82d1051353e41facf24d27b9f new file mode 100644 index 000000000..94140f861 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/aabcb70d63f3a82d1051353e41facf24d27b9f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ee22afb159c20b04221e23121ff6fdc8997545 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ee22afb159c20b04221e23121ff6fdc8997545 new file mode 100644 index 000000000..e91045f79 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ee22afb159c20b04221e23121ff6fdc8997545 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/eee0cdb6e686bc7a4a3c9717c36a83ac369f09 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/eee0cdb6e686bc7a4a3c9717c36a83ac369f09 new file mode 100644 index 000000000..bb55b8b71 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/eee0cdb6e686bc7a4a3c9717c36a83ac369f09 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ef960e5a78d80cef2b359a0aeb9967a6ac507c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ef960e5a78d80cef2b359a0aeb9967a6ac507c new file mode 100644 index 000000000..f93dafaaa Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7e/ef960e5a78d80cef2b359a0aeb9967a6ac507c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/297978bd958df9b6304a47845ff46ce58ff041 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/297978bd958df9b6304a47845ff46ce58ff041 new file mode 100644 index 000000000..210aa92b0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/297978bd958df9b6304a47845ff46ce58ff041 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/6f22cdeaafe5fac7f9ba39560b576e8beccfcf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/6f22cdeaafe5fac7f9ba39560b576e8beccfcf new file mode 100644 index 000000000..779f129aa Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/7f/6f22cdeaafe5fac7f9ba39560b576e8beccfcf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/ac6a310a2935e6516091968fcf3e2ec9b43604 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/ac6a310a2935e6516091968fcf3e2ec9b43604 new file mode 100644 index 000000000..85479ef9d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/ac6a310a2935e6516091968fcf3e2ec9b43604 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/be43c96b0e1e60f45d7d8a2929236f237d6a4e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/be43c96b0e1e60f45d7d8a2929236f237d6a4e new file mode 100644 index 000000000..ea62cb0dd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/80/be43c96b0e1e60f45d7d8a2929236f237d6a4e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/2a666d58e596165f8099f31423eef6a207e10a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/2a666d58e596165f8099f31423eef6a207e10a new file mode 100644 index 000000000..8d75a831d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/2a666d58e596165f8099f31423eef6a207e10a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/65add6d866f71406b046093cfc46b390bf4a59 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/65add6d866f71406b046093cfc46b390bf4a59 new file mode 100644 index 000000000..aa37705dc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/65add6d866f71406b046093cfc46b390bf4a59 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/a80d89730018549fceb62c8049bc56dcd70368 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/a80d89730018549fceb62c8049bc56dcd70368 new file mode 100644 index 000000000..2b3e7aa1a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/81/a80d89730018549fceb62c8049bc56dcd70368 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/4e6ce0f62c181a5144c00dd7b37e7d2011a3d1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/4e6ce0f62c181a5144c00dd7b37e7d2011a3d1 new file mode 100644 index 000000000..17109a80f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/4e6ce0f62c181a5144c00dd7b37e7d2011a3d1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/5b16a6bc974f88904af8383dca3fd1bbd7e262 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/5b16a6bc974f88904af8383dca3fd1bbd7e262 new file mode 100644 index 000000000..f0ab513a7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/82/5b16a6bc974f88904af8383dca3fd1bbd7e262 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/83/61f1572c9ed5ae307d96962a2679743ef29140 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/83/61f1572c9ed5ae307d96962a2679743ef29140 new file mode 100644 index 000000000..41ff65ff2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/83/61f1572c9ed5ae307d96962a2679743ef29140 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/486460fca5044d8076e6994b9db490c56c7e9b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/486460fca5044d8076e6994b9db490c56c7e9b new file mode 100644 index 000000000..bf8c7be82 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/486460fca5044d8076e6994b9db490c56c7e9b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/f0edbc84ad3a068de856821fbf8ff63110ccfb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/f0edbc84ad3a068de856821fbf8ff63110ccfb new file mode 100644 index 000000000..cf35dcc94 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/84/f0edbc84ad3a068de856821fbf8ff63110ccfb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/0c3d364634626724279851ad9ac6246e02f9f6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/0c3d364634626724279851ad9ac6246e02f9f6 new file mode 100644 index 000000000..0a8e27518 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/0c3d364634626724279851ad9ac6246e02f9f6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/6792204a2c69776893677287fcde30606c6ece b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/6792204a2c69776893677287fcde30606c6ece new file mode 100644 index 000000000..275a8e1ec Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/6792204a2c69776893677287fcde30606c6ece differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/f5f6d4c96f0049736f1668e9f736ce3ba55653 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/f5f6d4c96f0049736f1668e9f736ce3ba55653 new file mode 100644 index 000000000..dad187acb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/85/f5f6d4c96f0049736f1668e9f736ce3ba55653 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/22dbd99a8b0023b985911ec838635ce160baae b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/22dbd99a8b0023b985911ec838635ce160baae new file mode 100644 index 000000000..c6518bef7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/22dbd99a8b0023b985911ec838635ce160baae differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/89660f5c7938b33ee1803e74dd4c95aae58a6e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/89660f5c7938b33ee1803e74dd4c95aae58a6e new file mode 100644 index 000000000..906bc7f41 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/89660f5c7938b33ee1803e74dd4c95aae58a6e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/ae80e2f540cce8aea01e902c5a210458a229df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/ae80e2f540cce8aea01e902c5a210458a229df new file mode 100644 index 000000000..25454c545 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/ae80e2f540cce8aea01e902c5a210458a229df @@ -0,0 +1,4 @@ +x•SïkÛ0Ý×è¯8ò©-©Ý:Æ2¶8iÇL»êt¥eûìˆ:’¦uÂèÿ¾“cºäËØŒ1:ß½§w祿Q9\^\}xŸ18ƒ¹Ò;#굃ñÅåÕ9}Þƒ[#(ú)$o€{·V†â~e#‚ä(PZ,ÁËMJ4/ÛgFðJÂ8º€“À:ìSÃÓI Ø)¾©x‹Ä!,T¢AÀmÚP¨n—B+ܺۧg Jà©çP¹ãTÎ  w ªÃBà® áY;§?ÆqÛ¶ïGÊÔq³ïÇÆwéüf‘Ýœ“êõ ´ þôÂPÇù¸&UÏIkÃÛÎžÚ åœ +ª[#œõ¬ª\Ë ©¥°ÎˆÜ»#Óúf€Z?, Û¸„a’Aš a–di6 +$éêÛòaÉý}²X¥7,ïa¾\\§«t¹ è+$‹'¸M×#@²Œ†ƒ[mB4EìIJó.ÃàùÛÀ ¢|ˆ­ÆBT¢ ÖdíyP«4’:f#l«%eÔˆpÜu¿þ†ƒ!ÅŒ‘ÏψœŽ¬6ÄT¾ÁV™ç¨h”/#òù&ÊE8Nù|ënÅ´¿/ß¹ÞÂLÕ +_D!ȧ˜M@LûœÎ §ÑdÔð^ýø¨*_Œ ¦A"ôˆ}ݧ/#ïgØ{vr@qÉGYì |[žÕ``Ðy#Ab{¸çÉAÙ„ ^{e¿Þwiš \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c1fb545e84269dd8dbaac082469bffbaa6d444 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c1fb545e84269dd8dbaac082469bffbaa6d444 new file mode 100644 index 000000000..f332a8591 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c1fb545e84269dd8dbaac082469bffbaa6d444 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c3fed90fe5889b753958d174b39142e0bce432 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c3fed90fe5889b753958d174b39142e0bce432 new file mode 100644 index 000000000..575aa53ef Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/c3fed90fe5889b753958d174b39142e0bce432 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/d130088d2f982cac810948f72cf2e741aa7d61 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/d130088d2f982cac810948f72cf2e741aa7d61 new file mode 100644 index 000000000..72023173d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/86/d130088d2f982cac810948f72cf2e741aa7d61 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/727019a0a468481b28939f8604a0be7252b1b3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/727019a0a468481b28939f8604a0be7252b1b3 new file mode 100644 index 000000000..f81bf0f02 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/727019a0a468481b28939f8604a0be7252b1b3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/9e02c3f619b93eae3b9e5fbe10de97daae6ef4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/9e02c3f619b93eae3b9e5fbe10de97daae6ef4 new file mode 100644 index 000000000..bc3f0b017 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/9e02c3f619b93eae3b9e5fbe10de97daae6ef4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/b11b772b5e2f514a21817e1e9276f16aceacbc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/b11b772b5e2f514a21817e1e9276f16aceacbc new file mode 100644 index 000000000..ba0110d29 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/b11b772b5e2f514a21817e1e9276f16aceacbc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/ebda703ef7fc41f779d2ea6ef28510ae9b52ed b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/ebda703ef7fc41f779d2ea6ef28510ae9b52ed new file mode 100644 index 000000000..2fd3a412a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/87/ebda703ef7fc41f779d2ea6ef28510ae9b52ed differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/6a486820271cc12bd6e2aeb481da92d5fbf858 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/6a486820271cc12bd6e2aeb481da92d5fbf858 new file mode 100644 index 000000000..ae0ed9975 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/6a486820271cc12bd6e2aeb481da92d5fbf858 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/fac94c47247d20aa75b9811dfea8eda52873ba b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/fac94c47247d20aa75b9811dfea8eda52873ba new file mode 100644 index 000000000..f1d31cad7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/88/fac94c47247d20aa75b9811dfea8eda52873ba differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/1815088fee2485a24955a74daab5efeecfb9b0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/1815088fee2485a24955a74daab5efeecfb9b0 new file mode 100644 index 000000000..b7c1205cb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/1815088fee2485a24955a74daab5efeecfb9b0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/3d068d23878d95b31186abd11fff8e9e384ed4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/3d068d23878d95b31186abd11fff8e9e384ed4 new file mode 100644 index 000000000..bb1471e75 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/3d068d23878d95b31186abd11fff8e9e384ed4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/e4b21c3aa3891535f2e122382b651340275745 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/e4b21c3aa3891535f2e122382b651340275745 new file mode 100644 index 000000000..de3cb72c8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/89/e4b21c3aa3891535f2e122382b651340275745 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/d721e90177eb4a765b23a31d12123ad8eb18a2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/d721e90177eb4a765b23a31d12123ad8eb18a2 new file mode 100644 index 000000000..146641a31 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/d721e90177eb4a765b23a31d12123ad8eb18a2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/db251a505c2be359720d2abe38a4103ba5cbb7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/db251a505c2be359720d2abe38a4103ba5cbb7 new file mode 100644 index 000000000..c5355bbbd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8a/db251a505c2be359720d2abe38a4103ba5cbb7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/137891791fe96927ad78e64b0aad7bded08bdc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/137891791fe96927ad78e64b0aad7bded08bdc new file mode 100644 index 000000000..9d8f60531 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/137891791fe96927ad78e64b0aad7bded08bdc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/349d50eb4456f09a025758d09d10a7583a647d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/349d50eb4456f09a025758d09d10a7583a647d new file mode 100644 index 000000000..34e89f9a5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/349d50eb4456f09a025758d09d10a7583a647d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/fea0624c68ab29634bbf2695d814d39aab3f81 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/fea0624c68ab29634bbf2695d814d39aab3f81 new file mode 100644 index 000000000..90e163b23 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8b/fea0624c68ab29634bbf2695d814d39aab3f81 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8c/584438c7938c36b76a200ebb62528cab53c52f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8c/584438c7938c36b76a200ebb62528cab53c52f new file mode 100644 index 000000000..08205be9f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8c/584438c7938c36b76a200ebb62528cab53c52f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/47fb6f5606dc41bfa3c9a93d6d323dc68442eb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/47fb6f5606dc41bfa3c9a93d6d323dc68442eb new file mode 100644 index 000000000..6beefea3d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/47fb6f5606dc41bfa3c9a93d6d323dc68442eb @@ -0,0 +1,4 @@ +x…“ßOÛ0€÷Úü§>* °=lC“V +h¨HâÑI®©Eb{þÑPMüï;§´¢}hûîËwwn^ËN?9ý”Ep©ÖšWK g'§_éëØ%‚¤M.X ÌÙ¥ÔôV&¦4ŸyË Kp¢DÝ%+(7œŒàjÃ¥€³ø+í+ )rßN,»Þeè{þ:0Xй6 + ¾à•&*Ç*„J®P ªê†?VC‚¥WªyÃ-³ÝÖÛeØREÔç'¢NÇFi"-4k°•ú).jéʘzƒ¬‰åµ9"ò”Ú~Ÿsýâ‹î' ⿵$OËÑÐjÅ)€ˆÉQwo†IãdI^’ŠH"årš(5£F]K鉤ù†êZ‡ +kà£×Á¿(P™+fÑÈRºõM…-øÛýAô?ÙáL¨í®Ùáaëó>(p6îשÐö’‡¤?h´N ºÜĽìy4xñFÒJòÌVâ~tŸ¼ÁîðHº?ÝEé}@’ýîɾ»¤^ò5q?ºOÞ’ì·H²_nÐ/ÑaMЬ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/793d046f6348632f6d5550f30bd066d053e781 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/793d046f6348632f6d5550f30bd066d053e781 new file mode 100644 index 000000000..1365a66c5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/793d046f6348632f6d5550f30bd066d053e781 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/a2cce9a89f67945c72e4ddcec91949596b5992 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/a2cce9a89f67945c72e4ddcec91949596b5992 new file mode 100644 index 000000000..b489aa76c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/a2cce9a89f67945c72e4ddcec91949596b5992 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/de78bf39d91d2bf6243df8db97a8d0185b5e26 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/de78bf39d91d2bf6243df8db97a8d0185b5e26 new file mode 100644 index 000000000..b245648a8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8d/de78bf39d91d2bf6243df8db97a8d0185b5e26 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8e/4a435a3165447fae95837308ebb47801e89a92 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8e/4a435a3165447fae95837308ebb47801e89a92 new file mode 100644 index 000000000..8b3bedfb3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8e/4a435a3165447fae95837308ebb47801e89a92 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/5a1546830844d49d9b884dca81e5da5426422c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/5a1546830844d49d9b884dca81e5da5426422c new file mode 100644 index 000000000..1ecaecdeb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/5a1546830844d49d9b884dca81e5da5426422c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/6207d3b2e6748f1ef51e6c4250b710189fd041 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/6207d3b2e6748f1ef51e6c4250b710189fd041 new file mode 100644 index 000000000..c090d65c4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/6207d3b2e6748f1ef51e6c4250b710189fd041 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/8666d4c7ae086cf3ee04f68dba27a572895c21 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/8666d4c7ae086cf3ee04f68dba27a572895c21 new file mode 100644 index 000000000..c67930d35 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/8666d4c7ae086cf3ee04f68dba27a572895c21 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/d11b7d0ca213ec4be7cc82e550239aa1d130ac b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/d11b7d0ca213ec4be7cc82e550239aa1d130ac new file mode 100644 index 000000000..77f262ba8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/8f/d11b7d0ca213ec4be7cc82e550239aa1d130ac differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/89fbe542ad98739320481e3e1f120deb30a70a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/89fbe542ad98739320481e3e1f120deb30a70a new file mode 100644 index 000000000..88b30113d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/89fbe542ad98739320481e3e1f120deb30a70a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/a1ab4034bed5a09f6278d3e940fc3453c28fc1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/a1ab4034bed5a09f6278d3e940fc3453c28fc1 new file mode 100644 index 000000000..6fc0c2fbd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/a1ab4034bed5a09f6278d3e940fc3453c28fc1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b75ca88b4b95ab8b67006d309a48d42d369667 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b75ca88b4b95ab8b67006d309a48d42d369667 new file mode 100644 index 000000000..d64fbfda5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b75ca88b4b95ab8b67006d309a48d42d369667 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b7ced8cc3dd3cd9b64d81dee7df8657023f0be b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b7ced8cc3dd3cd9b64d81dee7df8657023f0be new file mode 100644 index 000000000..fb1aa0f98 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/b7ced8cc3dd3cd9b64d81dee7df8657023f0be @@ -0,0 +1 @@ +xŒ9Â0E©ç£©#$((|JDa'Dò¦±ƒ‚PîŽ1†††ßýí n7»Õ¼vœ¢î™ñ¬]´¼Ö7 Ôµ’ÒÁ¥êó=–’pd¨Ñyd;$RGÀ¦BmªðÂ×ày?9Ãòú|Ú7R–Ñ_è›/Ý/!óœÿ¹ž`'Û9€ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/e77a1d0bb8118bddbcc78cabe55730f2739eb4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/e77a1d0bb8118bddbcc78cabe55730f2739eb4 new file mode 100644 index 000000000..dcb19b1b9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/e77a1d0bb8118bddbcc78cabe55730f2739eb4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/ff16c100c7a9d3c82ae4e8583932d51663b613 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/ff16c100c7a9d3c82ae4e8583932d51663b613 new file mode 100644 index 000000000..3323b01ff Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/90/ff16c100c7a9d3c82ae4e8583932d51663b613 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/41695b27a83ddae7d33172186844f97f6e6cf6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/41695b27a83ddae7d33172186844f97f6e6cf6 new file mode 100644 index 000000000..7f2fff2db Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/41695b27a83ddae7d33172186844f97f6e6cf6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/9dde13533bf4a66b608361c4eccfe7dc81f5a2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/9dde13533bf4a66b608361c4eccfe7dc81f5a2 new file mode 100644 index 000000000..d479898c3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/91/9dde13533bf4a66b608361c4eccfe7dc81f5a2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/272d16bb677558e3bae81d3c3cc70da9cb9b0d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/272d16bb677558e3bae81d3c3cc70da9cb9b0d new file mode 100644 index 000000000..0742dc86f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/272d16bb677558e3bae81d3c3cc70da9cb9b0d @@ -0,0 +1 @@ +xŽMjÃ0…»Ö)fZäѯ!”Þ!'GŽieY!äöMCY÷-¼Å÷ø¸–²tÀ€o½‰@d­5‘Ãx/ãSð„ZKJF¦ä ;Ìj£&kĆœ„­¡4±ÓòíDèMÉ1[ËŠ.ý\¤Ëò3Ú»48¾Œ¯}kË:çFE®µ}Ô6Âàz?ÚÁÁAߣø¡û¿°ÿ9R§ÛÊ0UÞ!·Zà©Ô+Ìç÷fÙÕ/ ÑZÜ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/842ad55f62416e39b61b87dfea65d9f09f94cf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/842ad55f62416e39b61b87dfea65d9f09f94cf new file mode 100644 index 000000000..8993b24c7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/842ad55f62416e39b61b87dfea65d9f09f94cf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/90dd5b3b0951ec444fbc88921626e5ff8be76d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/90dd5b3b0951ec444fbc88921626e5ff8be76d new file mode 100644 index 000000000..eda66f18d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/92/90dd5b3b0951ec444fbc88921626e5ff8be76d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/52d22c7ff1b136e24339227587759d23d889de b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/52d22c7ff1b136e24339227587759d23d889de new file mode 100644 index 000000000..8c5a8ba55 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/52d22c7ff1b136e24339227587759d23d889de differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/e29d2b262e680d22e531caf960200343995c5b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/e29d2b262e680d22e531caf960200343995c5b new file mode 100644 index 000000000..6df6e373e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/93/e29d2b262e680d22e531caf960200343995c5b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/0384c97c89effd1bb1fa467aa14f479f21fcfb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/0384c97c89effd1bb1fa467aa14f479f21fcfb new file mode 100644 index 000000000..210ce157d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/0384c97c89effd1bb1fa467aa14f479f21fcfb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/331186e71556c24f728fc894fac4efddff423a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/331186e71556c24f728fc894fac4efddff423a new file mode 100644 index 000000000..6c9c44585 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/331186e71556c24f728fc894fac4efddff423a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/3d8fdb77a6711251a1ec429c58d2414ff84190 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/3d8fdb77a6711251a1ec429c58d2414ff84190 new file mode 100644 index 000000000..b195800a8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/3d8fdb77a6711251a1ec429c58d2414ff84190 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/f5b8b5e40eea9daf9f32eeab34199729e96b48 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/f5b8b5e40eea9daf9f32eeab34199729e96b48 new file mode 100644 index 000000000..e28d79134 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/94/f5b8b5e40eea9daf9f32eeab34199729e96b48 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/30fbeff569b6ab369031d366687d80f7a383db b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/30fbeff569b6ab369031d366687d80f7a383db new file mode 100644 index 000000000..81a351f7d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/30fbeff569b6ab369031d366687d80f7a383db differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/50a0c5a70042eacb564e452c8a0a3adcc0b258 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/50a0c5a70042eacb564e452c8a0a3adcc0b258 new file mode 100644 index 000000000..193b94042 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/50a0c5a70042eacb564e452c8a0a3adcc0b258 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/d4cb61c51208adcf4c59c91b45f76c01a2e905 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/d4cb61c51208adcf4c59c91b45f76c01a2e905 new file mode 100644 index 000000000..a90b296b7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/95/d4cb61c51208adcf4c59c91b45f76c01a2e905 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/200679cda0fdfbe5a2841cbd838ccaf030fc29 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/200679cda0fdfbe5a2841cbd838ccaf030fc29 new file mode 100644 index 000000000..1a32ae2c9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/200679cda0fdfbe5a2841cbd838ccaf030fc29 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/6ca5d7adea099eba5bf0faaac60364be06c08c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/6ca5d7adea099eba5bf0faaac60364be06c08c new file mode 100644 index 000000000..a4f1c5b3b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/6ca5d7adea099eba5bf0faaac60364be06c08c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/87e9ceabe3e4da142e08b44b8c025a0e091800 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/87e9ceabe3e4da142e08b44b8c025a0e091800 new file mode 100644 index 000000000..d628c048e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/96/87e9ceabe3e4da142e08b44b8c025a0e091800 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/2a80c087b11a3afe3d62f62dd40fe507b358ec b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/2a80c087b11a3afe3d62f62dd40fe507b358ec new file mode 100644 index 000000000..d60a39489 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/2a80c087b11a3afe3d62f62dd40fe507b358ec differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/36d06c77df0dc0f8c13a67bb06d92b5feeb04f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/36d06c77df0dc0f8c13a67bb06d92b5feeb04f new file mode 100644 index 000000000..7e50532be Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/36d06c77df0dc0f8c13a67bb06d92b5feeb04f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/e6d27c55495d59d729619d478acc51165df5d5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/e6d27c55495d59d729619d478acc51165df5d5 new file mode 100644 index 000000000..1cbc47551 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/97/e6d27c55495d59d729619d478acc51165df5d5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/4d863c8e51aa864ce0c1d6d92cc9e3b6f5d9d0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/4d863c8e51aa864ce0c1d6d92cc9e3b6f5d9d0 new file mode 100644 index 000000000..91b17b8b6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/4d863c8e51aa864ce0c1d6d92cc9e3b6f5d9d0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/b3de66c2851785874312d58a5f039ead557c3e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/b3de66c2851785874312d58a5f039ead557c3e new file mode 100644 index 000000000..49f06777b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/98/b3de66c2851785874312d58a5f039ead557c3e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/40b2adaf348a24b0f1b63e27dd8758e057ef11 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/40b2adaf348a24b0f1b63e27dd8758e057ef11 new file mode 100644 index 000000000..aa889c2da Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/40b2adaf348a24b0f1b63e27dd8758e057ef11 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/5887775f84bc5f91ec4ed64ed37483a3ff57d9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/5887775f84bc5f91ec4ed64ed37483a3ff57d9 new file mode 100644 index 000000000..b43b3ad38 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/99/5887775f84bc5f91ec4ed64ed37483a3ff57d9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/3795c28d8070420fe42dc9c0f6a8b4874019b4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/3795c28d8070420fe42dc9c0f6a8b4874019b4 new file mode 100644 index 000000000..0083620ff Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/3795c28d8070420fe42dc9c0f6a8b4874019b4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/e8060832421cdee2ccaf56fb298530aeb711df b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/e8060832421cdee2ccaf56fb298530aeb711df new file mode 100644 index 000000000..108d41505 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9a/e8060832421cdee2ccaf56fb298530aeb711df differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9b/bb9b2a49904d3679d250429cdc181d23529403 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9b/bb9b2a49904d3679d250429cdc181d23529403 new file mode 100644 index 000000000..a48bca6b0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9b/bb9b2a49904d3679d250429cdc181d23529403 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/0497373388beebe402b46f69bca61ec07214fa b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/0497373388beebe402b46f69bca61ec07214fa new file mode 100644 index 000000000..1f5afe942 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/0497373388beebe402b46f69bca61ec07214fa differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/27a380c79829e5382bb0d9637008f20ccf8c01 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/27a380c79829e5382bb0d9637008f20ccf8c01 new file mode 100644 index 000000000..d18eda2c2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/27a380c79829e5382bb0d9637008f20ccf8c01 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/3e4cf5534c5a693b8819df7556e7b7bfd35b66 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/3e4cf5534c5a693b8819df7556e7b7bfd35b66 new file mode 100644 index 000000000..ddcc6f56a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/3e4cf5534c5a693b8819df7556e7b7bfd35b66 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/424ec9df18e2659824c7019f41988c70f88c6a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/424ec9df18e2659824c7019f41988c70f88c6a new file mode 100644 index 000000000..86704be79 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/424ec9df18e2659824c7019f41988c70f88c6a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/a56c5205bbee746e006f6147b745c708ce106c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/a56c5205bbee746e006f6147b745c708ce106c new file mode 100644 index 000000000..e9c0c0e9b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/a56c5205bbee746e006f6147b745c708ce106c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/c0ef3adde9fcc573ad3a8e3f45278c72aeb573 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/c0ef3adde9fcc573ad3a8e3f45278c72aeb573 new file mode 100644 index 000000000..a1f58f7fc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/c0ef3adde9fcc573ad3a8e3f45278c72aeb573 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/d9e9c6bd8975e23de1244c240534463af2f6b4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/d9e9c6bd8975e23de1244c240534463af2f6b4 new file mode 100644 index 000000000..6074799ad Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9c/d9e9c6bd8975e23de1244c240534463af2f6b4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9d/18019133cde8b8062e0db39d4674c90f6b1602 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9d/18019133cde8b8062e0db39d4674c90f6b1602 new file mode 100644 index 000000000..71ffeb640 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9d/18019133cde8b8062e0db39d4674c90f6b1602 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9e/0277312a325cbc4425dba3cde8fb2c7abeea52 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9e/0277312a325cbc4425dba3cde8fb2c7abeea52 new file mode 100644 index 000000000..54beb8cb4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9e/0277312a325cbc4425dba3cde8fb2c7abeea52 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/2bcebdadd1e060a00a5b35832594e4ca566642 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/2bcebdadd1e060a00a5b35832594e4ca566642 new file mode 100644 index 000000000..921c45f2b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/2bcebdadd1e060a00a5b35832594e4ca566642 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/36e4ddee55f6350db6cc9ba3ef73be8f15310d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/36e4ddee55f6350db6cc9ba3ef73be8f15310d new file mode 100644 index 000000000..aff8083f6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/9f/36e4ddee55f6350db6cc9ba3ef73be8f15310d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/2d5a3ee287d56a42417204d2e96c6a626b785f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/2d5a3ee287d56a42417204d2e96c6a626b785f new file mode 100644 index 000000000..9e12b1ec0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/2d5a3ee287d56a42417204d2e96c6a626b785f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/8a8f72754cff457f5008798fb8635c66e52f86 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/8a8f72754cff457f5008798fb8635c66e52f86 new file mode 100644 index 000000000..0e883ce52 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/8a8f72754cff457f5008798fb8635c66e52f86 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/e5881c237f5737b74e26f8d3131bacf73ff971 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/e5881c237f5737b74e26f8d3131bacf73ff971 new file mode 100644 index 000000000..b7b36d5e7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a0/e5881c237f5737b74e26f8d3131bacf73ff971 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/84716e16cc2c95c6b9d8dc7af6e08a4d70d2c3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/84716e16cc2c95c6b9d8dc7af6e08a4d70d2c3 new file mode 100644 index 000000000..8573c3e1d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/84716e16cc2c95c6b9d8dc7af6e08a4d70d2c3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/95c5193816d1b077029e3430a3f70a8ad47140 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/95c5193816d1b077029e3430a3f70a8ad47140 new file mode 100644 index 000000000..3bce9bbfa Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/95c5193816d1b077029e3430a3f70a8ad47140 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/ca42ecff57f997db84ed6e5e33118f175adaeb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/ca42ecff57f997db84ed6e5e33118f175adaeb new file mode 100644 index 000000000..bc84b6d26 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a1/ca42ecff57f997db84ed6e5e33118f175adaeb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6515a35a10fbdc5e7ab5b3e94776169f60a84e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6515a35a10fbdc5e7ab5b3e94776169f60a84e new file mode 100644 index 000000000..1460e9777 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6515a35a10fbdc5e7ab5b3e94776169f60a84e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6e72259817d5897f9606afef3cbd6f007359b8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6e72259817d5897f9606afef3cbd6f007359b8 new file mode 100644 index 000000000..18ca02505 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/6e72259817d5897f9606afef3cbd6f007359b8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/81a63716545334bb54da6eee3241c574946bc4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/81a63716545334bb54da6eee3241c574946bc4 new file mode 100644 index 000000000..327961a19 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a2/81a63716545334bb54da6eee3241c574946bc4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a3/ce6e9b0b6a272982af74dae3b04c9901a7c7b6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a3/ce6e9b0b6a272982af74dae3b04c9901a7c7b6 new file mode 100644 index 000000000..993589fc3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a3/ce6e9b0b6a272982af74dae3b04c9901a7c7b6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/260c39a393bf90b16644c08995558a54101f23 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/260c39a393bf90b16644c08995558a54101f23 new file mode 100644 index 000000000..9a16ba3ca Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/260c39a393bf90b16644c08995558a54101f23 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/5140383050be01fc09cb9b4b345b30b45804f5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/5140383050be01fc09cb9b4b345b30b45804f5 new file mode 100644 index 000000000..8df42836a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/5140383050be01fc09cb9b4b345b30b45804f5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/735585717c2a10f2d85a56066c162e3fcbcb0c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/735585717c2a10f2d85a56066c162e3fcbcb0c new file mode 100644 index 000000000..aef424e58 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/735585717c2a10f2d85a56066c162e3fcbcb0c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/78922f5fedecf2e819bea24b410d1bb6c18707 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/78922f5fedecf2e819bea24b410d1bb6c18707 new file mode 100644 index 000000000..65ee4367a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/78922f5fedecf2e819bea24b410d1bb6c18707 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/8cfe7facd8376ac8148e181be7bb996a40cfe1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/8cfe7facd8376ac8148e181be7bb996a40cfe1 new file mode 100644 index 000000000..dc4f4d6ba Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/8cfe7facd8376ac8148e181be7bb996a40cfe1 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/97d28b01c330d1bef287462c876ff2a83cf382 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/97d28b01c330d1bef287462c876ff2a83cf382 new file mode 100644 index 000000000..5515a9f56 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/97d28b01c330d1bef287462c876ff2a83cf382 @@ -0,0 +1,6 @@ +xTïoÓ0åkòWœú©›:‚Á´¬ 1Úi) >:É%5Klc; ÚÿÎ9ɶŒmýÕ?îÝ»wïœV*…¯^¿y6ßaJïŒ(7^>ñú€>oÀmm +É+àÛ(CëáŸeæ#ÏE†ÒbÌÑtA‘æÅ'3ø†Æ +%á%{S:Ž&{‡b§¨ù¤rÐX$ a¡þÎP;2UëJp™!´Âmº<Šg? •:N×9è¨b|¸HƒÿmœÓoçó¶mï3eÊyÕ×cççñât™œë!ꫬÐZ0ø«†*NwÀ5±ÊxJ\+Þvò”éÌ)Ϻ5 YÎÀªÂµÜ §š ëŒHwO´¡ ÒÇH6.a%'8‰’8™yïñúlõu ߣËËh¹ŽOX]Âbµü¯ãÕ’V!Zþ€Ïñòà $£æàom|ÔEáåļÓ.A¯ùmà s¿¶3QˆŒJ“eÃK„RmÑHª4šZXßVKsO©µpÜu[wf5i†¤ó•"¥™Õ† +Ãkl•¹bY¥šœ‘6ÈkæÐ:– +ï©Ã0$¶Ê8øÉ·œ5NTìŒÛÍ®žt»7Û¥I•r,érG}÷<å[¤'CPnÙ©Ü +£dÒ](ë.ŒÊHOE†EgÊ óÑ % Q6ÆÛe„ôÁT¥#ÙÝ.QÉd™ïw#Iøs\ y#Ô{ü®©ŸÜEgÉŠ5Òt T|í {`4vÊø^xkêBÒh/þI׋뛱?„¸â;N¬šš‰OJÓcA©$aÌCݤ4Uœ<ç¡zŒ§HvžD¯­}²ø†ÁñŠ\hDŽa0¤Ø*‘ƒ¾ëÈ(Åô Õïz9 ƒ x`‰›áöíQâ  ¼KhvýD¯ÒŸ˜¹£‘vkå9¼‰- þ|w4¥'.îî.1ݸé¤÷ÿ}Û÷Ž·½ýs,xS¹͗ȹÃÉ &¯,úw3F°½Gî°Ó=_TÀxžŸs릞ÕM'~Îú¶ô:QŽÈîùd×axþǾ*ç \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/aad28b18e02bf5d7971498118089809c21a0e9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/aad28b18e02bf5d7971498118089809c21a0e9 new file mode 100644 index 000000000..b1a37bfb8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a5/aad28b18e02bf5d7971498118089809c21a0e9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/06c72c16f52f130f84f1067be8da2988de99f3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/06c72c16f52f130f84f1067be8da2988de99f3 new file mode 100644 index 000000000..c4f77fc5e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/06c72c16f52f130f84f1067be8da2988de99f3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/7d1c504bfbeb47a9ce14545ed1d17ee2adb071 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/7d1c504bfbeb47a9ce14545ed1d17ee2adb071 new file mode 100644 index 000000000..4d7020f2d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/7d1c504bfbeb47a9ce14545ed1d17ee2adb071 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/d56f4b40c61cf0996157a4490da95fabecf53a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/d56f4b40c61cf0996157a4490da95fabecf53a new file mode 100644 index 000000000..808b7f854 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/d56f4b40c61cf0996157a4490da95fabecf53a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/e933bf96f8ee9e1f29cf4574d481517d615e97 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/e933bf96f8ee9e1f29cf4574d481517d615e97 new file mode 100644 index 000000000..b64c222d4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/e933bf96f8ee9e1f29cf4574d481517d615e97 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/f9f5962bdbecf106df3cca5648240dba4d18b5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/f9f5962bdbecf106df3cca5648240dba4d18b5 new file mode 100644 index 000000000..288c3f660 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a6/f9f5962bdbecf106df3cca5648240dba4d18b5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a7/b0172b8a08d3ab71b5461825cfb43a2547ea10 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a7/b0172b8a08d3ab71b5461825cfb43a2547ea10 new file mode 100644 index 000000000..5fcad9ac7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a7/b0172b8a08d3ab71b5461825cfb43a2547ea10 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/0669804d5cd2221beee37874c3282292fe99d6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/0669804d5cd2221beee37874c3282292fe99d6 new file mode 100644 index 000000000..1b662db7b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/0669804d5cd2221beee37874c3282292fe99d6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/23e8815611fea9a4c2fb4dfec0cbc41aacab58 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/23e8815611fea9a4c2fb4dfec0cbc41aacab58 new file mode 100644 index 000000000..cb7c5a294 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/23e8815611fea9a4c2fb4dfec0cbc41aacab58 @@ -0,0 +1,7 @@ +xµWQS7îëùWlxèœGNÒ‡¦Pp&ž$vC2i§òY¶UΧ«N‡ñdøïýV§3Ú„)Àï¤ýv÷ÛO«õ$5úéç—Ï~è>nÑc:4ùÚêùÂÑ‹gÏ_>ÅŸ_È-¼Ô™LI–na,žÃ§BÀŒ-?èDe…šR™M•õF½\&° +ú¤l¡MF/Ä3Šu',í´wbmJZÊ5eÆQY(`è‚f:U¤.•;Ò%f™§Zf‰¢•v ï' p$ô%`˜‰“Ø.a¯ÉÌšIº4ñϹüU·»Z­„ô cçݴʧè~ö‡ãþSD¬N³TYõw©-2ž¬Iæˆ*‘Ěʕ§gnÖœá¨WV;Í;T˜™[I«8Ô©.œÕ“Ò]#-$CH½¹´ÉŒvzcŒwèMo<wäóàäÝèô„>÷Ž{ÓAL£c: 'ƒÑOo©7üBïã)P†â¨‹Ür¨¢f:ÕÔs7VÌù¦`4Ã:?¹JôL'H-›—r®hnΕÍåÊ.uÁe-à”CJõR;éü«+14ŠÔmµÀóiQäH3+—jeì™HRSN¸Qr)“Íô|·ÕB Æ:úKžKQ:Š:;SÓw²X|”ùîíuÿ¶~½ÍÏÄÇøN]8‘[ƒTœV…8ô.KëSømóþÎà-›¦Êö¦çº0vãô[Ð'€Œ7ìùa ! ‡«¢LKÿû£8—©žz¾Å§ÍÇáß…\˜Ò&jk9×Cˆío]ދ̇H Ë4ýWʼÌMšª„OÓ)T_Ü }›Fi«ûØwèƒÐ»G©šÓï üÌ”ÅÙšÏìA¡¹¥¾Ïñ„ßn+/'èh”¤‚K3Ú”¿ò­C-Uæ +\|JÿÑ¡¯­V„ó~.Bkççto›½¯Qg+´_ÚÇ]‘çˆ +œÜÄ "2ö?)♈¶%·¢(úþ(ätŠæn +0|<<¨KL›`®Biƒ‡(âûMÔIÐkÊÔ +—e£«ííǸolyéâ; fïTõz#T%,vÚ>¼èÛ¦j&Ñ'vÚ>&=£øÑ q +]ô—¹[Ç·9iW)_Ï™Sé¥é¶íœöeú¦„ éêó#0†cD¿zâ6,£ä_»$Š^ÝÞs —PÎÁw™ÕSUšo4±¯ãæ&2„*§æ⢺ B1CLGc&”}“Fà¥ETDZw²ïO7 +þl²±“öžâS†¶V•×+½6„Õ\¹ŽCÝùÚÁ\äÿ÷wËBÙ¹c"-àU:à1;ôrˆ"Dso(OíÀTÝ4œ æh €!`&IÙ¸-˜â&V‡ª ‚'婢âÞ q­/ÔÚ*WÚ,˜žûµÐ)PRÅÁaÅ:ÃO¥’êo°ç BEͼç7 wêÐ+ ¶eIÕÈê–wç©»IìÝ=å¾ð|£éˆ~æìúaM”^aÔk´#O‹@s·ë±'Ê{¼G&3k–(ghg\Î÷j]µ¯{¬0_³©*T1Ê‚ŠEîáv*œ±£Y%¹ªj?þèÏΆår¢°ÜO«+)nÓ>yã­‹¹ÜÍsS˯1ÇWJñœë ßEøÝøô„žïbÏž°Š6Ú'¶=y²9%×½6žðÝ$W:—ŸB¢±îât8x;:þX+úš”0> ¯Õ+©r :åQ!Â<€±÷-±¨ëYà[L’–<ø¯q³,ت»é  HÙ™ÄDQ߉Ðúà·-2?ªúOÌóü²õ6r \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/59349a86e0149def3f1b06e6514a70eca1ed2f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/59349a86e0149def3f1b06e6514a70eca1ed2f new file mode 100644 index 000000000..3f4ec2385 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/59349a86e0149def3f1b06e6514a70eca1ed2f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/72e5a0a255e248d53dce5635b03e9eb81b8fa4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/72e5a0a255e248d53dce5635b03e9eb81b8fa4 new file mode 100644 index 000000000..5e6c6d974 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/72e5a0a255e248d53dce5635b03e9eb81b8fa4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/786ee7d502e3e0beb154d99ce3188194423d51 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/786ee7d502e3e0beb154d99ce3188194423d51 new file mode 100644 index 000000000..47465ff44 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/786ee7d502e3e0beb154d99ce3188194423d51 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/cbf7779458bf72b4e2439136299c33eb797697 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/cbf7779458bf72b4e2439136299c33eb797697 new file mode 100644 index 000000000..75e416c33 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/cbf7779458bf72b4e2439136299c33eb797697 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/db2e11dd62358b39752ef3a4808cfc7b4baf43 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/db2e11dd62358b39752ef3a4808cfc7b4baf43 new file mode 100644 index 000000000..b48ccf4ef Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a8/db2e11dd62358b39752ef3a4808cfc7b4baf43 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/00cd5678d3688af2f5f5c52507ffb507b104b8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/00cd5678d3688af2f5f5c52507ffb507b104b8 new file mode 100644 index 000000000..d5495447f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/00cd5678d3688af2f5f5c52507ffb507b104b8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/31fd6547120cb2fc1bfcb8b98c3b655829f2f3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/31fd6547120cb2fc1bfcb8b98c3b655829f2f3 new file mode 100644 index 000000000..4289272ef Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/31fd6547120cb2fc1bfcb8b98c3b655829f2f3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/488800388c3bff844ebddf4e38c9a32eb1c259 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/488800388c3bff844ebddf4e38c9a32eb1c259 new file mode 100644 index 000000000..aeb567378 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/488800388c3bff844ebddf4e38c9a32eb1c259 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/60a1c873d6fd156386701b37b8f6046c19ad21 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/60a1c873d6fd156386701b37b8f6046c19ad21 new file mode 100644 index 000000000..e06490342 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/a9/60a1c873d6fd156386701b37b8f6046c19ad21 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/aa/856db602ade7b601f495f14a01b30ad9a87bb0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/aa/856db602ade7b601f495f14a01b30ad9a87bb0 new file mode 100644 index 000000000..a2a8ba69c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/aa/856db602ade7b601f495f14a01b30ad9a87bb0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/4144910c8ce28ac6223a9c66482d74c238b8f7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/4144910c8ce28ac6223a9c66482d74c238b8f7 new file mode 100644 index 000000000..0152c6a06 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/4144910c8ce28ac6223a9c66482d74c238b8f7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/7c4c4f2a9a0837acd0738148a0181f768f211e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/7c4c4f2a9a0837acd0738148a0181f768f211e new file mode 100644 index 000000000..79b5361ac Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/7c4c4f2a9a0837acd0738148a0181f768f211e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/ce235280129ae1627c2201f7ad3835a7a3b4dc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/ce235280129ae1627c2201f7ad3835a7a3b4dc new file mode 100644 index 000000000..b8535e325 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ab/ce235280129ae1627c2201f7ad3835a7a3b4dc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9518dfd7c8b30c30b215db77e6fa667baec165 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9518dfd7c8b30c30b215db77e6fa667baec165 new file mode 100644 index 000000000..e77c5970a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9518dfd7c8b30c30b215db77e6fa667baec165 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9ad7f950bebdbdbd42d531de38b0eb70f5e236 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9ad7f950bebdbdbd42d531de38b0eb70f5e236 new file mode 100644 index 000000000..bd41b748f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/9ad7f950bebdbdbd42d531de38b0eb70f5e236 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/a260cc11434349cf7e416cc9493f4b817edeac b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/a260cc11434349cf7e416cc9493f4b817edeac new file mode 100644 index 000000000..b6b0760c3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ac/a260cc11434349cf7e416cc9493f4b817edeac differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/7072769ecc3f317c201d81dc0d9a871fb90c08 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/7072769ecc3f317c201d81dc0d9a871fb90c08 new file mode 100644 index 000000000..36d064959 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/7072769ecc3f317c201d81dc0d9a871fb90c08 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/bc8dc36428a32e6cc6c9dd4032bb678e93e6ec b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/bc8dc36428a32e6cc6c9dd4032bb678e93e6ec new file mode 100644 index 000000000..0f4e5a53b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ad/bc8dc36428a32e6cc6c9dd4032bb678e93e6ec differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/16251780600735497451a039b11e42879b060a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/16251780600735497451a039b11e42879b060a new file mode 100644 index 000000000..ff7c1c808 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/16251780600735497451a039b11e42879b060a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/3205c2cb9fd108052304bfd162b1f3d8cd08ad b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/3205c2cb9fd108052304bfd162b1f3d8cd08ad new file mode 100644 index 000000000..e01fb6b53 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/3205c2cb9fd108052304bfd162b1f3d8cd08ad differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/eb3f6db9844ee328d9e9159d00a0c135e10d67 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/eb3f6db9844ee328d9e9159d00a0c135e10d67 new file mode 100644 index 000000000..013bc543a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ae/eb3f6db9844ee328d9e9159d00a0c135e10d67 @@ -0,0 +1,4 @@ +xµUMsÛ6íUü;êEòØTâK§µ«Z±Ý)§Ž”šr=i§\I¨ €Å‡hGÿ½ r”¤“öR>H»xïíÛU%uç¯Î_5:Éà®u³3b¹rôðõ7gôõ-¸‚¦‡B1 Ì»•6tŸ®lNa!òNpTkðªFƒ& ã›VNáW4Vhçù+„¬ý´Ô^„;íaÃv ´o‘r !ð‰cã@(àzÓHÁGh…[ÅsR–€Þ§ºrŒ¶3 +hv Ç¹Âkå\óÝhÔ¶mÎ"â\›åHv|ì许¾–·g„:E=(‰Ö‚Á¿¼0ĸÚkga•¬ò, ÒšÓuk„jy +V/\Ë ¨µ°ÎˆÊ»DKd€¨o Ù˜‚þ¤„¢ìÛIY”§!Éc1ÿiö0‡ÇÉýýd:/nK˜ÝÃõlzSÌ‹Ù”î~„Éô=ü\LoNI2*>5&0 *Š 'ÖQ»ƒæ/ƒ­‡{Û  Á‰šZz¶DXê-EŒ A³6”ÕÀ:@’b#sñÑ3i”e¤ó:$"¥sÛÊ´0lƒ­6ëœKí뜴A¶É+ì”;´î"Ë­6þd[–{'dεâÞT.#5_S¢_DµÏ¿»ßÇ•*áÕá›×/Ò}û0SzgDÓ:8:|õö€>ïÀµŠ.…äpïZeèÿÙ„`y.*”kð²F3€2Í+ÂÆʾ£±BI8JaºŽci¼wZ씇-ßT¼Eê!,¬E‡€·jBB¥¶º\V½pí0'v Là:öP¥ãôœ@ï@­Ÿ>î"i¿Ö9ý>Mû¾OøÀ8Q¦I»=6=ÏgóE1? Öu);´ þôÂâr\«Š—ĵãý`OcjNÖ½NÈf +V­]Ï ªµ°ÎˆÒ»¿L‹b€¤?}@¶q 㬀¼ÃiVäÅ44¹ÊW_–—+¸Ê..²Å*Ÿ°¼€Ùrq–¯òå‚NŸ![\Ã×|q6$Ë(¼Õ&( E°ëÁ»ƒçƒ5ÕÃÙj¬ÄZT$M6ž7ºA#Ih4[aC¬–ÖR'¶Âq7\=.ÓRÆÈçMhDN'Vê´6|‹½2›¤ê”¯òù6)EX§cƈ¨2îy€’o]’=„&ÏoP:B¥ûÃrþ[¡­ +Áõ-J$%ƒÊ ô”¦Ud~Eã)™ßë}Ùa?ZoøFy»ÙÁ'V„}|\—–œÌJ’À+÷œ&©³–K‰]˜€†P);)¼r¸âƒ—v2¶hïÆ{Lû’ö ªŽSL‘ÕìÑ "t(k ÿ)»clÑÏà&ñîç´‹ÞT¸wl4²ž‚œÄ›c6ºgìžýæR_ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b5/2eee7c670a18630d4d04f191d370ced6c82dc8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b5/2eee7c670a18630d4d04f191d370ced6c82dc8 new file mode 100644 index 000000000..953f82b6f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b5/2eee7c670a18630d4d04f191d370ced6c82dc8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/369b9668ef737bfb885c72ba715776b909b8cc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/369b9668ef737bfb885c72ba715776b909b8cc new file mode 100644 index 000000000..8b6a0a5eb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/369b9668ef737bfb885c72ba715776b909b8cc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/84471ade4cc9da3aef75f6105c6424423ba616 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/84471ade4cc9da3aef75f6105c6424423ba616 new file mode 100644 index 000000000..9f7b33f3b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b6/84471ade4cc9da3aef75f6105c6424423ba616 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/cc3593653cded64341d6edbae9ebe6ddfb19fc b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/cc3593653cded64341d6edbae9ebe6ddfb19fc new file mode 100644 index 000000000..cfee24e53 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/cc3593653cded64341d6edbae9ebe6ddfb19fc differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/ff8f03ae44ee4dc115d063625d1dda3cf074fe b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/ff8f03ae44ee4dc115d063625d1dda3cf074fe new file mode 100644 index 000000000..f9ec85431 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b7/ff8f03ae44ee4dc115d063625d1dda3cf074fe @@ -0,0 +1,9 @@ +xW[s7î+ü + OKŠ…“LÚ™:N i˜:Øc žôMì +½+mt1¦ü÷~ÒŠ«qêÆ3fÐåóï\˜äjB^¿yóú§ö‹:yAºª\j1›[òêøå/Gøø•Ø9' +›B²œ0gçJc¿ +1/y!R. ψ“×A¨S²²ñ¤E>sm„’ä=&‰×ÚˆGæ‰W±TŽlI¤²Ä©È9á)/-’¤ª(sÁdÊÉBØyx'jñ–/Q‡šX†ë å’¨éöEÂl4šø¿¹µåoíöb± ,XL•žµóÊÓ¾èw{ƒaïVG©±Ì¹1Dó¯NhxÎà ù³?xß"!8ü¡ÔÞDQx8y°rù:`dŠs¿6%OÅT¤pMΛq2S÷\KxDJ® a|X ̼I¹(„e6lmÈ°¤v½œï¼" MM©¡iªYÁJßÑ4W.£À†³‚â#µâžŸÔë0UiKnÙ=£ÎŠœvUžsœ*yò½Csàt<î¿?°*™:­¹´t$ +>–Ânö¶Þ:lÑ7Øöl¶µ“’kzíä p~ò‚¡WÌ;k¹ól}/¸ª4M•æ´t“\‹~ÈÝîû€M”²tPìT<ÜÁäÆA¹¬àñTÌ^ìIOßöºq/ÄpmÞ!5;¡b‰d¢®s!3Dö‡4ôeév1Þ÷ú©·/ýQÑaàÜò’#Ï·»@21ÔǽÒ*ÅBýa 2Ñ  B¬?E<û¿´(iùƒET! áÛÄÛþø»^l /?ó¶q¥ÏÅ•½çNä0ÃRãy†ìfÆpmo+V³RÐNØðÕVg£9óYÖ~úÈYì0Ÿ˜Îs5Sü^¤ÂW–ÕY?gK†Êã +t¤?T‰ÎdçLn_¹ÌùŒü5wšÝ)gî–8k×Ïbj&; ˆ‚Cšõr) +²K…ëX…++‚uôÌø:`È?õz ÉwÏ,']¯êíïïÐv|P¾բîçkMijâ­ZÍ·EÀ¹ÖONw_«}Ãg».®WfmKŒÑ¦ˆz³ƒƒ¡LR=§¹uZb2X[C ¨žs«¤O•$€£´cŸˆìI½2iÖø¾sÃBï0IÃI éÏÍ †Ñ°{%2b¸Ì"5;2ûÌr‘椲Áõëå=žúÐéÑÒjaàÊ­v%šm/  `@s±¤\ånhÈ­·çœÉd}kãR ÁÞD{Å–¹bsžçŠ4ÈÏÄ÷ªÑUá¿&MjU%“`Þ©3±VPá‹ν·It7æõSNTŸl=|«AÂ~ä ¹—4‚ÓÒŽ–%o´HÛß.s A&øMšáÙ¨]cH±Wt¸¿/³…Bm +IR½ß¤Â ”8¾AÚ®xF˜ÞWÇò‘Ú—¨3]fx…^H-Ïôušú¾Ç}dO¤Þ.ßžG]à÷¨¡SÌ›w“¿@l¡Êb&\Úž{`+Œô¶x <4÷ìtÊrz¬…ª;­Ô·"ðÚT¬ô…Ý0Ðêt‹SÏSQ…ñ)!0Odø*…}¢Äï òqX•“j`‰CÆ£ E |b¢Y‡.šªà?]ÅB¯íÖn˜kz €æ)Ç”šœlÓ\GM⇸U"àÒ½ð¬j&[æ×CÔŒëŠU5®EHC©ŠhÁʤ GïH±Ç×€¯g,þ¿Õÿ«ìU \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/37916531e5b620fc822c507f9111332bafdb9d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/37916531e5b620fc822c507f9111332bafdb9d new file mode 100644 index 000000000..17a8b64e9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/37916531e5b620fc822c507f9111332bafdb9d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/385092eb3207b8b6336869a280f2277daef4c0 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/385092eb3207b8b6336869a280f2277daef4c0 new file mode 100644 index 000000000..db20063e2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/385092eb3207b8b6336869a280f2277daef4c0 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/3ae523d33339e3ece14071e03e34d40fcef5ba b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/3ae523d33339e3ece14071e03e34d40fcef5ba new file mode 100644 index 000000000..563fb0bd6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/3ae523d33339e3ece14071e03e34d40fcef5ba differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/5ef42fed20d5100ad9c30ed3bbf22026d2f750 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/5ef42fed20d5100ad9c30ed3bbf22026d2f750 new file mode 100644 index 000000000..b9bc22909 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/5ef42fed20d5100ad9c30ed3bbf22026d2f750 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/90fea7cd858a5f60fa423b3ee75a4a819a57e4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/90fea7cd858a5f60fa423b3ee75a4a819a57e4 new file mode 100644 index 000000000..21910db2d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/90fea7cd858a5f60fa423b3ee75a4a819a57e4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/d48f6a72b38d7559c193e8f5bb12cd31e3ef6c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/d48f6a72b38d7559c193e8f5bb12cd31e3ef6c new file mode 100644 index 000000000..8ec2cc693 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b8/d48f6a72b38d7559c193e8f5bb12cd31e3ef6c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3b742a457e419c6e68d47a3b1fc07323fc7425 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3b742a457e419c6e68d47a3b1fc07323fc7425 new file mode 100644 index 000000000..8cef5d899 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3b742a457e419c6e68d47a3b1fc07323fc7425 @@ -0,0 +1 @@ +xmTMoÛ0 ݹ¿BÀ.ÛPÇé×Z¤—¢Ã°Û6 »ì4Pc«ÑG&ÉIƒ¡ÿ}”mÊi»hÀ'’~~|”4^Š³ËËë7õgg‡JA؈ԡEñ¡>!ô"*á]ÅOo}~/¾ê¶Kâsþ¯1Ž+Ñ¥´«ºnuêz¹h¼­u„ à®î(Ùä‚ÅC¬%½¸¶†:†¦Žé`0Öiê_¹œXáÔÑÄ8ѹƒ>u>¬Ýì%hƒ‡|”©~òÖ¢K9\tæ!VD §Sø§÷ Åß!or·êR-AÞž< õ?èCKí‚i‹Ú­„Õ«h9Ç-ù 6ɇJ+>.Pc FF¶ø¸åHQYÒ$÷1Íõ–×À4¿pí0žñ+]o%n {mÒoížÅÚUÇÜŒ&ÝÁpJ:l í-°…›Åœe´ÛcšM|ïùò\æ'ÃìÔõ =çÙæwíJ,WhƣȠD‡—®½KÕ°U+¡Ýe’ÐÞ̇¼ý°hDÐ5¤ÝYTDl†Sâ]ec•W˜–Ê¥@›KÔIƾÞ7R๜Ï aÌÐìPNâÑGÎqÙf²#ç¶ÅÔ|<š—#ÞŽÙè³ùJ<ÙãqI8š¯1üMc*w\”t­äŸQ‰â¡*û( ñŸ™dø‰®©¬¡ÏY \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3f2f6845b1c999a34c28b9e4c2be8ab786b8a9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3f2f6845b1c999a34c28b9e4c2be8ab786b8a9 new file mode 100644 index 000000000..ede92c56e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/3f2f6845b1c999a34c28b9e4c2be8ab786b8a9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/5c5df429b27545adba6a3b673964f500abd065 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/5c5df429b27545adba6a3b673964f500abd065 new file mode 100644 index 000000000..7c788b839 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/b9/5c5df429b27545adba6a3b673964f500abd065 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/21764d01dd67d96a586dd145bfdfb7aebeff84 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/21764d01dd67d96a586dd145bfdfb7aebeff84 new file mode 100644 index 000000000..f6d009921 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/21764d01dd67d96a586dd145bfdfb7aebeff84 @@ -0,0 +1,8 @@ +x­UÛnÛFÍ«ø=Ɇ²Š Aꦈ,;)G*L§‡äÚšÚeö"Zò泌‹å NƒèAÚÛœ=sÎÌ*­t +'¯N_=Gp ]¯Œ,çN_œüöœ¿^‚›h^” ++@ïæÚð¼YÁa!òJf¤,åàUN¦ טql·3„ÈX©œŠ0¨ýn«t VÚÃW ´o‰1¤…BVtŸQí@*Èô¢®$ªŒ ‘nÞÞÓ¡&pÛaèÔ!G¨W ‹ýƒ€®# á3w®þ}4jšF`ËXhSŽªu>vtO.§ÉåsfÝE}TY †>{i8ãtX3« SæZaÓÊSâ=§ëÆH'U9« × ¡@5—Ö™z÷@´.àÔ÷°l¨ ?N Núp>Nâd@>Å7Í>ÞÀ§ñõõxz_&0»†Élzßij)ÏÞÂxz ïãéň%csè¾6!vQ9)oµK(h¾5 +Þs[S& ™qjªôX”zIFqFP“YHlµL0”*¹]»´+†=“FQÄ:ß VZØÚ0RapA6w"«´ÏkC¸™V…,Ï¢ˆ‰jãà_\¢ðNVâŠåÛ­ (%TV˜9mV×UKKŒ½ÓM°îlûÍp­à`Ž×½^sñ†©¼ÖR9qÙ ⤥êM{Ç ?@Ü qáÈü_f—s•i%˜Èz„ÕL³¿jR¡µßÅz`]*™†*Å9ÿ†Îø¹HFHÈ,¹<~kÏdήSe76}B+G÷lù®PžE?Œ9(ƒhtܾo¸‚ÂËyK‚dE&´Êfñé-œëR§*÷÷â +WÈmæüü¾Ó5?ÃŽÓâ#£è̓ËÚéÎù֮ⶂ×в·¢íå¿Ñ­t€„cwöpßryðÁÇ¥<Øàìo­[± ª}Êï'´38tíQ|‰¢^{OÛŃíKü +¬,E=~S–Ü·üçcÝ› +ü0Çš{‹«˜B|r}õá¥ÔÎAºͦ8Iêíðˆ™õz†œ7 +5Rá¿=iņÅp?ô,ê}¢¯Ñ · \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/5fc37e39546597c26ee3d9b0bd297b7493525f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/5fc37e39546597c26ee3d9b0bd297b7493525f new file mode 100644 index 000000000..54162dbd5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/5fc37e39546597c26ee3d9b0bd297b7493525f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/ab08ae183e6c23abb48c3e79c9764b947e9253 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/ab08ae183e6c23abb48c3e79c9764b947e9253 new file mode 100644 index 000000000..cf8e648cc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ba/ab08ae183e6c23abb48c3e79c9764b947e9253 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/859e9579cca1541fb7548dc3e35061acf4e7ac b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/859e9579cca1541fb7548dc3e35061acf4e7ac new file mode 100644 index 000000000..23fe2ea66 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/859e9579cca1541fb7548dc3e35061acf4e7ac differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/b7998bbc456ce8e7c43fd26e77fb2c8e10723a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/b7998bbc456ce8e7c43fd26e77fb2c8e10723a new file mode 100644 index 000000000..0b6f4b960 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/b7998bbc456ce8e7c43fd26e77fb2c8e10723a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/ce0cf915081aa45a323511ac425ec1cfd06a0c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/ce0cf915081aa45a323511ac425ec1cfd06a0c new file mode 100644 index 000000000..318e23abb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bb/ce0cf915081aa45a323511ac425ec1cfd06a0c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/be093410906a0ed859cc6933db7473d17c0002 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/be093410906a0ed859cc6933db7473d17c0002 new file mode 100644 index 000000000..fbb697f88 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/be093410906a0ed859cc6933db7473d17c0002 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/fc9a4614bb52aa77ac9be3fae3721ae0daeabe b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/fc9a4614bb52aa77ac9be3fae3721ae0daeabe new file mode 100644 index 000000000..a7f40ed66 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bd/fc9a4614bb52aa77ac9be3fae3721ae0daeabe differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/2e4280938a21395225aa7fee3530fb249ca99e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/2e4280938a21395225aa7fee3530fb249ca99e new file mode 100644 index 000000000..c19ce763c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/2e4280938a21395225aa7fee3530fb249ca99e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/40ce5ef6df5714a888ba60197befab99511216 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/40ce5ef6df5714a888ba60197befab99511216 new file mode 100644 index 000000000..07fe452b7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/40ce5ef6df5714a888ba60197befab99511216 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/67cc7d44193d027c2c9f5d436d6951fa864e6f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/67cc7d44193d027c2c9f5d436d6951fa864e6f new file mode 100644 index 000000000..864afe03f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/be/67cc7d44193d027c2c9f5d436d6951fa864e6f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/17886fc854384a7ce05a85f65a5404b534439a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/17886fc854384a7ce05a85f65a5404b534439a new file mode 100644 index 000000000..0ea9ac911 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/17886fc854384a7ce05a85f65a5404b534439a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/d7d1a56c3751b372578c026b0224f43dfc2c0e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/d7d1a56c3751b372578c026b0224f43dfc2c0e new file mode 100644 index 000000000..0f64be5e7 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/bf/d7d1a56c3751b372578c026b0224f43dfc2c0e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c0/903a98e75cd4a3e97529379d0a9bd366c40553 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c0/903a98e75cd4a3e97529379d0a9bd366c40553 new file mode 100644 index 000000000..e66ccc194 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c0/903a98e75cd4a3e97529379d0a9bd366c40553 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c1/4041bce0cb59da08cfa7e8c724a687348f7258 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c1/4041bce0cb59da08cfa7e8c724a687348f7258 new file mode 100644 index 000000000..17542596e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c1/4041bce0cb59da08cfa7e8c724a687348f7258 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/57713bd0d723d59314db6fce66394c483a6b4b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/57713bd0d723d59314db6fce66394c483a6b4b new file mode 100644 index 000000000..25b6141db Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/57713bd0d723d59314db6fce66394c483a6b4b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/7be1a05f2b92a9fa5388fd78446aabc802f232 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/7be1a05f2b92a9fa5388fd78446aabc802f232 new file mode 100644 index 000000000..d7ca6e66f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/7be1a05f2b92a9fa5388fd78446aabc802f232 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/f2fc7b912ebe2a624b787db50337d4659a16d7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/f2fc7b912ebe2a624b787db50337d4659a16d7 new file mode 100644 index 000000000..e6d27a179 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c2/f2fc7b912ebe2a624b787db50337d4659a16d7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/3a6a6fdaa833b8d76eb4ff2fdc041603376dfd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/3a6a6fdaa833b8d76eb4ff2fdc041603376dfd new file mode 100644 index 000000000..b09be11a9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/3a6a6fdaa833b8d76eb4ff2fdc041603376dfd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/55fdd7300afc7e9a54aefd2cf50c8f33e11e53 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/55fdd7300afc7e9a54aefd2cf50c8f33e11e53 new file mode 100644 index 000000000..c6f483954 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/55fdd7300afc7e9a54aefd2cf50c8f33e11e53 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/c82ac70bd70156295ca3c3c36b2f38849b5a01 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/c82ac70bd70156295ca3c3c36b2f38849b5a01 new file mode 100644 index 000000000..deb2081d1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/c82ac70bd70156295ca3c3c36b2f38849b5a01 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/e65915b813aa95facabe02eb0c44e76ba2eef4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/e65915b813aa95facabe02eb0c44e76ba2eef4 new file mode 100644 index 000000000..e3c70f7e5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/e65915b813aa95facabe02eb0c44e76ba2eef4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/efca1ca16e0be1f54480c41f1d19b1a77432f5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/efca1ca16e0be1f54480c41f1d19b1a77432f5 new file mode 100644 index 000000000..befca8716 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c3/efca1ca16e0be1f54480c41f1d19b1a77432f5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/0db720b2764e2501bbfc520e51a1f7874f5ad3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/0db720b2764e2501bbfc520e51a1f7874f5ad3 new file mode 100644 index 000000000..61ce02b7c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/0db720b2764e2501bbfc520e51a1f7874f5ad3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/5f5bd3bd1bfbb3fac374ba7a540590096bf7bf b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/5f5bd3bd1bfbb3fac374ba7a540590096bf7bf new file mode 100644 index 000000000..89a47324b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/5f5bd3bd1bfbb3fac374ba7a540590096bf7bf differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/fff8f5e98886699bdf98d2c5719241082f18ca b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/fff8f5e98886699bdf98d2c5719241082f18ca new file mode 100644 index 000000000..cc600a2dd Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c4/fff8f5e98886699bdf98d2c5719241082f18ca differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/03dadc8968f672ac9823e2d2648d58e6f0fa7b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/03dadc8968f672ac9823e2d2648d58e6f0fa7b new file mode 100644 index 000000000..873a0a12a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/03dadc8968f672ac9823e2d2648d58e6f0fa7b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/7d7386d49794ff03e4735e54302c754009cf45 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/7d7386d49794ff03e4735e54302c754009cf45 new file mode 100644 index 000000000..f557ed0fc Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/7d7386d49794ff03e4735e54302c754009cf45 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/bfaf03692197b127d94636f79a50775822f360 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/bfaf03692197b127d94636f79a50775822f360 new file mode 100644 index 000000000..58461390e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/bfaf03692197b127d94636f79a50775822f360 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/ed31907548f871dcdafeeec4e82c1161c02b5f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/ed31907548f871dcdafeeec4e82c1161c02b5f new file mode 100644 index 000000000..62d1ff627 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c5/ed31907548f871dcdafeeec4e82c1161c02b5f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/4e9d013fb8e2a14ba82d53fe6ea9c15ec90ae3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/4e9d013fb8e2a14ba82d53fe6ea9c15ec90ae3 new file mode 100644 index 000000000..9452b9e67 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/4e9d013fb8e2a14ba82d53fe6ea9c15ec90ae3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/81b4fc9ca20d5c4a84419bebe23d5e07235af9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/81b4fc9ca20d5c4a84419bebe23d5e07235af9 new file mode 100644 index 000000000..4bc8f5c80 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/81b4fc9ca20d5c4a84419bebe23d5e07235af9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/b520feaedf65830ecd95c377bf77b49d8eca46 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/b520feaedf65830ecd95c377bf77b49d8eca46 new file mode 100644 index 000000000..d8d261835 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/c6/b520feaedf65830ecd95c377bf77b49d8eca46 @@ -0,0 +1,2 @@ +x…SÑNÛ0ÝkýW}T’ÂÃ$èKCé´h¨•šBÓç&µšØ™í¢‰ßume -Qßs|ι×i©S¸˜^]| +ÏœÁBבÅÎÁåôâó9½®Àí4ý”Š—À·Ó†Ö× æ‘wR ²˜A£24=(ª¹ ì°34Vj—ÁN<ëxØŸÎÞ=|Žiùr=¬׋¡ +]ó“rR¡Ê!ª—ªñX.ž¼ÂÆ4—SC»Bä•¡ª=ó +JK ª4ï<˜ó§EЯø„XIÑCÅE2o¡ªüKKàÊT:ù[…Ì=zDýUå3›âš?O ¡Ê‚KŠRs}2‹KRóR‹ ¦2¤/´9½©éç¦÷G×3_ž7ÿüG復Åʼné©Îùye©E%0m6ÒóíºÝJ•²V„÷°ýy´‰?E[PjIQeHjnANbI*Ī’ôõ+šô}*¿,2´¼½sã‚­¾ ÷-É}¸ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/83de010d33c10058bcf4a1d33c049deb178c68 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/83de010d33c10058bcf4a1d33c049deb178c68 new file mode 100644 index 000000000..d0c10a734 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/83de010d33c10058bcf4a1d33c049deb178c68 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/a1b68243cd74f98af4d155cea153ef3d3dc253 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/a1b68243cd74f98af4d155cea153ef3d3dc253 new file mode 100644 index 000000000..c30f2d3f4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/a1b68243cd74f98af4d155cea153ef3d3dc253 @@ -0,0 +1,7 @@ +x½TMoÓ@åì_1—XgmÇqRU¨'¤J- Äa¼;ŽW±½f?šäß3¶Ó ' 'kžçãÍ{c+Û÷&@¶Î‹WÁļƢÌvH¸®HoeYVu†mk½Ó˜c±Þ`µNFt4Øåy•ëlS×›ª*Ë-5Ò6Ó…*”ª¤ÆªwµÔ ÆÐZ£3ÃîGr8¾õ3 츋žœOëhìÎéÞ„6Ö©²ý{ÈÊ2¯ÊLn%YJ™0Êä9¸ïhßÚèð`£?œáÖ2rç£ +=¥ÆÎå…”Y&sx+3.O¾<|‚áÇ$ùÜKGˆ£Æ@8ÁC°0:jxJh ÚFÏ€ VÙ.…ÒÆ‘ +Xl€Æv=’ž +ñÉ ¨”Ñ,vÝè4â ' Ì4v˜qÏêpÌuóÔ+“R +¦è§\œPxÎq×i’¼†ætIçè9|ŒJ‘÷¼yZz‰©ù#kÑ^h —=¦¦Œ·Ü??À ѧðµ5Íë.у_7±{g8z2t–ÍÓuOòÑ I3½7óˆ›ÕŠ[¨ƒ}"×°,““«Ÿ‘¸Â~••»B–ÙªµG¬`Y÷ûƒQ“(B»Û›k/TtÓÍ ^CH ƒàµÃAµË"9Xµä± +Ðîæ¥oX–~IJWßç{ùq5?^pò5»ác —’åÌÈÙ þ¸6+5úVxåÌ&_fÁ…i +¦ïWÏfñ±f`xrž_ÿ&½Eg|¶YÒÿ‘3ÿß_ÙôßhüæÙ/.¬öp \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/cf35e7c9916ecc2d9f3f4e3b87430150bce4d1 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/cf35e7c9916ecc2d9f3f4e3b87430150bce4d1 new file mode 100644 index 000000000..441f71ad1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/d8/cf35e7c9916ecc2d9f3f4e3b87430150bce4d1 @@ -0,0 +1,8 @@ +x}SaoÓ0åkó+NýÂ6&1 &$J7 bꦥcÚG'¹¤Žl§Y…øïÜ9éV °ScûÞ½÷î]®L¯^Ÿž>KŽ"8‚¹i7VÖ+'/_¾ oÀ¯ J-ˆÎ¯Œ¥ïñ—‹©Œ+/eÚa .ц¢Y+ +ªoŽá+Z'†“ø%0êt¼šž1ÄÆtЈ hã¡sHÒA%>Øz +Ó´J +] ôÒ¯BŸ…™Àýˆar/è¹ ‚v¦Ú}¤ÿVÞ·o“¤ïûXƱ±u¢=.¹Lç‹ìⱫnµBçÀâ÷NZRœo@´Äª9qU¢öÔéÎfÝ[饮Á™Ê÷Â"S-¥óVæÿÍ´Q ôÝd›Ð0efSø0ËÒì˜AîÒåç«Û%ÜÍnnf‹ez‘ÁÕ Ì¯çé2½ZÐ×G˜-îáKº8?$Ëh8øÐZV@S”l'–Á» ÙóÇAE÷üíZ,d% ’¦ëNÔµY£Õ¤Z´t­ "“`cdg;ÿÀž¹èïbèoÅÂ:ª¦vy9ÜLq{‰óøw!Koìæ姟fS4Ö”¤ccv>î¾þ¢3Á–áŠøúr,O)@.V®ô6ÖçaŒöxïžF\³C–i™·_!5ŸXãWêÙÃ+Ê¡¤Y‰?âÉ4©ô˜>¾²,É+ÖÑ:fPÆîΡõ7$rR£l”èÇî.±Ý›.%ëºwçð»4¡GµÜHêΰ¢©ý«ihz{rà º3™ÔXÁ_K’ô­ îvC{½Vf4É ¬ ŽI–+ãàs«•½ãWúã:ñ<¹–q&¸¼4 þòçíN+KˆwFÑ8¤Ç@óMkÖ†qÔSxä"Ëž/( ÷XXø4®²M –V–óÌ1l½Ðࡘ;b³|!k‡±°f•³ÅVÊÉ"be™ð&îí»f`ƒÎéF˲xò¡ˆcüdMÔ·šuvEO¢BŽRçš~çŒ7ã0Èu¨ë.$&I%+&óxö? ¾ žxpmqcÇ çÂØ1Íç¼m¢5_Ø{ÄŸ +ºÆtõ ]ˆ%ª;ºÍ(¦äm·×¡ ÿ+ømàÀÔ5ò ËI,ôºw’Ûü ËŽhÈ8ÿ¾ »A/Š!_vE'2:4D¾#ÓÊSr¶E9bÃ~”›ÚÈyÞ¨çîš.ʼ¼³Œfj2±L£ôײ/Ü{¹òÄ ï¹=ZYjðç»l‹[п´þ<µnº \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/878df3275bcee29f342f737af0db892eba37f4 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/878df3275bcee29f342f737af0db892eba37f4 new file mode 100644 index 000000000..1f34f3e49 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/878df3275bcee29f342f737af0db892eba37f4 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/cad72355392337c6a99cb72fd915460264bd35 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/cad72355392337c6a99cb72fd915460264bd35 new file mode 100644 index 000000000..3e31ff3bf Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e4/cad72355392337c6a99cb72fd915460264bd35 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/10a7eececf4d28b2ebf33c85891f133dff49a7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/10a7eececf4d28b2ebf33c85891f133dff49a7 new file mode 100644 index 000000000..e600b6389 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/10a7eececf4d28b2ebf33c85891f133dff49a7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/a91cbe4ce67ac017da3e27db0bdcb388c0f836 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/a91cbe4ce67ac017da3e27db0bdcb388c0f836 new file mode 100644 index 000000000..aeaaacf53 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e5/a91cbe4ce67ac017da3e27db0bdcb388c0f836 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e7/e179431c218afd6ef043bdaef8f6e0185d7389 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e7/e179431c218afd6ef043bdaef8f6e0185d7389 new file mode 100644 index 000000000..6f6fa5ba3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e7/e179431c218afd6ef043bdaef8f6e0185d7389 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/24368620d9e5ed0f29bf6182efa534bde2c022 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/24368620d9e5ed0f29bf6182efa534bde2c022 new file mode 100644 index 000000000..bae8d5107 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/24368620d9e5ed0f29bf6182efa534bde2c022 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/4be09e7451a7c9270992c39c599d71af927f77 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/4be09e7451a7c9270992c39c599d71af927f77 new file mode 100644 index 000000000..2b2cbeece Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e8/4be09e7451a7c9270992c39c599d71af927f77 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/019aa651589a1c045011f485f7c2f1e5b0fc90 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/019aa651589a1c045011f485f7c2f1e5b0fc90 new file mode 100644 index 000000000..a4ebda717 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/019aa651589a1c045011f485f7c2f1e5b0fc90 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/0a2f8c4f48af5cc6b9fa33b759e2d79660c5ed b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/0a2f8c4f48af5cc6b9fa33b759e2d79660c5ed new file mode 100644 index 000000000..d713e98ef Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/0a2f8c4f48af5cc6b9fa33b759e2d79660c5ed differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/8b457186a905a983b0d7ab13e1ae30d25869d9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/8b457186a905a983b0d7ab13e1ae30d25869d9 new file mode 100644 index 000000000..15f432c43 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/8b457186a905a983b0d7ab13e1ae30d25869d9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/916a3c5cb8a2c7e6372652a1b4125ba2502715 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/916a3c5cb8a2c7e6372652a1b4125ba2502715 new file mode 100644 index 000000000..00fa1273c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/e9/916a3c5cb8a2c7e6372652a1b4125ba2502715 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/898bcbcac2f97565976b9fb4c14f4cbeb4a0f6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/898bcbcac2f97565976b9fb4c14f4cbeb4a0f6 new file mode 100644 index 000000000..145e8b5a3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/898bcbcac2f97565976b9fb4c14f4cbeb4a0f6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/b6f2ebdf4a5865312a77a2c7f3d13ca5f2fad5 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/b6f2ebdf4a5865312a77a2c7f3d13ca5f2fad5 new file mode 100644 index 000000000..795de9c46 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ea/b6f2ebdf4a5865312a77a2c7f3d13ca5f2fad5 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/204bc1e174797cd985ee310ae1fe1d7b39fbe8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/204bc1e174797cd985ee310ae1fe1d7b39fbe8 new file mode 100644 index 000000000..9739033cb Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/204bc1e174797cd985ee310ae1fe1d7b39fbe8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/5320cac85dc8f58342c275eb1531c466a25d85 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/5320cac85dc8f58342c275eb1531c466a25d85 new file mode 100644 index 000000000..0e18c7532 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/5320cac85dc8f58342c275eb1531c466a25d85 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/6a5c8b13b1a82e95f493894520247c3036f882 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/6a5c8b13b1a82e95f493894520247c3036f882 new file mode 100644 index 000000000..824ed4a63 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/6a5c8b13b1a82e95f493894520247c3036f882 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/ffb8efd50379f85cce876449fe4cd3686a70c7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/ffb8efd50379f85cce876449fe4cd3686a70c7 new file mode 100644 index 000000000..f433bc9d1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ec/ffb8efd50379f85cce876449fe4cd3686a70c7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/52c92eb6f02751ab034928a48e9223d16e8894 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/52c92eb6f02751ab034928a48e9223d16e8894 new file mode 100644 index 000000000..e081d075a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/52c92eb6f02751ab034928a48e9223d16e8894 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/8469d27d5d1a171fa0366a40357255dd900a7a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/8469d27d5d1a171fa0366a40357255dd900a7a new file mode 100644 index 000000000..f31529422 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/8469d27d5d1a171fa0366a40357255dd900a7a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/906fcdfff53eec49e845ed5fd25265ecc281e3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/906fcdfff53eec49e845ed5fd25265ecc281e3 new file mode 100644 index 000000000..8a90bbab6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/906fcdfff53eec49e845ed5fd25265ecc281e3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/cc9447e70c5ec936307c352b7583194960faf7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/cc9447e70c5ec936307c352b7583194960faf7 new file mode 100644 index 000000000..b8569fc53 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ed/cc9447e70c5ec936307c352b7583194960faf7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/55c990e9366504517828abfda363ca10d8a0dd b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/55c990e9366504517828abfda363ca10d8a0dd new file mode 100644 index 000000000..363b4ff9d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/55c990e9366504517828abfda363ca10d8a0dd differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/5a14587b9d5864b6d6d6c04f67fcbb53f61aa9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/5a14587b9d5864b6d6d6c04f67fcbb53f61aa9 new file mode 100644 index 000000000..a4ae0fd5f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ee/5a14587b9d5864b6d6d6c04f67fcbb53f61aa9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8304bfae040d89121dd4f46bb8783ed096b2a3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8304bfae040d89121dd4f46bb8783ed096b2a3 new file mode 100644 index 000000000..2f5eed178 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8304bfae040d89121dd4f46bb8783ed096b2a3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/879f884ac5365f2297fd27878ed43e473900c7 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/879f884ac5365f2297fd27878ed43e473900c7 new file mode 100644 index 000000000..9e9dd27d6 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/879f884ac5365f2297fd27878ed43e473900c7 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8e60a38777be0b17833e65ee01cb8fd5e60a0d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8e60a38777be0b17833e65ee01cb8fd5e60a0d new file mode 100644 index 000000000..7354100e9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/8e60a38777be0b17833e65ee01cb8fd5e60a0d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/a09194e0d3bf6fb155352bc16f22e591c5e58c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/a09194e0d3bf6fb155352bc16f22e591c5e58c new file mode 100644 index 000000000..c745fb934 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ef/a09194e0d3bf6fb155352bc16f22e591c5e58c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/2ef0939fa5a79951c220d43d768795896c6010 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/2ef0939fa5a79951c220d43d768795896c6010 new file mode 100644 index 000000000..ae5ae78e3 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/2ef0939fa5a79951c220d43d768795896c6010 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/8f891fb0f78c1b8a92b34a3928857c13ccf955 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/8f891fb0f78c1b8a92b34a3928857c13ccf955 new file mode 100644 index 000000000..e0ca5657e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/8f891fb0f78c1b8a92b34a3928857c13ccf955 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/bb1c299091ac14191cf0e873654123a7324b5d b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/bb1c299091ac14191cf0e873654123a7324b5d new file mode 100644 index 000000000..f8466ce53 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f0/bb1c299091ac14191cf0e873654123a7324b5d differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/df72f97e2e25bd1de08958b0a83d2e77511ba8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/df72f97e2e25bd1de08958b0a83d2e77511ba8 new file mode 100644 index 000000000..1d5e649c9 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/df72f97e2e25bd1de08958b0a83d2e77511ba8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/f8cfbef921613c5865799f44dd6c6bc3f05b44 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/f8cfbef921613c5865799f44dd6c6bc3f05b44 new file mode 100644 index 000000000..1555c6983 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f1/f8cfbef921613c5865799f44dd6c6bc3f05b44 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/bc59197c9af03e9913c6f6fcd63d1a6b92a50c b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/bc59197c9af03e9913c6f6fcd63d1a6b92a50c new file mode 100644 index 000000000..63daae636 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/bc59197c9af03e9913c6f6fcd63d1a6b92a50c differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/e0adc54f70fabef96ac181c35038b0a9ff6420 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/e0adc54f70fabef96ac181c35038b0a9ff6420 new file mode 100644 index 000000000..856526246 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/e0adc54f70fabef96ac181c35038b0a9ff6420 @@ -0,0 +1 @@ +x…’MoÛ0 †wö¯ rÚ‚Ôn ôÐõ²´Í0c]2ÄéŠe™vˆÚ’¦8A±ÿ>Êu¶ì4 Séç}ɲÕ%\_\½Ë¦ LáN›ƒ¥fëáòüâêŒ_×à·šI‰Dð[m9¿\Êe±ò$*‡U¡ŠæFH®33øÖ‘Vp™žÃûØu2¦&nb‹ƒЉ(í!8ää ¦÷R ugZJ"ôä·ÃÆ.‘žÇºô‚¯ .0ÐõéE~„†øl½7³¬ïûT Ä©¶MÖ¾éqÙC~·X‹3¦«U‹ÎÅŸ,+. SIQ2k+úÁžÆ"缎Խ%Oª™Óµï…ňZ‘ó–Êàÿ1m,ýôÛ&LæäÅnçE^Ìb“§|óeõ¸§ùz=_nòE«5Ü­–÷ù&_-9ú óå3|Í—÷3@¶Œ‡ƒ{c£ž"E;±¼+0zþg`Ps>ÆΠ¤š$KSM B£wh+ƒ¶#Çê°ŠH-uä…Žþ.Ãɲ$aŸ_b#v:uÆr§ÚŠ{m_RÙêP¥ì Š.í˜Sðê57I’M‡M»%U N“òhkq\­½6ÁGˆ‘>†r+”Âö¸©ŸÆ¾;„â€6B¿ KÁÁ­n4îHÒslÌIùúMvºP‘/r28wÈJÞÆ;·Z²4v÷Uå ÐÁJœAAê^“äWòÍK3ï \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/ee4de23c1043486acd6beda4890f359b231c29 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/ee4de23c1043486acd6beda4890f359b231c29 new file mode 100644 index 000000000..2b7ceebb4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f2/ee4de23c1043486acd6beda4890f359b231c29 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f3/48689da366bbc22515d5392b15d656c76f84c2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f3/48689da366bbc22515d5392b15d656c76f84c2 new file mode 100644 index 000000000..61dee9224 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f3/48689da366bbc22515d5392b15d656c76f84c2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/10c9db6c390fe878318c186500395fe222539a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/10c9db6c390fe878318c186500395fe222539a new file mode 100644 index 000000000..a0ebbffb5 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/10c9db6c390fe878318c186500395fe222539a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/c456831516f3bca7c2f72187f484b8ef4f594a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/c456831516f3bca7c2f72187f484b8ef4f594a new file mode 100644 index 000000000..18f5b6e95 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f4/c456831516f3bca7c2f72187f484b8ef4f594a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/3e0791d0f4d1d051a79451bfcb0cc5001728de b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/3e0791d0f4d1d051a79451bfcb0cc5001728de new file mode 100644 index 000000000..34b518257 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/3e0791d0f4d1d051a79451bfcb0cc5001728de differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/df8e0319c008a6a1f50467246945c4812cae42 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/df8e0319c008a6a1f50467246945c4812cae42 new file mode 100644 index 000000000..52a7d4d98 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/df8e0319c008a6a1f50467246945c4812cae42 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/f3e377bd316321d92687bf4d60c38260b53db2 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/f3e377bd316321d92687bf4d60c38260b53db2 new file mode 100644 index 000000000..20718189d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f5/f3e377bd316321d92687bf4d60c38260b53db2 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/49e7f8468c22957dbd751965d6e671ac6c342f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/49e7f8468c22957dbd751965d6e671ac6c342f new file mode 100644 index 000000000..d58ede3d4 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/49e7f8468c22957dbd751965d6e671ac6c342f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/ca3df12c8851aa031d98b450e4b820ebd4fbf6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/ca3df12c8851aa031d98b450e4b820ebd4fbf6 new file mode 100644 index 000000000..5695f21ea Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f6/ca3df12c8851aa031d98b450e4b820ebd4fbf6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/28f2191dc300253ce4dce052d6774a35baa351 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/28f2191dc300253ce4dce052d6774a35baa351 new file mode 100644 index 000000000..32c0629b2 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/28f2191dc300253ce4dce052d6774a35baa351 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/4bc4c8f9739b5bc4d7e8b59253a8c976f92873 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/4bc4c8f9739b5bc4d7e8b59253a8c976f92873 new file mode 100644 index 000000000..15391bbc0 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/4bc4c8f9739b5bc4d7e8b59253a8c976f92873 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/6535aa35ad6bf395a9ae984db51ef00fddefb3 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/6535aa35ad6bf395a9ae984db51ef00fddefb3 new file mode 100644 index 000000000..d35432299 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/6535aa35ad6bf395a9ae984db51ef00fddefb3 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/67aa2f6b3546fa2149d96180a7854220b8ee71 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/67aa2f6b3546fa2149d96180a7854220b8ee71 new file mode 100644 index 000000000..3414f6960 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/67aa2f6b3546fa2149d96180a7854220b8ee71 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/9121eede2d537dc0ce5276cb5f9fc785b0dfbb b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/9121eede2d537dc0ce5276cb5f9fc785b0dfbb new file mode 100644 index 000000000..173827048 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/9121eede2d537dc0ce5276cb5f9fc785b0dfbb differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/979acf5d344497eb5feea8a437651476b05e97 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/979acf5d344497eb5feea8a437651476b05e97 new file mode 100644 index 000000000..ead3e197c Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/979acf5d344497eb5feea8a437651476b05e97 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/b101f7328657009acb0deff36078b49ae9fe08 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/b101f7328657009acb0deff36078b49ae9fe08 new file mode 100644 index 000000000..ce5ff761d Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f7/b101f7328657009acb0deff36078b49ae9fe08 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/10370fe5875f5ae157e9aa79969d6171b3e822 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/10370fe5875f5ae157e9aa79969d6171b3e822 new file mode 100644 index 000000000..ba263e456 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/10370fe5875f5ae157e9aa79969d6171b3e822 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/a3c3c08174b6cdd828dcfaafcf20b8173795f6 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/a3c3c08174b6cdd828dcfaafcf20b8173795f6 new file mode 100644 index 000000000..6bc5df505 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/f9/a3c3c08174b6cdd828dcfaafcf20b8173795f6 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/01c99811643c68d2cac61f015d9c53b9eea28e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/01c99811643c68d2cac61f015d9c53b9eea28e new file mode 100644 index 000000000..5eb705c67 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/01c99811643c68d2cac61f015d9c53b9eea28e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/38632a22e2be3be43639c5dccf979fa5e6e20b b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/38632a22e2be3be43639c5dccf979fa5e6e20b new file mode 100644 index 000000000..867f7202e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/38632a22e2be3be43639c5dccf979fa5e6e20b differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/44160e523269d4cb56c7c2a76a4ae37316953a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/44160e523269d4cb56c7c2a76a4ae37316953a new file mode 100644 index 000000000..60e24ab3a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/44160e523269d4cb56c7c2a76a4ae37316953a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/4f7b499fdd7dd860763f348ca4c58c9cbf69f8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/4f7b499fdd7dd860763f348ca4c58c9cbf69f8 new file mode 100644 index 000000000..f5f11aae8 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/4f7b499fdd7dd860763f348ca4c58c9cbf69f8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/86202dc96482cc757803ffa2f60c7feab97c48 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/86202dc96482cc757803ffa2f60c7feab97c48 new file mode 100644 index 000000000..f6e2fa434 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fa/86202dc96482cc757803ffa2f60c7feab97c48 @@ -0,0 +1 @@ +xŽMjÃ0…³Ö)f¤±dIJïÐŒþ“È2c™ÐÛ7-]d·{ð¾Ç[­s´xèœ3D´!+’¦`ðH¾œ+É:­G¢Ä‚Š•8/bPÚëÑiïìhTdHIéGc%>÷Ú¢ ½_CØç{ª´õÌpy)ŸÛÊó2¦šoçÆÓ(cÐX? GùŒˆº¿ð ûΑøú^"¤7(Ü*ü+õÓõ´Ò”7ñ‘ŽZ \ No newline at end of file diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fb/da57ea8b836832f0e7f485762e18f1d565eb0e b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fb/da57ea8b836832f0e7f485762e18f1d565eb0e new file mode 100644 index 000000000..a2df97771 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fb/da57ea8b836832f0e7f485762e18f1d565eb0e differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fc/1560f0892c2da057aa904c7d7974a5c0615a3f b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fc/1560f0892c2da057aa904c7d7974a5c0615a3f new file mode 100644 index 000000000..c49ccfa2f Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fc/1560f0892c2da057aa904c7d7974a5c0615a3f differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/41c996a3cd8c437724ef1367f0a769f658d5f8 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/41c996a3cd8c437724ef1367f0a769f658d5f8 new file mode 100644 index 000000000..31ec60876 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/41c996a3cd8c437724ef1367f0a769f658d5f8 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/cfcc72c4570f06e7256b7899d8ed0c59b20c2a b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/cfcc72c4570f06e7256b7899d8ed0c59b20c2a new file mode 100644 index 000000000..94ca2d66b Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fd/cfcc72c4570f06e7256b7899d8ed0c59b20c2a differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/0af91f8982ba44b21372f42845ddbb92a4deb9 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/0af91f8982ba44b21372f42845ddbb92a4deb9 new file mode 100644 index 000000000..473285dfa Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/0af91f8982ba44b21372f42845ddbb92a4deb9 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/2b6a7824f96ddc7989ca71acc95daf6b9f6147 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/2b6a7824f96ddc7989ca71acc95daf6b9f6147 new file mode 100644 index 000000000..f2e008094 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/2b6a7824f96ddc7989ca71acc95daf6b9f6147 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/77cacf5179bff62c3e2faeb7361b58bfa8ad06 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/77cacf5179bff62c3e2faeb7361b58bfa8ad06 new file mode 100644 index 000000000..7dd272f0e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/fe/77cacf5179bff62c3e2faeb7361b58bfa8ad06 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ff/cdd4cf9e7316b2024d3bd7b9696a718ce76256 b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ff/cdd4cf9e7316b2024d3bd7b9696a718ce76256 new file mode 100644 index 000000000..fe70f6c10 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/ff/cdd4cf9e7316b2024d3bd7b9696a718ce76256 differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.idx b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.idx new file mode 100644 index 000000000..bd98b826e Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.idx differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.pack b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.pack new file mode 100644 index 000000000..0addaf592 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-0a93134843258aca78d9594fd03115c2907bf6b6.pack differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.idx b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.idx new file mode 100644 index 000000000..5610dc9a1 Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.idx differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.pack b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.pack new file mode 100644 index 000000000..57469c35a Binary files /dev/null and b/docs/src/test/bats/fixtures/spring-cloud-stream/git/objects/pack/pack-727194b6259551e0b34b2e841f2a36c5197cfac0.pack differ diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/packed-refs b/docs/src/test/bats/fixtures/spring-cloud-stream/git/packed-refs new file mode 100644 index 000000000..c6e089236 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/packed-refs @@ -0,0 +1,66 @@ +# pack-refs with: peeled fully-peeled sorted +0cd12064355a96f3064a91fa39d59c8fb8b35cea refs/remotes/origin/1.0.M1-docker +6eb007cdfc2b0ac7d491acf09c38c7078067c8a5 refs/remotes/origin/1.0.x +339617cb364317560329bb4c7add5abc9397dc35 refs/remotes/origin/1.1.x +16448a86b95e1a030389698d42713430c29c24ba refs/remotes/origin/1.2.x +1c47daa6863a96676468a2d1b0919c7cc80d3d93 refs/remotes/origin/1.3.x +b9fee7f86361f6a7aac44927441648530c8e6540 refs/remotes/origin/2.0.x +5b39e57ac9086d19e53851eb4558fe0a83ad3357 refs/remotes/origin/2.2.x +7b980d7644c03e913963daf46d74476ee881eea6 refs/remotes/origin/gh-pages +c6d238085f11a086b62813da5d08cd90310da5ea refs/remotes/origin/master +b1bf785c3078ad0559960662df61d1890c0d38b0 refs/remotes/origin/snicoll-patch-1 +9f3dac58e610826154c7c6a5631a71d229c27e9e refs/tags/v1.0.0.M1 +^83f762a31e5dfe033e11bdd8ca530a39da20f60a +7e5ddd3e7bfb63e38c89617cfc7e98d1f73ab353 refs/tags/v1.0.0.M2 +^bd22e119c88a7f57013326189b57310901e566bb +2edb94b6567acea491c06928c71414d7b5911555 refs/tags/v1.0.0.M3 +^f3d46fc120684c1bf80a94fb7950b2398f015111 +8f18455a6a5a6668421afe3fa352e14a8fafe825 refs/tags/v1.0.0.M4 +^082e5e733057d7154b779dd3cac81e8115ce5d3c +70a6abbc6177bef81ac006b46481c38bb3136141 refs/tags/v1.0.0.RC1 +b837d5b5c03c6111ce7a9a836699995907fcce24 refs/tags/v1.0.0.RC2 +^727434b732fe1c711599071500516b441040dd55 +53f1839a82ee5e58cf8f20a61d70fb2e0186d2aa refs/tags/v1.0.0.RELEASE +b51e3b14af010a6ef168a3d2e96f0cd65321a0c9 refs/tags/v1.0.1.RELEASE +f9aa3d7f8100ec8f665974e6884000191fd0fcb0 refs/tags/v1.0.2.RELEASE +7dc210312225e6b1104fd193672cfb16e1e838d0 refs/tags/v1.0.3.RELEASE +^962db72bbcebf688aa4165765150bad91f2eac8d +7b71e07c3ad3e4454258dbcdd9a8f9157ca41724 refs/tags/v1.1.0.M1 +ef8d3fde03bbc8e7add909b94dcc7bbb1a092e93 refs/tags/v1.1.0.RC1 +d739052c55896e3f4f5ba7407a1e58a3a097b062 refs/tags/v1.1.0.RELEASE +702fe0fdefd194c5a16e39c7862691c076fc976a refs/tags/v1.1.1.RELEASE +6b675848cbf4d2eb0c62177edbcbc5beb3f4dce6 refs/tags/v1.1.2.RELEASE +f602ae838c93928cd0c5311b9c894511f42679b6 refs/tags/v1.2.0.M1 +33f54fc75879153c3c17ee8728f5fab8d912a42b refs/tags/v1.2.0.M2 +23a738d3f917938664306dc5fc3b9d329a8d06e7 refs/tags/v1.2.0.RC1 +9cc591a079e14cc0c64895ab0c89261ae5ded825 refs/tags/v1.2.0.RELEASE +^54678571df906c987ee86518c5a73e048135d997 +a33b9bfbe474e4adffe9350eb6171bbeb64003bc refs/tags/v1.2.1.RELEASE +43352fa8507eb87d5b221057b7580a7aa6cc9e12 refs/tags/v1.2.2.RELEASE +f9b111afb5f3c53914a3ea8b819849473436bd54 refs/tags/v1.3.0.M1 +cc31d826aac960a7e77ace8187af93c904702cd0 refs/tags/v1.3.0.M2 +7b41c2df60e1020a505664e3d7dafd9cb6cd48c3 refs/tags/v1.3.0.RC1 +^5722f5af0653da8aee20f79ecf249b84c7511f39 +9e46ec00a08e28282e4989a96549f97856ccb698 refs/tags/v1.3.0.RELEASE +3f7925429325b57cb73e88799578c598f44ac29d refs/tags/v1.3.1.RELEASE +950c3e0ac338fb44f610e5033e82014bed6cd5e5 refs/tags/v1.3.2.RELEASE +6300fb69714e7ca50e2bc9e9e00822b9ddcf99e7 refs/tags/v1.3.3.RELEASE +f5952f561754f4459bc42a22f119127af92bc973 refs/tags/v2.0.0.M1 +061d6bed5cda969ab6b07e848be06445df96c393 refs/tags/v2.0.0.M2 +03b7b0d612087c802533fd7bd9fd4f6389142676 refs/tags/v2.0.0.M3 +5a622155144a8dd5c77ebe7ffc0d4f1e6e59b1a2 refs/tags/v2.0.0.M4 +388345e2f53c6fadd88bb41db2e494a5884e65e4 refs/tags/v2.0.0.RC1 +9e1930e1fdfabb5dc6ad68aed94eb2def4ba21fa refs/tags/v2.0.0.RC2 +1bcfb8cd4adf5450a6d67f32ab2a1db6cf5e4808 refs/tags/v2.0.0.RC3 +e6cff0c6e63916058925d726f86ada48a36bf0a0 refs/tags/v2.0.0.RELEASE +ff32173e9fff75cbdaeb02b36247afc16e28d9c2 refs/tags/v2.0.1.RELEASE +56be7d16b0a0543da2f8971a52fb7f18844e39bd refs/tags/v2.0.2.RELEASE +79a09791cd77daf28bc86782a85dcd696105e592 refs/tags/v2.1.0.M1 +7ce25617d2ed2ba3cccf7524408bacdc35a5804a refs/tags/v2.1.0.M2 +f498323f61edee146cddfd27484e6f913cd8564a refs/tags/v2.1.0.M3 +be67c92f0785408fa7634ccf3a42ab3da486e7cf refs/tags/v2.1.0.M4 +a83bdb7f34d7362ce67434b26f8966565e744e97 refs/tags/v2.1.0.RC1 +4775defbf048eee482a9eda4a6b72acb1d3c00ca refs/tags/v2.1.0.RC2 +5d07afc956248a62ffc0275a57a90c97fbefa7a4 refs/tags/v2.1.0.RC3 +953de7f02e249dea8fa5cf459739b54fc90d221a refs/tags/v2.1.0.RC4 +1535f37e16990ff48b68476c94ee7d27ab600d59 refs/tags/v2.1.0.RELEASE diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/gh-pages new file mode 100644 index 000000000..21e53a056 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/gh-pages @@ -0,0 +1 @@ +3e7f960197e196ec9a2f536711a97cc7cfc7e45d diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/master b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/master new file mode 100644 index 000000000..b6f1a3c25 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/heads/master @@ -0,0 +1 @@ +070ab0adfa59e54951f6b759d930f5da1e9f218b diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/HEAD b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/HEAD new file mode 100644 index 000000000..6efe28fff --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/HEAD @@ -0,0 +1 @@ +ref: refs/remotes/origin/master diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/gh-pages b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/gh-pages new file mode 100644 index 000000000..21e53a056 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/gh-pages @@ -0,0 +1 @@ +3e7f960197e196ec9a2f536711a97cc7cfc7e45d diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/master b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/master new file mode 100644 index 000000000..b6f1a3c25 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/git/refs/remotes/origin/master @@ -0,0 +1 @@ +070ab0adfa59e54951f6b759d930f5da1e9f218b diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw b/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw new file mode 100755 index 000000000..8b9da3b8b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw @@ -0,0 +1,286 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# https://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar" + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + wget "$jarUrl" -O "$wrapperJarPath" + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + curl -o "$wrapperJarPath" "$jarUrl" + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw.cmd b/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw.cmd new file mode 100755 index 000000000..fef5a8f7f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/mvnw.cmd @@ -0,0 +1,161 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar" +FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + echo Found %WRAPPER_JAR% +) else ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')" + echo Finished downloading %WRAPPER_JAR% +) +@REM End of extension + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/pom.xml new file mode 100644 index 000000000..8696ff675 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/pom.xml @@ -0,0 +1,270 @@ + + + 4.0.0 + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + pom + + org.springframework.cloud + spring-cloud-build + 2.1.4.BUILD-SNAPSHOT + + + + https://github.com/spring-cloud/spring-cloud-stream + scm:git:git://github.com/spring-cloud/spring-cloud-stream.git + + + scm:git:ssh://git@github.com/spring-cloud/spring-cloud-stream.git + + HEAD + + + 1.8 + 1.0.0.RELEASE + 1.0.0.RELEASE + Californium-SR5 + 3.0.3 + 2.1 + 2.1.0.M1 + + + true + true + true + + + + + org.springframework.cloud + spring-cloud-function-context + ${spring-cloud-function.version} + + + org.springframework.cloud + spring-cloud-stream + ${project.version} + + + org.springframework.cloud + spring-cloud-stream-tools + ${project.version} + + + org.springframework.cloud + spring-cloud-stream-schema-server + ${project.version} + + + org.springframework.cloud + spring-cloud-stream-binder-test + ${project.version} + + + org.springframework + spring-tuple + ${spring.tuple.version} + + + org.springframework.integration + spring-integration-tuple + ${spring.integration.tuple.version} + + + org.springframework.cloud + spring-cloud-stream-test-support + ${project.version} + + + org.springframework.cloud + spring-cloud-stream-test-support-internal + ${project.version} + + + com.esotericsoftware + kryo-shaded + ${kryo-shaded.version} + + + io.projectreactor + reactor-bom + ${reactor.version} + pom + import + + + org.objenesis + objenesis + ${objenesis.version} + + + org.springframework.cloud + spring-cloud-stream + ${project.version} + test-jar + test + test-binder + + + + + spring-cloud-stream + spring-cloud-stream-binder-test + spring-cloud-stream-test-support + spring-cloud-stream-test-support-internal + spring-cloud-stream-integration-tests + spring-cloud-stream-reactive + spring-cloud-stream-schema + spring-cloud-stream-schema-server + docs + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 1.7 + + + org.apache.maven.plugins + maven-javadoc-plugin + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.version} + ${java.version} + -parameters + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + + + + spring + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + false + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/release + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + false + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/libs-release-local + + false + + + + + + coverage + + + env.TRAVIS + true + + + + + + org.jacoco + jacoco-maven-plugin + 0.7.9 + + + agent + + prepare-agent + + + + report + test + + report + + + + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/pom.xml new file mode 100644 index 000000000..154ed8453 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + spring-cloud-stream-binder-test + jar + + spring-cloud-stream-binder-test + Test support for binder implementations + + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + + + + org.springframework.integration + spring-integration-test + + + org.apache.avro + avro-compiler + + + + + org.springframework + spring-web + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.boot + spring-boot-starter-test + compile + + + org.springframework.cloud + spring-cloud-stream + test-jar + test-binder + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractBinderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractBinderTests.java new file mode 100644 index 000000000..75292e929 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractBinderTests.java @@ -0,0 +1,906 @@ +/* + * Copyright 2013-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.Serializable; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.cloud.stream.binder.AbstractBinderTests.Station.Readings; +import org.springframework.cloud.stream.binding.MessageConverterConfigurer; +import org.springframework.cloud.stream.binding.StreamListenerMessageHandler; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.converter.JavaSerializationMessageConverter; +import org.springframework.cloud.stream.converter.KryoMessageConverter; +import org.springframework.cloud.stream.converter.MessageConverterUtils; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.Lifecycle; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.PollableChannel; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.handler.annotation.support.PayloadArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolverComposite; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author David Turanski + * @author Mark Fisher + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @author Jacob Severson + * @author Artem Bilan + */ +// @checkstyle:off +@SuppressWarnings("unchecked") +public abstract class AbstractBinderTests, CP, PP>, CP extends ConsumerProperties, PP extends ProducerProperties> { + + // @checkstyle:on + + protected final Log logger = LogFactory.getLog(this.getClass()); + + protected B testBinder; + + protected SmartMessageConverter messageConverter; + + /** + * Subclasses may override this default value to have tests wait longer for a message + * receive, for example if running in an environment that is known to be slow (e.g. + * travis). + */ + protected double timeoutMultiplier = 1.0D; + + @Before + public void before() { + this.messageConverter = new CompositeMessageConverterFactory() + .getMessageConverterForAllRegistered(); + } + + /** + * Attempt to receive a message on the given channel, waiting up to 1s (times the + * {@link #timeoutMultiplier}). + */ + protected Message receive(PollableChannel channel) { + return receive(channel, 1); + } + + /** + * Attempt to receive a message on the given channel, waiting up to 1s * + * additionalMultiplier * {@link #timeoutMultiplier}). + * + * Allows accomodating tests which are slower than normal (e.g. retry). + */ + protected Message receive(PollableChannel channel, int additionalMultiplier) { + long startTime = System.currentTimeMillis(); + Message receive = channel + .receive((int) (1000 * this.timeoutMultiplier * additionalMultiplier)); + long elapsed = System.currentTimeMillis() - startTime; + this.logger.debug("receive() took " + elapsed / 1000 + " seconds"); + return receive; + } + + @Test + @SuppressWarnings("rawtypes") + public void testClean() throws Exception { + Binder binder = getBinder(); + Binding foo0ProducerBinding = binder.bindProducer( + String.format("foo%s0", getDestinationNameDelimiter()), + this.createBindableChannel("output", new BindingProperties()), + createProducerProperties()); + Binding foo0ConsumerBinding = binder.bindConsumer( + String.format("foo%s0", getDestinationNameDelimiter()), "testClean", + this.createBindableChannel("input", new BindingProperties()), + createConsumerProperties()); + Binding foo1ProducerBinding = binder.bindProducer( + String.format("foo%s1", getDestinationNameDelimiter()), + this.createBindableChannel("output", new BindingProperties()), + createProducerProperties()); + Binding foo1ConsumerBinding = binder.bindConsumer( + String.format("foo%s1", getDestinationNameDelimiter()), "testClean", + this.createBindableChannel("input", new BindingProperties()), + createConsumerProperties()); + Binding foo2ProducerBinding = binder.bindProducer( + String.format("foo%s2", getDestinationNameDelimiter()), + this.createBindableChannel("output", new BindingProperties()), + createProducerProperties()); + foo0ProducerBinding.unbind(); + assertThat(TestUtils + .getPropertyValue(foo0ProducerBinding, "lifecycle", Lifecycle.class) + .isRunning()).isFalse(); + foo0ConsumerBinding.unbind(); + foo1ProducerBinding.unbind(); + assertThat(TestUtils + .getPropertyValue(foo0ConsumerBinding, "lifecycle", Lifecycle.class) + .isRunning()).isFalse(); + assertThat(TestUtils + .getPropertyValue(foo1ProducerBinding, "lifecycle", Lifecycle.class) + .isRunning()).isFalse(); + foo1ConsumerBinding.unbind(); + foo2ProducerBinding.unbind(); + assertThat(TestUtils + .getPropertyValue(foo1ConsumerBinding, "lifecycle", Lifecycle.class) + .isRunning()).isFalse(); + assertThat(TestUtils + .getPropertyValue(foo2ProducerBinding, "lifecycle", Lifecycle.class) + .isRunning()).isFalse(); + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendAndReceive() throws Exception { + Binder binder = getBinder(); + BindingProperties outputBindingProperties = createProducerBindingProperties( + createProducerProperties()); + DirectChannel moduleOutputChannel = createBindableChannel("output", + outputBindingProperties); + + BindingProperties inputBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + DirectChannel moduleInputChannel = createBindableChannel("input", + inputBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("foo%s0", getDestinationNameDelimiter()), + moduleOutputChannel, outputBindingProperties.getProducer()); + Binding consumerBinding = binder.bindConsumer( + String.format("foo%s0", getDestinationNameDelimiter()), + "testSendAndReceive", moduleInputChannel, + inputBindingProperties.getConsumer()); + Message message = MessageBuilder.withPayload("foo") + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + // Let the consumer actually bind to the producer before sending a msg + binderBindUnbindLatency(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> inboundMessageRef = new AtomicReference>(); + moduleInputChannel.subscribe(message1 -> { + try { + inboundMessageRef.set((Message) message1); + } + finally { + latch.countDown(); + } + }); + + moduleOutputChannel.send(message); + Assert.isTrue(latch.await(5, TimeUnit.SECONDS), "Failed to receive message"); + + assertThat(inboundMessageRef.get().getPayload()).isEqualTo("foo".getBytes()); + assertThat(inboundMessageRef.get().getHeaders() + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE)).isNull(); + assertThat(inboundMessageRef.get().getHeaders().get(MessageHeaders.CONTENT_TYPE) + .toString()).isEqualTo("text/plain"); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @Test + @SuppressWarnings({ "rawtypes", "deprecation" }) + public void testSendAndReceiveKryo() throws Exception { + Binder binder = getBinder(); + BindingProperties outputBindingProperties = createProducerBindingProperties( + createProducerProperties()); + DirectChannel moduleOutputChannel = createBindableChannel("output", + outputBindingProperties); + + BindingProperties inputBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + DirectChannel moduleInputChannel = createBindableChannel("input", + inputBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("foo%s0x", getDestinationNameDelimiter()), + moduleOutputChannel, outputBindingProperties.getProducer()); + Binding consumerBinding = binder.bindConsumer( + String.format("foo%s0x", getDestinationNameDelimiter()), + "testSendAndReceiveKryo", moduleInputChannel, + inputBindingProperties.getConsumer()); + Foo foo = new Foo(); + foo.setName("Bill"); + Message message = MessageBuilder.withPayload(foo).setHeader( + MessageHeaders.CONTENT_TYPE, MessageConverterUtils.X_JAVA_OBJECT).build(); + // Let the consumer actually bind to the producer before sending a msg + binderBindUnbindLatency(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> inboundMessageRef = new AtomicReference>(); + moduleInputChannel.subscribe(message1 -> { + try { + inboundMessageRef.set((Message) message1); + } + finally { + latch.countDown(); + } + }); + + moduleOutputChannel.send(message); + Assert.isTrue(latch.await(5, TimeUnit.SECONDS), "Failed to receive message"); + + KryoMessageConverter kryo = new KryoMessageConverter(null, true); + Foo fooPayload = (Foo) kryo.fromMessage(inboundMessageRef.get(), Foo.class); + assertThat(fooPayload).isNotNull(); + assertThat(inboundMessageRef.get().getHeaders() + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE)).isNull(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @Test + @SuppressWarnings({ "rawtypes", "deprecation" }) + public void testSendAndReceiveJavaSerialization() throws Exception { + Binder binder = getBinder(); + BindingProperties outputBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + outputBindingProperties); + + BindingProperties inputBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + DirectChannel moduleInputChannel = createBindableChannel("input", + inputBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("foo%s0y", getDestinationNameDelimiter()), + moduleOutputChannel, outputBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("foo%s0y", getDestinationNameDelimiter()), + "testSendAndReceiveJavaSerialization", moduleInputChannel, + inputBindingProperties.getConsumer()); + SerializableFoo foo = new SerializableFoo(); + Message message = MessageBuilder.withPayload(foo) + .setHeader(MessageHeaders.CONTENT_TYPE, + MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT) + .build(); + // Let the consumer actually bind to the producer before sending a msg + binderBindUnbindLatency(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> inboundMessageRef = new AtomicReference>(); + moduleInputChannel.subscribe(message1 -> { + try { + inboundMessageRef.set((Message) message1); + } + finally { + latch.countDown(); + } + }); + + moduleOutputChannel.send(message); + Assert.isTrue(latch.await(5, TimeUnit.SECONDS), "Failed to receive message"); + + JavaSerializationMessageConverter converter = new JavaSerializationMessageConverter(); + SerializableFoo serializableFoo = (SerializableFoo) converter.convertFromInternal( + inboundMessageRef.get(), SerializableFoo.class, null); + assertThat(serializableFoo).isNotNull(); + assertThat(inboundMessageRef.get().getHeaders() + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE)).isNull(); + assertThat(inboundMessageRef.get().getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @Test + @SuppressWarnings("rawtypes") + public void testSendAndReceiveMultipleTopics() throws Exception { + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel1 = createBindableChannel("output1", + producerBindingProperties); + + DirectChannel moduleOutputChannel2 = createBindableChannel("output2", + producerBindingProperties); + + QueueChannel moduleInputChannel = new QueueChannel(); + + Binding producerBinding1 = binder.bindProducer( + String.format("foo%sxy", getDestinationNameDelimiter()), + moduleOutputChannel1, producerBindingProperties.getProducer()); + Binding producerBinding2 = binder.bindProducer( + String.format("foo%syz", + + getDestinationNameDelimiter()), + moduleOutputChannel2, producerBindingProperties.getProducer()); + + Binding consumerBinding1 = binder.bindConsumer( + String.format("foo%sxy", getDestinationNameDelimiter()), + "testSendAndReceiveMultipleTopics", moduleInputChannel, + createConsumerProperties()); + Binding consumerBinding2 = binder.bindConsumer( + String.format("foo%syz", getDestinationNameDelimiter()), + "testSendAndReceiveMultipleTopics", moduleInputChannel, + createConsumerProperties()); + + String testPayload1 = "foo" + UUID.randomUUID().toString(); + Message message1 = MessageBuilder.withPayload(testPayload1.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.APPLICATION_OCTET_STREAM) + .build(); + String testPayload2 = "foo" + UUID.randomUUID().toString(); + Message message2 = MessageBuilder.withPayload(testPayload2.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.APPLICATION_OCTET_STREAM) + .build(); + + // Let the consumer actually bind to the producer before sending a msg + binderBindUnbindLatency(); + moduleOutputChannel1.send(message1); + moduleOutputChannel2.send(message2); + + Message[] messages = new Message[2]; + messages[0] = receive(moduleInputChannel); + messages[1] = receive(moduleInputChannel); + + assertThat(messages[0]).isNotNull(); + assertThat(messages[1]).isNotNull(); + assertThat(messages).extracting("payload").containsExactlyInAnyOrder( + testPayload1.getBytes(), testPayload2.getBytes()); + + producerBinding1.unbind(); + producerBinding2.unbind(); + + consumerBinding1.unbind(); + consumerBinding2.unbind(); + } + + @Test + @SuppressWarnings("rawtypes") + public void testSendAndReceiveNoOriginalContentType() throws Exception { + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + BindingProperties inputBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + DirectChannel moduleInputChannel = createBindableChannel("input", + inputBindingProperties); + Binding producerBinding = binder.bindProducer( + String.format("bar%s0", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + Binding consumerBinding = binder.bindConsumer( + String.format("bar%s0", getDestinationNameDelimiter()), + "testSendAndReceiveNoOriginalContentType", moduleInputChannel, + createConsumerProperties()); + binderBindUnbindLatency(); + + Message message = MessageBuilder.withPayload("foo") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN).build(); + moduleOutputChannel.send(message); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> inboundMessageRef = new AtomicReference>(); + moduleInputChannel.subscribe(message1 -> { + try { + inboundMessageRef.set((Message) message1); + } + finally { + latch.countDown(); + } + }); + + moduleOutputChannel.send(message); + Assert.isTrue(latch.await(5, TimeUnit.SECONDS), "Failed to receive message"); + assertThat(inboundMessageRef.get()).isNotNull(); + assertThat(inboundMessageRef.get().getPayload()).isEqualTo("foo".getBytes()); + assertThat(inboundMessageRef.get().getHeaders().get(MessageHeaders.CONTENT_TYPE) + .toString()).isEqualTo(MimeTypeUtils.TEXT_PLAIN_VALUE); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + protected abstract B getBinder() throws Exception; + + protected abstract CP createConsumerProperties(); + + protected abstract PP createProducerProperties(); + + protected final BindingProperties createConsumerBindingProperties( + CP consumerProperties) { + BindingProperties bindingProperties = new BindingProperties(); + bindingProperties.setConsumer(consumerProperties); + return bindingProperties; + } + + protected BindingProperties createProducerBindingProperties(PP producerProperties) { + BindingProperties bindingProperties = new BindingProperties(); + bindingProperties.setProducer(producerProperties); + return bindingProperties; + } + + protected DirectChannel createBindableChannel(String channelName, + BindingProperties bindingProperties) throws Exception { + // The 'channelName.contains("input")' is strictly for convenience to avoid + // modifications in multiple tests + return this.createBindableChannel(channelName, bindingProperties, + channelName.contains("input")); + } + + protected DirectChannel createBindableChannel(String channelName, + BindingProperties bindingProperties, boolean inputChannel) throws Exception { + MessageConverterConfigurer messageConverterConfigurer = createConverterConfigurer( + channelName, bindingProperties); + DirectChannel channel = new DirectChannel(); + channel.setBeanName(channelName); + if (inputChannel) { + messageConverterConfigurer.configureInputChannel(channel, channelName); + } + else { + messageConverterConfigurer.configureOutputChannel(channel, channelName); + } + return channel; + } + + protected DefaultPollableMessageSource createBindableMessageSource(String bindingName, + BindingProperties bindingProperties) throws Exception { + DefaultPollableMessageSource source = new DefaultPollableMessageSource( + new CompositeMessageConverterFactory() + .getMessageConverterForAllRegistered()); + createConverterConfigurer(bindingName, bindingProperties) + .configurePolledMessageSource(source, bindingName); + return source; + } + + private MessageConverterConfigurer createConverterConfigurer(String channelName, + BindingProperties bindingProperties) throws Exception { + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + bindingServiceProperties.getBindings().put(channelName, bindingProperties); + ConfigurableApplicationContext applicationContext = new GenericApplicationContext(); + applicationContext.refresh(); + bindingServiceProperties.setApplicationContext(applicationContext); + bindingServiceProperties.setConversionService(new DefaultConversionService()); + bindingServiceProperties.afterPropertiesSet(); + MessageConverterConfigurer messageConverterConfigurer = new MessageConverterConfigurer( + bindingServiceProperties, + new CompositeMessageConverterFactory(null, null)); + messageConverterConfigurer.setBeanFactory(applicationContext.getBeanFactory()); + return messageConverterConfigurer; + } + + @After + public void cleanup() { + if (this.testBinder != null) { + this.testBinder.cleanup(); + } + } + + /** + * If appropriate, let the binder middleware settle down a bit while binding/unbinding + * actually happens. + */ + protected void binderBindUnbindLatency() throws InterruptedException { + // default none + } + + /** + * Create a new spy on the given 'queue'. This allows de-correlating the creation of + * the 'connection' from its actual usage, which may be needed by some implementations + * to see messages sent after connection creation. + */ + public abstract Spy spyOn(String name); + + /** + * Set the delimiter that will be used in the message source/target name. Some brokers + * may have naming constraints (such as SQS), so this provides a way to override the + * character being used as a delimiter. The default is a period. + */ + protected String getDestinationNameDelimiter() { + return "."; + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendPojoReceivePojoWithStreamListenerDefaultContentType() + throws Exception { + StreamListenerMessageHandler handler = this.buildStreamListener( + AbstractBinderTests.class, "echoStation", Station.class); + + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0a", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0a", getDestinationNameDelimiter()), "test-1", + moduleInputChannel, consumerBindingProperties.getConsumer()); + + Station station = new Station(); + Message message = MessageBuilder.withPayload(station).build(); + moduleInputChannel.subscribe(handler); + moduleOutputChannel.send(message); + + QueueChannel replyChannel = (QueueChannel) handler.getOutputChannel(); + + Message replyMessage = replyChannel.receive(5000); + assertThat(replyMessage.getPayload() instanceof Station).isTrue(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendPojoReceivePojoKryoWithStreamListener() throws Exception { + StreamListenerMessageHandler handler = this.buildStreamListener( + AbstractBinderTests.class, "echoStation", Station.class); + + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0b", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0b", getDestinationNameDelimiter()), "test-2", + moduleInputChannel, consumerBindingProperties.getConsumer()); + + Station station = new Station(); + Message message = MessageBuilder.withPayload(station).setHeader( + MessageHeaders.CONTENT_TYPE, MessageConverterUtils.X_JAVA_OBJECT).build(); + moduleInputChannel.subscribe(handler); + moduleOutputChannel.send(message); + + QueueChannel replyChannel = (QueueChannel) handler.getOutputChannel(); + + Message replyMessage = replyChannel.receive(5000); + assertThat(replyMessage.getPayload() instanceof Station).isTrue(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @SuppressWarnings("rawtypes") + @Test(expected = MessageDeliveryException.class) + public void testStreamListenerJavaSerializationNonSerializable() throws Exception { + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0c", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0c", getDestinationNameDelimiter()), "test-3", + moduleInputChannel, consumerBindingProperties.getConsumer()); + try { + Station station = new Station(); + Message message = MessageBuilder.withPayload(station) + .setHeader(MessageHeaders.CONTENT_TYPE, + MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT) + .build(); + moduleOutputChannel.send(message); + } + finally { + producerBinding.unbind(); + consumerBinding.unbind(); + } + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendJsonReceivePojoWithStreamListener() throws Exception { + StreamListenerMessageHandler handler = this.buildStreamListener( + AbstractBinderTests.class, "echoStation", Station.class); + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0d", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0d", getDestinationNameDelimiter()), "test-4", + moduleInputChannel, consumerBindingProperties.getConsumer()); + + String value = "{\"readings\":[{\"stationid\":\"fgh\"," + + "\"customerid\":\"12345\",\"timestamp\":null}," + + "{\"stationid\":\"hjk\",\"customerid\":\"222\",\"timestamp\":null}]}"; + + Message message = MessageBuilder.withPayload(value) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON) + .build(); + moduleInputChannel.subscribe(handler); + moduleOutputChannel.send(message); + + QueueChannel channel = (QueueChannel) handler.getOutputChannel(); + + Message reply = (Message) channel.receive(5000); + + assertThat(reply).isNotNull(); + assertThat(reply.getPayload() instanceof Station).isTrue(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendJsonReceiveJsonWithStreamListener() throws Exception { + StreamListenerMessageHandler handler = this.buildStreamListener( + AbstractBinderTests.class, "echoStationString", String.class); + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0e", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0e", getDestinationNameDelimiter()), "test-5", + moduleInputChannel, consumerBindingProperties.getConsumer()); + + String value = "{\"readings\":[{\"stationid\":\"fgh\"," + + "\"customerid\":\"12345\",\"timestamp\":null}," + + "{\"stationid\":\"hjk\",\"customerid\":\"222\",\"timestamp\":null}]}"; + + Message message = MessageBuilder.withPayload(value) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON) + .build(); + moduleInputChannel.subscribe(handler); + moduleOutputChannel.send(message); + + QueueChannel channel = (QueueChannel) handler.getOutputChannel(); + + Message reply = (Message) channel.receive(5000); + + assertThat(reply).isNotNull(); + assertThat(reply.getPayload() instanceof String).isTrue(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @SuppressWarnings("rawtypes") + @Test + public void testSendPojoReceivePojoWithStreamListener() throws Exception { + StreamListenerMessageHandler handler = this.buildStreamListener( + AbstractBinderTests.class, "echoStation", Station.class); + Binder binder = getBinder(); + + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + + DirectChannel moduleOutputChannel = createBindableChannel("output", + producerBindingProperties); + + BindingProperties consumerBindingProperties = createConsumerBindingProperties( + createConsumerProperties()); + + DirectChannel moduleInputChannel = createBindableChannel("input", + consumerBindingProperties); + + Binding producerBinding = binder.bindProducer( + String.format("bad%s0f", getDestinationNameDelimiter()), + moduleOutputChannel, producerBindingProperties.getProducer()); + + Binding consumerBinding = binder.bindConsumer( + String.format("bad%s0f", getDestinationNameDelimiter()), "test-6", + moduleInputChannel, consumerBindingProperties.getConsumer()); + + Readings r1 = new Readings(); + r1.setCustomerid("123"); + r1.setStationid("XYZ"); + Readings r2 = new Readings(); + r2.setCustomerid("546"); + r2.setStationid("ABC"); + Station station = new Station(); + station.setReadings(Arrays.asList(r1, r2)); + Message message = MessageBuilder.withPayload(station) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON) + .build(); + moduleInputChannel.subscribe(handler); + moduleOutputChannel.send(message); + + QueueChannel channel = (QueueChannel) handler.getOutputChannel(); + + Message reply = (Message) channel.receive(5000); + + assertThat(reply).isNotNull(); + assertThat(reply.getPayload() instanceof Station).isTrue(); + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @SuppressWarnings("unused") // it is used via reflection + private Station echoStation(Station station) { + return station; + } + + @SuppressWarnings("unused") // it is used via reflection + private String echoStationString(String station) { + return station; + } + + private StreamListenerMessageHandler buildStreamListener(Class handlerClass, + String handlerMethodName, Class... parameters) throws Exception { + String channelName = "reply_" + System.nanoTime(); + GenericApplicationContext context = new GenericApplicationContext(); + context.getBeanFactory().registerSingleton(channelName, new QueueChannel()); + + Method m = ReflectionUtils.findMethod(handlerClass, handlerMethodName, + parameters); + InvocableHandlerMethod method = new InvocableHandlerMethod(this, m); + HandlerMethodArgumentResolverComposite resolver = new HandlerMethodArgumentResolverComposite(); + CompositeMessageConverterFactory factory = new CompositeMessageConverterFactory(); + resolver.addResolver(new PayloadArgumentResolver( + factory.getMessageConverterForAllRegistered())); + method.setMessageMethodArgumentResolvers(resolver); + Constructor c = ReflectionUtils.accessibleConstructor( + StreamListenerMessageHandler.class, InvocableHandlerMethod.class, + boolean.class, String[].class); + StreamListenerMessageHandler handler = (StreamListenerMessageHandler) c + .newInstance(method, false, new String[] {}); + handler.setOutputChannelName(channelName); + handler.setBeanFactory(context); + handler.afterPropertiesSet(); + context.refresh(); + return handler; + } + + public static class Station { + + List readings = new ArrayList<>(); + + public List getReadings() { + return this.readings; + } + + public void setReadings(List readings) { + this.readings = readings; + } + + @SuppressWarnings("serial") + public static class Readings implements Serializable { + + public String stationid; + + public String customerid; + + public String timestamp; + + public String getStationid() { + return this.stationid; + } + + public void setStationid(String stationid) { + this.stationid = stationid; + } + + public String getCustomerid() { + return this.customerid; + } + + public void setCustomerid(String customerid) { + this.customerid = customerid; + } + + public String getTimestamp() { + return this.timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + } + + } + + private class Foo { + + private String name; + + @SuppressWarnings("unused") + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractPollableConsumerTestBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractPollableConsumerTestBinder.java new file mode 100644 index 000000000..956cfa048 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractPollableConsumerTestBinder.java @@ -0,0 +1,53 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; + +/** + * @param binder type + * @param consumer properties type + * @param producer properties type + * @author Gary Russell + * @since 2.0 + */ +// @checkstyle:off +public abstract class AbstractPollableConsumerTestBinder, CP extends ConsumerProperties, PP extends ProducerProperties> + extends AbstractTestBinder + implements PollableConsumerBinder { + + // @checkstyle:on + + private PollableConsumerBinder binder; + + @SuppressWarnings("unchecked") + public void setPollableConsumerBinder( + PollableConsumerBinder binder) { + super.setBinder((C) binder); + this.binder = binder; + } + + @Override + public Binding> bindPollableConsumer(String name, + String group, PollableSource inboundBindTarget, + CP consumerProperties) { + return this.binder.bindPollableConsumer(name, group, inboundBindTarget, + consumerProperties); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractTestBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractTestBinder.java new file mode 100644 index 000000000..c2abc3c12 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/AbstractTestBinder.java @@ -0,0 +1,100 @@ +/* + * Copyright 2014-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.integration.channel.AbstractSubscribableChannel; +import org.springframework.messaging.MessageChannel; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Abstract class that adds test support for {@link Binder}. + * + * @param binder type + * @param consumer properties type + * @param producer properties type + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Mark Fisher + * @author Oleg Zhurakousky + */ +// @checkstyle:off +public abstract class AbstractTestBinder, CP extends ConsumerProperties, PP extends ProducerProperties> + implements Binder { + + // @checkstyle:on + + protected Set queues = new HashSet(); + + private C binder; + + @Override + public Binding bindConsumer(String name, String group, + MessageChannel moduleInputChannel, CP properties) { + this.checkChannelIsConfigured(moduleInputChannel, properties); + this.queues.add(name); + return this.binder.bindConsumer(name, group, moduleInputChannel, properties); + } + + @Override + public Binding bindProducer(String name, + MessageChannel moduleOutputChannel, PP properties) { + this.queues.add(name); + return this.binder.bindProducer(name, moduleOutputChannel, properties); + } + + public C getCoreBinder() { + return this.binder; + } + + public abstract void cleanup(); + + public C getBinder() { + return this.binder; + } + + public void setBinder(C binder) { + try { + binder.afterPropertiesSet(); + } + catch (Exception e) { + throw new RuntimeException("Failed to initialize binder", e); + } + this.binder = binder; + } + + /* + * This will ensure that any MessageChannel that was passed to one of the bind*() + * methods was properly configured (i.e., interceptors, converters etc). see + * org.springframework.cloud.stream.binding.MessageConverterConfigurer + */ + private void checkChannelIsConfigured(MessageChannel messageChannel, CP properties) { + if (messageChannel instanceof AbstractSubscribableChannel + && !properties.isUseNativeDecoding()) { + Assert.isTrue( + !CollectionUtils + .isEmpty(((AbstractSubscribableChannel) messageChannel) + .getChannelInterceptors()), + "'messageChannel' appears to be misconfigured. " + + "Consider creating channel via AbstractBinderTest.createBindableChannel(..)"); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestEnvironmentPostProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestEnvironmentPostProcessor.java new file mode 100644 index 000000000..8bdd78318 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestEnvironmentPostProcessor.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * An {@link EnvironmentPostProcessor} that sets some common configuration properties (log + * config etc.,) for binder tests. + * + * @author Ilayaperumal Gopinathan + */ +public class BinderTestEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, + SpringApplication application) { + Map propertiesToAdd = new HashMap<>(); + propertiesToAdd.put("logging.pattern.console", + "%d{ISO8601} %5p %t %c{2}:%L - %m%n"); + propertiesToAdd.put("logging.level.root", "WARN"); + environment.getPropertySources().addLast( + new MapPropertySource("binderTestPropertiesConfig", propertiesToAdd)); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestUtils.java new file mode 100644 index 000000000..0387a0bd5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/BinderTestUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.integration.support.MessageBuilderFactory; +import org.springframework.integration.support.MutableMessageBuilderFactory; +import org.springframework.integration.support.utils.IntegrationUtils; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Gary Russell + */ +public abstract class BinderTestUtils { + + /** + * Mocked application context. + */ + public static final AbstractApplicationContext MOCK_AC = mock( + AbstractApplicationContext.class); + + /** + * Mocked application bean factory. + */ + public static final ConfigurableListableBeanFactory MOCK_BF = mock( + ConfigurableListableBeanFactory.class); + + private static final MessageBuilderFactory mbf = new MutableMessageBuilderFactory(); + + static { + when(MOCK_BF.getBean( + IntegrationUtils.INTEGRATION_MESSAGE_BUILDER_FACTORY_BEAN_NAME, + MessageBuilderFactory.class)).thenReturn(mbf); + when(MOCK_AC.getBean( + IntegrationUtils.INTEGRATION_MESSAGE_BUILDER_FACTORY_BEAN_NAME, + MessageBuilderFactory.class)).thenReturn(mbf); + when(MOCK_AC.getBeanFactory()).thenReturn(MOCK_BF); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionCapableBinderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionCapableBinderTests.java new file mode 100644 index 000000000..b7a7a456c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionCapableBinderTests.java @@ -0,0 +1,421 @@ +/* + * Copyright 2014-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import org.assertj.core.api.Condition; +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.context.Lifecycle; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.IntegrationMessageHeaderAccessor; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for binders that support partitioning. + * + * @author Gary Russell + * @author Mark Fisher + * @author Marius Bogoevici + * @author Vinicius Carvalho + */ +// @checkstyle:off +public abstract class PartitionCapableBinderTests, CP, PP>, CP extends ConsumerProperties, PP extends ProducerProperties> + extends AbstractBinderTests { + + // @checkstyle:on + + protected static final SpelExpressionParser spelExpressionParser = new SpelExpressionParser(); + + @Test + @SuppressWarnings("unchecked") + public void testAnonymousGroup() throws Exception { + B binder = getBinder(); + BindingProperties producerBindingProperties = createProducerBindingProperties( + createProducerProperties()); + DirectChannel output = createBindableChannel("output", producerBindingProperties); + Binding producerBinding = binder.bindProducer( + String.format("defaultGroup%s0", getDestinationNameDelimiter()), output, + (PP) producerBindingProperties.getProducer()); + + QueueChannel input1 = new QueueChannel(); + Binding binding1 = binder.bindConsumer( + String.format("defaultGroup%s0", getDestinationNameDelimiter()), null, + input1, createConsumerProperties()); + + QueueChannel input2 = new QueueChannel(); + Binding binding2 = binder.bindConsumer( + String.format("defaultGroup%s0", getDestinationNameDelimiter()), null, + input2, createConsumerProperties()); + + String testPayload1 = "foo-" + UUID.randomUUID().toString(); + output.send(MessageBuilder.withPayload(testPayload1) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + Message receivedMessage1 = (Message) receive(input1); + assertThat(receivedMessage1).isNotNull(); + assertThat(new String(receivedMessage1.getPayload())).isEqualTo(testPayload1); + + Message receivedMessage2 = (Message) receive(input2); + assertThat(receivedMessage2).isNotNull(); + assertThat(new String(receivedMessage2.getPayload())).isEqualTo(testPayload1); + + binding2.unbind(); + + String testPayload2 = "foo-" + UUID.randomUUID().toString(); + output.send(MessageBuilder.withPayload(testPayload2) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + binding2 = binder.bindConsumer( + String.format("defaultGroup%s0", getDestinationNameDelimiter()), null, + input2, createConsumerProperties()); + String testPayload3 = "foo-" + UUID.randomUUID().toString(); + output.send(MessageBuilder.withPayload(testPayload3) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + receivedMessage1 = (Message) receive(input1); + assertThat(receivedMessage1).isNotNull(); + assertThat(new String(receivedMessage1.getPayload())).isEqualTo(testPayload2); + receivedMessage1 = (Message) receive(input1); + assertThat(receivedMessage1).isNotNull(); + assertThat(new String(receivedMessage1.getPayload())).isNotNull(); + + receivedMessage2 = (Message) receive(input2); + assertThat(receivedMessage2).isNotNull(); + assertThat(new String(receivedMessage2.getPayload())).isEqualTo(testPayload3); + + producerBinding.unbind(); + binding1.unbind(); + binding2.unbind(); + } + + @Test + public void testOneRequiredGroup() throws Exception { + B binder = getBinder(); + PP producerProperties = createProducerProperties(); + DirectChannel output = createBindableChannel("output", + createProducerBindingProperties(producerProperties)); + + String testDestination = "testDestination" + + UUID.randomUUID().toString().replace("-", ""); + + producerProperties.setRequiredGroups("test1"); + Binding producerBinding = binder.bindProducer(testDestination, + output, producerProperties); + + String testPayload = "foo-" + UUID.randomUUID().toString(); + output.send(MessageBuilder.withPayload(testPayload) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + QueueChannel inbound1 = new QueueChannel(); + Binding consumerBinding = binder.bindConsumer(testDestination, + "test1", inbound1, createConsumerProperties()); + + Message receivedMessage1 = receive(inbound1); + assertThat(receivedMessage1).isNotNull(); + assertThat(new String((byte[]) receivedMessage1.getPayload())) + .isEqualTo(testPayload); + + producerBinding.unbind(); + consumerBinding.unbind(); + } + + @Test + public void testTwoRequiredGroups() throws Exception { + B binder = getBinder(); + PP producerProperties = createProducerProperties(); + + DirectChannel output = createBindableChannel("output", + createProducerBindingProperties(producerProperties)); + + String testDestination = "testDestination" + + UUID.randomUUID().toString().replace("-", ""); + + producerProperties.setRequiredGroups("test1", "test2"); + Binding producerBinding = binder.bindProducer(testDestination, + output, producerProperties); + + String testPayload = "foo-" + UUID.randomUUID().toString(); + output.send(MessageBuilder.withPayload(testPayload) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + QueueChannel inbound1 = new QueueChannel(); + Binding consumerBinding1 = binder.bindConsumer(testDestination, + "test1", inbound1, createConsumerProperties()); + QueueChannel inbound2 = new QueueChannel(); + Binding consumerBinding2 = binder.bindConsumer(testDestination, + "test2", inbound2, createConsumerProperties()); + + Message receivedMessage1 = receive(inbound1); + assertThat(receivedMessage1).isNotNull(); + assertThat(new String((byte[]) receivedMessage1.getPayload())) + .isEqualTo(testPayload); + Message receivedMessage2 = receive(inbound2); + assertThat(receivedMessage2).isNotNull(); + assertThat(new String((byte[]) receivedMessage2.getPayload())) + .isEqualTo(testPayload); + + consumerBinding1.unbind(); + consumerBinding2.unbind(); + producerBinding.unbind(); + } + + @Test + public void testPartitionedModuleSpEL() throws Exception { + B binder = getBinder(); + + CP consumerProperties = createConsumerProperties(); + consumerProperties.setConcurrency(2); + consumerProperties.setInstanceIndex(0); + consumerProperties.setInstanceCount(3); + consumerProperties.setPartitioned(true); + QueueChannel input0 = new QueueChannel(); + input0.setBeanName("test.input0S"); + Binding input0Binding = binder.bindConsumer( + String.format("part%s0", getDestinationNameDelimiter()), + "testPartitionedModuleSpEL", input0, consumerProperties); + consumerProperties.setInstanceIndex(1); + QueueChannel input1 = new QueueChannel(); + input1.setBeanName("test.input1S"); + Binding input1Binding = binder.bindConsumer( + String.format("part%s0", getDestinationNameDelimiter()), + "testPartitionedModuleSpEL", input1, consumerProperties); + consumerProperties.setInstanceIndex(2); + QueueChannel input2 = new QueueChannel(); + input2.setBeanName("test.input2S"); + Binding input2Binding = binder.bindConsumer( + String.format("part%s0", getDestinationNameDelimiter()), + "testPartitionedModuleSpEL", input2, consumerProperties); + + PP producerProperties = createProducerProperties(); + producerProperties.setPartitionKeyExpression( + spelExpressionParser.parseExpression("payload")); + producerProperties.setPartitionSelectorExpression( + spelExpressionParser.parseExpression("hashCode()")); + producerProperties.setPartitionCount(3); + + DirectChannel output = createBindableChannel("output", + createProducerBindingProperties(producerProperties)); + output.setBeanName("test.output"); + Binding outputBinding = binder.bindProducer( + String.format("part%s0", getDestinationNameDelimiter()), output, + producerProperties); + try { + Object endpoint = extractEndpoint(outputBinding); + checkRkExpressionForPartitionedModuleSpEL(endpoint); + } + catch (UnsupportedOperationException ignored) { + } + + Message message2 = MessageBuilder.withPayload("2") + .setHeader(IntegrationMessageHeaderAccessor.CORRELATION_ID, "foo") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_NUMBER, 42) + .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_SIZE, 43).build(); + output.send(message2); + output.send(MessageBuilder.withPayload("1") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + output.send(MessageBuilder.withPayload("0") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + Message receive0 = receive(input0); + assertThat(receive0).isNotNull(); + Message receive1 = receive(input1); + assertThat(receive1).isNotNull(); + Message receive2 = receive(input2); + assertThat(receive2).isNotNull(); + + Condition> correlationHeadersForPayload2 = new Condition>() { + @Override + public boolean matches(Message value) { + IntegrationMessageHeaderAccessor accessor = new IntegrationMessageHeaderAccessor( + value); + return "foo".equals(accessor.getCorrelationId()) + && 42 == accessor.getSequenceNumber() + && 43 == accessor.getSequenceSize(); + } + }; + + if (usesExplicitRouting()) { + assertThat(receive0.getPayload()).isEqualTo("0".getBytes()); + assertThat(receive1.getPayload()).isEqualTo("1".getBytes()); + assertThat(receive2.getPayload()).isEqualTo("2".getBytes()); + assertThat(receive2).has(correlationHeadersForPayload2); + } + else { + List> receivedMessages = Arrays.asList(receive0, receive1, + receive2); + assertThat(receivedMessages).extracting("payload").containsExactlyInAnyOrder( + "0".getBytes(), "1".getBytes(), "2".getBytes()); + Condition> payloadIs2 = new Condition>() { + + @Override + public boolean matches(Message value) { + return value.getPayload().equals("2".getBytes()); + } + }; + assertThat(receivedMessages).filteredOn(payloadIs2).areExactly(1, + correlationHeadersForPayload2); + + } + input0Binding.unbind(); + input1Binding.unbind(); + input2Binding.unbind(); + outputBinding.unbind(); + } + + protected void checkRkExpressionForPartitionedModuleSpEL(Object endpoint) { + assertThat(getEndpointRouting(endpoint)) + .contains(getExpectedRoutingBaseDestination( + String.format("part%s0", getDestinationNameDelimiter()), "test") + + "-' + headers['partition']"); + } + + @Test + @SuppressWarnings("deprecation") + public void testPartitionedModuleJava() throws Exception { + B binder = getBinder(); + + CP consumerProperties = createConsumerProperties(); + consumerProperties.setConcurrency(2); + consumerProperties.setInstanceCount(3); + consumerProperties.setInstanceIndex(0); + consumerProperties.setPartitioned(true); + QueueChannel input0 = new QueueChannel(); + input0.setBeanName("test.input0J"); + Binding input0Binding = binder.bindConsumer( + String.format("partJ%s0", getDestinationNameDelimiter()), + "testPartitionedModuleJava", input0, consumerProperties); + consumerProperties.setInstanceIndex(1); + QueueChannel input1 = new QueueChannel(); + input1.setBeanName("test.input1J"); + Binding input1Binding = binder.bindConsumer( + String.format("partJ%s0", getDestinationNameDelimiter()), + "testPartitionedModuleJava", input1, consumerProperties); + consumerProperties.setInstanceIndex(2); + QueueChannel input2 = new QueueChannel(); + input2.setBeanName("test.input2J"); + Binding input2Binding = binder.bindConsumer( + String.format("partJ%s0", getDestinationNameDelimiter()), + "testPartitionedModuleJava", input2, consumerProperties); + + PP producerProperties = createProducerProperties(); + producerProperties.setPartitionKeyExtractorClass(PartitionTestSupport.class); + producerProperties.setPartitionSelectorClass(PartitionTestSupport.class); + producerProperties.setPartitionCount(3); + DirectChannel output = createBindableChannel("output", + createProducerBindingProperties(producerProperties)); + output.setBeanName("test.output"); + Binding outputBinding = binder.bindProducer("partJ.0", output, + producerProperties); + if (usesExplicitRouting()) { + Object endpoint = extractEndpoint(outputBinding); + assertThat(getEndpointRouting(endpoint)) + .contains(getExpectedRoutingBaseDestination( + String.format("partJ%s0", getDestinationNameDelimiter()), + "testPartitionedModuleJava") + "-' + headers['" + + BinderHeaders.PARTITION_HEADER + "']"); + } + + output.send(MessageBuilder.withPayload("2") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + output.send(MessageBuilder.withPayload("1") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + output.send(MessageBuilder.withPayload("0") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build()); + + Message receive0 = receive(input0); + assertThat(receive0).isNotNull(); + Message receive1 = receive(input1); + assertThat(receive1).isNotNull(); + Message receive2 = receive(input2); + assertThat(receive2).isNotNull(); + + if (usesExplicitRouting()) { + assertThat(receive0.getPayload()).isEqualTo("0".getBytes()); + assertThat(receive1.getPayload()).isEqualTo("1".getBytes()); + assertThat(receive2.getPayload()).isEqualTo("2".getBytes()); + } + else { + List> receivedMessages = Arrays.asList(receive0, receive1, + receive2); + assertThat(receivedMessages).extracting("payload").containsExactlyInAnyOrder( + "0".getBytes(), "1".getBytes(), "2".getBytes()); + } + + input0Binding.unbind(); + input1Binding.unbind(); + input2Binding.unbind(); + outputBinding.unbind(); + } + + /** + * Implementations should return whether the binder under test uses "explicit" routing + * (e.g. Rabbit) whereby Spring Cloud Stream is responsible for assigning a partition + * and knows which exact consumer will receive the message (i.e. honor + * "partitionIndex") or "implicit" routing (e.g. Kafka) whereby the only guarantee is + * that messages will be spread, but we don't control exactly which consumer gets + * which message. + */ + protected abstract boolean usesExplicitRouting(); + + /** + * For implementations that rely on explicit routing, return the routing expression. + */ + protected String getEndpointRouting(Object endpoint) { + throw new UnsupportedOperationException(); + } + + /** + * For implementations that rely on explicit routing, return the expected base + * destination (the part that precedes '-partition' within the expression). + */ + protected String getExpectedRoutingBaseDestination(String name, String group) { + throw new UnsupportedOperationException(); + } + + protected abstract String getClassUnderTestName(); + + protected Lifecycle extractEndpoint(Binding binding) { + DirectFieldAccessor accessor = new DirectFieldAccessor(binding); + return (Lifecycle) accessor.getPropertyValue("lifecycle"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionTestSupport.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionTestSupport.java new file mode 100644 index 000000000..da73364f7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/PartitionTestSupport.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.Message; + +/** + * @author Gary Russell + */ +public class PartitionTestSupport + implements PartitionKeyExtractorStrategy, PartitionSelectorStrategy { + + @Override + public int selectPartition(Object key, int divisor) { + return key.hashCode() % divisor; + } + + @Override + public Object extractKey(Message message) { + return message.getPayload(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/SerializableFoo.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/SerializableFoo.java new file mode 100644 index 000000000..41fbba302 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/SerializableFoo.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.Serializable; + +/** + * @author Oleg Zhurakousky + * + */ +public class SerializableFoo implements Serializable { + + /** + * + */ + private static final long serialVersionUID = 1L; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/Spy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/Spy.java new file mode 100644 index 000000000..ee0ef97c0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/Spy.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * Represents an out-of-band connection to the underlying middleware, so that tests can + * check that some messages actually do (or do not) transit through it. + * + * @author Eric Bottard + */ +public interface Spy { + + Object receive(boolean expectNull) throws Exception; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/TestUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/TestUtils.java new file mode 100644 index 000000000..8be71557a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/java/org/springframework/cloud/stream/binder/TestUtils.java @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.util.Assert; + +/** + * Copy of class in org.springframework.amqp.utils.test to avoid dependency on + * spring-amqp. + * + * @author David Turanski + */ +public abstract class TestUtils { + + /** + * Uses nested {@link DirectFieldAccessor}s to obtain a property using dotted notation + * to traverse fields; e.g. "foo.bar.baz" will obtain a reference to the baz field of + * the bar field of foo. Adopted from Spring Integration. + * @param root The object. + * @param propertyPath The path. + * @return The field. + */ + public static Object getPropertyValue(Object root, String propertyPath) { + Object value = null; + DirectFieldAccessor accessor = new DirectFieldAccessor(root); + String[] tokens = propertyPath.split("\\."); + for (int i = 0; i < tokens.length; i++) { + value = accessor.getPropertyValue(tokens[i]); + if (value != null) { + accessor = new DirectFieldAccessor(value); + } + else if (i == tokens.length - 1) { + return null; + } + else { + throw new IllegalArgumentException( + "intermediate property '" + tokens[i] + "' is null"); + } + } + return value; + } + + @SuppressWarnings("unchecked") + public static T getPropertyValue(Object root, String propertyPath, + Class type) { + Object value = getPropertyValue(root, propertyPath); + if (value != null) { + Assert.isAssignable(type, value.getClass()); + } + return (T) value; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..9a0f710e7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-binder-test/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + org.springframework.cloud.stream.binder.BinderTestEnvironmentPostProcessor diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/pom.xml new file mode 100644 index 000000000..afa33f28f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + spring-cloud-stream-integration-tests + jar + spring-cloud-stream-integration-tests + Integration tests for Spring Cloud Stream + + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.springframework.cloud + spring-cloud-stream + test-jar + test-binder + + + org.springframework.cloud + spring-cloud-stream-test-support-internal + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/ContentTypeOutboundSourceTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/ContentTypeOutboundSourceTests.java new file mode 100644 index 000000000..ebe7b5f5e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/ContentTypeOutboundSourceTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { ContentTypeOutboundSourceTests.TestSource.class }) +public class ContentTypeOutboundSourceTests { + + @Autowired + private Source testSource; + + @Autowired + private BinderFactory binderFactory; + + @Test + @SuppressWarnings("unchecked") + public void testMessageHeaderWhenNoExplicitContentTypeOnMessage() throws Exception { + this.testSource.output().send(MessageBuilder.withPayload("{\"message\":\"Hi\"}") + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build()); + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testSource.output()).poll(); + assertThat(received.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString()) + .contains("text/plain"); + assertThat("{\"message\":\"Hi\"}").isEqualTo(received.getPayload()); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/channel/source-channel-configurers.properties") + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomHeaderPropagationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomHeaderPropagationTests.java new file mode 100644 index 000000000..15fce68c3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomHeaderPropagationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = CustomHeaderPropagationTests.HeaderPropagationProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { + "spring.cloud.stream.integration.messageHandlerNotPropagatedHeaders=bar,contentType" }) +public class CustomHeaderPropagationTests { + + // @checkstyle:on + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + /** + * @since 2.0 The behavior of content type handling has changed. All input/output + * channels have a default content type of application/json When a processor or a + * source returns a String, and if the content type is json it will be quoted + */ + public void testCustomHeaderPropagation() throws Exception { + this.testProcessor.input().send(MessageBuilder.withPayload("{'name':'foo'}") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json") + .setHeader("foo", "fooValue").setHeader("bar", "barValue").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(10, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getHeaders()).containsEntry("foo", "fooValue"); + assertThat(received.getHeaders()).doesNotContainKey("bar"); + assertThat(received.getHeaders()).containsKeys(MessageHeaders.CONTENT_TYPE); + assertThat(new String(received.getPayload())).isEqualTo("{'name':'foo'}"); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class HeaderPropagationProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Message consume(String data) { + // if we don't force content to be String, it will be quoted on the outbound + // channel + return MessageBuilder.withPayload(data) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomMessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomMessageConverterTests.java new file mode 100644 index 000000000..515e4ebe1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/CustomMessageConverterTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ilayaperumal Gopinathan + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = CustomMessageConverterTests.TestSource.class) +public class CustomMessageConverterTests { + + @Autowired + private Source testSource; + + @Autowired + private BinderFactory binderFactory; + + @Autowired + @StreamMessageConverter + private List customMessageConverters; + + @Test + public void testCustomMessageConverter() throws Exception { + assertThat(this.customMessageConverters).hasSize(2); + assertThat(this.customMessageConverters).extracting("class") + .contains(FooConverter.class, BarConverter.class); + this.testSource.output().send(MessageBuilder.withPayload(new Foo("hi")).build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testSource.output()).poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeType.valueOf("test/foo")); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/custom/source-channel-configurers.properties") + @Configuration + public static class TestSource { + + @Bean + @StreamMessageConverter + public MessageConverter fooConverter() { + return new FooConverter(); + } + + @Bean + @StreamMessageConverter + public MessageConverter barConverter() { + return new BarConverter(); + } + + } + + public static class FooConverter extends AbstractMessageConverter { + + public FooConverter() { + super(MimeType.valueOf("test/foo")); + } + + @Override + protected boolean supports(Class clazz) { + return clazz.equals(Foo.class); + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + Object result = null; + try { + if (payload instanceof Foo) { + Foo fooPayload = (Foo) payload; + result = fooPayload.test.getBytes(); + } + } + catch (Exception e) { + this.logger.error(e.getMessage(), e); + return null; + } + return result; + } + + } + + public static class BarConverter extends AbstractMessageConverter { + + public BarConverter() { + super(MimeType.valueOf("test/bar")); + } + + @Override + protected boolean supports(Class clazz) { + return clazz.equals(Bar.class); + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + Object result = null; + try { + if (payload instanceof Bar) { + Bar barPayload = (Bar) payload; + result = barPayload.testing.getBytes(); + } + } + catch (Exception e) { + this.logger.error(e.getMessage(), e); + return null; + } + return result; + } + + } + + public static class Foo { + + final String test; + + public Foo(String test) { + this.test = test; + } + + } + + public static class Bar { + + final String testing; + + public Bar(String testing) { + this.testing = testing; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationTests.java new file mode 100644 index 000000000..20c1cb963 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = DefaultHeaderPropagationTests.HeaderPropagationProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +public class DefaultHeaderPropagationTests { + + // @checkstyle:on + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + public void testDefaultHeaderPropagation() throws Exception { + this.testProcessor.input().send(MessageBuilder.withPayload("{'name':'foo'}") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json") + .setHeader("foo", "fooValue").setHeader("bar", "barValue").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getHeaders()).containsEntry("foo", "fooValue"); + assertThat(received.getHeaders()).containsEntry("bar", "barValue"); + assertThat(received.getHeaders()).containsKeys(MessageHeaders.CONTENT_TYPE); + assertThat(received.getPayload()).isEqualTo("{'name':'foo'}"); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class HeaderPropagationProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Message consume(String data) { + return MessageBuilder.withPayload(data) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationWithApplicationProvidedHeaderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationWithApplicationProvidedHeaderTests.java new file mode 100644 index 000000000..3bfd8427f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DefaultHeaderPropagationWithApplicationProvidedHeaderTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = DefaultHeaderPropagationWithApplicationProvidedHeaderTests.HeaderPropagationProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +public class DefaultHeaderPropagationWithApplicationProvidedHeaderTests { + + // @checkstyle:on + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + public void testHeaderPropagationIfSetByApplication() throws Exception { + this.testProcessor.input().send(MessageBuilder.withPayload("{'name':'foo'}") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json") + .setHeader("foo", "fooValue").setHeader("bar", "barValue").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received.getHeaders().get("foo")).isEqualTo("fooValue"); + assertThat(received.getHeaders().get("bar")).isEqualTo("barValue"); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class HeaderPropagationProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Message consume(String data) { + return MessageBuilder.withPayload(data) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DeserializeJSONToJavaTypeTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DeserializeJSONToJavaTypeTests.java new file mode 100644 index 000000000..e6fb13ce4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/DeserializeJSONToJavaTypeTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = DeserializeJSONToJavaTypeTests.FooProcessor.class) +public class DeserializeJSONToJavaTypeTests { + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + public void testMessageDeserialized() throws Exception { + this.testProcessor.input().send(MessageBuilder.withPayload("{\"name\":\"Bar\"}") + .setHeader("contentType", "application/json").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isEqualTo("{\"name\":\"Bar\"}"); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/fooprocesor/foo-sink.properties") + @Configuration + public static class FooProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Foo consume(Foo foo) { + return foo; + } + + } + + public static class Foo { + + private String name; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/InboundJsonToTupleConversionTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/InboundJsonToTupleConversionTest.java new file mode 100644 index 000000000..73be68443 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/InboundJsonToTupleConversionTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.tuple.Tuple; +import org.springframework.tuple.TupleBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = InboundJsonToTupleConversionTest.FooProcessor.class) +public class InboundJsonToTupleConversionTest { + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + public void testInboundJsonTupleConversion() throws Exception { + this.testProcessor.input() + .send(MessageBuilder.withPayload("{'name':'foo'}").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + String payload = new String(received.getPayload(), StandardCharsets.UTF_8); + assertThat(TupleBuilder.fromString(payload)) + .isEqualTo(TupleBuilder.tuple().of("name", "foo")); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/inboundjsontuple/inbound-json-tuple.properties") + public static class FooProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Tuple consume(Tuple tuple) { + return tuple; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/LegacyContentTypeTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/LegacyContentTypeTests.java new file mode 100644 index 000000000..3224ff8c9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/LegacyContentTypeTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { LegacyContentTypeTests.LegacyTestSink.class }) +public class LegacyContentTypeTests { + + @Autowired + private Sink testSink; + + @Test + public void testOriginalContentTypeIsRetrievedForLegacyContentHeaderType() + throws Exception { + final CountDownLatch latch = new CountDownLatch(1); + MessageHandler messageHandler = new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + assertThat(message.getPayload()).isInstanceOf(byte[].class); + assertThat(((byte[]) message.getPayload())).isEqualTo( + "{\"message\":\"Hi\"}".getBytes(StandardCharsets.UTF_8)); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString()) + .isEqualTo("application/json"); + latch.countDown(); + } + }; + this.testSink.input().subscribe(messageHandler); + this.testSink.input().send(MessageBuilder + .withPayload("{\"message\":\"Hi\"}".getBytes()) + .setHeader(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE, "application/json") + .build()); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + this.testSink.input().unsubscribe(messageHandler); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class LegacyTestSink { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelConfigurerTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelConfigurerTests.java new file mode 100644 index 000000000..e6cadc049 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelConfigurerTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.messaging.DirectWithAttributesChannel; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Soby Chacko + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { MessageChannelConfigurerTests.TestSink.class, + MessageChannelConfigurerTests.TestSource.class, + SpelExpressionConverterConfiguration.class }) +public class MessageChannelConfigurerTests { + + @Autowired + private Sink testSink; + + @Autowired + private Source testSource; + + @Autowired + private CompositeMessageConverterFactory messageConverterFactory; + + @Autowired + private MessageCollector messageCollector; + + @Test + public void testChannelTypes() throws Exception { + DirectWithAttributesChannel inputChannel = (DirectWithAttributesChannel) this.testSink + .input(); + DirectWithAttributesChannel outputChannel = (DirectWithAttributesChannel) this.testSource + .output(); + assertThat(inputChannel.getAttribute("type")).isEqualTo(Sink.INPUT); + assertThat(outputChannel.getAttribute("type")).isEqualTo(Source.OUTPUT); + } + + @Test + public void testMessageConverterConfigurer() throws Exception { + final CountDownLatch latch = new CountDownLatch(1); + MessageHandler messageHandler = message -> { + assertThat(message.getPayload()).isInstanceOf(byte[].class); + assertThat(message.getPayload()).isEqualTo("{\"message\":\"Hi\"}".getBytes()); + latch.countDown(); + }; + this.testSink.input().subscribe(messageHandler); + this.testSink.input().send( + MessageBuilder.withPayload("{\"message\":\"Hi\"}".getBytes()).build()); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + this.testSink.input().unsubscribe(messageHandler); + } + + @Test + public void testObjectMapperConfig() throws Exception { + CompositeMessageConverter converters = (CompositeMessageConverter) this.messageConverterFactory + .getMessageConverterForType(MimeTypeUtils.APPLICATION_JSON); + for (MessageConverter converter : converters.getConverters()) { + DirectFieldAccessor converterAccessor = new DirectFieldAccessor(converter); + ObjectMapper objectMapper = (ObjectMapper) converterAccessor + .getPropertyValue("objectMapper"); + // assert that the ObjectMapper used by the converters is compliant with the + // Boot configuration + assertThat(!objectMapper.getSerializationConfig().isEnabled( + SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).withFailMessage( + "SerializationFeature 'WRITE_DATES_AS_TIMESTAMPS' should be disabled"); + // assert that the globally set bean is used by the converters + } + } + + @Test + public void testPartitionHeader() throws Exception { + this.testSource.output() + .send(MessageBuilder.withPayload("{\"message\":\"Hi\"}").build()); + Message message = this.messageCollector.forChannel(this.testSource.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(message.getHeaders().get(BinderHeaders.PARTITION_HEADER).equals(0)); + assertThat(message.getHeaders().get(BinderHeaders.PARTITION_OVERRIDE)).isNull(); + } + + @Test + public void testPartitionHeaderWithPartitionOverride() throws Exception { + this.testSource.output().send(MessageBuilder.withPayload("{\"message\":\"Hi\"}") + .setHeader(BinderHeaders.PARTITION_OVERRIDE, 123).build()); + Message message = this.messageCollector.forChannel(this.testSource.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(message.getHeaders().get(BinderHeaders.PARTITION_HEADER).equals(123)); + assertThat(message.getHeaders().get(BinderHeaders.PARTITION_OVERRIDE)).isNull(); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/channel/sink-channel-configurers.properties") + public static class TestSink { + + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/channel/partitioned-configurers.properties") + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeDecodingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeDecodingTests.java new file mode 100644 index 000000000..df7d39335 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeDecodingTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutput; +import java.io.ObjectOutputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.MessageHandler; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { + MessageChannelWithNativeDecodingTests.NativeDecodingSink.class }) +public class MessageChannelWithNativeDecodingTests { + + @Autowired + private Sink nativeDecodingSink; + + @Test + public void testMessageConverterInterceptorsAreSkippedWhenNativeDecodingIsEnabled() + throws Exception { + final CountDownLatch latch = new CountDownLatch(1); + + byte[] serializedData; + ObjectOutput out; + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + out = new ObjectOutputStream(bos); + out.writeObject(123); + out.flush(); + serializedData = bos.toByteArray(); + } + + MessageHandler messageHandler = message -> { + // ensure that the data is not deserialized becasue of native decoding + // and the content type set in the properties file didn't take any effect + assertThat(message.getPayload()).isInstanceOf(byte[].class); + assertThat(message.getPayload()).isEqualTo(serializedData); + latch.countDown(); + }; + this.nativeDecodingSink.input().subscribe(messageHandler); + + this.nativeDecodingSink.input() + .send(MessageBuilder.withPayload(serializedData).build()); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + this.nativeDecodingSink.input().unsubscribe(messageHandler); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/channel/native-decoding-sink.properties") + public static class NativeDecodingSink { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeEncodingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeEncodingTests.java new file mode 100644 index 000000000..d9b309401 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/MessageChannelWithNativeEncodingTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { + MessageChannelWithNativeEncodingTests.NativeEncodingSource.class }) +public class MessageChannelWithNativeEncodingTests { + + @Autowired + private Source nativeEncodingSource; + + @Autowired + private MessageCollector messageCollector; + + @Test + public void testOutboundContentTypeInterceptorIsSkippedWhenNativeEncodingIsEnabled() + throws Exception { + this.nativeEncodingSource.output() + .send(MessageBuilder.withPayload("hello foobar!").build()); + Message message = this.messageCollector + .forChannel(this.nativeEncodingSource.output()).poll(1, TimeUnit.SECONDS); + // should not convert the payload to byte[] even though we set a contentType on + // the channel. + // This is becasue, we are using native encoding. + assertThat(message.getPayload()).isInstanceOf(String.class); + assertThat(message.getPayload()).isEqualTo("hello foobar!"); + assertThat(message.getHeaders().get(MessageHeaders.CONTENT_TYPE)).isNull(); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/channel/native-encoding-source.properties") + public static class NativeEncodingSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotatedMethodArgumentsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotatedMethodArgumentsTests.java new file mode 100644 index 000000000..0e9fe8156 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotatedMethodArgumentsTests.java @@ -0,0 +1,191 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import javax.validation.Valid; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + * @author Artem Bilan + */ +public class StreamListenerAnnotatedMethodArgumentsTests { + + @BeforeClass + public static void init() { + Locale.setDefault(Locale.US); + } + + @Test + @SuppressWarnings("unchecked") + public void testAnnotatedArguments() { + ConfigurableApplicationContext context = SpringApplication + .run(TestPojoWithAnnotatedArguments.class, "--server.port=0"); + + TestPojoWithAnnotatedArguments testPojoWithAnnotatedArguments = context + .getBean(TestPojoWithAnnotatedArguments.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", MimeType.valueOf("application/json")) + .setHeader("testHeader", "testValue").build()); + assertThat(testPojoWithAnnotatedArguments.receivedArguments).hasSize(3); + assertThat(testPojoWithAnnotatedArguments.receivedArguments.get(0)) + .isInstanceOf(StreamListenerTestUtils.FooPojo.class); + assertThat(testPojoWithAnnotatedArguments.receivedArguments.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + assertThat(testPojoWithAnnotatedArguments.receivedArguments.get(1)) + .isInstanceOf(Map.class); + assertThat((Map) testPojoWithAnnotatedArguments.receivedArguments + .get(1)).containsEntry(MessageHeaders.CONTENT_TYPE, + MimeType.valueOf("application/json")); + assertThat((Map) testPojoWithAnnotatedArguments.receivedArguments + .get(1)).containsEntry("testHeader", "testValue"); + assertThat(testPojoWithAnnotatedArguments.receivedArguments.get(2)) + .isEqualTo("application/json"); + context.close(); + } + + @Test + public void testInputAnnotationAtMethodParameter() { + try { + SpringApplication.run(TestPojoWithInvalidInputAnnotatedArgument.class, + "--server.port=0"); + fail("Exception expected: " + INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @Test + public void testValidAnnotationAtMethodParameterWithPojoThatPassesValidation() { + ConfigurableApplicationContext context = SpringApplication.run( + TestPojoWithValidAnnotationThatPassesValidation.class, "--server.port=0"); + + TestPojoWithValidAnnotationThatPassesValidation testPojoWithValidAnnotationThatPassesValidation = context + .getBean(TestPojoWithValidAnnotationThatPassesValidation.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"" + id + "\"}") + .setHeader("contentType", MimeType.valueOf("application/json")).build()); + assertThat( + testPojoWithValidAnnotationThatPassesValidation.receivedArguments.get(0)) + .hasFieldOrPropertyWithValue("foo", id); + context.close(); + } + + @Test + public void testValidAnnotationAtMethodParameterWithPojoThatFailsValidation() { + ConfigurableApplicationContext context = SpringApplication.run( + TestPojoWithValidAnnotationThatPassesValidation.class, "--server.port=0"); + + Sink sink = context.getBean(Sink.class); + try { + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"\"}") + .setHeader("contentType", MimeType.valueOf("application/json")) + .build()); + fail("Exception expected: MethodArgumentNotValidException!"); + } + catch (MethodArgumentNotValidException e) { + assertThat(e.getMessage()).contains( + "default message [foo]]; default message [must not be blank]]"); + } + context.close(); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithAnnotatedArguments { + + List receivedArguments = new ArrayList<>(); + + @StreamListener(Processor.INPUT) + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo, + @Headers Map headers, + @Header(MessageHeaders.CONTENT_TYPE) String contentType) { + this.receivedArguments.add(fooPojo); + this.receivedArguments.add(headers); + this.receivedArguments.add(contentType); + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithInvalidInputAnnotatedArgument { + + List receivedArguments = new ArrayList<>(); + + @StreamListener + public void receive( + @Input(Processor.INPUT) @Payload StreamListenerTestUtils.FooPojo fooPojo, + @Headers Map headers, + @Header(MessageHeaders.CONTENT_TYPE) String contentType) { + this.receivedArguments.add(fooPojo); + this.receivedArguments.add(headers); + this.receivedArguments.add(contentType); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithValidAnnotationThatPassesValidation { + + List receivedArguments = new ArrayList<>(); + + @StreamListener(Processor.INPUT) + public void receive( + @Valid StreamListenerTestUtils.PojoWithValidation pojoWithValidation) { + this.receivedArguments.add(pojoWithValidation); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotationBeanPostProcessorOverrideTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotationBeanPostProcessorOverrideTest.java new file mode 100644 index 000000000..f5ba9e217 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAnnotationBeanPostProcessorOverrideTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerAnnotationBeanPostProcessor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.handler.annotation.Payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.stream.config.BindingServiceConfiguration.STREAM_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_NAME; + +/** + * @author Marius Bogoevici + */ +public class StreamListenerAnnotationBeanPostProcessorOverrideTest { + + @Test + @SuppressWarnings("unchecked") + public void testOverrideStreamListenerAnnotationBeanPostProcessor() throws Exception { + ConfigurableApplicationContext context = SpringApplication + .run(TestPojoWithAnnotatedArguments.class, "--server.port=0"); + + TestPojoWithAnnotatedArguments testPojoWithAnnotatedArguments = context + .getBean(TestPojoWithAnnotatedArguments.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "foo").build()); + sink.input().send(MessageBuilder.withPayload("{\"bar\":\"foofoo" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "bar").build()); + assertThat(testPojoWithAnnotatedArguments.receivedFoo).hasSize(1); + assertThat(testPojoWithAnnotatedArguments.receivedFoo.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + context.close(); + } + + @Configuration + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithAnnotatedArguments { + + List receivedFoo = new ArrayList<>(); + + /** + * Overrides the default {@link StreamListenerAnnotationBeanPostProcessor}. + */ + @Bean(name = STREAM_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_NAME) + public static StreamListenerAnnotationBeanPostProcessor streamListenerAnnotationBeanPostProcessor() { + return new StreamListenerAnnotationBeanPostProcessor() { + @Override + protected StreamListener postProcessAnnotation( + StreamListener originalAnnotation, Method annotatedMethod) { + Map attributes = new HashMap<>( + AnnotationUtils.getAnnotationAttributes(originalAnnotation)); + attributes.put("condition", + "headers['type']=='" + originalAnnotation.condition() + "'"); + return AnnotationUtils.synthesizeAnnotation(attributes, + StreamListener.class, annotatedMethod); + } + }; + } + + @StreamListener(value = Sink.INPUT, condition = "foo") + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedFoo.add(fooPojo); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAsMetaAnnotationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAsMetaAnnotationTests.java new file mode 100644 index 000000000..64855b066 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerAsMetaAnnotationTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.annotation.AliasFor; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; + +import static org.assertj.core.api.Assertions.assertThat; + +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@MessageMapping +@Documented +@StreamListener +@interface EventHandler { + + /** + * The name of the binding target (e.g. channel) that the method subscribes to. + * @return the name of the binding target. + */ + @AliasFor(annotation = StreamListener.class, attribute = "target") + String value() default ""; + + /** + * The name of the binding target (e.g. channel) that the method subscribes to. + * @return the name of the binding target. + */ + @AliasFor(annotation = StreamListener.class, attribute = "target") + String target() default ""; + + /** + * A condition that must be met by all items that are dispatched to this method. + * @return a SpEL expression that must evaluate to a {@code boolean} value. + */ + @AliasFor(annotation = StreamListener.class, attribute = "condition") + String condition() default ""; + +} + +/** + * @author David Turanski + */ +public class StreamListenerAsMetaAnnotationTests { + + @Test + public void testCustomAnnotation() { + ConfigurableApplicationContext context = SpringApplication + .run(TestPojoWithCustomAnnotatedArguments.class, "--server.port=0"); + + TestPojoWithCustomAnnotatedArguments testPojoWithAnnotatedArguments = context + .getBean(TestPojoWithCustomAnnotatedArguments.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "foo").build()); + assertThat(testPojoWithAnnotatedArguments.receivedFoo).hasSize(1); + assertThat(testPojoWithAnnotatedArguments.receivedFoo.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + context.close(); + } + + @Test + public void testAnnotation() { + ConfigurableApplicationContext context = SpringApplication + .run(TestPojoWithAnnotatedArguments.class, "--server.port=0"); + + TestPojoWithAnnotatedArguments testPojoWithAnnotatedArguments = context + .getBean(TestPojoWithAnnotatedArguments.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "foo").build()); + assertThat(testPojoWithAnnotatedArguments.receivedFoo).hasSize(1); + assertThat(testPojoWithAnnotatedArguments.receivedFoo.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + context.close(); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithCustomAnnotatedArguments { + + List receivedFoo = new ArrayList<>(); + + List receivedBar = new ArrayList<>(); + + @EventHandler(value = Sink.INPUT, condition = "headers['type']=='foo'") + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedFoo.add(fooPojo); + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithAnnotatedArguments { + + List receivedFoo = new ArrayList<>(); + + List receivedBar = new ArrayList<>(); + + @StreamListener(value = Sink.INPUT, condition = "headers['type']=='foo'") + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedFoo.add(fooPojo); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerContentTypeConversionTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerContentTypeConversionTests.java new file mode 100644 index 000000000..a827552c3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerContentTypeConversionTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +public class StreamListenerContentTypeConversionTests { + + @Test + public void testContentTypeConversion() throws Exception { + ConfigurableApplicationContext context = SpringApplication + .run(TestSinkWithContentTypeConversion.class, "--server.port=0"); + @SuppressWarnings("unchecked") + TestSinkWithContentTypeConversion testSink = context + .getBean(TestSinkWithContentTypeConversion.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + assertThat(testSink.latch.await(10, TimeUnit.SECONDS)); + assertThat(testSink.receivedArguments).hasSize(1); + assertThat(testSink.receivedArguments.get(0)).hasFieldOrPropertyWithValue("foo", + "barbar" + id); + context.close(); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestSinkWithContentTypeConversion { + + List receivedArguments = new ArrayList<>(); + + CountDownLatch latch = new CountDownLatch(1); + + @StreamListener(Sink.INPUT) + public void receive(StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedArguments.add(fooPojo); + this.latch.countDown(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerDuplicateMappingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerDuplicateMappingTests.java new file mode 100644 index 000000000..c5cb84694 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerDuplicateMappingTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.junit.Test; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerErrorMessages; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +public class StreamListenerDuplicateMappingTests { + + @Test + @SuppressWarnings("unchecked") + public void testMultipleMappingsWithReturnValue() { + ConfigurableApplicationContext context = null; + try { + context = SpringApplication.run(TestMultipleMappingsWithReturnValue.class, + "--server.port=0"); + fail("Exception expected on duplicate mapping"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).startsWith( + StreamListenerErrorMessages.MULTIPLE_VALUE_RETURNING_METHODS); + } + finally { + if (context != null) { + context.close(); + } + } + } + + @Test + public void testDuplicateMappingFromAbstractMethod() { + ConfigurableApplicationContext context = null; + try { + context = SpringApplication.run(TestDuplicateMappingFromAbstractMethod.class, + "--server.port=0"); + } + catch (BeanCreationException e) { + String errorMessage = e.getCause().getMessage() + .startsWith("Duplicate @StreamListener mapping") + ? "Duplicate mapping exception is not expected" + : "Test failed with exception"; + fail(errorMessage + ": " + e.getMessage()); + } + finally { + if (context != null) { + context.close(); + } + } + } + + public interface GenericSink { + + void testMethod(T msg); + + } + + public interface Base { + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestMultipleMappingsWithReturnValue { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receive(Message fooMessage) { + return null; + } + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receiveDuplicateMapping(Message fooMessage) { + return null; + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestDuplicateMappingFromAbstractMethod + implements GenericSink { + + @Override + @StreamListener(Sink.INPUT) + public void testMethod(TestBase msg) { + } + + } + + public class TestBase implements Base { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerBeanTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerBeanTests.java new file mode 100644 index 000000000..160c9aefd --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerBeanTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerHandlerBeanTests { + + private Class configClass; + + public StreamListenerHandlerBeanTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(TestHandlerBeanWithSendTo.class, TestHandlerBean2.class); + } + + @Test + @SuppressWarnings("unchecked") + public void testHandlerBean() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--spring.cloud.stream.bindings.output.contentType=application/json", + "--server.port=0"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + HandlerBean handlerBean = context.getBean(HandlerBean.class); + Assertions.assertThat(handlerBean.receivedPojos).hasSize(1); + Assertions.assertThat(handlerBean.receivedPojos.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo("{\"bar\":\"barbar" + id + "\"}"); + assertThat(message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_JSON)); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestHandlerBeanWithSendTo { + + @Bean + public HandlerBeanWithSendTo handlerBean() { + return new HandlerBeanWithSendTo(); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestHandlerBean2 { + + @Bean + public HandlerBeanWithOutput handlerBean() { + return new HandlerBeanWithOutput(); + } + + } + + public static class HandlerBeanWithSendTo extends HandlerBean { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive( + StreamListenerTestUtils.FooPojo fooMessage) { + this.receivedPojos.add(fooMessage); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooMessage.getFoo()); + return barPojo; + } + + } + + public static class HandlerBeanWithOutput extends HandlerBean { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive( + StreamListenerTestUtils.FooPojo fooMessage) { + this.receivedPojos.add(fooMessage); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooMessage.getFoo()); + return barPojo; + } + + } + + public static class HandlerBean { + + List receivedPojos = new ArrayList<>(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerMethodTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerMethodTests.java new file mode 100644 index 000000000..3471ae741 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerHandlerMethodTests.java @@ -0,0 +1,621 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerErrorMessages; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.annotation.Router; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.support.DefaultMessageBuilderFactory; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INPUT_AT_STREAM_LISTENER; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_INBOUND_NAME; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_OUTBOUND_NAME; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_OUTPUT_VALUES; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.NO_INPUT_DESTINATION; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.RETURN_TYPE_MULTIPLE_OUTBOUND_SPECIFIED; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.RETURN_TYPE_NO_OUTBOUND_SPECIFIED; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +public class StreamListenerHandlerMethodTests { + + @Test + public void testInvalidInputOnMethod() throws Exception { + try { + SpringApplication.run(TestInvalidInputOnMethod.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected: " + INPUT_AT_STREAM_LISTENER); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INPUT_AT_STREAM_LISTENER); + } + } + + @SuppressWarnings("unchecked") + @Test + public void testMethodWithObjectAsMethodArgument() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMethodWithObjectAsMethodArgument.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + Processor processor = context.getBean(Processor.class); + final String testMessage = "testing"; + processor.input().send(MessageBuilder.withPayload(testMessage).build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(testMessage.toUpperCase()); + context.close(); + } + + @SuppressWarnings("unchecked") + @Test + /** + * @since 2.0 : This test is an example of the new behavior of 2.0 when it comes to + * contentType handling. The default contentType being JSON in order to be able to + * check a message without quotes the user needs to set the input/output contentType + * accordingly Also, received messages are always of Message now. + */ + public void testMethodHeadersPropagatged() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMethodHeadersPropagated.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + Processor processor = context.getBean(Processor.class); + final String testMessage = "testing"; + processor.input().send( + MessageBuilder.withPayload(testMessage).setHeader("foo", "bar").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(testMessage.toUpperCase()); + assertThat(result.getHeaders().get("foo")).isEqualTo("bar"); + context.close(); + } + + @SuppressWarnings("unchecked") + @Test + public void testMethodHeadersNotPropagatged() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMethodHeadersNotPropagated.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + Processor processor = context.getBean(Processor.class); + final String testMessage = "testing"; + processor.input().send( + MessageBuilder.withPayload(testMessage).setHeader("foo", "bar").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(testMessage.toUpperCase()); + assertThat(result.getHeaders().get("foo")).isNull(); + context.close(); + } + + // TODO: Handle dynamic destinations and contentType + @SuppressWarnings("unchecked") + public void testStreamListenerMethodWithTargetBeanFromOutside() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestStreamListenerMethodWithTargetBeanFromOutside.class, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + Sink sink = context.getBean(Sink.class); + final String testMessageToSend = "testing"; + sink.input().send(MessageBuilder.withPayload(testMessageToSend).build()); + DirectChannel directChannel = (DirectChannel) context + .getBean(testMessageToSend.toUpperCase(), MessageChannel.class); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(directChannel).poll(1000, TimeUnit.MILLISECONDS); + sink.input().send(MessageBuilder.withPayload(testMessageToSend).build()); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(testMessageToSend.toUpperCase()); + context.close(); + } + + @Test + public void testInvalidReturnTypeWithSendToAndOutput() throws Exception { + try { + SpringApplication.run(TestReturnTypeWithMultipleOutput.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected: " + RETURN_TYPE_MULTIPLE_OUTBOUND_SPECIFIED); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(RETURN_TYPE_MULTIPLE_OUTBOUND_SPECIFIED); + } + } + + @Test + public void testInvalidReturnTypeWithNoOutput() throws Exception { + try { + SpringApplication.run(TestInvalidReturnTypeWithNoOutput.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected: " + RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + } + + @Test + public void testInvalidInputAnnotationWithNoValue() throws Exception { + try { + SpringApplication.run(TestInvalidInputAnnotationWithNoValue.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected: " + INVALID_INBOUND_NAME); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INVALID_INBOUND_NAME); + } + } + + @Test + public void testInvalidOutputAnnotationWithNoValue() throws Exception { + try { + SpringApplication.run(TestInvalidOutputAnnotationWithNoValue.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected: " + INVALID_OUTBOUND_NAME); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INVALID_OUTBOUND_NAME); + } + } + + @Test + public void testMethodInvalidInboundName() throws Exception { + try { + SpringApplication.run(TestMethodInvalidInboundName.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected on using invalid inbound name"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains( + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @Test + public void testMethodInvalidOutboundName() throws Exception { + try { + SpringApplication.run(TestMethodInvalidOutboundName.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected on using invalid outbound name"); + } + catch (NoSuchBeanDefinitionException e) { + assertThat(e.getMessage()).contains("invalid"); + } + } + + @Test + public void testAmbiguousMethodArguments1() throws Exception { + try { + SpringApplication.run(TestAmbiguousMethodArguments1.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected: " + AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()) + .contains(AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS); + } + } + + @Test + public void testAmbiguousMethodArguments2() throws Exception { + try { + SpringApplication.run(TestAmbiguousMethodArguments2.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected:" + AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()) + .contains(AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS); + } + } + + @Test + public void testMethodWithInputAsMethodAndParameter() throws Exception { + try { + SpringApplication.run(TestMethodWithInputAsMethodAndParameter.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected: " + INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @Test + public void testMethodWithOutputAsMethodAndParameter() throws Exception { + try { + SpringApplication.run(TestMethodWithOutputAsMethodAndParameter.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected:" + INVALID_OUTPUT_VALUES); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).startsWith(INVALID_OUTPUT_VALUES); + } + } + + @Test + public void testMethodWithoutInput() throws Exception { + try { + SpringApplication.run(TestMethodWithoutInput.class, "--server.port=0", + "--spring.jmx.enabled=false"); + fail("Exception expected when inbound target is not set"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(NO_INPUT_DESTINATION); + } + } + + @Test + public void testMethodWithMultipleInputParameters() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMethodWithMultipleInputParameters.class, "--server.port=0", + "--spring.jmx.enabled=false"); + Processor processor = context.getBean(Processor.class); + StreamListenerTestUtils.FooInboundChannel1 inboundChannel2 = context + .getBean(StreamListenerTestUtils.FooInboundChannel1.class); + final CountDownLatch latch = new CountDownLatch(2); + ((SubscribableChannel) processor.output()).subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + Assert.isTrue( + message.getPayload().equals("footesting") + || message.getPayload().equals("BARTESTING"), + "Assert failed"); + latch.countDown(); + } + }); + processor.input().send(MessageBuilder.withPayload("{\"foo\":\"fooTESTing\"}") + .setHeader("contentType", "application/json").build()); + inboundChannel2.input() + .send(MessageBuilder.withPayload("{\"bar\":\"bartestING\"}") + .setHeader("contentType", "application/json").build()); + assertThat(latch.await(1, TimeUnit.SECONDS)); + context.close(); + } + + @Test + public void testMethodWithMultipleOutputParameters() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMethodWithMultipleOutputParameters.class, "--server.port=0", + "--spring.jmx.enabled=false"); + Processor processor = context.getBean(Processor.class); + StreamListenerTestUtils.FooOutboundChannel1 source2 = context + .getBean(StreamListenerTestUtils.FooOutboundChannel1.class); + final CountDownLatch latch = new CountDownLatch(2); + ((SubscribableChannel) processor.output()).subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + Assert.isTrue(message.getPayload().equals("testing"), "Assert failed"); + Assert.isTrue(message.getHeaders().get("output").equals("output2"), + "Assert failed"); + latch.countDown(); + } + }); + ((SubscribableChannel) source2.output()).subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + Assert.isTrue(message.getPayload().equals("TESTING"), "Assert failed"); + Assert.isTrue(message.getHeaders().get("output").equals("output1"), + "Assert failed"); + latch.countDown(); + } + }); + processor.input().send(MessageBuilder.withPayload("testING") + .setHeader("output", "output1").build()); + processor.input().send(MessageBuilder.withPayload("TESTing") + .setHeader("output", "output2").build()); + assertThat(latch.await(1, TimeUnit.SECONDS)); + context.close(); + } + + @EnableBinding({ Processor.class, StreamListenerTestUtils.FooOutboundChannel1.class }) + @EnableAutoConfiguration + public static class TestMethodWithMultipleOutputParameters { + + @StreamListener + public void receive(@Input(Processor.INPUT) SubscribableChannel input, + @Output(Processor.OUTPUT) final MessageChannel output1, + @Output(StreamListenerTestUtils.FooOutboundChannel1.OUTPUT) final MessageChannel output2) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + if (message.getHeaders().get("output").equals("output1")) { + output1.send(org.springframework.messaging.support.MessageBuilder + .withPayload( + message.getPayload().toString().toUpperCase()) + .build()); + } + else if (message.getHeaders().get("output").equals("output2")) { + output2.send(org.springframework.messaging.support.MessageBuilder + .withPayload( + message.getPayload().toString().toLowerCase()) + .build()); + } + } + }); + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestMethodWithoutInput { + + @StreamListener + public void receive(StreamListenerTestUtils.FooPojo fooPojo) { + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestMethodWithObjectAsMethodArgument { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receive(Object received) { + return received.toString().toUpperCase(); + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestMethodHeadersPropagated { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receive(String received) { + return received.toUpperCase(); + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestMethodHeadersNotPropagated { + + @StreamListener(value = Processor.INPUT, copyHeaders = "${foo.bar:false}") + @SendTo(Processor.OUTPUT) + public String receive(String received) { + return received.toUpperCase(); + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestStreamListenerMethodWithTargetBeanFromOutside { + + private static final String ROUTER_QUEUE = "routeInstruction"; + + @StreamListener(Sink.INPUT) + @SendTo(ROUTER_QUEUE) + public Message convertMessageBody(Message message) { + return new DefaultMessageBuilderFactory() + .withPayload(message.getPayload().toUpperCase()).build(); + } + + @Router(inputChannel = ROUTER_QUEUE) + public String route(String message) { + return message.toUpperCase(); + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestInvalidInputOnMethod { + + @StreamListener + @Input(Sink.INPUT) + public void receive(StreamListenerTestUtils.FooPojo fooPojo) { + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestAmbiguousMethodArguments1 { + + @StreamListener(Processor.INPUT) + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo, + String value) { + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestAmbiguousMethodArguments2 { + + @StreamListener(Processor.INPUT) + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo, + @Payload StreamListenerTestUtils.BarPojo barPojo) { + } + + } + + @EnableBinding({ Processor.class, StreamListenerTestUtils.FooOutboundChannel1.class }) + @EnableAutoConfiguration + public static class TestReturnTypeWithMultipleOutput { + + @StreamListener + public String receive(@Input(Processor.INPUT) SubscribableChannel input1, + @Output(Processor.OUTPUT) MessageChannel output1, + @Output(StreamListenerTestUtils.FooOutboundChannel1.OUTPUT) MessageChannel output2) { + return "foo"; + } + + } + + @EnableBinding({ Processor.class, StreamListenerTestUtils.FooOutboundChannel1.class }) + @EnableAutoConfiguration + public static class TestInvalidReturnTypeWithNoOutput { + + @StreamListener + public String receive(@Input(Processor.INPUT) SubscribableChannel input1) { + return "foo"; + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestInvalidInputAnnotationWithNoValue { + + @StreamListener + public void receive(@Input SubscribableChannel input) { + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestInvalidOutputAnnotationWithNoValue { + + @StreamListener + public void receive(@Input(Processor.OUTPUT) SubscribableChannel input, + @Output MessageChannel output) { + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestMethodInvalidInboundName { + + @StreamListener + public void receive(@Input("invalid") SubscribableChannel input) { + } + + } + + @EnableBinding({ Processor.class }) + @EnableAutoConfiguration + public static class TestMethodInvalidOutboundName { + + @StreamListener + public void receive(@Input(Processor.INPUT) SubscribableChannel input, + @Output("invalid") MessageChannel output) { + } + + } + + @EnableBinding({ Sink.class }) + @EnableAutoConfiguration + public static class TestMethodWithInputAsMethodAndParameter { + + @StreamListener + public void receive(@Input(Sink.INPUT) StreamListenerTestUtils.FooPojo fooPojo) { + } + + } + + @EnableBinding({ Processor.class, StreamListenerTestUtils.FooOutboundChannel1.class }) + @EnableAutoConfiguration + public static class TestMethodWithOutputAsMethodAndParameter { + + @StreamListener + @Output(StreamListenerTestUtils.FooOutboundChannel1.OUTPUT) + public void receive(@Input(Processor.INPUT) SubscribableChannel input, + @Output(Processor.OUTPUT) final MessageChannel output1) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output1.send(org.springframework.messaging.support.MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + + @EnableBinding({ Processor.class, StreamListenerTestUtils.FooInboundChannel1.class }) + @EnableAutoConfiguration + public static class TestMethodWithMultipleInputParameters { + + @StreamListener + public void receive(@Input(Processor.INPUT) SubscribableChannel input1, + @Input(StreamListenerTestUtils.FooInboundChannel1.INPUT) SubscribableChannel input2, + final @Output(Processor.OUTPUT) MessageChannel output) { + input1.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(org.springframework.messaging.support.MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + input2.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(org.springframework.messaging.support.MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMessageArgumentTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMessageArgumentTests.java new file mode 100644 index 000000000..028e2222e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMessageArgumentTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerMessageArgumentTests { + + private Class configClass; + + public StreamListenerMessageArgumentTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(new Class[] { TestPojoWithMessageArgument1.class, + TestPojoWithMessageArgument2.class }); + } + + @Test + @SuppressWarnings("unchecked") + public void testMessageArgument() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload("barbar" + id) + .setHeader("contentType", "text/plain").build()); + TestPojoWithMessageArgument testPojoWithMessageArgument = context + .getBean(TestPojoWithMessageArgument.class); + assertThat(testPojoWithMessageArgument.receivedMessages).hasSize(1); + assertThat(testPojoWithMessageArgument.receivedMessages.get(0).getPayload()) + .isEqualTo("barbar" + id); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(message.getPayload()).contains("barbar" + id); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMessageArgument1 extends TestPojoWithMessageArgument { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive(Message fooMessage) { + this.receivedMessages.add(fooMessage); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooMessage.getPayload()); + return barPojo; + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMessageArgument2 extends TestPojoWithMessageArgument { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive(Message fooMessage) { + this.receivedMessages.add(fooMessage); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooMessage.getPayload()); + return barPojo; + } + + } + + public static class TestPojoWithMessageArgument { + + List> receivedMessages = new ArrayList<>(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodRegisteredOnlyOnceTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodRegisteredOnlyOnceTest.java new file mode 100644 index 000000000..7b475d835 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodRegisteredOnlyOnceTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.Mockito.verify; + +/** + * See issue https://github.com/spring-cloud/spring-cloud-stream/issues/1080 + * + * StreamListener method called twice when using @SpyBean + * + * @author Soby Chacko + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest +public class StreamListenerMethodRegisteredOnlyOnceTest { + + @Autowired + private SomeSink sink; + + @SpyBean + private SomeHandler handler; + + @Test + public void should_handleSomeMessage() { + this.sink.channel().send(new GenericMessage<>("Payload")); + verify(this.handler).handleMessage(); // should only be invoked once. + } + + public interface SomeSink { + + @Input(Sink.INPUT) + SubscribableChannel channel(); + + } + + @EnableBinding(SomeSink.class) + @EnableAutoConfiguration + public static class SomeHandler { + + @StreamListener(Sink.INPUT) + public void handleMessage() { + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodReturnWithConversionTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodReturnWithConversionTests.java new file mode 100644 index 000000000..5c6fe2eea --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodReturnWithConversionTests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Suite; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.RunnerBuilder; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + * + */ +@RunWith(StreamListenerMethodReturnWithConversionTests.class) +@Suite.SuiteClasses({ + StreamListenerMethodReturnWithConversionTests.TestReturnConversion.class, + StreamListenerMethodReturnWithConversionTests.TestReturnNoConversion.class }) +public class StreamListenerMethodReturnWithConversionTests extends Suite { + + public StreamListenerMethodReturnWithConversionTests(Class klass, + RunnerBuilder builder) throws InitializationError { + super(klass, builder); + } + + @RunWith(Parameterized.class) + public static class TestReturnConversion { + + private Class configClass; + + public TestReturnConversion(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(new Class[] { TestPojoWithMimeType1.class, + TestPojoWithMimeType2.class }); + } + + @Test + @SuppressWarnings("unchecked") + public void testReturnConversion() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + this.configClass, + "--spring.cloud.stream.bindings.output.contentType=application/json", + "--server.port=0", "--spring.jmx.enabled=false"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + TestPojoWithMimeType testPojoWithMimeType = context + .getBean(TestPojoWithMimeType.class); + Assertions.assertThat(testPojoWithMimeType.receivedPojos).hasSize(1); + Assertions.assertThat(testPojoWithMimeType.receivedPojos.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(new String(message.getPayload())) + .isEqualTo("{\"bar\":\"barbar" + id + "\"}"); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_JSON)); + context.close(); + } + + } + + @RunWith(Parameterized.class) + public static class TestReturnNoConversion { + + private Class configClass; + + private ObjectMapper mapper = new ObjectMapper(); + + public TestReturnNoConversion(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(new Class[] { TestPojoWithMimeType1.class, + TestPojoWithMimeType2.class }); + } + + @Test + @SuppressWarnings("unchecked") + public void testReturnNoConversion() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + this.configClass, "--server.port=0", "--spring.jmx.enabled=false"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + TestPojoWithMimeType testPojoWithMimeType = context + .getBean(TestPojoWithMimeType.class); + Assertions.assertThat(testPojoWithMimeType.receivedPojos).hasSize(1); + Assertions.assertThat(testPojoWithMimeType.receivedPojos.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + StreamListenerTestUtils.BarPojo barPojo = this.mapper.readValue( + message.getPayload(), StreamListenerTestUtils.BarPojo.class); + assertThat(barPojo.getBar()).isEqualTo("barbar" + id); + assertThat(message.getHeaders().get(MessageHeaders.CONTENT_TYPE, + MimeType.class) != null); + context.close(); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMimeType1 extends TestPojoWithMimeType { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive( + StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooPojo.getFoo()); + return barPojo; + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMimeType2 extends TestPojoWithMimeType { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public StreamListenerTestUtils.BarPojo receive( + StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooPojo.getFoo()); + return barPojo; + } + + } + + public static class TestPojoWithMimeType { + + List receivedPojos = new ArrayList<>(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodSetupOrchestratorTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodSetupOrchestratorTests.java new file mode 100644 index 000000000..ba89ee697 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodSetupOrchestratorTests.java @@ -0,0 +1,161 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerAnnotationBeanPostProcessor; +import org.springframework.cloud.stream.binding.StreamListenerSetupMethodOrchestrator; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * @author Soby Chacko + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest +public class StreamListenerMethodSetupOrchestratorTests { + + @SpyBean + CustomOrchestrator customOrchestrator; + + @SpyBean + MultipleStreamListenerProcessor multipleStreamListenerProcessor; + + @SpyBean + StreamListenerAnnotationBeanPostProcessor streamListenerAnnotationBeanPostProcessor; + + @Test + @SuppressWarnings("unchecked") + public void testCustomStreamListenerOrchestratorAndDefaultTogetherInSameContext() + throws Exception { + + // Two StreamListener methods, so 2 invocations + verify(this.customOrchestrator, times(2)).supports(any()); + + Method method = this.multipleStreamListenerProcessor.getClass() + .getMethod("handleMessage"); + StreamListener streamListener = AnnotatedElementUtils.findMergedAnnotation(method, + StreamListener.class); + // verify that the invocation happened on the custom Orchestrator + verify(this.customOrchestrator).orchestrateStreamListenerSetupMethod( + streamListener, method, this.multipleStreamListenerProcessor); + + Method method1 = this.multipleStreamListenerProcessor.getClass() + .getMethod("produceString"); + StreamListener streamListener1 = AnnotatedElementUtils + .findMergedAnnotation(method, StreamListener.class); + + // Verify that the invocation did not happen on the custom orchestrator + verify(this.customOrchestrator, never()).orchestrateStreamListenerSetupMethod( + streamListener1, method1, this.multipleStreamListenerProcessor); + + Field field = ReflectionUtils.findField( + this.streamListenerAnnotationBeanPostProcessor.getClass(), + "streamListenerSetupMethodOrchestrators"); + ReflectionUtils.makeAccessible(field); + + Set field1; + field1 = (LinkedHashSet) ReflectionUtils + .getField(field, this.streamListenerAnnotationBeanPostProcessor); + List list = new ArrayList<>(field1); + + // Ensure that the custom orchestrator did not support this request + assertThat(list.get(0).supports(method1)).isEqualTo(false); + // Ensure that we are using the default Orchestrator in + // StreamListenerAnnoatationBeanPostProcessor + assertThat(list.get(1).supports(method1)).isEqualTo(true); + } + + public interface SomeProcessor { + + @Input(Sink.INPUT) + SubscribableChannel channel1(); + + @Input("foobar") + SubscribableChannel channel2(); + + @Output(Source.OUTPUT) + MessageChannel channel3(); + + } + + @EnableBinding(SomeProcessor.class) + @EnableAutoConfiguration + public static class MultipleStreamListenerProcessor { + + @StreamListener(Sink.INPUT) + public void handleMessage() { + } + + @StreamListener("foobar") + @SendTo("output") + public String produceString() { + return "foobar"; + } + + @Bean + public CustomOrchestrator myOrchestrator() { + return new CustomOrchestrator(); + } + + } + + static class CustomOrchestrator implements StreamListenerSetupMethodOrchestrator { + + @Override + public boolean supports(Method method) { + return method.getReturnType() != String.class; + } + + @Override + public void orchestrateStreamListenerSetupMethod(StreamListener streamListener, + Method method, Object bean) { + // stub method + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnMessageTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnMessageTests.java new file mode 100644 index 000000000..03fa383e9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnMessageTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerMethodWithReturnMessageTests { + + private Class configClass; + + public StreamListenerMethodWithReturnMessageTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(new Class[] { TestPojoWithMessageReturn1.class, + TestPojoWithMessageReturn2.class }); + } + + @Test + @SuppressWarnings("unchecked") + public void testReturnMessage() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + TestPojoWithMessageReturn testPojoWithMessageReturn = context + .getBean(TestPojoWithMessageReturn.class); + Assertions.assertThat(testPojoWithMessageReturn.receivedPojos).hasSize(1); + Assertions.assertThat(testPojoWithMessageReturn.receivedPojos.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(message.getPayload()).contains("barbar" + id); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMessageReturn1 extends TestPojoWithMessageReturn { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message receive(StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + StreamListenerTestUtils.BarPojo barPojo = new StreamListenerTestUtils.BarPojo(); + barPojo.setBar(fooPojo.getFoo()); + return MessageBuilder.withPayload(barPojo).setHeader("foo", "bar").build(); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestPojoWithMessageReturn2 extends TestPojoWithMessageReturn { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public Message receive(StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + StreamListenerTestUtils.BarPojo bazPojo = new StreamListenerTestUtils.BarPojo(); + bazPojo.setBar(fooPojo.getFoo()); + return MessageBuilder.withPayload(bazPojo).setHeader("foo", "bar").build(); + } + + } + + public static class TestPojoWithMessageReturn { + + List receivedPojos = new ArrayList<>(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnValueTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnValueTests.java new file mode 100644 index 000000000..8829ff44e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerMethodWithReturnValueTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerMethodWithReturnValueTests { + + private Class configClass; + + public StreamListenerMethodWithReturnValueTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList( + new Class[] { TestStringProcessor1.class, TestStringProcessor2.class }); + } + + @Test + @SuppressWarnings("unchecked") + public void testReturn() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false"); + MessageCollector collector = context.getBean(MessageCollector.class); + Processor processor = context.getBean(Processor.class); + String id = UUID.randomUUID().toString(); + processor.input() + .send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json").build()); + Message message = (Message) collector + .forChannel(processor.output()).poll(1, TimeUnit.SECONDS); + TestStringProcessor testStringProcessor = context + .getBean(TestStringProcessor.class); + Assertions.assertThat(testStringProcessor.receivedPojos).hasSize(1); + Assertions.assertThat(testStringProcessor.receivedPojos.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + assertThat(message).isNotNull(); + assertThat(message.getPayload()).contains("barbar" + id); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestStringProcessor1 extends TestStringProcessor { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receive(StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + return fooPojo.getFoo(); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestStringProcessor2 extends TestStringProcessor { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public String receive(StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedPojos.add(fooPojo); + return fooPojo.getFoo(); + } + + } + + public static class TestStringProcessor { + + List receivedPojos = new ArrayList<>(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerTestUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerTestUtils.java new file mode 100644 index 000000000..8ab4b6b74 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerTestUtils.java @@ -0,0 +1,108 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import javax.validation.constraints.NotBlank; + +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; + +/** + * @author Ilayaperumal Gopinathan + */ +public class StreamListenerTestUtils { + + public interface FooInboundChannel1 { + + String INPUT = "foo1-input"; + + @Input(FooInboundChannel1.INPUT) + SubscribableChannel input(); + + } + + public interface FooOutboundChannel1 { + + String OUTPUT = "foo1-output"; + + @Output(FooOutboundChannel1.OUTPUT) + MessageChannel output(); + + } + + public static class FooPojo { + + private String foo; + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("FooPojo{"); + sb.append("foo='").append(this.foo).append('\''); + sb.append('}'); + return sb.toString(); + } + + } + + public static class BarPojo { + + private String bar; + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("BarPojo{"); + sb.append("bar='").append(this.bar).append('\''); + sb.append('}'); + return sb.toString(); + } + + } + + public static class PojoWithValidation { + + @NotBlank + private String foo; + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithAnnotatedInputOutputArgsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithAnnotatedInputOutputArgsTests.java new file mode 100644 index 000000000..2e2662dc9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithAnnotatedInputOutputArgsTests.java @@ -0,0 +1,185 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerErrorMessages; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +public class StreamListenerWithAnnotatedInputOutputArgsTests { + + @Test + public void testInputOutputArgs() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestInputOutputArgs.class, "--server.port=0", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + sendMessageAndValidate(context); + } + + @Test + public void testInputOutputArgsWithMoreParameters() { + try { + SpringApplication.run(TestInputOutputArgsWithMoreParameters.class, + "--server.port=0"); + fail("Expected exception: " + INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains(INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @Test + public void testInputOutputArgsWithInvalidBindableTarget() { + try { + SpringApplication.run(TestInputOutputArgsWithInvalidBindableTarget.class, + "--server.port=0", "--spring.jmx.enabled=false"); + fail("Exception expected on using invalid bindable target as method parameter"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains( + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @Test + public void testInputOutputArgsWithParameterOrderChanged() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestInputOutputArgsWithParameterOrderChanged.class, "--server.port=0", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + sendMessageAndValidate(context); + } + + @SuppressWarnings("unchecked") + private void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + processor.input().send(MessageBuilder.withPayload("hello") + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo("HELLO"); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputOutputArgs { + + @StreamListener + public void receive(@Input(Processor.INPUT) SubscribableChannel input, + @Output(Processor.OUTPUT) final MessageChannel output) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputOutputArgsWithMoreParameters { + + @StreamListener + public void receive(@Input(Processor.INPUT) SubscribableChannel input, + @Output(Processor.OUTPUT) final MessageChannel output, String someArg) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputOutputArgsWithInvalidBindableTarget { + + @StreamListener + public void receive(@Input("invalid") SubscribableChannel input, + @Output(Processor.OUTPUT) final MessageChannel output) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputOutputArgsWithParameterOrderChanged { + + @StreamListener + public void receive(@Output(Processor.OUTPUT) final MessageChannel output, + @Input("input") SubscribableChannel input) { + input.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + output.send(MessageBuilder + .withPayload(message.getPayload().toString().toUpperCase()) + .build()); + } + }); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithConditionsTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithConditionsTest.java new file mode 100644 index 000000000..3a39237c3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/StreamListenerWithConditionsTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binding.StreamListenerErrorMessages; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.handler.annotation.Payload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Marius Bogoevici + */ +public class StreamListenerWithConditionsTest { + + @Test + public void testAnnotatedArgumentsWithConditionalClass() throws Exception { + ConfigurableApplicationContext context = SpringApplication + .run(TestPojoWithAnnotatedArguments.class, "--server.port=0"); + + TestPojoWithAnnotatedArguments testPojoWithAnnotatedArguments = context + .getBean(TestPojoWithAnnotatedArguments.class); + Sink sink = context.getBean(Sink.class); + String id = UUID.randomUUID().toString(); + sink.input().send(MessageBuilder.withPayload("{\"foo\":\"barbar" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "foo").build()); + sink.input().send(MessageBuilder.withPayload("{\"bar\":\"foofoo" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "bar").build()); + sink.input().send(MessageBuilder.withPayload("{\"bar\":\"foofoo" + id + "\"}") + .setHeader("contentType", "application/json") + .setHeader("testHeader", "testValue").setHeader("type", "qux").build()); + assertThat(testPojoWithAnnotatedArguments.receivedFoo).hasSize(1); + assertThat(testPojoWithAnnotatedArguments.receivedFoo.get(0)) + .hasFieldOrPropertyWithValue("foo", "barbar" + id); + assertThat(testPojoWithAnnotatedArguments.receivedBar).hasSize(1); + assertThat(testPojoWithAnnotatedArguments.receivedBar.get(0)) + .hasFieldOrPropertyWithValue("bar", "foofoo" + id); + context.close(); + } + + @Test + public void testConditionalFailsWithReturnValue() throws Exception { + try { + ConfigurableApplicationContext context = SpringApplication.run( + TestConditionalOnMethodWithReturnValueFails.class, "--server.port=0"); + context.close(); + fail("Context creation failure expected"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains( + StreamListenerErrorMessages.CONDITION_ON_METHOD_RETURNING_VALUE); + } + } + + @Test + public void testConditionalFailsWithDeclarativeMethod() throws Exception { + try { + ConfigurableApplicationContext context = SpringApplication.run( + TestConditionalOnDeclarativeMethodFails.class, "--server.port=0"); + context.close(); + fail("Context creation failure expected"); + } + catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains( + StreamListenerErrorMessages.CONDITION_ON_DECLARATIVE_METHOD); + } + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestPojoWithAnnotatedArguments { + + List receivedFoo = new ArrayList<>(); + + List receivedBar = new ArrayList<>(); + + @StreamListener(value = Sink.INPUT, condition = "headers['type']=='foo'") + public void receive(@Payload StreamListenerTestUtils.FooPojo fooPojo) { + this.receivedFoo.add(fooPojo); + } + + @StreamListener(target = Sink.INPUT, condition = "headers['type']=='bar'") + public void receive(@Payload StreamListenerTestUtils.BarPojo barPojo) { + this.receivedBar.add(barPojo); + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestConditionalOnDeclarativeMethodFails { + + @StreamListener(condition = "headers['type']=='foo'") + public void receive(@Input("input") MessageChannel input) { + // do nothing + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestConditionalOnMethodWithReturnValueFails { + + @StreamListener(value = Sink.INPUT, condition = "headers['type']=='foo'") + public String receive(String value) { + return null; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainConversionTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainConversionTest.java new file mode 100644 index 000000000..c74434fb3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainConversionTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @since 1.2 + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = TextPlainConversionTest.FooProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +// @checkstyle:on +public class TextPlainConversionTest { + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + @Test + public void testTextPlainConversionOnOutput() throws Exception { + this.testProcessor.input().send(MessageBuilder.withPayload("Bar").build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isEqualTo("Foo{name='Bar'}"); + } + + @Test + public void testByteArrayConversionOnOutput() throws Exception { + this.testProcessor.output() + .send(MessageBuilder.withPayload("Bar".getBytes()).build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isEqualTo("Bar"); + } + + @Test + public void testTextPlainConversionOnInputAndOutput() throws Exception { + this.testProcessor.input() + .send(MessageBuilder.withPayload(new Foo("Bar")).build()); + @SuppressWarnings("unchecked") + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isEqualTo("Foo{name='Foo{name='Bar'}'}"); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/config/textplain/text-plain.properties") + public static class FooProcessor { + + @ServiceActivator(inputChannel = "input", outputChannel = "output") + public Foo consume(String foo) { + return new Foo(foo); + } + + } + + public static class Foo { + + private String name; + + public Foo(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "Foo{name='" + this.name + "'}"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainToJsonConversionTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainToJsonConversionTest.java new file mode 100644 index 000000000..33c9094d8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/TextPlainToJsonConversionTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + * @since 1.2 + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = TextPlainToJsonConversionTest.FooProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +// @checkstyle:on +public class TextPlainToJsonConversionTest { + + @Autowired + private Processor testProcessor; + + @Autowired + private BinderFactory binderFactory; + + private ObjectMapper mapper = new ObjectMapper(); + + @SuppressWarnings("unchecked") + @Test + public void testNoContentTypeToJsonConversionOnInput() throws Exception { + this.testProcessor.input() + .send(MessageBuilder.withPayload("{\"name\":\"Bar\"}").build()); + Message received = (Message) ((TestSupportBinder) this.binderFactory + .getBinder(null, MessageChannel.class)).messageCollector() + .forChannel(this.testProcessor.output()) + .poll(1, TimeUnit.SECONDS); + assertThat(received).isNotNull(); + Foo foo = this.mapper.readValue(received.getPayload(), Foo.class); + assertThat(foo.getName()).isEqualTo("transformed-Bar"); + } + + /** + * @since 2.0: Conversion from text/plain -> json is no longer supported. Strict + * contentType only. + */ + @Test(expected = MessagingException.class) + public void testTextPlainToJsonConversionOnInput() { + this.testProcessor.input().send(MessageBuilder.withPayload("{\"name\":\"Bar\"}") + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build()); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class FooProcessor { + + @StreamListener("input") + @SendTo("output") + public Foo consume(Foo foo) { + Foo returnFoo = new Foo(); + returnFoo.setName("transformed-" + foo.getName()); + return returnFoo; + } + + } + + public static class Foo { + + private String name; + + public Foo() { + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "Foo{name='" + this.name + "'}"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/AggregateApplicationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/AggregateApplicationTests.java new file mode 100644 index 000000000..66a368528 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/AggregateApplicationTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config.aggregate; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.aggregate.AggregateApplicationBuilder; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.config.aggregate.processor.TestProcessor; +import org.springframework.cloud.stream.config.aggregate.source.TestSource; +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class AggregateApplicationTests { + + @Test + @SuppressWarnings("unchecked") + public void testAggregateApplication() throws Exception { + ConfigurableApplicationContext context = new AggregateApplicationBuilder( + AggregateApplicationTestConfig.class).web(false).from(TestSource.class) + .to(TestProcessor.class).run(); + TestSupportBinder testSupportBinder = (TestSupportBinder) context + .getBean(BinderFactory.class).getBinder(null, MessageChannel.class); + MessageChannel processorOutput = testSupportBinder.getChannelForName("output"); + Message received = (Message) (testSupportBinder.messageCollector() + .forChannel(processorOutput).poll(5, TimeUnit.SECONDS)); + assertThat(received).isNotNull(); + assertThat(received.getPayload().endsWith("processed")).isTrue(); + + context.close(); + } + + @Configuration + @EnableAutoConfiguration + static class AggregateApplicationTestConfig { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/processor/TestProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/processor/TestProcessor.java new file mode 100644 index 000000000..bd1867c7d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/processor/TestProcessor.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config.aggregate.processor; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Ilayaperumal Gopinathan + */ +@EnableBinding(Processor.class) +@EnableAutoConfiguration +@Configuration +public class TestProcessor { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message process(String message) { + return MessageBuilder.withPayload(message + " processed") + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/source/TestSource.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/source/TestSource.java new file mode 100644 index 000000000..3f5a36934 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/aggregate/source/TestSource.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config.aggregate.source; + +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.annotation.InboundChannelAdapter; +import org.springframework.integration.core.MessageSource; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Ilayaperumal Gopinathan + */ +@EnableBinding(Source.class) +@EnableAutoConfiguration +@Configuration +public class TestSource { + + @Bean + @InboundChannelAdapter(Source.OUTPUT) + public MessageSource timerMessageSource() { + return new MessageSource() { + @Override + public Message receive() { + return MessageBuilder + .withPayload(new SimpleDateFormat("DDMMMYYYY").format(new Date())) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain").build(); + } + }; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/ContentTypeTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/ContentTypeTests.java new file mode 100644 index 000000000..9a89f751c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/ContentTypeTests.java @@ -0,0 +1,414 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config.contentType; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.util.LinkedList; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Output; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.converter.KryoMessageConverter; +import org.springframework.cloud.stream.converter.MessageConverterUtils; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.tuple.Tuple; +import org.springframework.tuple.TupleBuilder; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@SuppressWarnings("unchecked") +public class ContentTypeTests { + + private ObjectMapper mapper = new ObjectMapper(); + + @Test + public void testSendWithDefaultContentType() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false")) { + + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + User user = new User("Alice"); + source.output().send(MessageBuilder.withPayload(user).build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + User received = this.mapper.readValue(message.getPayload(), User.class); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_JSON)); + assertThat(user.getName()).isEqualTo(received.getName()); + } + } + + @Test + public void testSendJsonAsString() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + User user = new User("Alice"); + String json = this.mapper.writeValueAsString(user); + source.output().send(MessageBuilder.withPayload(user).build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_JSON)); + assertThat(json).isEqualTo(message.getPayload()); + } + } + + @Test + public void testSendJsonString() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + source.output().send(MessageBuilder.withPayload("foo").build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_JSON)); + assertThat("foo").isEqualTo(message.getPayload()); + } + } + + @Test + public void testSendBynaryData() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false")) { + + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + byte[] data = new byte[] { 0, 1, 2, 3 }; + source.output() + .send(MessageBuilder.withPayload(data) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.APPLICATION_OCTET_STREAM) + .build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.APPLICATION_OCTET_STREAM)); + assertThat(message.getPayload()).isEqualTo(data); + } + } + + @Test + public void testSendBinaryDataWithContentType() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=image/jpeg")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + byte[] data = new byte[] { 0, 1, 2, 3 }; + source.output().send(MessageBuilder.withPayload(data).build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat(message.getPayload()).isEqualTo(data); + } + } + + @Test + public void testSendBinaryDataWithContentTypeUsingHeaders() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + byte[] data = new byte[] { 0, 1, 2, 3 }; + source.output().send(MessageBuilder.withPayload(data) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.IMAGE_JPEG) + .build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.IMAGE_JPEG)); + assertThat(message.getPayload()).isEqualTo(data); + } + } + + @Test + public void testSendJavaSerializable() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/x-java-serialized-object")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + User user = new User("Alice"); + source.output().send(MessageBuilder.withPayload(user).build()); + Message message = (Message) collector.forChannel(source.output()) + .poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT)); + User received = message.getPayload(); + assertThat(user.getName()).isEqualTo(received.getName()); + } + } + + @Test + public void testSendKryoSerialized() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/x-java-object")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + User user = new User("Alice"); + source.output().send(MessageBuilder.withPayload(user).build()); + Message message = (Message) collector.forChannel(source.output()) + .poll(1, TimeUnit.SECONDS); + User received = message.getPayload(); + assertThat(message.getHeaders() + .get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeType.valueOf(KryoMessageConverter.KRYO_MIME_TYPE))); + assertThat(user.getName()).isEqualTo(received.getName()); + + } + } + + @Test + public void testSendStringType() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=text/plain")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + User user = new User("Alice"); + source.output().send(MessageBuilder.withPayload(user).build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MimeTypeUtils.TEXT_PLAIN)); + assertThat(message.getPayload()).isEqualTo(user.toString()); + } + } + + @Test + public void testSendTuple() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SourceApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/x-spring-tuple")) { + MessageCollector collector = context.getBean(MessageCollector.class); + Source source = context.getBean(Source.class); + Tuple tuple = TupleBuilder.tuple().of("foo", "bar"); + source.output().send(MessageBuilder.withPayload(tuple).build()); + Message message = (Message) collector + .forChannel(source.output()).poll(1, TimeUnit.SECONDS); + assertThat( + message.getHeaders().get(MessageHeaders.CONTENT_TYPE, MimeType.class) + .includes(MessageConverterUtils.X_SPRING_TUPLE)); + assertThat(TupleBuilder.fromString(new String(message.getPayload()))) + .isEqualTo(tuple); + } + } + + @Test + public void testReceiveWithDefaults() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SinkApplication.class, "--server.port=0", "--spring.jmx.enabled=false")) { + TestSink testSink = context.getBean(TestSink.class); + SinkApplication sourceApp = context.getBean(SinkApplication.class); + User user = new User("Alice"); + testSink.pojo().send(MessageBuilder + .withPayload(this.mapper.writeValueAsBytes(user)).build()); + Map headers = (Map) sourceApp.arguments.pop(); + User received = (User) sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MimeTypeUtils.APPLICATION_JSON)); + assertThat(user.getName()).isEqualTo(received.getName()); + } + } + + @Test + public void testReceiveRawWithDifferentContentTypes() { + try (ConfigurableApplicationContext context = SpringApplication.run( + SinkApplication.class, "--server.port=0", "--spring.jmx.enabled=false")) { + TestSink testSink = context.getBean(TestSink.class); + SinkApplication sourceApp = context.getBean(SinkApplication.class); + testSink.raw().send(MessageBuilder.withPayload(new byte[4]) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.IMAGE_JPEG) + .build()); + testSink.raw().send(MessageBuilder.withPayload(new byte[4]) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.IMAGE_GIF) + .build()); + Map headers = (Map) sourceApp.arguments.pop(); + sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MimeTypeUtils.IMAGE_GIF)); + headers = (Map) sourceApp.arguments.pop(); + sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MimeTypeUtils.IMAGE_JPEG)); + } + } + + @Test + @Ignore + public void testReceiveKryoPayload() { + try (ConfigurableApplicationContext context = SpringApplication.run( + SinkApplication.class, "--server.port=0", "--debug", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.pojo_input.contentType=" + + "application/x-java-object;type=org.springframework.cloud.stream.config.contentType.User")) { + TestSink testSink = context.getBean(TestSink.class); + SinkApplication sourceApp = context.getBean(SinkApplication.class); + Kryo kryo = new Kryo(); + User user = new User("Alice"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Output output = new Output(baos); + kryo.writeObject(output, user); + output.close(); + testSink.pojo().send(MessageBuilder.withPayload(baos.toByteArray()).build()); + Map headers = (Map) sourceApp.arguments.pop(); + User received = (User) sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MimeType.valueOf(KryoMessageConverter.KRYO_MIME_TYPE))); + assertThat(user.getName()).isEqualTo(received.getName()); + } + } + + @Test + @SuppressWarnings("deprecation") + public void testReceiveKryoWithHeadersOverridingDefault() { + try (ConfigurableApplicationContext context = SpringApplication.run( + SinkApplication.class, "--server.port=0", "--spring.jmx.enabled=false")) { + TestSink testSink = context.getBean(TestSink.class); + SinkApplication sourceApp = context.getBean(SinkApplication.class); + Kryo kryo = new Kryo(); + User user = new User("Alice"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Output output = new Output(baos); + kryo.writeObject(output, user); + output.close(); + testSink.pojo() + .send(MessageBuilder.withPayload(baos.toByteArray()) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeType.valueOf(KryoMessageConverter.KRYO_MIME_TYPE)) + .build()); + Map headers = (Map) sourceApp.arguments.pop(); + User received = (User) sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MimeType.valueOf(KryoMessageConverter.KRYO_MIME_TYPE))); + assertThat(user.getName()).isEqualTo(received.getName()); + } + } + + @Test + @Ignore + public void testReceiveJavaSerializable() throws Exception { + try (ConfigurableApplicationContext context = SpringApplication.run( + SinkApplication.class, "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.pojo_input.contentType=application/x-java-serialized-object")) { + TestSink testSink = context.getBean(TestSink.class); + SinkApplication sourceApp = context.getBean(SinkApplication.class); + User user = new User("Alice"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + new ObjectOutputStream(baos).writeObject(user); + testSink.pojo().send(MessageBuilder.withPayload(baos.toByteArray()).build()); + Map headers = (Map) sourceApp.arguments.pop(); + User received = (User) sourceApp.arguments.pop(); + assertThat(((MimeType) headers.get(MessageHeaders.CONTENT_TYPE)) + .includes(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT)); + assertThat(user.getName()).isEqualTo(received.getName()); + + } + } + + public interface TestSink { + + @Input("POJO_INPUT") + SubscribableChannel pojo(); + + @Input("STRING_INPUT") + SubscribableChannel string(); + + @Input("TUPLE_INPUT") + SubscribableChannel tuple(); + + @Input("RAW_INPUT") + SubscribableChannel raw(); + + } + + @EnableBinding(Source.class) + @SpringBootApplication + public static class SourceApplication { + + } + + @EnableBinding(TestSink.class) + @SpringBootApplication + public static class SinkApplication { + + public LinkedList arguments = new LinkedList<>(); + + @StreamListener("POJO_INPUT") + public void receive(User user, @Headers Map headers) { + this.arguments.push(user); + this.arguments.push(headers); + } + + @StreamListener("TUPLE_INPUT") + public void receive(Tuple tuple) { + } + + @StreamListener("STRING_INPUT") + public void receive(String string) { + } + + @StreamListener("RAW_INPUT") + public void receive(byte[] data, @Headers Map headers) { + this.arguments.push(data); + this.arguments.push(headers); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/User.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/User.java new file mode 100644 index 000000000..40640eadb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/config/contentType/User.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config.contentType; + +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@SuppressWarnings("serial") +public class User implements Serializable { + + private String name; + + public User() { + } + + @JsonCreator + public User(@JsonProperty("name") String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("User{"); + sb.append("name='").append(this.name).append('\''); + sb.append('}'); + return sb.toString(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-decoding-sink.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-decoding-sink.properties new file mode 100644 index 000000000..86577d5ea --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-decoding-sink.properties @@ -0,0 +1,3 @@ +spring.cloud.stream.bindings.input.destination=foobar +spring.cloud.stream.bindings.input.consumer.useNativeDecoding=true +spring.cloud.stream.bindings.input.contentType=application/x-java-serialized-object diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-encoding-source.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-encoding-source.properties new file mode 100644 index 000000000..27d5bf0bd --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/native-encoding-source.properties @@ -0,0 +1,3 @@ +spring.cloud.stream.bindings.output.destination=foobar-x +spring.cloud.stream.bindings.output.producer.useNativeEncoding=true +spring.cloud.stream.bindings.output.contentType=application/x-java-serialized-object diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/partitioned-configurers.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/partitioned-configurers.properties new file mode 100644 index 000000000..aee346650 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/partitioned-configurers.properties @@ -0,0 +1,4 @@ +spring.cloud.stream.bindings.output.destination=partOut +spring.cloud.stream.bindings.output.producer.partitionKeyExpression=payload +spring.cloud.stream.bindings.output.producer.partitionCount=3 + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/sink-channel-configurers.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/sink-channel-configurers.properties new file mode 100644 index 000000000..9a5e464a4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/sink-channel-configurers.properties @@ -0,0 +1,3 @@ +spring.cloud.stream.bindings.input.destination=configure1 +spring.cloud.stream.bindings.input.contentType=application/x-spring-tuple +spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/source-channel-configurers.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/source-channel-configurers.properties new file mode 100644 index 000000000..1064e292c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/channel/source-channel-configurers.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.output.destination=interceptor-test +spring.cloud.stream.bindings.output.contentType=application/json diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/custom/source-channel-configurers.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/custom/source-channel-configurers.properties new file mode 100644 index 000000000..34a8481dc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/custom/source-channel-configurers.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.output.destination=configure1 +spring.cloud.stream.bindings.output.contentType=test/foo diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/fooprocesor/foo-sink.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/fooprocesor/foo-sink.properties new file mode 100644 index 000000000..dea469faa --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/fooprocesor/foo-sink.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.input.destination=foo-input +spring.cloud.stream.bindings.input.content-type=application/x-java-object;type=org.springframework.cloud.stream.config.DeserializeJSONToJavaTypeTests.Foo diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/inboundjsontuple/inbound-json-tuple.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/inboundjsontuple/inbound-json-tuple.properties new file mode 100644 index 000000000..6db22c3f3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/inboundjsontuple/inbound-json-tuple.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.input.content-type=application/x-spring-tuple +spring.cloud.stream.bindings.output.content-type=application/x-spring-tuple diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/textplain/text-plain.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/textplain/text-plain.properties new file mode 100644 index 000000000..794d41aa5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-integration-tests/src/test/resources/org/springframework/cloud/stream/config/textplain/text-plain.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.input.content-type=text/plain +spring.cloud.stream.bindings.output.content-type=text/plain diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/pom.xml new file mode 100644 index 000000000..4566fbcb3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/pom.xml @@ -0,0 +1,45 @@ + + + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + 4.0.0 + + spring-cloud-stream-reactive + + + + org.springframework.cloud + spring-cloud-stream + + + io.projectreactor + reactor-core + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.springframework.cloud + spring-cloud-stream-test-support-internal + test + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/DefaultFluxSender.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/DefaultFluxSender.java new file mode 100644 index 000000000..5613d4347 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/DefaultFluxSender.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.util.Assert; + +/** + * Default {@link org.springframework.cloud.stream.reactive.FluxSender} implementation. + * This implementation may be used for cancelling a subscription on the underlying + * {@link reactor.core.publisher.Flux}. + * + * @author Soby Chacko + * @since 1.3.0 + */ +class DefaultFluxSender implements FluxSender { + + private final Consumer consumer; + + private Log log = LogFactory.getLog(DefaultFluxSender.class); + + private volatile Disposable disposable; + + DefaultFluxSender(Consumer consumer) { + Assert.notNull(consumer, "Consumer must not be null"); + this.consumer = consumer; + } + + @Override + public Mono send(Flux flux) { + MonoProcessor sendResult = MonoProcessor.create(); + // add error handling and reconnect in the event of an error + this.disposable = flux + .doOnError(e -> this.log.error("Error during processing: ", e)).retry() + .subscribe(this.consumer, sendResult::onError, sendResult::onComplete); + return sendResult; + } + + @Override + public void close() { + if (this.disposable != null) { + this.disposable.dispose(); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/FluxSender.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/FluxSender.java new file mode 100644 index 000000000..5d2161880 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/FluxSender.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.io.Closeable; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Used for {@link org.springframework.cloud.stream.annotation.StreamListener} and + * {@link org.springframework.cloud.stream.reactive.StreamEmitter} arguments annotated + * with {@link org.springframework.cloud.stream.annotation.Output}. + * + * @author Marius Bogoevici + * @see reactor.core.Disposable + */ +public interface FluxSender extends Closeable { + + /** + * Streams the {@link reactor.core.publisher.Flux} through the binding target + * corresponding to the {@link org.springframework.cloud.stream.annotation.Output} + * annotation of the argument. + * @param flux a {@link Flux} that will be streamed through the binding target + * @return a {@link Mono} representing the result of sending the flux (completion or + * error) + */ + Mono send(Flux flux); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToFluxSenderParameterAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToFluxSenderParameterAdapter.java new file mode 100644 index 000000000..1ec39eda8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToFluxSenderParameterAdapter.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import org.springframework.cloud.stream.binding.StreamListenerParameterAdapter; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +/** + * Adapts an {@link org.springframework.cloud.stream.annotation.Output} annotated + * {@link FluxSender} to an outbound {@link MessageChannel}. + * + * @author Marius Bogoevici + * @author Soby Chacko + */ +public class MessageChannelToFluxSenderParameterAdapter + implements StreamListenerParameterAdapter { + + @Override + public boolean supports(Class bindingTargetType, MethodParameter methodParameter) { + ResolvableType type = ResolvableType.forMethodParameter(methodParameter); + return MessageChannel.class.isAssignableFrom(bindingTargetType) + && FluxSender.class.isAssignableFrom(type.getRawClass()); + } + + @Override + public FluxSender adapt(MessageChannel bindingTarget, MethodParameter parameter) { + return new DefaultFluxSender(result -> bindingTarget + .send(result instanceof Message ? (Message) result + : MessageBuilder.withPayload(result).build())); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapter.java new file mode 100644 index 000000000..9080c3408 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapter.java @@ -0,0 +1,117 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import reactor.core.publisher.Flux; + +import org.springframework.cloud.stream.binding.StreamListenerParameterAdapter; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.Assert; + +/** + * Adapts an {@link org.springframework.cloud.stream.annotation.Input} annotated + * {@link MessageChannel} to a {@link Flux}. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + */ +public class MessageChannelToInputFluxParameterAdapter + implements StreamListenerParameterAdapter, SubscribableChannel> { + + private final CompositeMessageConverter messageConverter; + + public MessageChannelToInputFluxParameterAdapter( + CompositeMessageConverter messageConverter) { + Assert.notNull(messageConverter, "cannot not be null"); + this.messageConverter = messageConverter; + } + + @Override + public boolean supports(Class bindingTargetType, MethodParameter methodParameter) { + return MessageChannel.class.isAssignableFrom(bindingTargetType) + && Flux.class.isAssignableFrom(methodParameter.getParameterType()); + } + + @Override + public Flux adapt(final SubscribableChannel bindingTarget, + MethodParameter parameter) { + final ResolvableType fluxResolvableType = ResolvableType + .forMethodParameter(parameter); + final ResolvableType fluxTypeParameter = fluxResolvableType.getGeneric(0); + final Class fluxTypeParameterRawClass = fluxTypeParameter.getRawClass(); + final Class fluxTypeParameterClass = (fluxTypeParameterRawClass != null) + ? fluxTypeParameterRawClass : Object.class; + + final Object monitor = new Object(); + + if (Message.class.isAssignableFrom(fluxTypeParameterClass)) { + + final ResolvableType payloadTypeParameter = fluxTypeParameter.getGeneric(0); + final Class payloadTypeParameterRawClass = payloadTypeParameter + .getRawClass(); + final Class payloadTypeParameterClass = (payloadTypeParameterRawClass != null) + ? payloadTypeParameterRawClass : Object.class; + + return Flux.create(emitter -> { + MessageHandler messageHandler = message -> { + synchronized (monitor) { + + if (payloadTypeParameterClass + .isAssignableFrom(message.getPayload().getClass())) { + emitter.next(message); + } + else { + emitter.next(MessageBuilder.createMessage( + this.messageConverter.fromMessage(message, + payloadTypeParameterClass), + message.getHeaders())); + } + } + }; + bindingTarget.subscribe(messageHandler); + emitter.onCancel(() -> bindingTarget.unsubscribe(messageHandler)); + }).publish().autoConnect(); + } + else { + return Flux.create(emitter -> { + MessageHandler messageHandler = message -> { + synchronized (monitor) { + if (fluxTypeParameterClass + .isAssignableFrom(message.getPayload().getClass())) { + emitter.next(message.getPayload()); + } + else { + emitter.next(this.messageConverter.fromMessage(message, + fluxTypeParameterClass)); + } + } + }; + bindingTarget.subscribe(messageHandler); + emitter.onCancel(() -> bindingTarget.unsubscribe(messageHandler)); + }).publish().autoConnect(); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/PublisherToMessageChannelResultAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/PublisherToMessageChannelResultAdapter.java new file mode 100644 index 000000000..39659328d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/PublisherToMessageChannelResultAdapter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.io.Closeable; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; + +import org.springframework.cloud.stream.binding.StreamListenerResultAdapter; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +/** + * A {@link org.springframework.cloud.stream.binding.StreamListenerResultAdapter} from a + * {@link Publisher} return type to a bound {@link MessageChannel}. + * + * @author Marius Bogoevici + * @author Soby Chacko + * + */ +public class PublisherToMessageChannelResultAdapter + implements StreamListenerResultAdapter, MessageChannel> { + + private Log log = LogFactory.getLog(PublisherToMessageChannelResultAdapter.class); + + @Override + public boolean supports(Class resultType, Class bindingTarget) { + return Publisher.class.isAssignableFrom(resultType) + && MessageChannel.class.isAssignableFrom(bindingTarget); + } + + public Closeable adapt(Publisher streamListenerResult, + MessageChannel bindingTarget) { + Disposable disposable = Flux.from(streamListenerResult) + .doOnError(e -> this.log.error("Error while processing result", e)) + .retry() + .subscribe(result -> bindingTarget + .send(result instanceof Message ? (Message) result + : MessageBuilder.withPayload(result).build())); + + return disposable::dispose; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/ReactiveSupportAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/ReactiveSupportAutoConfiguration.java new file mode 100644 index 000000000..1edb14788 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/ReactiveSupportAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Marius Bogoevici + */ +@Configuration +@ConditionalOnBean(BindingService.class) +public class ReactiveSupportAutoConfiguration { + + @Bean + public static StreamEmitterAnnotationBeanPostProcessor streamEmitterAnnotationBeanPostProcessor() { + return new StreamEmitterAnnotationBeanPostProcessor(); + } + + @Bean + @ConditionalOnMissingBean(MessageChannelToInputFluxParameterAdapter.class) + public MessageChannelToInputFluxParameterAdapter messageChannelToInputFluxArgumentAdapter( + CompositeMessageConverterFactory compositeMessageConverterFactory) { + return new MessageChannelToInputFluxParameterAdapter( + compositeMessageConverterFactory.getMessageConverterForAllRegistered()); + } + + @Bean + @ConditionalOnMissingBean(MessageChannelToFluxSenderParameterAdapter.class) + public MessageChannelToFluxSenderParameterAdapter messageChannelToFluxSenderArgumentAdapter() { + return new MessageChannelToFluxSenderParameterAdapter(); + } + + @Bean + @ConditionalOnMissingBean(PublisherToMessageChannelResultAdapter.class) + public PublisherToMessageChannelResultAdapter fluxToMessageChannelResultAdapter() { + return new PublisherToMessageChannelResultAdapter(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitter.java new file mode 100644 index 000000000..ec8a8d303 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitter.java @@ -0,0 +1,97 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; + +/** + * Method level annotation that marks a method to be an emitter to outputs declared via + * {@link EnableBinding} (e.g. channels). + * + * This annotation is intended to be used in a Spring Cloud Stream application that + * requires a source to write to one or more {@link Output}s using the reactive paradigm. + * + * No {@link Input}s are allowed on a method that is annotated with StreamEmitter. + * + * Depending on how the method is structured, there are some flexibility in how the + * {@link Output} may be used. + * + * Here are some supported usage patterns: + * + * A StreamEmitter method that has a return type cannot take any method parameters. + * + *
+ * @StreamEmitter
+ * @Output(Source.OUTPUT)
+ * public Flux<String> emit() {
+ * 	return Flux.intervalMillis(1000)
+ * 		.map(l -> "Hello World!!");
+ * }
+ * 
+ * + * The following examples show how a void return type can be used on a method with + * StreamEmitter and how the method signatures could be used in a flexible manner. + * + *
+ * @StreamEmitter
+ * public void emit(@Output(Source.OUTPUT) FluxSender output) {
+ * 	output.send(Flux.intervalMillis(1000)
+ *		.map(l -> "Hello World!!"));
+ * }
+ * 
+ * + *
+ * @StreamEmitter
+ * @Output(Source.OUTPUT)
+ * public void emit(FluxSender output) {
+ * 	output.send(Flux.intervalMillis(1000)
+ *		.map(l -> "Hello World!!"));
+ * }
+ * 
+ * + *
+ * @StreamEmitter
+ * public void emit(@Output("OUTPUT1") FluxSender output1,
+ *					@Output("OUTPUT2") FluxSender output2,
+ *					@Output("OUTPUT3)" FluxSender output3) {
+ *	output1.send(Flux.intervalMillis(1000)
+ *		.map(l -> "Hello World!!"));
+ *	output2.send(Flux.intervalMillis(1000)
+ *		.map(l -> "Hello World!!"));
+ *	output3.send(Flux.intervalMillis(1000)
+ *		.map(l -> "Hello World!!"));
+ *	}
+ *
+ * + * @author Soby Chacko + * + * @since 1.3.0 + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface StreamEmitter { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterAnnotationBeanPostProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterAnnotationBeanPostProcessor.java new file mode 100644 index 000000000..ea42d7575 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterAnnotationBeanPostProcessor.java @@ -0,0 +1,294 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.binding.MessageChannelStreamListenerResultAdapter; +import org.springframework.cloud.stream.binding.StreamAnnotationCommonMethodUtils; +import org.springframework.cloud.stream.binding.StreamListenerParameterAdapter; +import org.springframework.cloud.stream.binding.StreamListenerResultAdapter; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.SmartLifecycle; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * {@link BeanPostProcessor} that handles {@link StreamEmitter} annotations found on bean + * methods. + * + * @author Soby Chacko + * @author Artem Bilan + * @since 1.3.0 + */ +public class StreamEmitterAnnotationBeanPostProcessor implements BeanPostProcessor, + SmartInitializingSingleton, ApplicationContextAware, SmartLifecycle { + + private static final Log log = LogFactory + .getLog(StreamEmitterAnnotationBeanPostProcessor.class); + + private final List closeableFluxResources = new ArrayList<>(); + + private final Lock lock = new ReentrantLock(); + + @SuppressWarnings("rawtypes") + private Collection parameterAdapters; + + @SuppressWarnings("rawtypes") + private Collection resultAdapters; + + private ConfigurableApplicationContext applicationContext; + + private MultiValueMap mappedStreamEmitterMethods = new LinkedMultiValueMap<>(); + + private volatile boolean running; + + private static void validateStreamEmitterMethod(Method method, + int outputAnnotationCount, String methodAnnotatedOutboundName) { + + if (StringUtils.hasText(methodAnnotatedOutboundName)) { + Assert.isTrue(outputAnnotationCount == 0, + StreamEmitterErrorMessages.INVALID_OUTPUT_METHOD_PARAMETERS); + } + else { + Assert.isTrue(outputAnnotationCount > 0, + StreamEmitterErrorMessages.NO_OUTPUT_SPECIFIED); + } + + if (!method.getReturnType().equals(Void.TYPE)) { + Assert.isTrue(StringUtils.hasText(methodAnnotatedOutboundName), + StreamEmitterErrorMessages.RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + Assert.isTrue(method.getParameterCount() == 0, + StreamEmitterErrorMessages.RETURN_TYPE_METHOD_ARGUMENTS); + } + else { + if (!StringUtils.hasText(methodAnnotatedOutboundName)) { + int methodArgumentsLength = method.getParameterTypes().length; + for (int parameterIndex = 0; parameterIndex < methodArgumentsLength; parameterIndex++) { + MethodParameter methodParameter = new MethodParameter(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Output.class)) { + String outboundName = (String) AnnotationUtils.getValue( + methodParameter.getParameterAnnotation(Output.class)); + Assert.isTrue(StringUtils.hasText(outboundName), + StreamEmitterErrorMessages.INVALID_OUTBOUND_NAME); + } + else { + throw new IllegalArgumentException( + StreamEmitterErrorMessages.OUTPUT_ANNOTATION_MISSING_ON_METHOD_PARAMETERS_VOID_RETURN_TYPE); + } + } + } + } + } + + @Override + public final void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + Assert.isTrue(applicationContext instanceof ConfigurableApplicationContext, + "ConfigurableApplicationContext is required"); + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + } + + @Override + public void afterSingletonsInstantiated() { + this.parameterAdapters = this.applicationContext + .getBeansOfType(StreamListenerParameterAdapter.class).values(); + this.resultAdapters = new ArrayList<>(this.applicationContext + .getBeansOfType(StreamListenerResultAdapter.class).values()); + this.resultAdapters.add(new MessageChannelStreamListenerResultAdapter()); + } + + @Override + public Object postProcessAfterInitialization(final Object bean, final String beanName) + throws BeansException { + Class targetClass = AopUtils.getTargetClass(bean); + ReflectionUtils.doWithMethods(targetClass, method -> { + if (AnnotatedElementUtils.isAnnotated(method, StreamEmitter.class)) { + this.mappedStreamEmitterMethods.add(bean, method); + } + }, ReflectionUtils.USER_DECLARED_METHODS); + return bean; + } + + @Override + public void start() { + try { + this.lock.lock(); + if (!this.running) { + this.mappedStreamEmitterMethods.forEach((k, v) -> v.forEach(item -> { + Assert.isTrue(item.getAnnotation(Input.class) == null, + StreamEmitterErrorMessages.INPUT_ANNOTATIONS_ARE_NOT_ALLOWED); + String methodAnnotatedOutboundName = StreamAnnotationCommonMethodUtils + .getOutboundBindingTargetName(item); + int outputAnnotationCount = StreamAnnotationCommonMethodUtils + .outputAnnotationCount(item); + validateStreamEmitterMethod(item, outputAnnotationCount, + methodAnnotatedOutboundName); + invokeSetupMethodOnToTargetChannel(item, k, + methodAnnotatedOutboundName); + })); + this.running = true; + } + } + finally { + this.lock.unlock(); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private void invokeSetupMethodOnToTargetChannel(Method method, Object bean, + String outboundName) { + Object[] arguments = new Object[method.getParameterCount()]; + Object targetBean = null; + for (int parameterIndex = 0; parameterIndex < arguments.length; parameterIndex++) { + MethodParameter methodParameter = new SynthesizingMethodParameter(method, + parameterIndex); + Class parameterType = methodParameter.getParameterType(); + Object targetReferenceValue = null; + if (methodParameter.hasParameterAnnotation(Output.class)) { + targetReferenceValue = AnnotationUtils + .getValue(methodParameter.getParameterAnnotation(Output.class)); + } + else if (arguments.length == 1 && StringUtils.hasText(outboundName)) { + targetReferenceValue = outboundName; + } + if (targetReferenceValue != null) { + targetBean = this.applicationContext + .getBean((String) targetReferenceValue); + for (StreamListenerParameterAdapter streamListenerParameterAdapter : this.parameterAdapters) { + if (streamListenerParameterAdapter.supports(targetBean.getClass(), + methodParameter)) { + arguments[parameterIndex] = streamListenerParameterAdapter + .adapt(targetBean, methodParameter); + if (arguments[parameterIndex] instanceof FluxSender) { + this.closeableFluxResources + .add((FluxSender) arguments[parameterIndex]); + } + break; + } + } + Assert.notNull(arguments[parameterIndex], + "Cannot convert argument " + parameterIndex + " of " + method + + "from " + targetBean.getClass() + " to " + + parameterType); + } + else { + throw new IllegalStateException( + StreamEmitterErrorMessages.ATLEAST_ONE_OUTPUT); + } + } + Object result; + try { + result = method.invoke(bean, arguments); + } + catch (Exception e) { + throw new BeanInitializationException( + "Cannot setup StreamEmitter for " + method, e); + } + + if (!Void.TYPE.equals(method.getReturnType())) { + if (targetBean == null) { + targetBean = this.applicationContext.getBean(outboundName); + } + boolean streamListenerResultAdapterFound = false; + for (StreamListenerResultAdapter streamListenerResultAdapter : this.resultAdapters) { + if (streamListenerResultAdapter.supports(result.getClass(), + targetBean.getClass())) { + Closeable fluxDisposable = streamListenerResultAdapter.adapt(result, + targetBean); + this.closeableFluxResources.add(fluxDisposable); + streamListenerResultAdapterFound = true; + break; + } + } + Assert.state(streamListenerResultAdapterFound, + StreamEmitterErrorMessages.CANNOT_CONVERT_RETURN_TYPE_TO_ANY_AVAILABLE_RESULT_ADAPTERS); + } + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void stop(Runnable callback) { + stop(); + if (callback != null) { + callback.run(); + } + } + + @Override + public void stop() { + try { + this.lock.lock(); + if (this.running) { + for (Closeable closeable : this.closeableFluxResources) { + try { + closeable.close(); + } + catch (IOException e) { + log.error("Error closing reactive source", e); + } + } + this.running = false; + } + } + finally { + this.lock.unlock(); + } + } + + @Override + public boolean isRunning() { + return this.running; + } + + @Override + public int getPhase() { + return 0; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterErrorMessages.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterErrorMessages.java new file mode 100644 index 000000000..94bdaca40 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/java/org/springframework/cloud/stream/reactive/StreamEmitterErrorMessages.java @@ -0,0 +1,47 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import org.springframework.cloud.stream.binding.StreamAnnotationErrorMessages; + +/** + * @author Soby Chacko + * @since 1.3.0 + */ +abstract class StreamEmitterErrorMessages extends StreamAnnotationErrorMessages { + + static final String INVALID_OUTPUT_METHOD_PARAMETERS = "@Output annotations are not permitted on " + + "method parameters while using the @StreamEmitter and a method-level output specification"; + static final String NO_OUTPUT_SPECIFIED = "No method level or parameter level @Output annotations are detected. " + + "@StreamEmitter requires a method or parameter level @Output annotation."; + + // @checkstyle:off + static final String CANNOT_CONVERT_RETURN_TYPE_TO_ANY_AVAILABLE_RESULT_ADAPTERS = "No suitable adapters are found that can convert the return type"; + + // @checkstyle:on + + private static final String PREFIX = "A method annotated with @StreamEmitter "; + static final String RETURN_TYPE_NO_OUTBOUND_SPECIFIED = PREFIX + + "having a return type should also have an outbound target specified at the method level."; + static final String RETURN_TYPE_METHOD_ARGUMENTS = PREFIX + + "having a return type should not have any method arguments"; + static final String OUTPUT_ANNOTATION_MISSING_ON_METHOD_PARAMETERS_VOID_RETURN_TYPE = PREFIX + + "and void return type without method level @Output annotation requires @Output on each of the method parameter"; + static final String INPUT_ANNOTATIONS_ARE_NOT_ALLOWED = PREFIX + + "cannot contain @Input annotations"; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..a8ce9d92c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.stream.reactive.ReactiveSupportAutoConfiguration diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapterTests.java new file mode 100644 index 000000000..b871df9b3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/MessageChannelToInputFluxParameterAdapterTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.core.MethodParameter; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +public class MessageChannelToInputFluxParameterAdapterTests { + + @Test + public void testWrapperFluxSupportsMultipleSubscriptions() throws Exception { + List results = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch latch = new CountDownLatch(4); + final MessageChannelToInputFluxParameterAdapter messageChannelToInputFluxParameterAdapter; + messageChannelToInputFluxParameterAdapter = new MessageChannelToInputFluxParameterAdapter( + new CompositeMessageConverter( + Collections.singleton(new MappingJackson2MessageConverter()))); + final Method processMethod = ReflectionUtils.findMethod( + MessageChannelToInputFluxParameterAdapterTests.class, "process", + Flux.class); + final DirectChannel adaptedChannel = new DirectChannel(); + @SuppressWarnings("unchecked") + final Flux> adapterFlux = (Flux>) messageChannelToInputFluxParameterAdapter + .adapt(adaptedChannel, new MethodParameter(processMethod, 0)); + String uuid1 = UUID.randomUUID().toString(); + String uuid2 = UUID.randomUUID().toString(); + adapterFlux.map(m -> m.getPayload() + uuid1).subscribe(s -> { + results.add(s); + latch.countDown(); + }); + adapterFlux.map(m -> m.getPayload() + uuid2).subscribe(s -> { + results.add(s); + latch.countDown(); + }); + + adaptedChannel.send(MessageBuilder.withPayload("A").build()); + adaptedChannel.send(MessageBuilder.withPayload("B").build()); + + assertThat(latch.await(5000, TimeUnit.MILLISECONDS)).isTrue(); + assertThat(results).containsExactlyInAnyOrder("A" + uuid1, "B" + uuid1, + "A" + uuid2, "B" + uuid2); + + } + + public void process(Flux> message) { + // do nothing - we just reference this method from the test + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterBasicTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterBasicTests.java new file mode 100644 index 000000000..1e45fea35 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterBasicTests.java @@ -0,0 +1,345 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.dsl.IntegrationFlows; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + * @author Artem Bilan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +public class StreamEmitterBasicTests { + + private static void receiveAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Source source = context.getBean(Source.class); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + List messages = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + messages.add((String) messageCollector.forChannel(source.output()) + .poll(5000, TimeUnit.MILLISECONDS).getPayload()); + } + for (int i = 0; i < 1000; i++) { + assertThat(new String(messages.get(i))).isEqualTo("HELLO WORLD!!" + i); + } + } + + private static void receiveAndValidateMultipleOutputs( + ConfigurableApplicationContext context) throws InterruptedException { + TestMultiOutboundChannels source = context + .getBean(TestMultiOutboundChannels.class); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + List messages = new ArrayList<>(); + assertMessages(source.output1(), messageCollector, messages); + messages.clear(); + assertMessages(source.output2(), messageCollector, messages); + messages.clear(); + assertMessages(source.output3(), messageCollector, messages); + messages.clear(); + } + + private static void receiveAndValidateMultiStreamEmittersInSameContext( + ConfigurableApplicationContext context1) throws InterruptedException { + TestMultiOutboundChannels source1 = context1 + .getBean(TestMultiOutboundChannels.class); + MessageCollector messageCollector = context1.getBean(MessageCollector.class); + + List messages = new ArrayList<>(); + assertMessagesX(source1.output1(), messageCollector, messages); + messages.clear(); + assertMessagesY(source1.output2(), messageCollector, messages); + messages.clear(); + } + + private static void assertMessages(MessageChannel channel, + MessageCollector messageCollector, List messages) + throws InterruptedException { + for (int i = 0; i < 1000; i++) { + messages.add((String) messageCollector.forChannel(channel) + .poll(5000, TimeUnit.MILLISECONDS).getPayload()); + } + for (int i = 0; i < 1000; i++) { + assertThat(new String(messages.get(i))).isEqualTo("Hello World!!" + i); + } + } + + private static void assertMessagesX(MessageChannel channel, + MessageCollector messageCollector, List messages) + throws InterruptedException { + for (int i = 0; i < 1000; i++) { + messages.add((String) messageCollector.forChannel(channel) + .poll(5000, TimeUnit.MILLISECONDS).getPayload()); + } + for (int i = 0; i < 1000; i++) { + assertThat(new String(messages.get(i))).isEqualTo("Hello World!!" + i); + } + } + + private static void assertMessagesY(MessageChannel channel, + MessageCollector messageCollector, List messages) + throws InterruptedException { + for (int i = 0; i < 1000; i++) { + messages.add((String) messageCollector.forChannel(channel) + .poll(5000, TimeUnit.MILLISECONDS).getPayload()); + } + for (int i = 0; i < 1000; i++) { + assertThat(new String(messages.get(i))).isEqualTo("Hello FooBar!!" + i); + } + } + + @Test + public void testFluxReturnAndOutputMethodLevel() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestFluxReturnAndOutputMethodLevel.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + receiveAndValidate(context); + context.close(); + } + + @Test + public void testVoidReturnAndOutputMethodParameter() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestVoidReturnAndOutputMethodParameter.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + receiveAndValidate(context); + context.close(); + } + + @Test + public void testVoidReturnAndOutputAtMethodLevel() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestVoidReturnAndOutputAtMethodLevel.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + receiveAndValidate(context); + context.close(); + } + + @Test + public void testVoidReturnAndMultipleOutputMethodParameters() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestVoidReturnAndMultipleOutputMethodParameters.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.cloud.stream.bindings.output1.contentType=text/plain", + "--spring.cloud.stream.bindings.output2.contentType=text/plain", + "--spring.cloud.stream.bindings.output3.contentType=text/plain"); + receiveAndValidateMultipleOutputs(context); + context.close(); + } + + @Test + public void testMultipleStreamEmitterMethods() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestMultipleStreamEmitterMethods.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.cloud.stream.bindings.output1.contentType=text/plain", + "--spring.cloud.stream.bindings.output2.contentType=text/plain", + "--spring.cloud.stream.bindings.output3.contentType=text/plain"); + receiveAndValidateMultipleOutputs(context); + context.close(); + } + + @Test + public void testSameAppContextWithMultipleStreamEmitters() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestSameAppContextWithMultipleStreamEmitters.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.cloud.stream.bindings.output1.contentType=text/plain", + "--spring.cloud.stream.bindings.output2.contentType=text/plain", + "--spring.cloud.stream.bindings.output3.contentType=text/plain"); + receiveAndValidateMultiStreamEmittersInSameContext(context); + context.close(); + } + + interface TestMultiOutboundChannels { + + String OUTPUT1 = "output1"; + + String OUTPUT2 = "output2"; + + String OUTPUT3 = "output3"; + + @Output(TestMultiOutboundChannels.OUTPUT1) + MessageChannel output1(); + + @Output(TestMultiOutboundChannels.OUTPUT2) + MessageChannel output2(); + + @Output(TestMultiOutboundChannels.OUTPUT3) + MessageChannel output3(); + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestFluxReturnAndOutputMethodLevel { + + @StreamEmitter + @Output(Source.OUTPUT) + @Bean + public Publisher> emit() { + AtomicInteger atomicInteger = new AtomicInteger(); + return IntegrationFlows + .from(() -> new GenericMessage<>( + "Hello World!!" + atomicInteger.getAndIncrement()), + e -> e.poller(p -> p.fixedDelay(1))) + .transform(String::toUpperCase).toReactivePublisher(); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestVoidReturnAndOutputMethodParameter { + + @StreamEmitter + public void emit(@Output(Source.OUTPUT) FluxSender output) { + output.send(Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l) + .map(String::toUpperCase)); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestVoidReturnAndOutputAtMethodLevel { + + @StreamEmitter + @Output(Source.OUTPUT) + public void emit(FluxSender output) { + output.send(Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l) + .map(String::toUpperCase)); + } + + } + + @EnableBinding(TestMultiOutboundChannels.class) + @EnableAutoConfiguration + public static class TestVoidReturnAndMultipleOutputMethodParameters { + + @StreamEmitter + public void emit(@Output(TestMultiOutboundChannels.OUTPUT1) FluxSender output1, + @Output(TestMultiOutboundChannels.OUTPUT2) FluxSender output2, + @Output(TestMultiOutboundChannels.OUTPUT3) FluxSender output3) { + output1.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + output2.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + output3.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(TestMultiOutboundChannels.class) + @EnableAutoConfiguration + public static class TestMultipleStreamEmitterMethods { + + @StreamEmitter + @Output(TestMultiOutboundChannels.OUTPUT1) + public Flux emit1() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + @StreamEmitter + @Output(TestMultiOutboundChannels.OUTPUT2) + public Flux emit2() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + @StreamEmitter + public void emit3(@Output(TestMultiOutboundChannels.OUTPUT3) FluxSender outputX) { + outputX.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(TestMultiOutboundChannels.class) + @EnableAutoConfiguration + public static class TestSameAppContextWithMultipleStreamEmitters { + + @Bean + public Foo foo() { + return new Foo(); + } + + @Bean + public Bar bar() { + return new Bar(); + } + + static class Foo { + + @StreamEmitter + @Output(TestMultiOutboundChannels.OUTPUT1) + public Flux emit1() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + } + + static class Bar { + + @StreamEmitter + @Output(TestMultiOutboundChannels.OUTPUT2) + public Flux emit2() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello FooBar!!" + l); + } + + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterValidationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterValidationTests.java new file mode 100644 index 000000000..035796057 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamEmitterValidationTests.java @@ -0,0 +1,323 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.time.Duration; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamAnnotationErrorMessages.ATLEAST_ONE_OUTPUT; +import static org.springframework.cloud.stream.binding.StreamAnnotationErrorMessages.INVALID_OUTBOUND_NAME; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.CANNOT_CONVERT_RETURN_TYPE_TO_ANY_AVAILABLE_RESULT_ADAPTERS; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.INPUT_ANNOTATIONS_ARE_NOT_ALLOWED; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.INVALID_OUTPUT_METHOD_PARAMETERS; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.NO_OUTPUT_SPECIFIED; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.OUTPUT_ANNOTATION_MISSING_ON_METHOD_PARAMETERS_VOID_RETURN_TYPE; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.RETURN_TYPE_METHOD_ARGUMENTS; +import static org.springframework.cloud.stream.reactive.StreamEmitterErrorMessages.RETURN_TYPE_NO_OUTBOUND_SPECIFIED; + +/** + * @author Soby Chacko + * @author Vinicius Carvalho + */ +public class StreamEmitterValidationTests { + + @Test + public void testOutputAsMethodandMethodParameter() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestOutputAsMethodandMethodParameter.class); + context.refresh(); + context.close(); + fail("Expected exception: " + INVALID_OUTPUT_METHOD_PARAMETERS); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(INVALID_OUTPUT_METHOD_PARAMETERS); + } + } + + @Test + public void testFluxReturnTypeNoOutputGiven() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestFluxReturnTypeNoOutputGiven.class); + context.refresh(); + context.close(); + fail("Expected exception: " + NO_OUTPUT_SPECIFIED); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(NO_OUTPUT_SPECIFIED); + } + } + + @Test + public void testVoidReturnTypeNoOutputGiven() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestVoidReturnTypeNoOutputGiven.class); + context.refresh(); + context.close(); + fail("Expected exception: " + NO_OUTPUT_SPECIFIED); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(NO_OUTPUT_SPECIFIED); + } + } + + @Test + public void testNonVoidReturnButOutputAsMethodParameter() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestNonVoidReturnButOutputAsMethodParameter.class); + context.refresh(); + context.close(); + fail("Expected exception: " + RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + } + + @Test + public void testNonVoidReturnButMethodArguments() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestNonVoidReturnButMethodArguments.class); + context.refresh(); + context.close(); + fail("Expected exception: " + RETURN_TYPE_METHOD_ARGUMENTS); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(RETURN_TYPE_METHOD_ARGUMENTS); + } + } + + @Test + public void testVoidReturnTypeMultipleMethodParametersWithOneMissingOutput() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register( + TestVoidReturnTypeMultipleMethodParametersWithOneMissingOutput.class); + context.refresh(); + context.close(); + fail("Expected exception: " + + OUTPUT_ANNOTATION_MISSING_ON_METHOD_PARAMETERS_VOID_RETURN_TYPE); + } + catch (Exception e) { + assertThat(e.getMessage()).contains( + OUTPUT_ANNOTATION_MISSING_ON_METHOD_PARAMETERS_VOID_RETURN_TYPE); + } + } + + @Test + public void testOutputAtCorrectLocationButNameMissing1() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestOutputAtCorrectLocationButNameMissing1.class); + context.refresh(); + context.close(); + fail("Expected exception: " + ATLEAST_ONE_OUTPUT); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(ATLEAST_ONE_OUTPUT); + } + } + + @Test + public void testOutputAtCorrectLocationButNameMissing2() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestOutputAtCorrectLocationButNameMissing2.class); + context.refresh(); + context.close(); + fail("Expected exception: " + INVALID_OUTBOUND_NAME); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(INVALID_OUTBOUND_NAME); + } + } + + @Test + public void testInputAnnotationsAreNotPermitted() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestInputAnnotationsAreNotPermitted.class); + context.refresh(); + context.close(); + fail("Expected exception: " + INPUT_ANNOTATIONS_ARE_NOT_ALLOWED); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(INPUT_ANNOTATIONS_ARE_NOT_ALLOWED); + } + } + + @Test + public void testReturnTypeNotSupported() { + try { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestReturnTypeNotSupported.class); + context.refresh(); + context.close(); + fail("Expected exception: " + + CANNOT_CONVERT_RETURN_TYPE_TO_ANY_AVAILABLE_RESULT_ADAPTERS); + } + catch (Exception e) { + assertThat(e.getMessage()).contains( + CANNOT_CONVERT_RETURN_TYPE_TO_ANY_AVAILABLE_RESULT_ADAPTERS); + } + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestOutputAsMethodandMethodParameter { + + @StreamEmitter + @Output(Source.OUTPUT) + public void receive(@Output(Source.OUTPUT) FluxSender output) { + output.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestFluxReturnTypeNoOutputGiven { + + @StreamEmitter + public Flux emit() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestVoidReturnTypeNoOutputGiven { + + @StreamEmitter + public void emit(FluxSender output) { + output.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestNonVoidReturnButOutputAsMethodParameter { + + @StreamEmitter + public Flux emit(@Output(Source.OUTPUT) FluxSender output) { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestNonVoidReturnButMethodArguments { + + @StreamEmitter + @Output(Source.OUTPUT) + public Flux receive(FluxSender output) { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + } + + @EnableBinding(StreamEmitterBasicTests.TestMultiOutboundChannels.class) + @EnableAutoConfiguration + public static class TestVoidReturnTypeMultipleMethodParametersWithOneMissingOutput { + + @StreamEmitter + public void emit( + @Output(StreamEmitterBasicTests.TestMultiOutboundChannels.OUTPUT1) FluxSender output1, + @Output(StreamEmitterBasicTests.TestMultiOutboundChannels.OUTPUT2) FluxSender output2, + FluxSender output3) { + output1.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + output2.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + output3.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestOutputAtCorrectLocationButNameMissing1 { + + @StreamEmitter + @Output("") + public void receive(FluxSender output) { + output.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(StreamEmitterBasicTests.TestMultiOutboundChannels.class) + @EnableAutoConfiguration + public static class TestOutputAtCorrectLocationButNameMissing2 { + + @StreamEmitter + public void emit(@Output("") FluxSender output1) { + output1.send( + Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l)); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputAnnotationsAreNotPermitted { + + @StreamEmitter + @Output(Source.OUTPUT) + @Input(Processor.INPUT) + public Flux emit() { + return Flux.interval(Duration.ofMillis(1)).map(l -> "Hello World!!" + l); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestReturnTypeNotSupported { + + @StreamEmitter + @Output(Source.OUTPUT) + public String emit() { + return "hello"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerGenericFluxInputOutputArgsWithMessageTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerGenericFluxInputOutputArgsWithMessageTests.java new file mode 100644 index 000000000..b907dde27 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerGenericFluxInputOutputArgsWithMessageTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM; + +/** + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@SuppressWarnings("unchecked") +public class StreamListenerGenericFluxInputOutputArgsWithMessageTests { + + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testGenericFluxInputOutputArgsWithMessage() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestGenericStringFluxInputOutputArgsWithMessageImpl1.class, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + context.close(); + } + + @Test + public void testInvalidInputValueWithOutputMethodParameters() { + try { + SpringApplication.run( + TestGenericStringFluxInputOutputArgsWithMessageImpl2.class, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + fail("Expected exception: " + INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + catch (Exception e) { + assertThat(e.getMessage()) + .contains(INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + } + + public static class TestGenericStringFluxInputOutputArgsWithMessageImpl1 + extends TestGenericFluxInputOutputArgsWithMessage1 { + + } + + public static class TestGenericStringFluxInputOutputArgsWithMessageImpl2 + extends TestGenericFluxInputOutputArgsWithMessage2 { + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestGenericFluxInputOutputArgsWithMessage1 { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> MessageBuilder + .withPayload((A) m.toString().toUpperCase()).build())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestGenericFluxInputOutputArgsWithMessage2 { + + @StreamListener(Processor.INPUT) + public void receive(Flux input, @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> MessageBuilder + .withPayload((A) m.toString().toUpperCase()).build())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerInterruptionTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerInterruptionTests.java new file mode 100644 index 000000000..783e66d80 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerInterruptionTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test validating that a fix for + * is present. + * + * @author Marius Bogoevici + */ +public class StreamListenerInterruptionTests { + + @Test + public void testSubscribersNotInterrupted() throws Exception { + ConfigurableApplicationContext context = SpringApplication + .run(TestTimeWindows.class, "--server.port=0"); + Sink sink = context.getBean(Sink.class); + TestTimeWindows testTimeWindows = context.getBean(TestTimeWindows.class); + sink.input().send(MessageBuilder.withPayload("hello1").build()); + sink.input().send(MessageBuilder.withPayload("hello2").build()); + sink.input().send(MessageBuilder.withPayload("hello3").build()); + assertThat(testTimeWindows.latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(testTimeWindows.interruptionState).isNotNull(); + assertThat(testTimeWindows.interruptionState).isFalse(); + context.close(); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestTimeWindows { + + public CountDownLatch latch = new CountDownLatch(1); + + public Boolean interruptionState; + + @StreamListener + public void receive(@Input(Sink.INPUT) Flux input) { + input.window(Duration.ofMillis(500), Duration.ofMillis(100)) + .flatMap(w -> w.reduce("", (x, y) -> x + y)).subscribe(x -> { + this.interruptionState = Thread.currentThread().isInterrupted(); + this.latch.countDown(); + }); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsTests.java new file mode 100644 index 000000000..a1812dce9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveInputOutputArgsTests { + + private Class configClass; + + public StreamListenerReactiveInputOutputArgsTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Collections.singletonList(ReactorTestInputOutputArgs.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testInputOutputArgs() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestInputOutputArgs { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> m.toUpperCase())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithMessageTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithMessageTests.java new file mode 100644 index 000000000..b691ac675 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithMessageTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveInputOutputArgsWithMessageTests { + + private Class configClass; + + public StreamListenerReactiveInputOutputArgsWithMessageTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Collections.singletonList(ReactorTestInputOutputArgsWithMessage.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testInputOutputArgs() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestInputOutputArgsWithMessage { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux> input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> MessageBuilder + .withPayload(m.getPayload().toString().toUpperCase()).build())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderAndFailureTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderAndFailureTests.java new file mode 100644 index 000000000..efa5d4482 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderAndFailureTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveInputOutputArgsWithSenderAndFailureTests { + + private Class configClass; + + public StreamListenerReactiveInputOutputArgsWithSenderAndFailureTests( + Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Collections + .singletonList(TestInputOutputArgsWithFluxSenderAndFailure.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + private static void sendFailingMessage(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + processor.input().send(MessageBuilder.withPayload("fail") + .setHeader("contentType", "text/plain").build()); + } + + @Test + public void testInputOutputArgsWithFluxSenderAndFailure() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + sendFailingMessage(context); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestInputOutputArgsWithFluxSenderAndFailure { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux> input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> m.getPayload().toString()).map(m -> { + if (!m.equals("fail")) { + return m.toUpperCase(); + } + else { + throw new RuntimeException(); + } + }).map(o -> MessageBuilder.withPayload(o).build())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderTests.java new file mode 100644 index 000000000..ddb5b8716 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveInputOutputArgsWithSenderTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveInputOutputArgsWithSenderTests { + + private Class configClass; + + public StreamListenerReactiveInputOutputArgsWithSenderTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Collections.singletonList(ReactorTestInputOutputArgsWithFluxSender.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testInputOutputArgsWithFluxSender() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + // send multiple message + sendMessageAndValidate(context); + sendMessageAndValidate(context); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestInputOutputArgsWithFluxSender { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux> input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> m.getPayload().toString().toUpperCase()) + .map(o -> MessageBuilder.withPayload(o).build())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodTests.java new file mode 100644 index 000000000..a0bc0d731 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.RETURN_TYPE_NO_OUTBOUND_SPECIFIED; + +/** + * @author Ilayaperumal Gopinathan + */ +public class StreamListenerReactiveMethodTests { + + @Test + public void testReactiveInvalidInputValueWithOutputMethodParameters() { + try { + SpringApplication.run(ReactorTestInputOutputArgs.class, "--server.port=0"); + fail("IllegalArgumentException should have been thrown"); + } + catch (Exception e) { + assertThat(e.getMessage()) + .contains(INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + } + + @Test + public void testMethodReturnTypeWithNoOutboundSpecified() { + try { + SpringApplication.run(ReactorTestReturn5.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + fail("Exception expected: " + RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestInputOutputArgs { + + @StreamListener(Processor.INPUT) + public void receive(Flux input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map(m -> m.toUpperCase())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturn5 { + + @StreamListener + public Flux receive(@Input(Processor.INPUT) Flux input) { + return input.map(m -> m.toUpperCase()); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodWithReturnTypeTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodWithReturnTypeTests.java new file mode 100644 index 000000000..029ee61df --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveMethodWithReturnTypeTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveMethodWithReturnTypeTests { + + private Class configClass; + + public StreamListenerReactiveMethodWithReturnTypeTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(ReactorTestReturn1.class, ReactorTestReturn2.class, + ReactorTestReturn3.class, ReactorTestReturn4.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testReturn() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + sendMessageAndValidate(context); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturn1 { + + @StreamListener + public @Output(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux input) { + return input.map(m -> m.toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturn2 { + + @StreamListener(Processor.INPUT) + @Output(Processor.OUTPUT) + public Flux receive(Flux input) { + return input.map(m -> m.toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturn3 { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Flux receive(Flux input) { + return input.map(m -> m.toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturn4 { + + @StreamListener + @SendTo(Processor.OUTPUT) + public Flux receive(@Input(Processor.INPUT) Flux input) { + return input.map(m -> m.toUpperCase()); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithFailureTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithFailureTests.java new file mode 100644 index 000000000..2fba02f57 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithFailureTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveReturnWithFailureTests { + + private Class configClass; + + public StreamListenerReactiveReturnWithFailureTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(ReactorTestReturnWithFailure1.class, + ReactorTestReturnWithFailure2.class, ReactorTestReturnWithFailure3.class, + ReactorTestReturnWithFailure4.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + private static void sendFailingMessage(ConfigurableApplicationContext context) { + Processor processor = context.getBean(Processor.class); + processor.input().send(MessageBuilder.withPayload("fail") + .setHeader("contentType", "text/plain").build()); + } + + @Test + public void testReturnWithFailure() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + sendFailingMessage(context); + sendMessageAndValidate(context); + sendFailingMessage(context); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithFailure1 { + + @StreamListener + public @Output(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux input) { + return input.map(m -> { + if (!m.equals("fail")) { + return m.toUpperCase(); + } + else { + throw new RuntimeException(); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithFailure2 { + + @StreamListener(Processor.INPUT) + public @Output(Processor.OUTPUT) Flux receive(Flux input) { + return input.map(m -> { + if (!m.equals("fail")) { + return m.toUpperCase(); + } + else { + throw new RuntimeException(); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithFailure3 { + + @StreamListener(Processor.INPUT) + public @SendTo(Processor.OUTPUT) Flux receive(Flux input) { + return input.map(m -> { + if (!m.equals("fail")) { + return m.toUpperCase(); + } + else { + throw new RuntimeException(); + } + }); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithFailure4 { + + @StreamListener + public @SendTo(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux input) { + return input.map(m -> { + if (!m.equals("fail")) { + return m.toUpperCase(); + } + else { + throw new RuntimeException(); + } + }); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithMessageTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithMessageTests.java new file mode 100644 index 000000000..0b5a09c32 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithMessageTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveReturnWithMessageTests { + + private Class configClass; + + public StreamListenerReactiveReturnWithMessageTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(ReactorTestReturnWithMessage1.class, + ReactorTestReturnWithMessage2.class, ReactorTestReturnWithMessage3.class, + ReactorTestReturnWithMessage4.class); + } + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testReturnWithMessage() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithMessage1 { + + @StreamListener + public @Output(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux> input) { + return input.map(m -> m.getPayload().toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithMessage2 { + + @StreamListener(Processor.INPUT) + public @Output(Processor.OUTPUT) Flux receive( + Flux> input) { + return input.map(m -> m.getPayload().toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithMessage3 { + + @StreamListener(Processor.INPUT) + public @SendTo(Processor.OUTPUT) Flux receive( + Flux> input) { + return input.map(m -> m.getPayload().toUpperCase()); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithMessage4 { + + @StreamListener + public @SendTo(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux> input) { + return input.map(m -> m.getPayload().toUpperCase()); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithPojoTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithPojoTests.java new file mode 100644 index 000000000..8db8cc4db --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerReactiveReturnWithPojoTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@RunWith(Parameterized.class) +public class StreamListenerReactiveReturnWithPojoTests { + + private Class configClass; + + private ObjectMapper mapper = new ObjectMapper(); + + public StreamListenerReactiveReturnWithPojoTests(Class configClass) { + this.configClass = configClass; + } + + @Parameterized.Parameters + public static Collection InputConfigs() { + return Arrays.asList(ReactorTestReturnWithPojo1.class, + ReactorTestReturnWithPojo2.class, ReactorTestReturnWithPojo3.class, + ReactorTestReturnWithPojo4.class); + } + + @Test + @SuppressWarnings("unchecked") + public void testReturnWithPojo() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run(this.configClass, + "--server.port=0", "--spring.jmx.enabled=false"); + Processor processor = context.getBean(Processor.class); + processor.input().send(MessageBuilder.withPayload("{\"message\":\"helloPojo\"}") + .setHeader("contentType", "application/json").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + BarPojo barPojo = this.mapper.readValue(result.getPayload(), BarPojo.class); + assertThat(barPojo.getBarMessage()).isEqualTo("helloPojo"); + context.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithPojo1 { + + @StreamListener + public @Output(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux input) { + return input.map(m -> new BarPojo(m.getMessage())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithPojo2 { + + @StreamListener(Processor.INPUT) + public @Output(Processor.OUTPUT) Flux receive(Flux input) { + return input.map(m -> new BarPojo(m.getMessage())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithPojo3 { + + @StreamListener(Processor.INPUT) + public @SendTo(Processor.OUTPUT) Flux receive(Flux input) { + return input.map(m -> new BarPojo(m.getMessage())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ReactorTestReturnWithPojo4 { + + @StreamListener + public @SendTo(Processor.OUTPUT) Flux receive( + @Input(Processor.INPUT) Flux input) { + return input.map(m -> new BarPojo(m.getMessage())); + } + + } + + public static class FooPojo { + + private String message; + + public String getMessage() { + return this.message; + } + + public void setMessage(String message) { + this.message = message; + } + + } + + public static class BarPojo { + + private String barMessage; + + @JsonCreator + public BarPojo(@JsonProperty("barMessage") String barMessage) { + this.barMessage = barMessage; + } + + public String getBarMessage() { + return this.barMessage; + } + + public void setBarMessage(String barMessage) { + this.barMessage = barMessage; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerWildCardFluxInputOutputArgsWithMessageTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerWildCardFluxInputOutputArgsWithMessageTests.java new file mode 100644 index 000000000..6f7fc03be --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/java/org/springframework/cloud/stream/reactive/StreamListenerWildCardFluxInputOutputArgsWithMessageTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reactive; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS; +import static org.springframework.cloud.stream.binding.StreamListenerErrorMessages.INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM; + +/** + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class StreamListenerWildCardFluxInputOutputArgsWithMessageTests { + + @SuppressWarnings("unchecked") + private static void sendMessageAndValidate(ConfigurableApplicationContext context) + throws InterruptedException { + Processor processor = context.getBean(Processor.class); + String sentPayload = "hello " + UUID.randomUUID().toString(); + processor.input().send(MessageBuilder.withPayload(sentPayload) + .setHeader("contentType", "text/plain").build()); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Message result = (Message) messageCollector + .forChannel(processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(sentPayload.toUpperCase()); + } + + @Test + public void testWildCardFluxInputOutputArgsWithMessage() throws Exception { + ConfigurableApplicationContext context = SpringApplication.run( + TestWildCardFluxInputOutputArgsWithMessage1.class, "--server.port=0", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + sendMessageAndValidate(context); + context.close(); + } + + @Test + public void testInputAsStreamListenerAndOutputAsParameterUsage() { + try { + SpringApplication.run(TestWildCardFluxInputOutputArgsWithMessage2.class, + "--server.port=0"); + fail("Expected exception: " + INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + catch (Exception e) { + assertThat(e.getMessage()) + .contains(INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + } + + @Test + public void testIncorrectUsage1() throws Exception { + try { + SpringApplication.run(TestWildCardFluxInputOutputArgsWithMessage3.class, + "--server.port=0"); + fail("Expected exception: " + INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + catch (Exception e) { + assertThat(e.getMessage()).contains(INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestWildCardFluxInputOutputArgsWithMessage1 { + + @StreamListener + public void receive(@Input(Processor.INPUT) Flux input, + @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map( + m -> MessageBuilder.withPayload(m.toString().toUpperCase()).build())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestWildCardFluxInputOutputArgsWithMessage2 { + + @StreamListener(Processor.INPUT) + public void receive(Flux input, @Output(Processor.OUTPUT) FluxSender output) { + output.send(input.map( + m -> MessageBuilder.withPayload(m.toString().toUpperCase()).build())); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestWildCardFluxInputOutputArgsWithMessage3 { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public void receive(Flux input, FluxSender output) { + output.send(input.map( + m -> MessageBuilder.withPayload(m.toString().toUpperCase()).build())); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/resources/logback.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/resources/logback.xml new file mode 100644 index 000000000..412f0d7d9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-reactive/src/test/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{ISO8601} %5p %t %c{2}:%L - %m%n + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/pom.xml new file mode 100644 index 000000000..24906e094 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + spring-cloud-stream-schema-server + + + spring-cloud-stream-parent + org.springframework.cloud + 2.2.0.BUILD-SNAPSHOT + + + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + 1.4.192 + + + org.apache.avro + avro + 1.8.1 + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.springframework.cloud + spring-cloud-stream-test-support-internal + test + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/EnableSchemaRegistryServer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/EnableSchemaRegistryServer.java new file mode 100644 index 000000000..c8ac3a0c3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/EnableSchemaRegistryServer.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.cloud.stream.schema.server.config.SchemaServerConfiguration; +import org.springframework.context.annotation.Import; + +/** + * Enables the schema registry server enpoints. + * + * @author Vinicius Carvalho + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(SchemaServerConfiguration.class) +public @interface EnableSchemaRegistryServer { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerApplication.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerApplication.java new file mode 100644 index 000000000..743af9959 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerApplication.java @@ -0,0 +1,35 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Vinicius Carvalho + */ +// @checkstyle:off +@SpringBootApplication +@EnableSchemaRegistryServer +public class SchemaRegistryServerApplication { + + public static void main(String[] args) { + SpringApplication.run(SchemaRegistryServerApplication.class, args); + } + +} +// @checkstyle:on diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerConfiguration.java new file mode 100644 index 000000000..0666c7de4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerConfiguration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.domain.EntityScanPackages; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.schema.server.controllers.ServerController; +import org.springframework.cloud.stream.schema.server.model.Schema; +import org.springframework.cloud.stream.schema.server.repository.SchemaRepository; +import org.springframework.cloud.stream.schema.server.support.AvroSchemaValidator; +import org.springframework.cloud.stream.schema.server.support.SchemaValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * @author Vinicius Carvalho + * @author Soby Chacko + */ +@Configuration +@EnableJpaRepositories(basePackageClasses = SchemaRepository.class) +@EnableConfigurationProperties(SchemaServerProperties.class) +@Import(ServerController.class) +public class SchemaServerConfiguration { + + @Bean + public static BeanFactoryPostProcessor entityScanPackagesPostProcessor() { + return new BeanFactoryPostProcessor() { + + @Override + public void postProcessBeanFactory( + ConfigurableListableBeanFactory beanFactory) throws BeansException { + if (beanFactory instanceof BeanDefinitionRegistry) { + EntityScanPackages.register((BeanDefinitionRegistry) beanFactory, + Collections + .singletonList(Schema.class.getPackage().getName())); + } + } + }; + } + + @Bean + public Map schemaValidators() { + Map validatorMap = new HashMap<>(); + validatorMap.put("avro", new AvroSchemaValidator()); + return validatorMap; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerProperties.java new file mode 100644 index 000000000..93ffc346f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/config/SchemaServerProperties.java @@ -0,0 +1,56 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Vinicius Carvalho + * @author Ilayaperumal Gopinathan + */ +@ConfigurationProperties("spring.cloud.stream.schema.server") +public class SchemaServerProperties { + + /** + * Prefix for configuration resource paths (default is empty). Useful when embedding + * in another application when you don't want to change the context path or servlet + * path. + */ + private String path; + + /** + * Boolean flag to enable/disable schema deletion. + */ + private boolean allowSchemaDeletion; + + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isAllowSchemaDeletion() { + return this.allowSchemaDeletion; + } + + public void setAllowSchemaDeletion(boolean allowSchemaDeletion) { + this.allowSchemaDeletion = allowSchemaDeletion; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/controllers/ServerController.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/controllers/ServerController.java new file mode 100644 index 000000000..6b37b5bea --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/controllers/ServerController.java @@ -0,0 +1,220 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.controllers; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.cloud.stream.schema.server.config.SchemaServerProperties; +import org.springframework.cloud.stream.schema.server.model.Schema; +import org.springframework.cloud.stream.schema.server.repository.SchemaRepository; +import org.springframework.cloud.stream.schema.server.support.InvalidSchemaException; +import org.springframework.cloud.stream.schema.server.support.SchemaDeletionNotAllowedException; +import org.springframework.cloud.stream.schema.server.support.SchemaNotFoundException; +import org.springframework.cloud.stream.schema.server.support.SchemaValidator; +import org.springframework.cloud.stream.schema.server.support.UnsupportedFormatException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * @author Vinicius Carvalho + * @author Ilayaperumal Gopinathan + */ +@RestController +@RequestMapping(path = "${spring.cloud.stream.schema.server.path:}") +public class ServerController { + + private final SchemaRepository repository; + + private final Map validators; + + private final SchemaServerProperties schemaServerProperties; + + public ServerController(SchemaRepository repository, + Map validators, + SchemaServerProperties schemaServerProperties) { + Assert.notNull(repository, "cannot be null"); + Assert.notEmpty(validators, "cannot be empty"); + this.repository = repository; + this.validators = validators; + this.schemaServerProperties = schemaServerProperties; + } + + @RequestMapping(method = RequestMethod.POST, path = "/", consumes = "application/json", produces = "application/json") + public synchronized ResponseEntity register(@RequestBody Schema schema, + UriComponentsBuilder builder) { + SchemaValidator validator = this.validators.get(schema.getFormat()); + + if (validator == null) { + throw new UnsupportedFormatException( + String.format("Invalid format, supported types are: %s", StringUtils + .collectionToCommaDelimitedString(this.validators.keySet()))); + } + + if (!validator.isValid(schema.getDefinition())) { + throw new InvalidSchemaException("Invalid schema"); + } + + Schema result; + List registeredEntities = this.repository + .findBySubjectAndFormatOrderByVersion(schema.getSubject(), + schema.getFormat()); + if (registeredEntities == null || registeredEntities.size() == 0) { + schema.setVersion(1); + result = this.repository.save(schema); + } + else { + result = validator.match(registeredEntities, schema.getDefinition()); + if (result == null) { + schema.setVersion( + registeredEntities.get(registeredEntities.size() - 1).getVersion() + + 1); + result = this.repository.save(schema); + } + + } + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.LOCATION, + builder.path("/{subject}/{format}/v{version}") + .buildAndExpand(result.getSubject(), result.getFormat(), + result.getVersion()) + .toString()); + ResponseEntity response = new ResponseEntity<>(result, headers, + HttpStatus.CREATED); + + return response; + + } + + @RequestMapping(method = RequestMethod.GET, produces = "application/json", path = "/{subject}/{format}/v{version}") + public ResponseEntity findOne(@PathVariable("subject") String subject, + @PathVariable("format") String format, + @PathVariable("version") Integer version) { + Schema schema = this.repository.findOneBySubjectAndFormatAndVersion(subject, + format, version); + if (schema == null) { + throw new SchemaNotFoundException("Could not find Schema"); + } + return new ResponseEntity<>(schema, HttpStatus.OK); + } + + @RequestMapping(method = RequestMethod.GET, produces = "application/json", path = "/schemas/{id}") + public ResponseEntity findOne(@PathVariable("id") Integer id) { + Optional schema = this.repository.findById(id); + if (!schema.isPresent()) { + throw new SchemaNotFoundException("Could not find Schema"); + } + return new ResponseEntity<>(schema.get(), HttpStatus.OK); + } + + @RequestMapping(method = RequestMethod.GET, produces = "application/json", path = "/{subject}/{format}") + public ResponseEntity> findBySubjectAndVersion( + @PathVariable("subject") String subject, + @PathVariable("format") String format) { + List schemas = this.repository + .findBySubjectAndFormatOrderByVersion(subject, format); + if (schemas == null || schemas.size() == 0) { + throw new SchemaNotFoundException(String.format( + "No schemas found for subject %s and format %s", subject, format)); + } + return new ResponseEntity>(schemas, HttpStatus.OK); + } + + @RequestMapping(value = "/{subject}/{format}/v{version}", method = RequestMethod.DELETE) + public void delete(@PathVariable("subject") String subject, + @PathVariable("format") String format, + @PathVariable("version") Integer version) { + if (this.schemaServerProperties.isAllowSchemaDeletion()) { + Schema schema = this.repository.findOneBySubjectAndFormatAndVersion(subject, + format, version); + deleteSchema(schema); + } + else { + throw new SchemaDeletionNotAllowedException(); + } + } + + @RequestMapping(value = "/schemas/{id}", method = RequestMethod.DELETE) + public void delete(@PathVariable("id") Integer id) { + if (this.schemaServerProperties.isAllowSchemaDeletion()) { + Optional schema = this.repository.findById(id); + if (!schema.isPresent()) { + throw new SchemaNotFoundException("Could not find Schema"); + } + deleteSchema(schema.get()); + } + else { + throw new SchemaDeletionNotAllowedException(); + } + } + + @RequestMapping(value = "/{subject}", method = RequestMethod.DELETE) + public void delete(@PathVariable("subject") String subject) { + if (this.schemaServerProperties.isAllowSchemaDeletion()) { + for (Schema schema : this.repository.findAll()) { + if (schema.getSubject().equals(subject)) { + deleteSchema(schema); + } + } + } + else { + throw new SchemaDeletionNotAllowedException(); + } + + } + + private void deleteSchema(Schema schema) { + if (schema == null) { + throw new SchemaNotFoundException("Could not find Schema"); + } + this.repository.delete(schema); + } + + @ExceptionHandler(UnsupportedFormatException.class) + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Format not supported") + public void unsupportedFormat(UnsupportedFormatException ex) { + } + + @ExceptionHandler(InvalidSchemaException.class) + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid schema") + public void invalidSchema(InvalidSchemaException ex) { + } + + @ExceptionHandler(SchemaNotFoundException.class) + @ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Schema not found") + public void schemaNotFound(SchemaNotFoundException ex) { + } + + @ExceptionHandler(SchemaDeletionNotAllowedException.class) + @ResponseStatus(value = HttpStatus.METHOD_NOT_ALLOWED, reason = "Schema deletion is not permitted") + public void schemaDeletionNotPermitted(SchemaDeletionNotAllowedException ex) { + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Compatibility.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Compatibility.java new file mode 100644 index 000000000..46c13a7a1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Compatibility.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.model; + +/** + * @author Vinicius Carvalho + */ +public enum Compatibility { + + /** + * Backward compatibiltity. + */ + BACKWARD, + + /** + * Forward compatibility. + */ + FORWARD, + + /** + * Full compatibility. + */ + FULL, + + /** + * Lack of compatibility. + */ + INCOMPATIBLE; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Schema.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Schema.java new file mode 100644 index 000000000..250896c8d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/model/Schema.java @@ -0,0 +1,93 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; + +/** + * @author Vinicius Carvalho + * + * Represents a persisted schema entity. + */ +@Entity +@Table(name = "SCHEMA_REPOSITORY") +public class Schema { + + @Id + @GeneratedValue + @Column(name = "ID") + private Integer id; + + @Column(name = "VERSION", nullable = false) + private Integer version; + + @Column(name = "SUBJECT", nullable = false) + private String subject; + + @Column(name = "FORMAT", nullable = false) + private String format; + + @Lob + @Column(name = "DEFINITION", nullable = false, length = 8192) + private String definition; + + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getVersion() { + return this.version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public String getSubject() { + return this.subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getFormat() { + return this.format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getDefinition() { + return this.definition; + } + + public void setDefinition(String definition) { + this.definition = definition; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/repository/SchemaRepository.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/repository/SchemaRepository.java new file mode 100644 index 000000000..9fb8ad4ae --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/repository/SchemaRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.repository; + +import java.util.List; + +import org.springframework.cloud.stream.schema.server.model.Schema; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Vinicius Carvalho + */ +public interface SchemaRepository extends PagingAndSortingRepository { + + @Transactional + List findBySubjectAndFormatOrderByVersion(String subject, String format); + + @Transactional + Schema findOneBySubjectAndFormatAndVersion(String subject, String format, + Integer version); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/AvroSchemaValidator.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/AvroSchemaValidator.java new file mode 100644 index 000000000..0af3f5a64 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/AvroSchemaValidator.java @@ -0,0 +1,69 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +import java.util.List; + +import org.apache.avro.SchemaParseException; + +import org.springframework.cloud.stream.schema.server.model.Compatibility; +import org.springframework.cloud.stream.schema.server.model.Schema; + +/** + * @author Vinicius Carvalho + */ +public class AvroSchemaValidator implements SchemaValidator { + + @Override + public boolean isValid(String definition) { + boolean result = true; + try { + new org.apache.avro.Schema.Parser().parse(definition); + } + catch (SchemaParseException ex) { + result = false; + } + return result; + } + + @Override + public Compatibility compatibilityCheck(String source, String other) { + return null; + } + + @Override + public Schema match(List schemas, String definition) { + Schema result = null; + org.apache.avro.Schema source = new org.apache.avro.Schema.Parser() + .parse(definition); + for (Schema s : schemas) { + org.apache.avro.Schema target = new org.apache.avro.Schema.Parser() + .parse(s.getDefinition()); + if (target.equals(source)) { + result = s; + break; + } + } + return result; + } + + @Override + public String getFormat() { + return "avro"; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/InvalidSchemaException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/InvalidSchemaException.java new file mode 100644 index 000000000..c53a523db --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/InvalidSchemaException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +/** + * @author Vinicius Carvalho + */ +public class InvalidSchemaException extends RuntimeException { + + public InvalidSchemaException(String message) { + super(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaDeletionNotAllowedException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaDeletionNotAllowedException.java new file mode 100644 index 000000000..61709632d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaDeletionNotAllowedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +/** + * @author Ilayaperumal Gopinathan + */ +public class SchemaDeletionNotAllowedException extends RuntimeException { + + public SchemaDeletionNotAllowedException(String message) { + super(message); + } + + public SchemaDeletionNotAllowedException() { + super("Schema Deletion Not Allowed"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaNotFoundException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaNotFoundException.java new file mode 100644 index 000000000..b8f1d4784 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaNotFoundException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +/** + * @author Vinicius Carvalho + */ +public class SchemaNotFoundException extends RuntimeException { + + public SchemaNotFoundException(String message) { + super(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaValidator.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaValidator.java new file mode 100644 index 000000000..81067a82a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/SchemaValidator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +import java.util.List; + +import org.springframework.cloud.stream.schema.server.model.Compatibility; +import org.springframework.cloud.stream.schema.server.model.Schema; + +/** + * @author Vinicius Carvalho + * + * Provides utility methods to validate, check compatibility and match schemas of + * different implementations + */ +public interface SchemaValidator { + + /** + * Verifies if a definition is a valid schema. + * @param definition - The textual representation of the schema file + * @return true if valid, false otherwise + */ + boolean isValid(String definition); + + /** + * Checks for compatibility between two schemas @see Compatibility class for types + * This method may not be supported for certain formats. + * @param source - The textual representation of the schema to tested + * @param other - The textual representation of the other schema to tested + * @return {@link Compatibility} + */ + Compatibility compatibilityCheck(String source, String other); + + /** + * Return the Schema that is represented by the definition. + * @param schemas List of schemas to be tested + * @param definition Textual representation of the schema + * @return A full Schema object with identifier and subject properties + */ + Schema match(List schemas, String definition); + + String getFormat(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/UnsupportedFormatException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/UnsupportedFormatException.java new file mode 100644 index 000000000..2c2bf85e7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/java/org/springframework/cloud/stream/schema/server/support/UnsupportedFormatException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.support; + +/** + * @author Vinicius Carvalho + */ +public class UnsupportedFormatException extends RuntimeException { + + public UnsupportedFormatException(String message) { + super(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/application.yml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/application.yml new file mode 100644 index 000000000..a5aad28b1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/main/resources/application.yml @@ -0,0 +1,6 @@ +spring: + application: + name: SchemaRegistryServer +server: + port: 8990 + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerAvroTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerAvroTests.java new file mode 100644 index 000000000..65ef4743f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/SchemaRegistryServerAvroTests.java @@ -0,0 +1,286 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server; + +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.stream.schema.server.config.SchemaServerProperties; +import org.springframework.cloud.stream.schema.server.model.Schema; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD; + +/** + * @author Vinicius Carvalho + * @author Ilayaperumal Gopinathan + */ +@RunWith(SpringRunner.class) +// @checkstyle:off +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, properties = "spring.main.allow-bean-definition-overriding=true") +// @checkstyle:on +@DirtiesContext(classMode = AFTER_EACH_TEST_METHOD) +public class SchemaRegistryServerAvroTests { + + final String USER_SCHEMA_V1 = "{\"namespace\": \"example.avro\",\n" + + " \"type\": \"record\",\n" + " \"name\": \"User\",\n" + " \"fields\": [\n" + + " {\"name\": \"name\", \"type\": \"string\"},\n" + + " {\"name\": \"favorite_number\", \"type\": [\"int\", \"null\"]}\n" + + " ]\n" + "}"; + + final String USER_SCHEMA_V2 = "{\"namespace\": \"example.avro\",\n" + + " \"type\": \"record\",\n" + " \"name\": \"User\",\n" + " \"fields\": [\n" + + " {\"name\": \"name\", \"type\": \"string\"},\n" + + " {\"name\": \"favorite_number\", \"type\": [\"int\", \"null\"]},\n" + + " {\"name\": \"favorite_color\", \"type\": [\"string\", \"null\"]}\n" + + " ]\n" + "}"; + + @Autowired + private TestRestTemplate client; + + @Autowired + private SchemaServerProperties schemaServerProperties; + + @Autowired + private WebApplicationContext wac; + + @Test + public void testUnsupportedFormat() throws Exception { + Schema schema = new Schema(); + schema.setFormat("spring"); + schema.setSubject("boot"); + ResponseEntity response = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + public void testInvalidSchema() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("boot"); + schema.setDefinition("{}"); + ResponseEntity response = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + public void testUserSchemaV1() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("org.springframework.cloud.stream.schema.User"); + schema.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().getVersion()).isEqualTo(new Integer(1)); + List location = response.getHeaders().get(HttpHeaders.LOCATION); + assertThat(location).isNotNull(); + ResponseEntity persistedSchema = this.client.getForEntity(location.get(0), + Schema.class); + assertThat(persistedSchema.getBody().getId()) + .isEqualTo(response.getBody().getId()); + + } + + @Test + public void testUserSchemaV2() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("org.springframework.cloud.stream.schema.User"); + schema.setDefinition(this.USER_SCHEMA_V1); + + Schema schema2 = new Schema(); + schema2.setFormat("avro"); + schema2.setSubject("org.springframework.cloud.stream.schema.User"); + schema2.setDefinition(this.USER_SCHEMA_V2); + + ResponseEntity response = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().getVersion()).isEqualTo(new Integer(1)); + List location = response.getHeaders().get(HttpHeaders.LOCATION); + assertThat(location).isNotNull(); + + ResponseEntity response2 = this.client + .postForEntity("http://localhost:8990/", schema2, Schema.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response2.getBody().getVersion()).isEqualTo(new Integer(2)); + List location2 = response2.getHeaders().get(HttpHeaders.LOCATION); + assertThat(location2).isNotNull(); + + } + + @Test + public void testIdempotentRegistration() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("org.springframework.cloud.stream.schema.User"); + schema.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().getVersion()).isEqualTo(new Integer(1)); + List location = response.getHeaders().get(HttpHeaders.LOCATION); + assertThat(location).isNotNull(); + ResponseEntity response2 = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response2.getBody().getId()).isEqualTo(response.getBody().getId()); + + } + + @Test + public void testSchemaNotfound() throws Exception { + ResponseEntity response = this.client + .getForEntity("http://localhost:8990/foo/avro/v42", Schema.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testSchemaDeletionBySubjectFormatVersion() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("test"); + schema.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response1 = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response1.getStatusCode().is2xxSuccessful()).isTrue(); + this.schemaServerProperties.setAllowSchemaDeletion(true); + this.client.delete("http://localhost:8990/test/avro/v1"); + ResponseEntity response2 = this.client + .getForEntity("http://localhost:8990/test/avro/v1", Schema.class); + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testSchemaDeletionById() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("test"); + schema.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response1 = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response1.getStatusCode().is2xxSuccessful()).isTrue(); + ResponseEntity response2 = this.client + .getForEntity("http://localhost:8990/test/avro/v1", Schema.class); + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + this.schemaServerProperties.setAllowSchemaDeletion(true); + this.client.delete("http://localhost:8990/schemas/1"); + ResponseEntity response3 = this.client + .getForEntity("http://localhost:8990/test/avro/1", Schema.class); + assertThat(response3.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testSchemaDeletionBySubject() throws Exception { + Schema schema1 = new Schema(); + schema1.setFormat("avro"); + schema1.setSubject("test"); + schema1.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response1 = this.client + .postForEntity("http://localhost:8990/", schema1, Schema.class); + assertThat(response1.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(this.client + .getForEntity("http://localhost:8990/test/avro/v1", Schema.class) + .getStatusCode()).isEqualTo(HttpStatus.OK); + this.client.getForEntity("http://localhost:8990/test/avro/1", Schema.class); + Schema schema2 = new Schema(); + schema2.setFormat("avro"); + schema2.setSubject("test"); + schema2.setDefinition(this.USER_SCHEMA_V2); + ResponseEntity response2 = this.client + .postForEntity("http://localhost:8990/", schema2, Schema.class); + assertThat(response2.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(this.client + .getForEntity("http://localhost:8990/test/avro/v2", Schema.class) + .getStatusCode()).isEqualTo(HttpStatus.OK); + this.schemaServerProperties.setAllowSchemaDeletion(true); + this.client.delete("http://localhost:8990/test"); + ResponseEntity response4 = this.client + .getForEntity("http://localhost:8990/test/avro/v1", Schema.class); + assertThat(response4.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + ResponseEntity response5 = this.client + .getForEntity("http://localhost:8990/test/avro/v2", Schema.class); + assertThat(response5.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testSchemaDeletionNotAllowed() throws Exception { + Schema schema = new Schema(); + schema.setFormat("avro"); + schema.setSubject("test"); + schema.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response1 = this.client + .postForEntity("http://localhost:8990/", schema, Schema.class); + assertThat(response1.getStatusCode().is2xxSuccessful()).isTrue(); + ResponseEntity deleteBySubjectFormatVersion = this.client.exchange( + "http://localhost:8990/test/avro/v1", HttpMethod.DELETE, null, + Object.class); + assertThat(deleteBySubjectFormatVersion.getStatusCode()) + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + ResponseEntity deleteBySubject = this.client.exchange( + "http://localhost:8990/test", HttpMethod.DELETE, null, Object.class); + assertThat(deleteBySubject.getStatusCode()) + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + ResponseEntity deleteById = this.client.exchange( + "http://localhost:8990/schemas/1", HttpMethod.DELETE, null, Object.class); + assertThat(deleteById.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + } + + @Test + public void testFindSchemasBySubjectAndVersion() throws Exception { + Schema v1 = new Schema(); + v1.setFormat("avro"); + v1.setSubject("test"); + v1.setDefinition(this.USER_SCHEMA_V1); + ResponseEntity response1 = this.client + .postForEntity("http://localhost:8990/", v1, Schema.class); + assertThat(response1.getStatusCode().is2xxSuccessful()).isTrue(); + + Schema v2 = new Schema(); + v2.setFormat("avro"); + v2.setSubject("test"); + v2.setDefinition(this.USER_SCHEMA_V2); + + ResponseEntity response2 = this.client + .postForEntity("http://localhost:8990/", v2, Schema.class); + assertThat(response2.getStatusCode().is2xxSuccessful()).isTrue(); + + ResponseEntity> schemaResponse = this.client.exchange( + "http://localhost:8990/test/avro", HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + + assertThat(schemaResponse.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(schemaResponse.getBody().size()).isEqualTo(2); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTests.java new file mode 100644 index 000000000..3dad37920 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.entityScanning; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.schema.server.EnableSchemaRegistryServer; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * @author Marius Bogoevici + */ +public class EntityScanningTests { + + @Test + public void testApplicationWithEmbeddedSchemaRegistryServerOutsideOfRootPackage() + throws Exception { + final ConfigurableApplicationContext context = SpringApplication + .run(CustomApplicationEmbeddingSchemaServer.class, "--server.port=0"); + context.close(); + } + + @EnableAutoConfiguration + @EnableSchemaRegistryServer + public static class CustomApplicationEmbeddingSchemaServer { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTestsWithEntityScan.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTestsWithEntityScan.java new file mode 100644 index 000000000..7742f05d0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/EntityScanningTestsWithEntityScan.java @@ -0,0 +1,47 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.entityScanning; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.cloud.stream.schema.server.EnableSchemaRegistryServer; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * @author Marius Bogoevici + */ +public class EntityScanningTestsWithEntityScan { + + @Test + public void testApplicationWithEmbeddedSchemaRegistryServerOutsideOfRootPackage() + throws Exception { + final ConfigurableApplicationContext context = SpringApplication + .run(CustomApplicationEmbeddingSchemaServer.class, "--server.port=0"); + context.close(); + } + + @EnableAutoConfiguration + @EnableSchemaRegistryServer + @EntityScan(basePackages = "org.springframework.cloud.stream.schema.server.entityScanning.domain") + public static class CustomApplicationEmbeddingSchemaServer { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/domain/TestEntity.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/domain/TestEntity.java new file mode 100644 index 000000000..2c65ba6af --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema-server/src/test/java/org/springframework/cloud/stream/schema/server/entityScanning/domain/TestEntity.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.server.entityScanning.domain; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; + +/** + * @author Marius Bogoevici + */ +@Entity +public class TestEntity { + + @Id + private long id; + + @Column(name = "name") + private String name; + + public long getId() { + return this.id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/pom.xml new file mode 100644 index 000000000..84f04ba5e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/pom.xml @@ -0,0 +1,98 @@ + + + + spring-cloud-stream-parent + org.springframework.cloud + 2.2.0.BUILD-SNAPSHOT + + 4.0.0 + + spring-cloud-stream-schema + + 1.8.1 + + + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.apache.avro + avro + ${avro.version} + true + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.springframework.cloud + spring-cloud-stream-test-support-internal + test + + + org.springframework.cloud + spring-cloud-stream-schema-server + test + + + + + + org.apache.avro + avro-maven-plugin + ${avro.version} + + + generate-test-sources + + schema + + + + + ${project.basedir}/target/generated-test-sources + + + ${project.basedir}/target/generated-test-sources + + ${project.basedir}/src/test/resources/schemas + + + **/*.avsc + + + + ${project.basedir}/src/test/resources/schemas/imports/Email.avsc + + + ${project.basedir}/src/test/resources/schemas/imports/Sms.avsc + + + ${project.basedir}/src/test/resources/schemas/imports/PushNotification.avsc + + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/ParsedSchema.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/ParsedSchema.java new file mode 100644 index 000000000..1daada556 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/ParsedSchema.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema; + +import org.apache.avro.Schema; + +/** + * Stores a {@link Schema} together with its String representation. + * + * Helps to avoid unnecessary parsing of schema textual representation, as well as calls + * to {@link org.apache.avro.Schema} toString method which is very expensive due the + * utilization of {@link com.fasterxml.jackson.databind.ObjectMapper} to output a JSON + * representation of the schema. + * + * Once a schema is found for any Class, be it a POJO or a + * {@link org.apache.avro.generic.GenericContainer}, both textual representation as well + * as the {@link org.apache.avro.Schema} will be stored within this class. + * + * @author Vinicius Carvalho + * + */ +public class ParsedSchema { + + private final Schema schema; + + private final String representation; + + private SchemaRegistrationResponse registration; + + public ParsedSchema(Schema schema) { + this.schema = schema; + this.representation = schema.toString(); + } + + public Schema getSchema() { + return this.schema; + } + + public String getRepresentation() { + return this.representation; + } + + public SchemaRegistrationResponse getRegistration() { + return this.registration; + } + + public void setRegistration(SchemaRegistrationResponse registration) { + this.registration = registration; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaNotFoundException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaNotFoundException.java new file mode 100644 index 000000000..4c9707e23 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaNotFoundException.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema; + +/** + * @author Vinicius Carvalho + */ +public class SchemaNotFoundException extends RuntimeException { + + public SchemaNotFoundException(String message) { + super(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaReference.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaReference.java new file mode 100644 index 000000000..4a1b8c9e7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaReference.java @@ -0,0 +1,105 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema; + +import org.springframework.util.Assert; + +/** + * References a schema through its subject and version. + * + * @author Marius Bogoevici + */ +public class SchemaReference { + + private String subject; + + private int version; + + private String format; + + public SchemaReference(String subject, int version, String format) { + Assert.hasText(subject, "cannot be empty"); + Assert.isTrue(version > 0, "must be a positive integer"); + Assert.hasText(format, "cannot be empty"); + this.subject = subject; + this.version = version; + this.format = format; + } + + public String getSubject() { + return this.subject; + } + + public void setSubject(String subject) { + Assert.hasText(subject, "cannot be empty"); + this.subject = subject; + } + + public int getVersion() { + return this.version; + } + + public void setVersion(int version) { + Assert.isTrue(version > 0, "must be a positive integer"); + this.version = version; + } + + public String getFormat() { + return this.format; + } + + public void setFormat(String format) { + Assert.hasText(format, "cannot be empty"); + this.format = format; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + SchemaReference that = (SchemaReference) o; + + if (this.version != that.version) { + return false; + } + if (!this.subject.equals(that.subject)) { + return false; + } + return this.format.equals(that.format); + + } + + @Override + public int hashCode() { + int result = this.subject.hashCode(); + result = 31 * result + this.version; + result = 31 * result + this.format.hashCode(); + return result; + } + + @Override + public String toString() { + return "SchemaReference{" + "subject='" + this.subject + '\'' + ", version=" + + this.version + ", format='" + this.format + '\'' + '}'; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaRegistrationResponse.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaRegistrationResponse.java new file mode 100644 index 000000000..0a3e24efa --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/SchemaRegistrationResponse.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema; + +/** + * @author Marius Bogoevici + */ +public class SchemaRegistrationResponse { + + private int id; + + private SchemaReference schemaReference; + + public int getId() { + return this.id; + } + + public void setId(int id) { + this.id = id; + } + + public SchemaReference getSchemaReference() { + return this.schemaReference; + } + + public void setSchemaReference(SchemaReference schemaReference) { + this.schemaReference = schemaReference; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AbstractAvroMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AbstractAvroMessageConverter.java new file mode 100644 index 000000000..2ef6220c8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AbstractAvroMessageConverter.java @@ -0,0 +1,211 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.DatumReader; +import org.apache.avro.io.DatumWriter; +import org.apache.avro.io.Decoder; +import org.apache.avro.io.DecoderFactory; +import org.apache.avro.io.Encoder; +import org.apache.avro.io.EncoderFactory; +import org.apache.avro.reflect.ReflectDatumReader; +import org.apache.avro.reflect.ReflectDatumWriter; +import org.apache.avro.specific.SpecificDatumReader; +import org.apache.avro.specific.SpecificDatumWriter; +import org.apache.avro.specific.SpecificRecord; + +import org.springframework.core.io.Resource; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.util.MimeType; + +/** + * Base class for Apache Avro + * {@link org.springframework.messaging.converter.MessageConverter} implementations. + * + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Sercan Karaoglu + */ +public abstract class AbstractAvroMessageConverter extends AbstractMessageConverter { + + /** + * common parser will let user to import external schemas. + */ + private Schema.Parser schemaParser = new Schema.Parser(); + + protected AbstractAvroMessageConverter(MimeType supportedMimeType) { + this(Collections.singletonList(supportedMimeType)); + } + + protected AbstractAvroMessageConverter(Collection supportedMimeTypes) { + super(supportedMimeTypes); + setContentTypeResolver(new OriginalContentTypeResolver()); + } + + protected Schema parseSchema(Resource r) throws IOException { + return this.schemaParser.parse(r.getInputStream()); + } + + @Override + protected boolean canConvertFrom(Message message, Class targetClass) { + return super.canConvertFrom(message, targetClass) + && (message.getPayload() instanceof byte[]); + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, + Object conversionHint) { + Object result = null; + try { + byte[] payload = (byte[]) message.getPayload(); + + MimeType mimeType = getContentTypeResolver().resolve(message.getHeaders()); + if (mimeType == null) { + if (conversionHint instanceof MimeType) { + mimeType = (MimeType) conversionHint; + } + else { + return null; + } + } + + Schema writerSchema = resolveWriterSchemaForDeserialization(mimeType); + Schema readerSchema = resolveReaderSchemaForDeserialization(targetClass); + + @SuppressWarnings("unchecked") + DatumReader reader = getDatumReader((Class) targetClass, + readerSchema, writerSchema); + Decoder decoder = DecoderFactory.get().binaryDecoder(payload, null); + result = reader.read(null, decoder); + } + catch (IOException e) { + throw new MessageConversionException(message, "Failed to read payload", e); + } + return result; + } + + private DatumWriter getDatumWriter(Class type, Schema schema) { + DatumWriter writer; + this.logger.debug("Finding correct DatumWriter for type " + type.getName()); + if (SpecificRecord.class.isAssignableFrom(type)) { + if (schema != null) { + writer = new SpecificDatumWriter<>(schema); + } + else { + writer = new SpecificDatumWriter<>(type); + } + } + else if (GenericRecord.class.isAssignableFrom(type)) { + writer = new GenericDatumWriter<>(schema); + } + else { + if (schema != null) { + writer = new ReflectDatumWriter<>(schema); + } + else { + writer = new ReflectDatumWriter<>(type); + } + } + return writer; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + protected DatumReader getDatumReader(Class type, Schema schema, + Schema writerSchema) { + DatumReader reader = null; + if (SpecificRecord.class.isAssignableFrom(type)) { + if (schema != null) { + if (writerSchema != null) { + reader = new SpecificDatumReader<>(writerSchema, schema); + } + else { + reader = new SpecificDatumReader<>(schema); + } + } + else { + reader = new SpecificDatumReader<>(type); + if (writerSchema != null) { + reader.setSchema(writerSchema); + } + } + } + else if (GenericRecord.class.isAssignableFrom(type)) { + if (schema != null) { + if (writerSchema != null) { + reader = new GenericDatumReader<>(writerSchema, schema); + } + else { + reader = new GenericDatumReader<>(schema); + } + } + } + else { + reader = new ReflectDatumReader(type); + if (writerSchema != null) { + reader.setSchema(writerSchema); + } + } + if (reader == null) { + throw new MessageConversionException("No schema can be inferred from type " + + type.getName() + " and no schema has been explicitly configured."); + } + return reader; + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + MimeType hintedContentType = null; + if (conversionHint instanceof MimeType) { + hintedContentType = (MimeType) conversionHint; + } + Schema schema = resolveSchemaForWriting(payload, headers, hintedContentType); + @SuppressWarnings("unchecked") + DatumWriter writer = getDatumWriter( + (Class) payload.getClass(), schema); + Encoder encoder = EncoderFactory.get().binaryEncoder(baos, null); + writer.write(payload, encoder); + encoder.flush(); + } + catch (IOException e) { + throw new MessageConversionException("Failed to write payload", e); + } + return baos.toByteArray(); + } + + protected abstract Schema resolveSchemaForWriting(Object payload, + MessageHeaders headers, MimeType hintedContentType); + + protected abstract Schema resolveWriterSchemaForDeserialization(MimeType mimeType); + + protected abstract Schema resolveReaderSchemaForDeserialization(Class targetClass); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterAutoConfiguration.java new file mode 100644 index 000000000..4e4ba8441 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterAutoConfiguration.java @@ -0,0 +1,102 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import java.lang.reflect.Constructor; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; + +/** + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Sercan Karaoglu + */ +@Configuration +@ConditionalOnClass(name = "org.apache.avro.Schema") +@ConditionalOnProperty(value = "spring.cloud.stream.schemaRegistryClient.enabled", matchIfMissing = true) +@ConditionalOnBean(type = "org.springframework.cloud.stream.schema.client.SchemaRegistryClient") +@EnableConfigurationProperties({ AvroMessageConverterProperties.class }) +public class AvroMessageConverterAutoConfiguration { + + @Autowired + private AvroMessageConverterProperties avroMessageConverterProperties; + + @Bean + @ConditionalOnMissingBean(AvroSchemaRegistryClientMessageConverter.class) + @StreamMessageConverter + public AvroSchemaRegistryClientMessageConverter avroSchemaMessageConverter( + SchemaRegistryClient schemaRegistryClient) { + AvroSchemaRegistryClientMessageConverter avroSchemaRegistryClientMessageConverter; + avroSchemaRegistryClientMessageConverter = new AvroSchemaRegistryClientMessageConverter( + schemaRegistryClient, cacheManager()); + avroSchemaRegistryClientMessageConverter.setDynamicSchemaGenerationEnabled( + this.avroMessageConverterProperties.isDynamicSchemaGenerationEnabled()); + if (this.avroMessageConverterProperties.getReaderSchema() != null) { + avroSchemaRegistryClientMessageConverter.setReaderSchema( + this.avroMessageConverterProperties.getReaderSchema()); + } + if (!ObjectUtils + .isEmpty(this.avroMessageConverterProperties.getSchemaLocations())) { + avroSchemaRegistryClientMessageConverter.setSchemaLocations( + this.avroMessageConverterProperties.getSchemaLocations()); + } + if (!ObjectUtils + .isEmpty(this.avroMessageConverterProperties.getSchemaImports())) { + avroSchemaRegistryClientMessageConverter.setSchemaImports( + this.avroMessageConverterProperties.getSchemaImports()); + } + avroSchemaRegistryClientMessageConverter + .setPrefix(this.avroMessageConverterProperties.getPrefix()); + + try { + Class clazz = this.avroMessageConverterProperties + .getSubjectNamingStrategy(); + Constructor constructor = ReflectionUtils.accessibleConstructor(clazz); + + avroSchemaRegistryClientMessageConverter.setSubjectNamingStrategy( + (SubjectNamingStrategy) constructor.newInstance()); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to create SubjectNamingStrategy " + + this.avroMessageConverterProperties.getSubjectNamingStrategy() + .toString(), + ex); + } + + return avroSchemaRegistryClientMessageConverter; + } + + @Bean + @ConditionalOnMissingBean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterProperties.java new file mode 100644 index 000000000..a6082772a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroMessageConverterProperties.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +/** + * @author Vinicius Carvalho + * @author Sercan Karaoglu + */ +@ConfigurationProperties(prefix = "spring.cloud.stream.schema.avro") +public class AvroMessageConverterProperties { + + private boolean dynamicSchemaGenerationEnabled; + + private Resource readerSchema; + + /** + * The source directory of Apache Avro schema. This schema is used by this converter. + * If this schema depends on other schemas consider defining those those dependent + * ones in the {@link #schemaImports} + * @parameter + */ + private Resource[] schemaLocations; + + /** + * A list of files or directories that should be loaded first thus making them + * importable by subsequent schemas. Note that imported files should not reference + * each other. + * @parameter + */ + private Resource[] schemaImports; + + private String prefix = "vnd"; + + private Class subjectNamingStrategy = DefaultSubjectNamingStrategy.class; + + public Resource getReaderSchema() { + return this.readerSchema; + } + + public void setReaderSchema(Resource readerSchema) { + Assert.notNull(readerSchema, "cannot be null"); + this.readerSchema = readerSchema; + } + + public Resource[] getSchemaLocations() { + return this.schemaLocations; + } + + public void setSchemaLocations(Resource[] schemaLocations) { + Assert.notEmpty(schemaLocations, "cannot be null"); + this.schemaLocations = schemaLocations; + } + + public boolean isDynamicSchemaGenerationEnabled() { + return this.dynamicSchemaGenerationEnabled; + } + + public void setDynamicSchemaGenerationEnabled( + boolean dynamicSchemaGenerationEnabled) { + this.dynamicSchemaGenerationEnabled = dynamicSchemaGenerationEnabled; + } + + public String getPrefix() { + return this.prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + public Class getSubjectNamingStrategy() { + return this.subjectNamingStrategy; + } + + public void setSubjectNamingStrategy( + Class subjectNamingStrategy) { + Assert.notNull(subjectNamingStrategy, "cannot be null"); + this.subjectNamingStrategy = subjectNamingStrategy; + } + + public Resource[] getSchemaImports() { + return this.schemaImports; + } + + public void setSchemaImports(Resource[] schemaImports) { + this.schemaImports = schemaImports; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaMessageConverter.java new file mode 100644 index 000000000..8f073020c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaMessageConverter.java @@ -0,0 +1,118 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import java.io.IOException; +import java.util.Collection; + +import org.apache.avro.Schema; + +import org.springframework.core.io.Resource; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * A {@link org.springframework.messaging.converter.MessageConverter} using Apache Avro. + * The schema for serializing and deserializing will be automatically inferred from the + * class for {@link org.apache.avro.specific.SpecificRecord} and regular classes, unless a + * specific schema is set, case in which that schema will be used instead. For converting + * to {@link org.apache.avro.generic.GenericRecord} targets, a schema must be set.s + * + * @author Marius Bogoevici + */ + +public class AvroSchemaMessageConverter extends AbstractAvroMessageConverter { + + private Schema schema; + + /** + * Create a {@link AvroSchemaMessageConverter}. Uses the default {@link MimeType} of + * {@code "application/avro"}. + */ + public AvroSchemaMessageConverter() { + super(new MimeType("application", "avro")); + } + + /** + * Create a {@link AvroSchemaMessageConverter}. The converter will be used for the + * provided {@link MimeType}. + * @param supportedMimeType mime type to be supported by + * {@link AvroSchemaMessageConverter} + */ + public AvroSchemaMessageConverter(MimeType supportedMimeType) { + super(supportedMimeType); + } + + /** + * Create a {@link AvroSchemaMessageConverter}. The converter will be used for the + * provided {@link MimeType}s. + * @param supportedMimeTypes the mime types supported by this converter + */ + public AvroSchemaMessageConverter(Collection supportedMimeTypes) { + super(supportedMimeTypes); + } + + public Schema getSchema() { + return this.schema; + } + + /** + * Sets the Apache Avro schema to be used by this converter. + * @param schema schema to be used by this converter + */ + public void setSchema(Schema schema) { + Assert.notNull(schema, "schema cannot be null"); + this.schema = schema; + } + + /** + * The location of the Apache Avro schema to be used by this converter. + * @param schemaLocation the location of the schema used by this converter. + */ + public void setSchemaLocation(Resource schemaLocation) { + Assert.notNull(schemaLocation, "schema cannot be null"); + try { + this.schema = parseSchema(schemaLocation); + } + catch (IOException e) { + throw new IllegalStateException("Schema cannot be parsed:", e); + } + } + + @Override + protected boolean supports(Class clazz) { + return true; + } + + @Override + protected Schema resolveWriterSchemaForDeserialization(MimeType mimeType) { + return this.schema; + } + + @Override + protected Schema resolveReaderSchemaForDeserialization(Class targetClass) { + return this.schema; + } + + @Override + protected Schema resolveSchemaForWriting(Object payload, MessageHeaders headers, + MimeType hintedContentType) { + return this.schema; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaRegistryClientMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaRegistryClientMessageConverter.java new file mode 100644 index 000000000..4bece7a05 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/AvroSchemaRegistryClientMessageConverter.java @@ -0,0 +1,401 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericContainer; +import org.apache.avro.reflect.ReflectData; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.cloud.stream.schema.ParsedSchema; +import org.springframework.cloud.stream.schema.SchemaNotFoundException; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.core.io.Resource; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.ObjectUtils; + +/** + * A {@link org.springframework.messaging.converter.MessageConverter} for Apache Avro, + * with the ability to publish and retrieve schemas stored in a schema server, allowing + * for schema evolution in applications. The supported content types are in the form + * `application/*+avro`. + * + * During the conversion to a message, the converter will set the 'contentType' header to + * 'application/[prefix].[subject].v[version]+avro', where: + * + *
  • + *
      + * prefix is a configurable prefix (default 'vnd'); + *
    + *
      + * subject is a subject derived from the type of the outgoing object - typically + * the class name; + *
    + *
      + * version is the schema version for the given subject; + *
    + *
  • + * + * When converting from a message, the converter will parse the content-type and use it to + * fetch and cache the writer schema using the provided {@link SchemaRegistryClient}. + * + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + * @author Sercan Karaoglu + */ +public class AvroSchemaRegistryClientMessageConverter extends AbstractAvroMessageConverter + implements InitializingBean { + + /** + * Avro format defined in the Mime type. + */ + public static final String AVRO_FORMAT = "avro"; + + /** + * Pattern for validating the prefix to be used in the publised subtype. + */ + public static final Pattern PREFIX_VALIDATION_PATTERN = Pattern + .compile("[\\p{Alnum}]"); + + /** + * Spring Cloud Stream schema property prefix. + */ + public static final String CACHE_PREFIX = "org.springframework.cloud.stream.schema"; + + /** + * Property for reflection cache. + */ + public static final String REFLECTION_CACHE_NAME = CACHE_PREFIX + ".reflectionCache"; + + /** + * Property for schema cache. + */ + public static final String SCHEMA_CACHE_NAME = CACHE_PREFIX + ".schemaCache"; + + /** + * Property for reference cache. + */ + public static final String REFERENCE_CACHE_NAME = CACHE_PREFIX + ".referenceCache"; + + /** + * Default Mime type for Avro. + */ + public static final MimeType DEFAULT_AVRO_MIME_TYPE = new MimeType("application", + "*+" + AVRO_FORMAT); + + private final CacheManager cacheManager; + + protected Resource[] schemaImports = new Resource[] {}; + + private Pattern versionedSchema; + + private boolean dynamicSchemaGenerationEnabled; + + private Schema readerSchema; + + private Resource[] schemaLocations; + + private SchemaRegistryClient schemaRegistryClient; + + private String prefix = "vnd"; + + private SubjectNamingStrategy subjectNamingStrategy; + + /** + * Creates a new instance, configuring it with {@link SchemaRegistryClient} and + * {@link CacheManager}. + * @param schemaRegistryClient the {@link SchemaRegistryClient} used to interact with + * the schema registry server. + * @param cacheManager instance of {@link CacheManager} to cache parsed schemas. If + * caching is not required use {@link NoOpCacheManager} + */ + public AvroSchemaRegistryClientMessageConverter( + SchemaRegistryClient schemaRegistryClient, CacheManager cacheManager) { + super(Collections.singletonList(DEFAULT_AVRO_MIME_TYPE)); + Assert.notNull(schemaRegistryClient, "cannot be null"); + Assert.notNull(cacheManager, "'cacheManager' cannot be null"); + this.schemaRegistryClient = schemaRegistryClient; + this.cacheManager = cacheManager; + } + + public boolean isDynamicSchemaGenerationEnabled() { + return this.dynamicSchemaGenerationEnabled; + } + + /** + * Allows the converter to generate and register schemas automatically. If set to + * false, it only allows the converter to use pre-registered schemas. Default 'true'. + * @param dynamicSchemaGenerationEnabled true if dynamic schema generation is enabled + */ + public void setDynamicSchemaGenerationEnabled( + boolean dynamicSchemaGenerationEnabled) { + this.dynamicSchemaGenerationEnabled = dynamicSchemaGenerationEnabled; + } + + /** + * A set of locations where the converter can load schemas from. Schemas provided at + * these locations will be registered automatically. + * @param schemaLocations array of locations + */ + public void setSchemaLocations(Resource[] schemaLocations) { + Assert.notEmpty(schemaLocations, "cannot be empty"); + this.schemaLocations = schemaLocations; + } + + /** + * A set of schema locations where should be imported first. Schemas provided at these + * locations will be reference, thus they should not reference each other. + * @param schemaImports array of schema imports + */ + public void setSchemaImports(Resource[] schemaImports) { + this.schemaImports = schemaImports; + } + + /** + * Set the prefix to be used in the published subtype. Default 'vnd'. + * @param prefix prefix to be set + */ + public void setPrefix(String prefix) { + Assert.hasText(prefix, "Prefix cannot be empty"); + Assert.isTrue(!PREFIX_VALIDATION_PATTERN.matcher(this.prefix).matches(), + "Invalid prefix:" + this.prefix); + this.prefix = prefix; + } + + public void setReaderSchema(Resource readerSchema) { + Assert.notNull(readerSchema, "cannot be null"); + try { + this.readerSchema = parseSchema(readerSchema); + } + catch (IOException e) { + throw new BeanInitializationException("Cannot initialize reader schema", e); + } + } + + public void setSubjectNamingStrategy(SubjectNamingStrategy subjectNamingStrategy) { + this.subjectNamingStrategy = subjectNamingStrategy; + } + + @Override + public void afterPropertiesSet() throws Exception { + this.versionedSchema = Pattern.compile("application/" + this.prefix + + "\\.([\\p{Alnum}\\$\\.]+)\\.v(\\p{Digit}+)\\+" + AVRO_FORMAT); + + Stream.of(this.schemaImports, this.schemaLocations) + .filter(arr -> !ObjectUtils.isEmpty(arr)).distinct().peek(resources -> { + this.logger.info("Scanning avro schema resources on classpath"); + if (this.logger.isInfoEnabled()) { + this.logger.info("Parsing" + this.schemaImports.length); + } + }).flatMap(Arrays::stream).forEach(resource -> { + try { + Schema schema = parseSchema(resource); + if (schema.getType().equals(Schema.Type.UNION)) { + schema.getTypes().forEach( + innerSchema -> registerSchema(resource, innerSchema)); + } + else { + registerSchema(resource, schema); + } + } + catch (IOException e) { + if (this.logger.isWarnEnabled()) { + this.logger.warn( + "Failed to parse schema at " + resource.getFilename(), + e); + } + } + }); + + if (this.cacheManager instanceof NoOpCacheManager) { + this.logger.warn("Schema caching is effectively disabled " + + "since configured cache manager is a NoOpCacheManager. If this was not " + + "the intention, please provide the appropriate instance of CacheManager " + + "(i.e., ConcurrentMapCacheManager)."); + } + } + + protected String toSubject(Schema schema) { + return this.subjectNamingStrategy.toSubject(schema); + } + + @Override + protected boolean supports(Class clazz) { + // we support all types + return true; + } + + @Override + protected boolean supportsMimeType(MessageHeaders headers) { + if (super.supportsMimeType(headers)) { + return true; + } + MimeType mimeType = getContentTypeResolver().resolve(headers); + return DEFAULT_AVRO_MIME_TYPE.includes(mimeType); + } + + @Override + protected Schema resolveSchemaForWriting(Object payload, MessageHeaders headers, + MimeType hintedContentType) { + + Schema schema; + schema = extractSchemaForWriting(payload); + ParsedSchema parsedSchema = this.getCache(REFERENCE_CACHE_NAME) + .get(schema, ParsedSchema.class); + + if (parsedSchema == null) { + parsedSchema = new ParsedSchema(schema); + this.getCache(REFERENCE_CACHE_NAME).putIfAbsent(schema, + parsedSchema); + } + + if (parsedSchema.getRegistration() == null) { + SchemaRegistrationResponse response = this.schemaRegistryClient.register( + toSubject(schema), AVRO_FORMAT, parsedSchema.getRepresentation()); + parsedSchema.setRegistration(response); + + } + + SchemaReference schemaReference = parsedSchema.getRegistration() + .getSchemaReference(); + + DirectFieldAccessor dfa = new DirectFieldAccessor(headers); + @SuppressWarnings("unchecked") + Map _headers = (Map) dfa + .getPropertyValue("headers"); + _headers.put(MessageHeaders.CONTENT_TYPE, + "application/" + this.prefix + "." + schemaReference.getSubject() + ".v" + + schemaReference.getVersion() + "+" + AVRO_FORMAT); + + return schema; + } + + @Override + protected Schema resolveWriterSchemaForDeserialization(MimeType mimeType) { + if (this.readerSchema == null) { + SchemaReference schemaReference = extractSchemaReference(mimeType); + if (schemaReference != null) { + ParsedSchema parsedSchema = this.getCache(REFERENCE_CACHE_NAME) + .get(schemaReference, ParsedSchema.class); + if (parsedSchema == null) { + String schemaContent = this.schemaRegistryClient + .fetch(schemaReference); + if (schemaContent != null) { + Schema schema = new Schema.Parser().parse(schemaContent); + parsedSchema = new ParsedSchema(schema); + this.getCache(REFERENCE_CACHE_NAME) + .putIfAbsent(schemaReference, parsedSchema); + } + } + if (parsedSchema != null) { + return parsedSchema.getSchema(); + } + } + } + return this.readerSchema; + } + + @Override + protected Schema resolveReaderSchemaForDeserialization(Class targetClass) { + return this.readerSchema; + } + + private Schema extractSchemaForWriting(Object payload) { + Schema schema = null; + if (this.logger.isDebugEnabled()) { + this.logger.debug("Obtaining schema for class " + payload.getClass()); + } + if (GenericContainer.class.isAssignableFrom(payload.getClass())) { + schema = ((GenericContainer) payload).getSchema(); + if (this.logger.isDebugEnabled()) { + this.logger.debug("Avro type detected, using schema from object"); + } + } + else { + schema = this.getCache(REFLECTION_CACHE_NAME) + .get(payload.getClass().getName(), Schema.class); + if (schema == null) { + if (!isDynamicSchemaGenerationEnabled()) { + throw new SchemaNotFoundException(String.format( + "No schema found in the local cache for %s, and dynamic schema generation " + + "is not enabled", + payload.getClass())); + } + else { + schema = ReflectData.get().getSchema(payload.getClass()); + } + this.getCache(REFLECTION_CACHE_NAME) + .put(payload.getClass().getName(), schema); + } + } + return schema; + } + + private void registerSchema(Resource schemaLocation, Schema schema) { + if (this.logger.isInfoEnabled()) { + this.logger.info( + "Resource " + schemaLocation.getFilename() + " parsed into schema " + + schema.getNamespace() + "." + schema.getName()); + } + this.schemaRegistryClient.register(toSubject(schema), AVRO_FORMAT, + schema.toString()); + if (this.logger.isInfoEnabled()) { + this.logger + .info("Schema " + schema.getName() + " registered with id " + schema); + } + this.getCache(REFLECTION_CACHE_NAME) + .put(schema.getNamespace() + "." + schema.getName(), schema); + } + + private SchemaReference extractSchemaReference(MimeType mimeType) { + SchemaReference schemaReference = null; + Matcher schemaMatcher = this.versionedSchema.matcher(mimeType.toString()); + if (schemaMatcher.find()) { + String subject = schemaMatcher.group(1); + Integer version = Integer.parseInt(schemaMatcher.group(2)); + schemaReference = new SchemaReference(subject, version, AVRO_FORMAT); + } + return schemaReference; + } + + private Cache getCache(String name) { + Cache cache = this.cacheManager.getCache(""); + Assert.notNull(cache, "Cache by the name '" + name + "' is not present in this CacheManager - '" + + this.cacheManager + "'. Typically caches are auto-created by the CacheManagers. " + + "Consider reporting it as an issue to the developer of this CacheManager."); + return cache; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/DefaultSubjectNamingStrategy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/DefaultSubjectNamingStrategy.java new file mode 100644 index 000000000..90cd3598a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/DefaultSubjectNamingStrategy.java @@ -0,0 +1,31 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import org.apache.avro.Schema; + +/** + * @author David Kalosi + */ +public class DefaultSubjectNamingStrategy implements SubjectNamingStrategy { + + @Override + public String toSubject(Schema schema) { + return schema.getName().toLowerCase(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/OriginalContentTypeResolver.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/OriginalContentTypeResolver.java new file mode 100644 index 000000000..76b51d267 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/OriginalContentTypeResolver.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.ContentTypeResolver; +import org.springframework.util.MimeType; + +/** + * @author Vinicius Carvalho + * + * Resolves contentType looking for a originalContentType header first. If not found + * returns the contentType + * + */ +class OriginalContentTypeResolver implements ContentTypeResolver { + + private ConcurrentMap mimeTypeCache = new ConcurrentHashMap<>(); + + @Override + public MimeType resolve(MessageHeaders headers) { + Object contentType = headers + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE) != null + ? headers.get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE) + : headers.get(MessageHeaders.CONTENT_TYPE); + MimeType mimeType = null; + if (contentType instanceof MimeType) { + mimeType = (MimeType) contentType; + } + else if (contentType instanceof String) { + mimeType = this.mimeTypeCache.get(contentType); + if (mimeType == null) { + String valueAsString = (String) contentType; + mimeType = MimeType.valueOf(valueAsString); + this.mimeTypeCache.put(valueAsString, mimeType); + } + } + return mimeType; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/SubjectNamingStrategy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/SubjectNamingStrategy.java new file mode 100644 index 000000000..923e7da97 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/avro/SubjectNamingStrategy.java @@ -0,0 +1,36 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.avro; + +import org.apache.avro.Schema; + +/** + * Provides function towards naming schema registry subjects for Avro files. + * + * @author David Kalosi + */ +public interface SubjectNamingStrategy { + + /** + * Takes the Avro schema on input and returns the generated subject under which the + * schema should be registered. + * @param schema schema to register + * @return subject name + */ + String toSubject(Schema schema); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/CachingRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/CachingRegistryClient.java new file mode 100644 index 000000000..dadfacd98 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/CachingRegistryClient.java @@ -0,0 +1,69 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.util.Assert; + +/** + * @author Vinicius Carvalho + */ +public class CachingRegistryClient implements SchemaRegistryClient { + + private static final String CACHE_PREFIX = "org.springframework.cloud.stream.schema.client"; + + private static final String ID_CACHE = CACHE_PREFIX + ".schemaByIdCache"; + + private static final String REF_CACHE = CACHE_PREFIX + ".schemaByReferenceCache"; + + private SchemaRegistryClient delegate; + + @Autowired + private CacheManager cacheManager; + + public CachingRegistryClient(SchemaRegistryClient delegate) { + Assert.notNull(delegate, "The delegate cannot be null"); + this.delegate = delegate; + } + + @Override + public SchemaRegistrationResponse register(String subject, String format, + String schema) { + SchemaRegistrationResponse response = this.delegate.register(subject, format, + schema); + this.cacheManager.getCache(ID_CACHE).put(response.getId(), schema); + this.cacheManager.getCache(REF_CACHE).put(response.getSchemaReference(), schema); + return response; + } + + @Override + @Cacheable(cacheNames = REF_CACHE) + public String fetch(SchemaReference schemaReference) { + return this.delegate.fetch(schemaReference); + } + + @Override + @Cacheable(cacheNames = ID_CACHE) + public String fetch(int id) { + return this.delegate.fetch(id); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/ConfluentSchemaRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/ConfluentSchemaRegistryClient.java new file mode 100644 index 000000000..4fa3cb9f8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/ConfluentSchemaRegistryClient.java @@ -0,0 +1,186 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.cloud.stream.schema.SchemaNotFoundException; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +/** + * @author Vinicius Carvalho + * @author Marius Bogoevici + * @author Jon Archer + */ +public class ConfluentSchemaRegistryClient implements SchemaRegistryClient { + + private static final List ACCEPT_HEADERS = Arrays.asList( + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", "application/json"); + + private RestTemplate template; + + private String endpoint = "http://localhost:8081"; + + private ObjectMapper mapper; + + public ConfluentSchemaRegistryClient() { + this(new RestTemplate()); + } + + public ConfluentSchemaRegistryClient(RestTemplate template) { + this(template, new ObjectMapper()); + } + + public ConfluentSchemaRegistryClient(RestTemplate template, ObjectMapper mapper) { + this.template = template; + this.mapper = mapper; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + @Override + public SchemaRegistrationResponse register(String subject, String format, + String schema) { + Assert.isTrue("avro".equals(format), "Only Avro is supported"); + String path = String.format("/subjects/%s/versions", subject); + HttpHeaders headers = new HttpHeaders(); + headers.put("Accept", ACCEPT_HEADERS); + headers.add("Content-Type", "application/json"); + Integer version = null; + Integer id = null; + String payload = null; + try { + payload = this.mapper + .writeValueAsString(Collections.singletonMap("schema", schema)); + } + catch (JsonProcessingException e) { + throw new RuntimeException("Could not parse schema, invalid JSON format", e); + } + try { + HttpEntity request = new HttpEntity<>(payload, headers); + ResponseEntity response = this.template.exchange(this.endpoint + path, + HttpMethod.POST, request, Map.class); + id = (Integer) response.getBody().get("id"); + version = getSubjectVersion(subject, payload); + } + catch (HttpStatusCodeException httpException) { + throw new RuntimeException(String.format( + "Failed to register subject %s, server replied with status %d", + subject, httpException.getStatusCode().value()), httpException); + } + SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse(); + schemaRegistrationResponse.setId(id); + schemaRegistrationResponse + .setSchemaReference(new SchemaReference(subject, version, "avro")); + return schemaRegistrationResponse; + } + + /** + * Confluent register API returns the id, but we need the version of a given schema + * subject. After a successful registration we can inquire the server to get the + * version of a schema + * @param subject the schema subject + * @param payload payload to send + * @return the version of the returned schema + */ + private Integer getSubjectVersion(String subject, String payload) { + String path = String.format("/subjects/%s", subject); + HttpHeaders headers = new HttpHeaders(); + headers.put("Accept", ACCEPT_HEADERS); + headers.add("Content-Type", "application/json"); + Integer version = null; + try { + + HttpEntity request = new HttpEntity<>(payload, headers); + ResponseEntity response = this.template.exchange(this.endpoint + path, + HttpMethod.POST, request, Map.class); + version = (Integer) response.getBody().get("version"); + } + catch (HttpStatusCodeException httpException) { + throw new RuntimeException(String.format( + "Failed to register subject %s, server replied with status %d", + subject, httpException.getStatusCode().value()), httpException); + } + return version; + } + + @Override + public String fetch(SchemaReference schemaReference) { + String path = String.format("/subjects/%s/versions/%d", + schemaReference.getSubject(), schemaReference.getVersion()); + HttpHeaders headers = new HttpHeaders(); + headers.put("Accept", ACCEPT_HEADERS); + headers.add("Content-Type", "application/vnd.schemaregistry.v1+json"); + HttpEntity request = new HttpEntity<>("", headers); + try { + ResponseEntity response = this.template.exchange(this.endpoint + path, + HttpMethod.GET, request, Map.class); + return (String) response.getBody().get("schema"); + } + catch (HttpStatusCodeException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new SchemaNotFoundException(String.format( + "Could not find schema for reference: %s", schemaReference)); + } + else { + throw e; + } + } + } + + @Override + public String fetch(int id) { + String path = String.format("/schemas/ids/%d", id); + HttpHeaders headers = new HttpHeaders(); + headers.put("Accept", ACCEPT_HEADERS); + headers.add("Content-Type", "application/vnd.schemaregistry.v1+json"); + HttpEntity request = new HttpEntity<>("", headers); + try { + ResponseEntity response = this.template.exchange(this.endpoint + path, + HttpMethod.GET, request, Map.class); + return (String) response.getBody().get("schema"); + } + catch (HttpStatusCodeException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new SchemaNotFoundException( + String.format("Could not find schema with id: %s", id)); + } + else { + throw e; + } + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/DefaultSchemaRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/DefaultSchemaRegistryClient.java new file mode 100644 index 000000000..c2fc9abc8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/DefaultSchemaRegistryClient.java @@ -0,0 +1,109 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.RestTemplate; + +/** + * @author Marius Bogoevici + * @author Vinicius Carvalho + */ +public class DefaultSchemaRegistryClient implements SchemaRegistryClient { + + private RestTemplate restTemplate; + + private String endpoint = "http://localhost:8990"; + + public DefaultSchemaRegistryClient() { + this(new RestTemplate()); + } + + public DefaultSchemaRegistryClient(RestTemplate restTemplate) { + Assert.notNull(restTemplate, "'restTemplate' must not be null."); + this.restTemplate = restTemplate; + } + + protected String getEndpoint() { + return this.endpoint; + } + + public void setEndpoint(String endpoint) { + Assert.hasText(endpoint, "cannot be empty"); + this.endpoint = endpoint; + } + + protected RestTemplate getRestTemplate() { + return this.restTemplate; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Override + public SchemaRegistrationResponse register(String subject, String format, + String schema) { + Map requestBody = new HashMap<>(); + requestBody.put("subject", subject); + requestBody.put("format", format); + requestBody.put("definition", schema); + ResponseEntity responseEntity = this.restTemplate + .postForEntity(this.endpoint, requestBody, Map.class); + if (responseEntity.getStatusCode().is2xxSuccessful()) { + SchemaRegistrationResponse registrationResponse = new SchemaRegistrationResponse(); + Map responseBody = (Map) responseEntity + .getBody(); + registrationResponse.setId((Integer) responseBody.get("id")); + registrationResponse.setSchemaReference( + new SchemaReference(subject, (Integer) responseBody.get("version"), + responseBody.get("format").toString())); + return registrationResponse; + } + throw new RuntimeException( + "Failed to register schema: " + responseEntity.toString()); + } + + @SuppressWarnings("rawtypes") + @Override + public String fetch(SchemaReference schemaReference) { + ResponseEntity responseEntity = this.restTemplate.getForEntity(this.endpoint + + "/" + schemaReference.getSubject() + "/" + schemaReference.getFormat() + + "/v" + schemaReference.getVersion(), Map.class); + if (!responseEntity.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException( + "Failed to fetch schema: " + responseEntity.toString()); + } + return (String) responseEntity.getBody().get("definition"); + } + + @SuppressWarnings("rawtypes") + @Override + public String fetch(int id) { + ResponseEntity responseEntity = this.restTemplate + .getForEntity(this.endpoint + "/schemas/" + id, Map.class); + if (!responseEntity.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException( + "Failed to fetch schema: " + responseEntity.toString()); + } + return (String) responseEntity.getBody().get("definition"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/EnableSchemaRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/EnableSchemaRegistryClient.java new file mode 100644 index 000000000..087c94688 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/EnableSchemaRegistryClient.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.cloud.stream.schema.client.config.SchemaRegistryClientConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * @author Marius Bogoevici + */ +@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Configuration +@Import(SchemaRegistryClientConfiguration.class) +public @interface EnableSchemaRegistryClient { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/SchemaRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/SchemaRegistryClient.java new file mode 100644 index 000000000..e7b0172c1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/SchemaRegistryClient.java @@ -0,0 +1,54 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client; + +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; + +/** + * @author Vinicius Carvalho + * @author Marius Bogoevici + */ +public interface SchemaRegistryClient { + + /** + * Registers a schema with the remote repository returning the unique identifier + * associated with this schema. + * @param subject the full name of the schema + * @param format format of the schema + * @param schema string representation of the schema + * @return a {@link SchemaRegistrationResponse} representing the result of the + * operation + */ + SchemaRegistrationResponse register(String subject, String format, String schema); + + /** + * Retrieves a schema by its reference (subject and version). + * @param schemaReference a {@link SchemaReference} used to identify the target + * schema. + * @return schema + */ + String fetch(SchemaReference schemaReference); + + /** + * Retrieves a schema by its identifier. + * @param id the id of the target schema. + * @return schema + */ + String fetch(int id); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientConfiguration.java new file mode 100644 index 000000000..097b3a14c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.schema.client.CachingRegistryClient; +import org.springframework.cloud.stream.schema.client.DefaultSchemaRegistryClient; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Soby Chacko + */ +@Configuration +@EnableConfigurationProperties(SchemaRegistryClientProperties.class) +public class SchemaRegistryClientConfiguration { + + @Autowired + private SchemaRegistryClientProperties schemaRegistryClientProperties; + + @Bean + @ConditionalOnMissingBean + public SchemaRegistryClient schemaRegistryClient() { + DefaultSchemaRegistryClient defaultSchemaRegistryClient = new DefaultSchemaRegistryClient(); + + if (StringUtils.hasText(this.schemaRegistryClientProperties.getEndpoint())) { + defaultSchemaRegistryClient + .setEndpoint(this.schemaRegistryClientProperties.getEndpoint()); + } + + SchemaRegistryClient client = (this.schemaRegistryClientProperties.isCached()) + ? new CachingRegistryClient(defaultSchemaRegistryClient) + : defaultSchemaRegistryClient; + + return client; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientProperties.java new file mode 100644 index 000000000..4d3f5e255 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/java/org/springframework/cloud/stream/schema/client/config/SchemaRegistryClientProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.schema.client.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Marius Bogoevici + * @author Vinicius Carvalho + */ +@ConfigurationProperties(prefix = "spring.cloud.stream.schema-registry-client") +public class SchemaRegistryClientProperties { + + private String endpoint; + + private boolean cached = false; + + public String getEndpoint() { + return this.endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public boolean isCached() { + return this.cached; + } + + public void setCached(boolean cached) { + this.cached = cached; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..0530b64e9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.stream.schema.avro.AvroMessageConverterAutoConfiguration diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroMessageConverterSerializationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroMessageConverterSerializationTests.java new file mode 100644 index 000000000..6eb976b55 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroMessageConverterSerializationTests.java @@ -0,0 +1,219 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import example.avro.Command; +import example.avro.Email; +import example.avro.PushNotification; +import example.avro.Sms; +import example.avro.User; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.DatumWriter; +import org.apache.avro.io.Encoder; +import org.apache.avro.io.EncoderFactory; +import org.apache.avro.specific.SpecificDatumWriter; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.avro.AvroSchemaRegistryClientMessageConverter; +import org.springframework.cloud.stream.schema.avro.DefaultSubjectNamingStrategy; +import org.springframework.cloud.stream.schema.client.DefaultSchemaRegistryClient; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.cloud.stream.schema.server.SchemaRegistryServerApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.integration.support.MutableMessageHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Vinicius Carvalho + * @author Sercan Karaoglu + */ +public class AvroMessageConverterSerializationTests { + + Pattern versionedSchema = Pattern.compile( + "application/" + "vnd" + "\\.([\\p{Alnum}\\$\\.]+)\\.v(\\p{Digit}+)\\+avro"); + + Log logger = LogFactory.getLog(getClass()); + + private ConfigurableApplicationContext schemaRegistryServerContext; + + public static Command notification() { + Command messageToSend = getCommandToSend(); + messageToSend.setType("notification"); + PushNotification pushNotification = new PushNotification(); + pushNotification.setArn("google"); + pushNotification.setText("hello"); + messageToSend.setPayload(pushNotification); + return messageToSend; + } + + public static Command sms() { + Command messageToSend = getCommandToSend(); + messageToSend.setType("sms"); + Sms sms = new Sms(); + sms.setPhoneNumber("6141231212"); + sms.setText("hello"); + messageToSend.setPayload(sms); + return messageToSend; + } + + public static Command email() { + Command messageToSend = getCommandToSend(); + messageToSend.setType("email"); + Email email = new Email(); + email.setAddressTo("sercan"); + email.setText("hello"); + email.setTitle("hi"); + messageToSend.setPayload(email); + return messageToSend; + } + + public static Command getCommandToSend() { + Command messageToSend = new Command(); + messageToSend.setCorrelationId("abc"); + return messageToSend; + } + + @Before + public void setup() { + this.schemaRegistryServerContext = SpringApplication.run( + SchemaRegistryServerApplication.class, + "--spring.main.allow-bean-definition-overriding=true"); + } + + @After + public void tearDown() { + this.schemaRegistryServerContext.close(); + } + + @Test + public void testSchemaImport() throws Exception { + SchemaRegistryClient client = new DefaultSchemaRegistryClient(); + AvroSchemaRegistryClientMessageConverter converter = new AvroSchemaRegistryClientMessageConverter( + client, new NoOpCacheManager()); + converter.setSubjectNamingStrategy(new DefaultSubjectNamingStrategy()); + converter.setDynamicSchemaGenerationEnabled(false); + converter.setSchemaLocations(this.schemaRegistryServerContext + .getResources("classpath:schemas/Command.avsc")); + converter.setSchemaImports(this.schemaRegistryServerContext + .getResources("classpath:schemas/imports/*.avsc")); + converter.afterPropertiesSet(); + Command notification = notification(); + Message specificMessage = converter.toMessage(notification, + new MutableMessageHeaders(Collections.emptyMap())); + Object o = converter.fromMessage(specificMessage, Command.class); + + assertThat(o).isEqualTo(notification) + .as("Serialization issue when use schema-imports"); + } + + @Test + public void sourceWriteSameVersion() throws Exception { + User specificRecord = new User(); + specificRecord.setName("joe"); + Schema v1 = new Schema.Parser().parse(AvroMessageConverterSerializationTests.class + .getClassLoader().getResourceAsStream("schemas/user.avsc")); + GenericRecord genericRecord = new GenericData.Record(v1); + genericRecord.put("name", "joe"); + SchemaRegistryClient client = new DefaultSchemaRegistryClient(); + AvroSchemaRegistryClientMessageConverter converter = new AvroSchemaRegistryClientMessageConverter( + client, new NoOpCacheManager()); + + converter.setSubjectNamingStrategy(new DefaultSubjectNamingStrategy()); + converter.setDynamicSchemaGenerationEnabled(false); + converter.afterPropertiesSet(); + + Message specificMessage = converter.toMessage(specificRecord, + new MutableMessageHeaders(Collections.emptyMap()), + MimeTypeUtils.parseMimeType("application/*+avro")); + SchemaReference specificRef = extractSchemaReference(MimeTypeUtils.parseMimeType( + specificMessage.getHeaders().get("contentType").toString())); + + Message genericMessage = converter.toMessage(genericRecord, + new MutableMessageHeaders(Collections.emptyMap()), + MimeTypeUtils.parseMimeType("application/*+avro")); + SchemaReference genericRef = extractSchemaReference(MimeTypeUtils.parseMimeType( + genericMessage.getHeaders().get("contentType").toString())); + + assertThat(specificRef).isEqualTo(genericRef); + assertThat(genericRef.getVersion()).isEqualTo(1); + } + + @Test + public void testOriginalContentTypeHeaderOnly() throws Exception { + User specificRecord = new User(); + specificRecord.setName("joe"); + Schema v1 = new Schema.Parser().parse(AvroMessageConverterSerializationTests.class + .getClassLoader().getResourceAsStream("schemas/user.avsc")); + GenericRecord genericRecord = new GenericData.Record(v1); + genericRecord.put("name", "joe"); + SchemaRegistryClient client = new DefaultSchemaRegistryClient(); + client.register("user", "avro", v1.toString()); + AvroSchemaRegistryClientMessageConverter converter = new AvroSchemaRegistryClientMessageConverter( + client, new NoOpCacheManager()); + converter.setDynamicSchemaGenerationEnabled(false); + converter.afterPropertiesSet(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DatumWriter writer = new SpecificDatumWriter<>(User.class); + Encoder encoder = EncoderFactory.get().binaryEncoder(baos, null); + writer.write(specificRecord, encoder); + encoder.flush(); + Message source = MessageBuilder.withPayload(baos.toByteArray()) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.APPLICATION_OCTET_STREAM) + .setHeader(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE, + "application/vnd.user.v1+avro") + .build(); + Object converted = converter.fromMessage(source, User.class); + assertThat(converted).isNotNull(); + assertThat(specificRecord.getName().toString()) + .isEqualTo(((User) converted).getName().toString()); + } + + private SchemaReference extractSchemaReference(MimeType mimeType) { + SchemaReference schemaReference = null; + Matcher schemaMatcher = this.versionedSchema.matcher(mimeType.toString()); + if (schemaMatcher.find()) { + String subject = schemaMatcher.group(1); + Integer version = Integer.parseInt(schemaMatcher.group(2)); + schemaReference = new SchemaReference(subject, version, + AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT); + } + return schemaReference; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaMessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaMessageConverterTests.java new file mode 100644 index 000000000..5d4675b87 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaMessageConverterTests.java @@ -0,0 +1,266 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.schema.avro.AvroSchemaMessageConverter; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +public class AvroSchemaMessageConverterTests { + + static StubSchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient(); + + @Test + public void testSendMessageWithLocation() throws Exception { + ConfigurableApplicationContext sourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--schemaLocation=classpath:schemas/users_v1.schema", + "--spring.cloud.stream.schemaRegistryClient.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=avro/bytes"); + Source source = sourceContext.getBean(Source.class); + User1 firstOutboundFoo = new User1(); + firstOutboundFoo.setName("foo" + UUID.randomUUID().toString()); + firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build()); + MessageCollector sourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + Message outboundMessage = sourceMessageCollector.forChannel(source.output()) + .poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext barSourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--schemaLocation=classpath:schemas/users_v1.schema", + "--spring.cloud.stream.schemaRegistryClient.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=avro/bytes"); + Source barSource = barSourceContext.getBean(Source.class); + User2 firstOutboundUser2 = new User2(); + firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setFavoritePlace("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setName("foo" + UUID.randomUUID().toString()); + barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build()); + MessageCollector barSourceMessageCollector = barSourceContext + .getBean(MessageCollector.class); + Message barOutboundMessage = barSourceMessageCollector + .forChannel(barSource.output()).poll(1000, TimeUnit.MILLISECONDS); + + assertThat(barOutboundMessage).isNotNull(); + + User2 secondUser2OutboundPojo = new User2(); + secondUser2OutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + secondUser2OutboundPojo.setFavoritePlace("foo" + UUID.randomUUID().toString()); + secondUser2OutboundPojo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(secondUser2OutboundPojo).build()); + Message secondBarOutboundMessage = sourceMessageCollector + .forChannel(source.output()).poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext sinkContext = SpringApplication.run( + AvroSinkApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.schemaRegistryClient.enabled=false", + "--schemaLocation=classpath:schemas/users_v1.schema"); + Sink sink = sinkContext.getBean(Sink.class); + sink.input().send(outboundMessage); + sink.input().send(barOutboundMessage); + sink.input().send(secondBarOutboundMessage); + List receivedUsers = sinkContext + .getBean(AvroSinkApplication.class).receivedUsers; + assertThat(receivedUsers).hasSize(3); + assertThat(receivedUsers.get(0)).isNotSameAs(firstOutboundFoo); + assertThat(receivedUsers.get(0).getFavoriteColor()) + .isEqualTo(firstOutboundFoo.getFavoriteColor()); + assertThat(receivedUsers.get(0).getName()).isEqualTo(firstOutboundFoo.getName()); + + assertThat(receivedUsers.get(1)).isNotSameAs(firstOutboundUser2); + assertThat(receivedUsers.get(1).getFavoriteColor()) + .isEqualTo(firstOutboundUser2.getFavoriteColor()); + assertThat(receivedUsers.get(1).getName()) + .isEqualTo(firstOutboundUser2.getName()); + + assertThat(receivedUsers.get(2)).isNotSameAs(secondUser2OutboundPojo); + assertThat(receivedUsers.get(2).getFavoriteColor()) + .isEqualTo(secondUser2OutboundPojo.getFavoriteColor()); + assertThat(receivedUsers.get(2).getName()) + .isEqualTo(secondUser2OutboundPojo.getName()); + + sourceContext.close(); + } + + @Test + public void testSendMessageWithoutLocation() throws Exception { + ConfigurableApplicationContext sourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.schemaRegistryClient.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=avro/bytes"); + Source source = sourceContext.getBean(Source.class); + User1 firstOutboundFoo = new User1(); + firstOutboundFoo.setName("foo" + UUID.randomUUID().toString()); + firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build()); + MessageCollector sourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + Message outboundMessage = sourceMessageCollector.forChannel(source.output()) + .poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext barSourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.schemaRegistryClient.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=avro/bytes"); + Source barSource = barSourceContext.getBean(Source.class); + User2 firstOutboundUser2 = new User2(); + firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setFavoritePlace("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setName("foo" + UUID.randomUUID().toString()); + barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build()); + MessageCollector barSourceMessageCollector = barSourceContext + .getBean(MessageCollector.class); + Message barOutboundMessage = barSourceMessageCollector + .forChannel(barSource.output()).poll(1000, TimeUnit.MILLISECONDS); + + assertThat(barOutboundMessage).isNotNull(); + + User2 secondUser2OutboundPojo = new User2(); + secondUser2OutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + secondUser2OutboundPojo.setFavoritePlace("foo" + UUID.randomUUID().toString()); + secondUser2OutboundPojo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(secondUser2OutboundPojo).build()); + Message secondBarOutboundMessage = sourceMessageCollector + .forChannel(source.output()).poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext sinkContext = SpringApplication.run( + AvroSinkApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.schemaRegistryClient.enabled=false"); + Sink sink = sinkContext.getBean(Sink.class); + sink.input().send(outboundMessage); + sink.input().send(barOutboundMessage); + sink.input().send(secondBarOutboundMessage); + List receivedUsers = sinkContext + .getBean(AvroSinkApplication.class).receivedUsers; + assertThat(receivedUsers).hasSize(3); + assertThat(receivedUsers.get(0)).isNotSameAs(firstOutboundFoo); + assertThat(receivedUsers.get(0).getFavoriteColor()) + .isEqualTo(firstOutboundFoo.getFavoriteColor()); + assertThat(receivedUsers.get(0).getName()).isEqualTo(firstOutboundFoo.getName()); + + assertThat(receivedUsers.get(1)).isNotSameAs(firstOutboundUser2); + assertThat(receivedUsers.get(1).getFavoriteColor()) + .isEqualTo(firstOutboundUser2.getFavoriteColor()); + assertThat(receivedUsers.get(1).getName()) + .isEqualTo(firstOutboundUser2.getName()); + + assertThat(receivedUsers.get(2)).isNotSameAs(secondUser2OutboundPojo); + assertThat(receivedUsers.get(2).getFavoriteColor()) + .isEqualTo(secondUser2OutboundPojo.getFavoriteColor()); + assertThat(receivedUsers.get(2).getName()) + .isEqualTo(secondUser2OutboundPojo.getName()); + + sourceContext.close(); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @ConfigurationProperties + public static class AvroSourceApplication { + + private Resource schemaLocation; + + @Bean + public SchemaRegistryClient schemaRegistryClient() { + return stubSchemaRegistryClient; + } + + public void setSchemaLocation(Resource schemaLocation) { + this.schemaLocation = schemaLocation; + } + + @Bean + @StreamMessageConverter + public MessageConverter userMessageConverter() throws IOException { + AvroSchemaMessageConverter avroSchemaMessageConverter = new AvroSchemaMessageConverter( + MimeType.valueOf("avro/bytes")); + if (this.schemaLocation != null) { + avroSchemaMessageConverter.setSchemaLocation(this.schemaLocation); + } + return avroSchemaMessageConverter; + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @ConfigurationProperties + public static class AvroSinkApplication { + + public List receivedUsers = new ArrayList<>(); + + private Resource schemaLocation; + + @StreamListener(Sink.INPUT) + public void listen(User1 user) { + this.receivedUsers.add(user); + } + + public void setSchemaLocation(Resource schemaLocation) { + this.schemaLocation = schemaLocation; + } + + @Bean + @StreamMessageConverter + public MessageConverter userMessageConverter() throws IOException { + AvroSchemaMessageConverter avroSchemaMessageConverter = new AvroSchemaMessageConverter( + MimeType.valueOf("avro/bytes")); + if (this.schemaLocation != null) { + avroSchemaMessageConverter.setSchemaLocation(this.schemaLocation); + } + return avroSchemaMessageConverter; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaRegistryClientMessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaRegistryClientMessageConverterTests.java new file mode 100644 index 000000000..85cab986f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroSchemaRegistryClientMessageConverterTests.java @@ -0,0 +1,253 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import example.avro.Command; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.schema.avro.AvroSchemaRegistryClientMessageConverter; +import org.springframework.cloud.stream.schema.client.DefaultSchemaRegistryClient; +import org.springframework.cloud.stream.schema.client.EnableSchemaRegistryClient; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.cloud.stream.schema.server.SchemaRegistryServerApplication; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.schema.avro.AvroMessageConverterSerializationTests.notification; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @author Sercan Karaoglu + */ +public class AvroSchemaRegistryClientMessageConverterTests { + + static SchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient(); + + private ConfigurableApplicationContext schemaRegistryServerContext; + + @Before + public void setup() { + this.schemaRegistryServerContext = SpringApplication.run( + SchemaRegistryServerApplication.class, + "--spring.main.allow-bean-definition-overriding=true"); + } + + @After + public void tearDown() { + this.schemaRegistryServerContext.close(); + } + + @Test + public void testSendMessage() throws Exception { + + ConfigurableApplicationContext sourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/*+avro", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true"); + Source source = sourceContext.getBean(Source.class); + User1 firstOutboundFoo = new User1(); + firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundFoo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build()); + MessageCollector sourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + Message outboundMessage = sourceMessageCollector.forChannel(source.output()) + .poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext barSourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/vnd.user1.v1+avro", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true"); + Source barSource = barSourceContext.getBean(Source.class); + User2 firstOutboundUser2 = new User2(); + firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setName("foo" + UUID.randomUUID().toString()); + barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build()); + MessageCollector barSourceMessageCollector = barSourceContext + .getBean(MessageCollector.class); + Message barOutboundMessage = barSourceMessageCollector + .forChannel(barSource.output()).poll(1000, TimeUnit.MILLISECONDS); + + assertThat(barOutboundMessage).isNotNull(); + + User2 secondBarOutboundPojo = new User2(); + secondBarOutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + secondBarOutboundPojo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(secondBarOutboundPojo).build()); + Message secondBarOutboundMessage = sourceMessageCollector + .forChannel(source.output()).poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext sinkContext = SpringApplication.run( + AvroSinkApplication.class, "--server.port=0", + "--spring.jmx.enabled=false"); + Sink sink = sinkContext.getBean(Sink.class); + sink.input().send(outboundMessage); + sink.input().send(barOutboundMessage); + sink.input().send(secondBarOutboundMessage); + List receivedPojos = sinkContext + .getBean(AvroSinkApplication.class).receivedPojos; + assertThat(receivedPojos).hasSize(3); + assertThat(receivedPojos.get(0)).isNotSameAs(firstOutboundFoo); + assertThat(receivedPojos.get(0).getFavoriteColor()) + .isEqualTo(firstOutboundFoo.getFavoriteColor()); + assertThat(receivedPojos.get(0).getName()).isEqualTo(firstOutboundFoo.getName()); + assertThat(receivedPojos.get(0).getFavoritePlace()).isEqualTo("NYC"); + + assertThat(receivedPojos.get(1)).isNotSameAs(firstOutboundUser2); + assertThat(receivedPojos.get(1).getFavoriteColor()) + .isEqualTo(firstOutboundUser2.getFavoriteColor()); + assertThat(receivedPojos.get(1).getName()) + .isEqualTo(firstOutboundUser2.getName()); + assertThat(receivedPojos.get(1).getFavoritePlace()).isEqualTo("Boston"); + + assertThat(receivedPojos.get(2)).isNotSameAs(secondBarOutboundPojo); + assertThat(receivedPojos.get(2).getFavoriteColor()) + .isEqualTo(secondBarOutboundPojo.getFavoriteColor()); + assertThat(receivedPojos.get(2).getName()) + .isEqualTo(secondBarOutboundPojo.getName()); + assertThat(receivedPojos.get(2).getFavoritePlace()) + .isEqualTo(secondBarOutboundPojo.getFavoritePlace()); + + sinkContext.close(); + barSourceContext.close(); + sourceContext.close(); + this.schemaRegistryServerContext.close(); + } + + @Test + public void testSchemaImportConfiguration() throws Exception { + final String[] args = { "--server.port=0", "--spring.jmx.enabled=false", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true", + "--spring.cloud.stream.bindings.output.contentType=application/*+avro", + "--spring.cloud.stream.bindings.output.destination=test", + "--spring.cloud.stream.bindings.schema-registry-client.endpoint=http://localhost:8990", + "--spring.cloud.stream.schema.avro.schema-locations=classpath:schemas/Command.avsc", + "--spring.cloud.stream.schema.avro.schema-imports=classpath:schemas/imports/Sms.avsc," + + " classpath:schemas/imports/Email.avsc, classpath:schemas/imports/PushNotification.avsc" }; + + final ConfigurableApplicationContext sourceContext = SpringApplication + .run(AvroSourceApplication.class, args); + final ConfigurableApplicationContext sinkContext = SpringApplication + .run(CommandSinkApplication.class, args); + final Source barSource = sourceContext.getBean(Source.class); + final Command notification = notification(); + barSource.output().send(MessageBuilder.withPayload(notification).build()); + final MessageCollector barSourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + final Message outboundMessage = barSourceMessageCollector + .forChannel(barSource.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(outboundMessage).isNotNull(); + Sink sink = sinkContext.getBean(Sink.class); + sink.input().send(outboundMessage); + List receivedPojos = sinkContext + .getBean(CommandSinkApplication.class).receivedPojos; + + assertThat(receivedPojos).hasSize(1); + assertThat(receivedPojos.get(0)).isEqualTo(notification); + + } + + @Test + public void testNoCacheConfiguration() { + ConfigurableApplicationContext sourceContext = SpringApplication + .run(NoCacheConfiguration.class, "--spring.main.web-environment=false"); + AvroSchemaRegistryClientMessageConverter converter = sourceContext + .getBean(AvroSchemaRegistryClientMessageConverter.class); + DirectFieldAccessor accessor = new DirectFieldAccessor(converter); + assertThat(accessor.getPropertyValue("cacheManager")) + .isInstanceOf(NoOpCacheManager.class); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @EnableSchemaRegistryClient + public static class AvroSourceApplication { + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @EnableSchemaRegistryClient + public static class AvroSinkApplication { + + public List receivedPojos = new ArrayList<>(); + + @StreamListener(Sink.INPUT) + public void listen(User2 fooPojo) { + this.receivedPojos.add(fooPojo); + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @EnableSchemaRegistryClient + public static class CommandSinkApplication { + + public List receivedPojos = new ArrayList<>(); + + @StreamListener(Sink.INPUT) + public void listen(Command fooPojo) { + this.receivedPojos.add(fooPojo); + } + + } + + @Configuration + public static class NoCacheConfiguration { + + @Bean + @StreamMessageConverter + AvroSchemaRegistryClientMessageConverter avroSchemaRegistryClientMessageConverter() { + return new AvroSchemaRegistryClientMessageConverter( + new DefaultSchemaRegistryClient(), new NoOpCacheManager()); + } + + @Bean + ServletWebServerFactory servletWebServerFactory() { + return new TomcatServletWebServerFactory(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroStubSchemaRegistryClientMessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroStubSchemaRegistryClientMessageConverterTests.java new file mode 100644 index 000000000..d260a0450 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/AvroStubSchemaRegistryClientMessageConverterTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +public class AvroStubSchemaRegistryClientMessageConverterTests { + + static SchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient(); + + @Test + public void testSendMessage() throws Exception { + ConfigurableApplicationContext sourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", "--debug", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/*+avro", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true"); + Source source = sourceContext.getBean(Source.class); + User1 firstOutboundFoo = new User1(); + firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundFoo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build()); + MessageCollector sourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + Message outboundMessage = sourceMessageCollector.forChannel(source.output()) + .poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext barSourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/vnd.user1.v1+avro", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true"); + Source barSource = barSourceContext.getBean(Source.class); + User2 firstOutboundUser2 = new User2(); + firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString()); + firstOutboundUser2.setName("foo" + UUID.randomUUID().toString()); + barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build()); + MessageCollector barSourceMessageCollector = barSourceContext + .getBean(MessageCollector.class); + Message barOutboundMessage = barSourceMessageCollector + .forChannel(barSource.output()).poll(1000, TimeUnit.MILLISECONDS); + + assertThat(barOutboundMessage).isNotNull(); + + User2 secondBarOutboundPojo = new User2(); + secondBarOutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString()); + secondBarOutboundPojo.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(secondBarOutboundPojo).build()); + Message secondBarOutboundMessage = sourceMessageCollector + .forChannel(source.output()).poll(1000, TimeUnit.MILLISECONDS); + + ConfigurableApplicationContext sinkContext = SpringApplication.run( + AvroSinkApplication.class, "--server.port=0", + "--spring.jmx.enabled=false"); + Sink sink = sinkContext.getBean(Sink.class); + sink.input().send(outboundMessage); + sink.input().send(barOutboundMessage); + sink.input().send(secondBarOutboundMessage); + List receivedPojos = sinkContext + .getBean(AvroSinkApplication.class).receivedPojos; + assertThat(receivedPojos).hasSize(3); + assertThat(receivedPojos.get(0)).isNotSameAs(firstOutboundFoo); + assertThat(receivedPojos.get(0).getFavoriteColor()) + .isEqualTo(firstOutboundFoo.getFavoriteColor()); + assertThat(receivedPojos.get(0).getName()).isEqualTo(firstOutboundFoo.getName()); + assertThat(receivedPojos.get(0).getFavoritePlace()).isEqualTo("NYC"); + + assertThat(receivedPojos.get(1)).isNotSameAs(firstOutboundUser2); + assertThat(receivedPojos.get(1).getFavoriteColor()) + .isEqualTo(firstOutboundUser2.getFavoriteColor()); + assertThat(receivedPojos.get(1).getName()) + .isEqualTo(firstOutboundUser2.getName()); + assertThat(receivedPojos.get(1).getFavoritePlace()).isEqualTo("Boston"); + + assertThat(receivedPojos.get(2)).isNotSameAs(secondBarOutboundPojo); + assertThat(receivedPojos.get(2).getFavoriteColor()) + .isEqualTo(secondBarOutboundPojo.getFavoriteColor()); + assertThat(receivedPojos.get(2).getName()) + .isEqualTo(secondBarOutboundPojo.getName()); + assertThat(receivedPojos.get(2).getFavoritePlace()) + .isEqualTo(secondBarOutboundPojo.getFavoritePlace()); + + sourceContext.close(); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class AvroSourceApplication { + + @Bean + public SchemaRegistryClient schemaRegistryClient() { + return stubSchemaRegistryClient; + } + + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class AvroSinkApplication { + + public List receivedPojos = new ArrayList<>(); + + @StreamListener(Sink.INPUT) + public void listen(User2 fooPojo) { + this.receivedPojos.add(fooPojo); + } + + @Bean + public SchemaRegistryClient schemaRegistryClient() { + return stubSchemaRegistryClient; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/CustomSubjectNamingStrategy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/CustomSubjectNamingStrategy.java new file mode 100644 index 000000000..71ff5d526 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/CustomSubjectNamingStrategy.java @@ -0,0 +1,36 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import org.apache.avro.Schema; + +import org.springframework.cloud.stream.schema.avro.SubjectNamingStrategy; + +/** + * @author David Kalosi + */ +class CustomSubjectNamingStrategy implements SubjectNamingStrategy { + + CustomSubjectNamingStrategy() { + } + + @Override + public String toSubject(Schema schema) { + return schema.getFullName(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/StubSchemaRegistryClient.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/StubSchemaRegistryClient.java new file mode 100644 index 000000000..486444f78 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/StubSchemaRegistryClient.java @@ -0,0 +1,114 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.cloud.stream.schema.SchemaNotFoundException; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.cloud.stream.schema.avro.AvroSchemaRegistryClientMessageConverter; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; + +/** + * @author Marius Bogoevici + */ +public class StubSchemaRegistryClient implements SchemaRegistryClient { + + private final AtomicInteger index = new AtomicInteger(0); + + private final Map schemasById = new HashMap<>(); + + private final Map> storedSchemas = new HashMap<>(); + + @Override + public SchemaRegistrationResponse register(String subject, String format, + String schema) { + if (!this.storedSchemas.containsKey(subject)) { + this.storedSchemas.put(subject, new TreeMap()); + } + Map schemaVersions = this.storedSchemas.get(subject); + for (Map.Entry integerSchemaEntry : schemaVersions + .entrySet()) { + + if (integerSchemaEntry.getValue().getSchema().equals(schema)) { + SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse(); + schemaRegistrationResponse.setId(integerSchemaEntry.getValue().getId()); + schemaRegistrationResponse.setSchemaReference( + new SchemaReference(subject, integerSchemaEntry.getKey(), + AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT)); + return schemaRegistrationResponse; + } + } + int nextVersion = schemaVersions.size() + 1; + int id = this.index.incrementAndGet(); + schemaVersions.put(nextVersion, new SchemaWithId(id, schema)); + SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse(); + schemaRegistrationResponse.setId(this.index.getAndIncrement()); + schemaRegistrationResponse.setSchemaReference(new SchemaReference(subject, + nextVersion, AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT)); + this.schemasById.put(id, schema); + return schemaRegistrationResponse; + } + + @Override + public String fetch(SchemaReference schemaReference) { + if (!AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT + .equals(schemaReference.getFormat())) { + throw new IllegalArgumentException("Only 'avro' is supported by this client"); + } + if (!this.storedSchemas.containsKey(schemaReference.getSubject())) { + throw new SchemaNotFoundException("Not found: " + schemaReference); + } + if (!this.storedSchemas.get(schemaReference.getSubject()) + .containsKey(schemaReference.getVersion())) { + throw new SchemaNotFoundException("Not found: " + schemaReference); + } + return this.storedSchemas.get(schemaReference.getSubject()) + .get(schemaReference.getVersion()).getSchema(); + } + + @Override + public String fetch(int id) { + return this.schemasById.get(id); + } + + static class SchemaWithId { + + int id; + + String schema; + + SchemaWithId(int id, String schema) { + this.id = id; + this.schema = schema; + } + + public int getId() { + return this.id; + } + + public String getSchema() { + return this.schema; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/SubjectNamingStrategyTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/SubjectNamingStrategyTest.java new file mode 100644 index 000000000..843ed63f7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/SubjectNamingStrategyTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.schema.client.SchemaRegistryClient; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author David Kalosi + */ +public class SubjectNamingStrategyTest { + + static StubSchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient(); + + @Test + public void testCustomNamingStrategy() throws Exception { + ConfigurableApplicationContext sourceContext = SpringApplication.run( + AvroSourceApplication.class, "--server.port=0", "--debug", + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/*+avro", + "--spring.cloud.stream.schema.avro.subjectNamingStrategy=" + + "org.springframework.cloud.schema.avro.CustomSubjectNamingStrategy", + "--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true"); + + Source source = sourceContext.getBean(Source.class); + User1 user1 = new User1(); + user1.setFavoriteColor("foo" + UUID.randomUUID().toString()); + user1.setName("foo" + UUID.randomUUID().toString()); + source.output().send(MessageBuilder.withPayload(user1).build()); + + MessageCollector barSourceMessageCollector = sourceContext + .getBean(MessageCollector.class); + Message message = barSourceMessageCollector.forChannel(source.output()) + .poll(1000, TimeUnit.MILLISECONDS); + + assertThat(message.getHeaders().get("contentType")).isEqualTo(MimeType.valueOf( + "application/vnd.org.springframework.cloud.schema.avro.User1.v1+avro")); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class AvroSourceApplication { + + @Bean + public SchemaRegistryClient schemaRegistryClient() { + return stubSchemaRegistryClient; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User1.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User1.java new file mode 100644 index 000000000..aa11902b0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User1.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import org.apache.avro.reflect.Nullable; + +/** + * @author Marius Bogoevici + */ +public class User1 { + + @Nullable + private String name; + + private int favoriteNumber; + + @Nullable + private String favoriteColor; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getFavoriteNumber() { + return this.favoriteNumber; + } + + public void setFavoriteNumber(int favoriteNumber) { + this.favoriteNumber = favoriteNumber; + } + + public String getFavoriteColor() { + return this.favoriteColor; + } + + public void setFavoriteColor(String favoriteColor) { + this.favoriteColor = favoriteColor; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User2.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User2.java new file mode 100644 index 000000000..a3cb94c1a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/User2.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro; + +import org.apache.avro.reflect.AvroDefault; +import org.apache.avro.reflect.Nullable; + +/** + * @author Marius Bogoevici + */ +public class User2 { + + @Nullable + private String name; + + private int favoriteNumber; + + @Nullable + private String favoriteColor; + + @AvroDefault("\"NYC\"") + private String favoritePlace = "Boston"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getFavoriteNumber() { + return this.favoriteNumber; + } + + public void setFavoriteNumber(int favoriteNumber) { + this.favoriteNumber = favoriteNumber; + } + + public String getFavoriteColor() { + return this.favoriteColor; + } + + public void setFavoriteColor(String favoriteColor) { + this.favoriteColor = favoriteColor; + } + + public String getFavoritePlace() { + return this.favoritePlace; + } + + public void setFavoritePlace(String favoritePlace) { + this.favoritePlace = favoritePlace; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/client/ConfluentSchemaRegistryClientTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/client/ConfluentSchemaRegistryClientTests.java new file mode 100644 index 000000000..24e160437 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/java/org/springframework/cloud/schema/avro/client/ConfluentSchemaRegistryClientTests.java @@ -0,0 +1,177 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.schema.avro.client; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.cloud.stream.schema.SchemaNotFoundException; +import org.springframework.cloud.stream.schema.SchemaReference; +import org.springframework.cloud.stream.schema.SchemaRegistrationResponse; +import org.springframework.cloud.stream.schema.client.ConfluentSchemaRegistryClient; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withBadRequest; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * @author Vinicius Carvalho + */ +public class ConfluentSchemaRegistryClientTests { + + private RestTemplate restTemplate; + + private MockRestServiceServer mockRestServiceServer; + + @Before + public void setup() { + this.restTemplate = new RestTemplate(); + this.mockRestServiceServer = MockRestServiceServer + .createServer(this.restTemplate); + } + + @Test + public void registerSchema() throws Exception { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withSuccess("{\"id\":101}", MediaType.APPLICATION_JSON)); + + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withSuccess("{\"version\":1}", MediaType.APPLICATION_JSON)); + + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + SchemaRegistrationResponse response = client.register("user", "avro", "{}"); + assertThat(response.getSchemaReference().getVersion()).isEqualTo(1); + assertThat(response.getId()).isEqualTo(101); + this.mockRestServiceServer.verify(); + } + + @Test(expected = RuntimeException.class) + public void registerWithInvalidJson() { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withBadRequest()); + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + SchemaRegistrationResponse response = client.register("user", "avro", "<>"); + } + + @Test + public void registerIncompatibleSchema() { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withStatus(HttpStatus.CONFLICT)); + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + Exception expected = null; + try { + SchemaRegistrationResponse response = client.register("user", "avro", "{}"); + } + catch (Exception e) { + expected = e; + } + assertThat(expected instanceof RuntimeException).isTrue(); + assertThat(expected.getCause() instanceof HttpStatusCodeException).isTrue(); + this.mockRestServiceServer.verify(); + } + + @Test + public void responseErrorFetch() { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withSuccess("{\"id\":101}", MediaType.APPLICATION_JSON)); + + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withBadRequest()); + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + Exception expected = null; + try { + SchemaRegistrationResponse response = client.register("user", "avro", "{}"); + } + catch (Exception e) { + expected = e; + } + assertThat(expected instanceof RuntimeException).isTrue(); + assertThat(expected.getCause() instanceof HttpStatusCodeException).isTrue(); + this.mockRestServiceServer.verify(); + } + + @Test + public void findByReference() { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions/1")) + .andExpect(method(HttpMethod.GET)) + .andExpect( + header("Content-Type", "application/vnd.schemaregistry.v1+json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withSuccess("{\"schema\":\"\"}", MediaType.APPLICATION_JSON)); + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + SchemaReference reference = new SchemaReference("user", 1, "avro"); + String schema = client.fetch(reference); + assertThat(schema).isEqualTo(""); + this.mockRestServiceServer.verify(); + } + + @Test(expected = SchemaNotFoundException.class) + public void schemaNotFound() { + this.mockRestServiceServer + .expect(requestTo("http://localhost:8081/subjects/user/versions/1")) + .andExpect(method(HttpMethod.GET)) + .andExpect( + header("Content-Type", "application/vnd.schemaregistry.v1+json")) + .andExpect(header("Accept", "application/vnd.schemaregistry.v1+json")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + ConfluentSchemaRegistryClient client = new ConfluentSchemaRegistryClient( + this.restTemplate); + SchemaReference reference = new SchemaReference("user", 1, "avro"); + String schema = client.fetch(reference); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/Command.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/Command.avsc new file mode 100644 index 000000000..3cfc1d9ce --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/Command.avsc @@ -0,0 +1,19 @@ +{ + "namespace":"example.avro", + "name":"Command", + "type":"record", + "fields":[ + { + "name":"type", + "type":"string" + }, + { + "name":"correlationId", + "type":"string" + }, + { + "name":"payload", + "type":["Sms", "Email", "PushNotification"] + } + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Email.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Email.avsc new file mode 100644 index 000000000..2b24fc236 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Email.avsc @@ -0,0 +1,19 @@ +{ + "namespace":"example.avro", + "name": "Email", + "type": "record", + "fields":[ + { + "name":"addressTo", + "type":"string" + }, + { + "name":"title", + "type":"string" + }, + { + "name":"text", + "type":"string" + } + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/PushNotification.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/PushNotification.avsc new file mode 100644 index 000000000..afded1f20 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/PushNotification.avsc @@ -0,0 +1,15 @@ +{ + "namespace":"example.avro", + "name": "PushNotification", + "type": "record", + "fields":[ + { + "name":"arn", + "type":"string" + }, + { + "name":"text", + "type":"string" + } + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Sms.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Sms.avsc new file mode 100644 index 000000000..90b7ced8c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/imports/Sms.avsc @@ -0,0 +1,14 @@ +{ + "namespace":"example.avro", + "name": "Sms", + "type": "record", + "fields":[ + { + "name":"phoneNumber", + "type":"string" + },{ + "name":"text", + "type":"string" + } + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/status.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/status.avsc new file mode 100644 index 000000000..972a80c08 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/status.avsc @@ -0,0 +1,10 @@ +{ + "namespace":"org.springframework.cloud.stream.samples", + "name": "Status", + "type" : "record", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "text", "type": "string"}, + {"name": "timestamp", "type": "long"} + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/user.avsc b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/user.avsc new file mode 100644 index 000000000..662029a22 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/user.avsc @@ -0,0 +1,10 @@ +{"namespace": "example.avro", + "type": "record", + "name": "User", + "fields": [ + {"name": "name", "type": "string"}, + {"name": "favoriteNumber", "type": ["int", "null"]}, + {"name": "favoriteColor", "type": ["string", "null"]} + + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v1.schema b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v1.schema new file mode 100644 index 000000000..662029a22 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v1.schema @@ -0,0 +1,10 @@ +{"namespace": "example.avro", + "type": "record", + "name": "User", + "fields": [ + {"name": "name", "type": "string"}, + {"name": "favoriteNumber", "type": ["int", "null"]}, + {"name": "favoriteColor", "type": ["string", "null"]} + + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v2.schema b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v2.schema new file mode 100644 index 000000000..1cb8c4130 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-schema/src/test/resources/schemas/users_v2.schema @@ -0,0 +1,10 @@ +{"namespace": "example.avro", + "type": "record", + "name": "User", + "fields": [ + {"name": "name", "type": "string"}, + {"name": "favoriteNumber", "type": ["int", "null"]}, + {"name": "favoriteColor", "type": ["string", "null"]}, + {"name": "favoritePlace", "type": ["string","null"], "default" : "NYC"} + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/pom.xml new file mode 100644 index 000000000..db2c08e2c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + spring-cloud-stream-test-support-internal + Set of classes and utility code that may assist in testing both + spring-cloud-stream itself, and also modules. + + + + junit + junit + compile + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.boot + spring-boot-starter-logging + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/src/main/java/org/springframework/cloud/stream/test/junit/AbstractExternalResourceTestSupport.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/src/main/java/org/springframework/cloud/stream/test/junit/AbstractExternalResourceTestSupport.java new file mode 100644 index 000000000..dbbafbda5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support-internal/src/main/java/org/springframework/cloud/stream/test/junit/AbstractExternalResourceTestSupport.java @@ -0,0 +1,147 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.junit; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import org.springframework.util.Assert; + +import static org.junit.Assert.fail; + +/** + * Abstract base class for JUnit {@link Rule}s that detect the presence of some external + * resource. If the resource is indeed present, it will be available during the test + * lifecycle through {@link #getResource()}. If it is not, tests will either fail or be + * skipped, depending on the value of system property + * {@value #SCS_EXTERNAL_SERVERS_REQUIRED}. + * + * @param resource type + * @author Eric Bottard + * @author Gary Russell + */ +public abstract class AbstractExternalResourceTestSupport implements TestRule { + + /** + * SCS external servers required environment variable. + */ + public static final String SCS_EXTERNAL_SERVERS_REQUIRED = "SCS_EXTERNAL_SERVERS_REQUIRED"; + + protected final Log logger = LogFactory.getLog(getClass()); + + protected R resource; + + private String resourceDescription; + + protected AbstractExternalResourceTestSupport(String resourceDescription) { + Assert.hasText(resourceDescription, "resourceDescription is required"); + this.resourceDescription = resourceDescription; + } + + @Override + public Statement apply(final Statement base, Description description) { + try { + obtainResource(); + } + catch (Exception e) { + maybeCleanup(); + + return failOrSkip(e); + } + + return new Statement() { + + @Override + public void evaluate() throws Throwable { + try { + base.evaluate(); + } + finally { + try { + cleanupResource(); + } + catch (Exception ignored) { + AbstractExternalResourceTestSupport.this.logger.warn( + "Exception while trying to cleanup proper resource", + ignored); + } + } + } + + }; + } + + private Statement failOrSkip(final Exception e) { + String serversRequired = System.getenv(SCS_EXTERNAL_SERVERS_REQUIRED); + if ("true".equalsIgnoreCase(serversRequired)) { + this.logger.error(this.resourceDescription + " IS REQUIRED BUT NOT AVAILABLE", + e); + fail(this.resourceDescription + " IS NOT AVAILABLE"); + // Never reached, here to satisfy method signature + return null; + } + else { + this.logger.error( + this.resourceDescription + " IS NOT AVAILABLE, SKIPPING TESTS", e); + return new Statement() { + + @Override + public void evaluate() throws Throwable { + Assume.assumeTrue("Skipping test due to " + + AbstractExternalResourceTestSupport.this.resourceDescription + + " not being available " + e, false); + } + }; + } + } + + private void maybeCleanup() { + if (this.resource != null) { + try { + cleanupResource(); + } + catch (Exception ignored) { + this.logger.warn("Exception while trying to cleanup failed resource", + ignored); + } + } + } + + public R getResource() { + return this.resource; + } + + /** + * Perform cleanup of the {@link #resource} field, which is guaranteed to be non null. + * @throws Exception any exception thrown by this method will be logged and swallowed + */ + protected abstract void cleanupResource() throws Exception; + + /** + * Try to obtain and validate a resource. Implementors should either set the + * {@link #resource} field with a valid resource and return normally, or throw an + * exception. + * @throws Exception when resource couldn't be obtained + */ + protected abstract void obtainResource() throws Exception; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/pom.xml new file mode 100644 index 000000000..ddcf23c58 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + spring-cloud-stream-test-support + A set of classes to ease testing of Spring Cloud Stream modules. + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.cloud + spring-cloud-stream + + + org.springframework.boot + spring-boot-autoconfigure + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java new file mode 100644 index 000000000..47233e5f5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollector.java @@ -0,0 +1,39 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import java.util.concurrent.BlockingQueue; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; + +/** + * Maintains a map between (output) channels and messages received (in FIFO order). To be + * injected in tests that can then run assertions on the enqueued messages. + * + * @author Eric Bottard + */ +public interface MessageCollector { + + /** + * Obtain a queue that will receive messages sent to the given channel. + * @param channel message channel + * @return blocking queue for stored message + */ + BlockingQueue> forChannel(MessageChannel channel); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollectorAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollectorAutoConfiguration.java new file mode 100644 index 000000000..0b4b9ec5b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/MessageCollectorAutoConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.MessageChannel; + +/** + * Automatically registers the {@link MessageCollector} associated with the test binder as + * a bean. + * + * @author Marius Bogoevici + */ +@Configuration +public class MessageCollectorAutoConfiguration { + + @Bean + public MessageCollector messageCollector(BinderFactory binderFactory) { + return ((TestSupportBinder) binderFactory.getBinder("test", MessageChannel.class)) + .messageCollector(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestBinderEnvironmentPostProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestBinderEnvironmentPostProcessor.java new file mode 100644 index 000000000..567a54b0e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestBinderEnvironmentPostProcessor.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * An {@link EnvironmentPostProcessor} that sets some configuration properties for + * {@link TestSupportBinder}. + * + * @author Ilayaperumal Gopinathan + */ +public class TestBinderEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, + SpringApplication application) { + Map propertiesToAdd = new HashMap<>(); + propertiesToAdd.put("spring.cloud.stream.binders.test.defaultCandidate", "false"); + environment.getPropertySources() + .addLast(new MapPropertySource("testBinderConfig", propertiesToAdd)); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java new file mode 100644 index 000000000..f0479c384 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinder.java @@ -0,0 +1,275 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.LinkedBlockingDeque; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.converter.MessageConverterUtils; +import org.springframework.cloud.stream.test.matcher.MessageQueueMatcher; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; + +/** + * A minimal binder that + *
      + *
    • does nothing about binding consumers, leaving the channel as-is, so that a test + * author can interact with it directly,
    • + *
    • registers a queue channel on the producer side, so that it is easy to assert what + * is received.
    • + *
    + * + * @author Eric Bottard + * @author Gary Russell + * @author Mark Fisher + * @author Oleg Zhurakousky + * @author Soby Chacko + * @see MessageQueueMatcher + */ +public class TestSupportBinder + implements Binder { + + private final MessageCollectorImpl messageCollector = new MessageCollectorImpl(); + + private final ConcurrentMap messageChannels = new ConcurrentHashMap<>(); + + @Override + public Binding bindConsumer(String name, String group, + MessageChannel inboundBindTarget, ConsumerProperties properties) { + return new TestBinding(inboundBindTarget, null); + } + + /** + * Registers a single subscriber to the channel, that enqueues messages for later + * retrieval and assertion in tests. + */ + @Override + public Binding bindProducer(String name, + MessageChannel outboundBindTarget, ProducerProperties properties) { + final BlockingQueue> queue = this.messageCollector + .register(outboundBindTarget, properties.isUseNativeEncoding()); + ((SubscribableChannel) outboundBindTarget).subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + queue.add(message); + } + }); + this.messageChannels.put(name, outboundBindTarget); + return new TestBinding(outboundBindTarget, this.messageCollector); + } + + public MessageCollector messageCollector() { + return this.messageCollector; + } + + public MessageChannel getChannelForName(String name) { + return this.messageChannels.get(name); + } + + /** + * Maintains mappings between channels and queues. + * + * @author Eric Bottard + */ + private static class MessageCollectorImpl implements MessageCollector { + + private final Map>> results = new HashMap<>(); + + private BlockingQueue> register(MessageChannel channel, + boolean useNativeEncoding) { + // we need to add this interceptor to ensure MessageCollector's compatibility + // with + // previous versions of SCSt when native encoding is disabled. + if (!useNativeEncoding) { + ((AbstractMessageChannel) channel) + .addInterceptor(new InboundMessageConvertingInterceptor()); + } + LinkedBlockingDeque> result = new LinkedBlockingDeque<>(); + Assert.isTrue(!this.results.containsKey(channel), + "Channel [" + channel + "] was already bound"); + this.results.put(channel, result); + return result; + } + + private void unregister(MessageChannel channel) { + Assert.notNull(this.results.remove(channel), + "Trying to unregister a mapping for an unknown channel [" + channel + + "]"); + } + + @Override + public BlockingQueue> forChannel(MessageChannel channel) { + BlockingQueue> queue = this.results.get(channel); + Assert.notNull(queue, "Channel [" + channel + "] was not bound by " + + TestSupportBinder.class); + return queue; + } + + } + + /** + * @author Marius Bogoevici + */ + private static final class TestBinding implements Binding { + + private final MessageChannel target; + + private final MessageCollectorImpl messageCollector; + + private TestBinding(MessageChannel target, + MessageCollectorImpl messageCollector) { + this.target = target; + this.messageCollector = messageCollector; + } + + @Override + public void unbind() { + if (this.messageCollector != null) { + this.messageCollector.unregister(this.target); + } + } + + } + + /** + * This is really an interceptor to maintain MessageCollector's backward compatibility + * with the behavior established in 1.3 - BINDER_ORIGINAL_CONTENT_TYPE - Kryo and Java + * deserialization - byte[] to String conversion - etc. + */ + private final static class InboundMessageConvertingInterceptor + implements ChannelInterceptor { + + private final DefaultContentTypeResolver contentTypeResolver = new DefaultContentTypeResolver(); + + private final CompositeMessageConverterFactory converterFactory = new CompositeMessageConverterFactory(); + + /* + * Candidate to go into some utils class + */ + private static boolean equalTypeAndSubType(MimeType m1, MimeType m2) { + return m1 != null && m2 != null && m1.getType().equalsIgnoreCase(m2.getType()) + && m1.getSubtype().equalsIgnoreCase(m2.getSubtype()); + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + Class targetClass = null; + MessageConverter converter = null; + MimeType contentType = message.getHeaders() + .containsKey(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE) + ? MimeType.valueOf(message.getHeaders() + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE) + .toString()) + : MimeType.valueOf(this.contentTypeResolver + .resolve(message.getHeaders()).toString()); + + if (contentType != null) { + if (equalTypeAndSubType(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT, + contentType) + || equalTypeAndSubType(MessageConverterUtils.X_JAVA_OBJECT, + contentType)) { + // for Java and Kryo de-serialization we need to reset the content + // type + message = MessageBuilder.fromMessage(message) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType).build(); + converter = equalTypeAndSubType( + MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT, contentType) + ? this.converterFactory + .getMessageConverterForType(contentType) + : this.converterFactory + .getMessageConverterForAllRegistered(); + String targetClassName = contentType.getParameter("type"); + if (StringUtils.hasText(targetClassName)) { + try { + targetClass = Class.forName(targetClassName, false, + Thread.currentThread().getContextClassLoader()); + } + catch (Exception e) { + throw new IllegalStateException( + "Failed to determine class name for contentType: " + + message.getHeaders().get( + BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE), + e); + } + } + } + + } + + Object payload; + if (converter != null) { + Assert.isTrue( + !(equalTypeAndSubType(MessageConverterUtils.X_JAVA_OBJECT, + contentType) && targetClass == null), + "Cannot deserialize into message since 'contentType` is not " + + "encoded with the actual target type." + + "Consider 'application/x-java-object; type=foo.bar.MyClass'"); + payload = converter.fromMessage(message, targetClass); + } + else { + MimeType deserializeContentType = this.contentTypeResolver + .resolve(message.getHeaders()); + if (deserializeContentType == null) { + deserializeContentType = contentType; + } + payload = deserializeContentType == null ? message.getPayload() : this + .deserializePayload(message.getPayload(), deserializeContentType); + } + message = MessageBuilder.withPayload(payload) + .copyHeaders(message.getHeaders()) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType) + .removeHeader(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE).build(); + return message; + } + + private Object deserializePayload(Object payload, MimeType contentType) { + if (payload instanceof byte[] + && ("text".equalsIgnoreCase(contentType.getType()) + || equalTypeAndSubType(MimeTypeUtils.APPLICATION_JSON, + contentType))) { + payload = new String((byte[]) payload, StandardCharsets.UTF_8); + } + return payload; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java new file mode 100644 index 000000000..a031fb5cc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderAutoConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.messaging.MessageChannel; + +/** + * Installs the {@link TestSupportBinder} and exposes + * {@link TestSupportBinder.MessageCollectorImpl} to be injected in tests. + * + * Note that this auto-configuration has higher priority than regular binder + * configuration, so adding this on the classpath in test scope is sufficient to have + * support kick in and replace all binders with the test binder. + * + * The test binder instance is supplied by the {@link TestSupportBinderConfiguration}. + * + * @author Eric Bottard + * @author Marius Bogoevici + */ +@Configuration +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +@Import(TestSupportBinderConfiguration.class) +@AutoConfigureBefore(BindingServiceConfiguration.class) +public class TestSupportBinderAutoConfiguration { + + @Bean + @SuppressWarnings("unchecked") + public BinderFactory binderFactory(final Binder binder) { + return new BinderFactory() { + @Override + public Binder getBinder( + String configurationName, Class bindableType) { + return (Binder) binder; + } + }; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderConfiguration.java new file mode 100644 index 000000000..775c398b3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/binder/TestSupportBinderConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.binder; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.MessageChannel; + +/** + * Binder {@link org.springframework.context.annotation.Configuration} for the + * {@link TestSupportBinder} + * + * Either imported by the {@link TestSupportBinderAutoConfiguration} for the test binder + * default usage scenario (superseding all binders on the classpath), or used as a binder + * configuration on the classpath when test binder autoconfiguration is disabled. + * + * @author Marius Bogoevici + */ +@Configuration +@ConditionalOnMissingBean(Binder.class) +public class TestSupportBinderConfiguration { + + private Binder messageChannelBinder = new TestSupportBinder(); + + @Bean + public Binder binder() { + return this.messageChannelBinder; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java new file mode 100644 index 000000000..ab9fba30a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcher.java @@ -0,0 +1,187 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.matcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.SelfDescribing; + +import org.springframework.cloud.stream.test.binder.TestSupportBinder; +import org.springframework.messaging.Message; + +/** + * A Hamcrest Matcher meant to be used in conjunction with {@link TestSupportBinder}. + * + *

    + * Expected usage is of the form (with appropriate static imports): + * + *

    + * public class TransformProcessorApplicationTests {
    + *
    + *    {@literal @}Autowired
    + *    {@literal @}Bindings(TransformProcessor.class)
    + *    private Processor processor;
    + *
    + *    {@literal @}Autowired
    + *    private MessageCollectorImpl messageCollector;
    + *
    + *
    + *    {@literal @}Test
    + *    public void testUsingExpression() {
    + *        processor.input().send(new GenericMessage{@literal <}Object>("hello"));
    + *        assertThat(messageCollector.forChannel(processor.output()), receivesPayloadThat(is("hellofoo")).within(10));
    + *    }
    + *
    + * }
    + * 
    + *

    + * + * @param return type + * @author Eric Bottard + * @author Janne Valkealahti + */ +public class MessageQueueMatcher extends BaseMatcher>> { + + private final Matcher delegate; + + private final long timeout; + + private final TimeUnit unit; + + private Extractor, T> extractor; + + private Map>, T> actuallyReceived = new HashMap<>(); + + public MessageQueueMatcher(Matcher delegate, long timeout, TimeUnit unit, + Extractor, T> extractor) { + this.delegate = delegate; + this.timeout = timeout; + this.unit = (unit != null ? unit : TimeUnit.SECONDS); + this.extractor = extractor; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static

    MessageQueueMatcher

    receivesMessageThat( + Matcher> messageMatcher) { + return new MessageQueueMatcher(messageMatcher, 5, TimeUnit.SECONDS, + new Extractor, Message

    >("a message that ") { + @Override + public Message

    apply(Message

    m) { + return m; + } + }); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static

    MessageQueueMatcher

    receivesPayloadThat( + Matcher

    payloadMatcher) { + return new MessageQueueMatcher(payloadMatcher, 5, TimeUnit.SECONDS, + new Extractor, P>("a message whose payload ") { + @Override + public P apply(Message

    m) { + return m.getPayload(); + } + }); + } + + @Override + public boolean matches(Object item) { + @SuppressWarnings("unchecked") + BlockingQueue> queue = (BlockingQueue>) item; + Message received = null; + try { + if (this.timeout > 0) { + received = queue.poll(this.timeout, this.unit); + } + else if (this.timeout == 0) { + received = queue.poll(); + } + else { + received = queue.take(); + } + } + catch (InterruptedException e) { + return false; + } + T unwrapped = this.extractor.apply(received); + this.actuallyReceived.put(queue, unwrapped); + return this.delegate.matches(unwrapped); + } + + @Override + public void describeMismatch(Object item, Description description) { + @SuppressWarnings("unchecked") + BlockingQueue> queue = (BlockingQueue>) item; + T value = this.actuallyReceived.get(queue); + if (value != null) { + description.appendText("received: ").appendValue(value); + } + else { + description.appendText("timed out after " + this.timeout + " " + + this.unit.name().toLowerCase()); + } + } + + public MessageQueueMatcher within(long timeout, TimeUnit unit) { + return new MessageQueueMatcher<>(this.delegate, timeout, unit, this.extractor); + } + + public MessageQueueMatcher immediately() { + return new MessageQueueMatcher<>(this.delegate, 0, null, this.extractor); + } + + public MessageQueueMatcher indefinitely() { + return new MessageQueueMatcher<>(this.delegate, -1, null, this.extractor); + } + + @Override + public void describeTo(Description description) { + description.appendText("Channel to receive ").appendDescriptionOf(this.extractor) + .appendDescriptionOf(this.delegate); + } + + /** + * A transformation to be applied to a received message before asserting, e.g. + * to only inspect the payload. + * + * @param input type + * @param return type + */ + public static abstract class Extractor + implements Function, SelfDescribing { + + private final String behaviorDescription; + + protected Extractor(String behaviorDescription) { + this.behaviorDescription = behaviorDescription; + } + + @Override + public void describeTo(Description description) { + description.appendText(this.behaviorDescription); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.binders b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.binders new file mode 100644 index 000000000..f12cc2353 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.binders @@ -0,0 +1,2 @@ +test:\ +org.springframework.cloud.stream.test.binder.TestSupportBinderConfiguration diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..1bf7aa99f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration,\ +org.springframework.cloud.stream.test.binder.MessageCollectorAutoConfiguration +org.springframework.boot.env.EnvironmentPostProcessor=\ + org.springframework.cloud.stream.test.binder.TestBinderEnvironmentPostProcessor diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/bean/AggregateWithBeanTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/bean/AggregateWithBeanTest.java new file mode 100644 index 000000000..4461c2b79 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/bean/AggregateWithBeanTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.aggregate.bean; + +import java.util.concurrent.TimeUnit; + +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.aggregate.AggregateApplication; +import org.springframework.cloud.stream.aggregate.AggregateApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.annotation.Transformer; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = AggregateWithBeanTest.ChainedProcessors.class, properties = { + "server.port=-1", "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain" }) +@Ignore +public class AggregateWithBeanTest { + + @Autowired + public MessageCollector messageCollector; + + @Autowired + public AggregateApplication aggregateApplication; + + @Test + @SuppressWarnings("unchecked") + public void testAggregateApplication() throws InterruptedException { + Processor uppercaseProcessor = this.aggregateApplication + .getBinding(Processor.class, "upper"); + Processor suffixProcessor = this.aggregateApplication.getBinding(Processor.class, + "suffix"); + uppercaseProcessor.input().send(MessageBuilder.withPayload("Hello").build()); + Message receivedMessage = (Message) this.messageCollector + .forChannel(suffixProcessor.output()).poll(1, TimeUnit.SECONDS); + assertThat(receivedMessage).isNotNull(); + assertThat(receivedMessage.getPayload()).isEqualTo("HELLO WORLD!"); + } + + @SpringBootApplication + @EnableBinding + public static class ChainedProcessors { + + @Bean + public AggregateApplication aggregateApplication() { + return new AggregateApplicationBuilder().from(UppercaseProcessor.class) + .namespace("upper").to(SuffixProcessor.class).namespace("suffix") + .build(); + } + + } + + @Configuration + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class UppercaseProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in.toUpperCase(); + } + + } + + @Configuration + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class SuffixProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " WORLD!"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/main/AggregateWithMainTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/main/AggregateWithMainTest.java new file mode 100644 index 000000000..b61256f47 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/aggregate/main/AggregateWithMainTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.aggregate.main; + +import java.util.concurrent.TimeUnit; + +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.aggregate.AggregateApplication; +import org.springframework.cloud.stream.aggregate.AggregateApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.annotation.Transformer; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Artem Bilan + */ +@Ignore +public class AggregateWithMainTest { + + @SuppressWarnings("unchecked") + @Test + public void testAggregateApplication() throws InterruptedException { + // emulate a main method + ConfigurableApplicationContext context = new AggregateApplicationBuilder( + MainConfiguration.class).web(false).from(UppercaseProcessor.class) + .namespace("upper").to(SuffixProcessor.class).namespace("suffix") + .run("--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain"); + + AggregateApplication aggregateAccessor = context + .getBean(AggregateApplication.class); + MessageCollector messageCollector = context.getBean(MessageCollector.class); + Processor uppercaseProcessor = aggregateAccessor.getBinding(Processor.class, + "upper"); + Processor suffixProcessor = aggregateAccessor.getBinding(Processor.class, + "suffix"); + uppercaseProcessor.input().send(MessageBuilder.withPayload("Hello").build()); + Message receivedMessage = (Message) messageCollector + .forChannel(suffixProcessor.output()).poll(1, TimeUnit.SECONDS); + assertThat(receivedMessage).isNotNull(); + assertThat(receivedMessage.getPayload()).isEqualTo("HELLO WORLD!"); + context.close(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + public static class MainConfiguration { + + } + + @Configuration + @EnableBinding(Processor.class) + @EnableAutoConfiguration + static class UppercaseProcessor { + + @Autowired + Processor processor; + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in.toUpperCase(); + } + + } + + @Configuration + @EnableBinding(Processor.class) + @EnableAutoConfiguration + static class SuffixProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " WORLD!"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/disable/AutoconfigurationDisabledTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/disable/AutoconfigurationDisabledTest.java new file mode 100644 index 000000000..18ad79613 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/disable/AutoconfigurationDisabledTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.disable; + +import java.util.concurrent.TimeUnit; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.cloud.stream.test.binder.TestSupportBinderAutoConfiguration; +import org.springframework.integration.annotation.Transformer; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = AutoconfigurationDisabledTest.MyProcessor.class, properties = { + "server.port=-1", "spring.cloud.stream.defaultBinder=test", + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain" }) +@DirtiesContext +public class AutoconfigurationDisabledTest { + + @Autowired + public MessageCollector messageCollector; + + @Autowired + public Processor processor; + + @SuppressWarnings("unchecked") + @Test + public void testAutoconfigurationDisabled() throws Exception { + this.processor.input().send(MessageBuilder.withPayload("Hello").build()); + // Since the interaction is synchronous, the result should be immediate + Message response = (Message) this.messageCollector + .forChannel(this.processor.output()).poll(1000, TimeUnit.MILLISECONDS); + assertThat(response).isNotNull(); + assertThat(response.getPayload()).isEqualTo("Hello world"); + } + + @SpringBootApplication(exclude = TestSupportBinderAutoConfiguration.class) + @EnableBinding(Processor.class) + public static class MyProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " world"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/example/ExampleTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/example/ExampleTest.java new file mode 100644 index 000000000..ef14959e7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/example/ExampleTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.example; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.test.binder.MessageCollector; +import org.springframework.integration.annotation.Transformer; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test that validates that + * {@link org.springframework.cloud.stream.test.binder.TestSupportBinder} applies + * correctly. + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = ExampleTest.MyProcessor.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain" }) +// @checkstyle:on +@DirtiesContext +public class ExampleTest { + + @Autowired + private MessageCollector messageCollector; + + @Autowired + private Processor processor; + + @Test + @SuppressWarnings("unchecked") + public void testWiring() { + Message message = new GenericMessage<>("hello"); + this.processor.input().send(message); + Message received = (Message) this.messageCollector + .forChannel(this.processor.output()).poll(); + assertThat(received.getPayload()).isEqualTo("hello world"); + } + + @SpringBootApplication + @EnableBinding(Processor.class) + public static class MyProcessor { + + @Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public String transform(String in) { + return in + " world"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java new file mode 100644 index 000000000..8af5cd3b1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-test-support/src/test/java/org/springframework/cloud/stream/test/matcher/MessageQueueMatcherTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.test.matcher; + +import java.util.Collections; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; + +import org.hamcrest.StringDescription; +import org.junit.Test; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * Tests for MessageQueueMatcher. + * + * @author Eric Bottard + */ +public class MessageQueueMatcherTest { + + private final BlockingDeque> queue = new LinkedBlockingDeque<>(); + + private final StringDescription description = new StringDescription(); + + @Test + public void testTimeout() { + Message msg = new GenericMessage<>("hello"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)) + .within(2, TimeUnit.MILLISECONDS); + + boolean result = matcher.matches(this.queue); + assertThat(result).isFalse(); + matcher.describeMismatch(this.queue, this.description); + assertThat(this.description.toString()) + .isEqualTo("timed out after 2 milliseconds"); + } + + @Test + public void testMatch() { + Message msg = new GenericMessage<>("hello"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + this.queue.offer(msg); + boolean result = matcher.matches(this.queue); + assertThat(result).isTrue(); + } + + @Test + public void testMismatch() { + Message msg = new GenericMessage<>("hello"); + Message other = new GenericMessage<>("world"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + this.queue.offer(other); + boolean result = matcher.matches(this.queue); + assertThat(result).isFalse(); + matcher.describeMismatch(this.queue, this.description); + assertThat(this.description.toString()).isEqualTo(("received: <" + other + ">")); + } + + @Test + public void testExtractor() { + Message msg = new GenericMessage<>("hello", + Collections.singletonMap("foo", (Object) "bar")); + + MessageQueueMatcher.Extractor, String> headerExtractor; + headerExtractor = new MessageQueueMatcher.Extractor, String>( + "whose 'foo' header") { + @Override + public String apply(Message message) { + return message.getHeaders().get("foo", String.class); + } + }; + MessageQueueMatcher matcher = new MessageQueueMatcher<>(is("bar"), -1, null, + headerExtractor); + this.queue.offer(msg); + boolean result = matcher.matches(this.queue); + assertThat(result); + matcher = new MessageQueueMatcher<>(is("wizz"), -1, null, headerExtractor); + this.queue.offer(msg); + result = matcher.matches(this.queue); + assertThat(result).isFalse(); + matcher.describeMismatch(this.queue, this.description); + assertThat(this.description.toString()).isEqualTo(("received: \"bar\"")); + } + + @Test + public void testDescription() { + Message msg = new GenericMessage<>("hello"); + MessageQueueMatcher matcher = MessageQueueMatcher.receivesMessageThat(is(msg)); + this.description.appendDescriptionOf(matcher); + assertThat(this.description.toString()) + .isEqualTo(("Channel to receive a message that is <" + msg + ">")); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-tools/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-tools/pom.xml new file mode 100644 index 000000000..d371cd7ea --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream-tools/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + 2.2.0.BUILD-SNAPSHOT + + org.springframework.cloud + spring-cloud-build + 2.1.4.BUILD-SNAPSHOT + + + + spring-cloud-stream-tools + spring-cloud-stream-build-tools + Spring Cloud Stream Build Tools + + + 1.8 + + + + + spring + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + false + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/release + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + true + + + false + + + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/libs-release-local + + false + + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/.jdk8 b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/pom.xml b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/pom.xml new file mode 100644 index 000000000..b1c5dc0d7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + spring-cloud-stream + jar + spring-cloud-stream + Messaging Microservices with Spring Integration + + + org.springframework.cloud + spring-cloud-stream-parent + 2.2.0.BUILD-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-actuator + true + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework + spring-messaging + + + org.springframework.integration + spring-integration-core + + + org.springframework.integration + spring-integration-jmx + + + org.springframework + spring-tuple + + + org.springframework.integration + spring-integration-tuple + + + org.springframework.retry + spring-retry + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.cloud + spring-cloud-function-context + + + org.springframework.integration + spring-integration-test + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.integration + spring-integration-http + test + + + org.springframework.boot + spring-boot-starter-web + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + + **/test/* + + test-binder + + + test-jar + + + + + + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java new file mode 100644 index 000000000..f2374ba57 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregate; + +/** + * Handle to an aggregate application, providing access to the underlying components of + * the aggregate (e.g. bindable instances). + * + * @author Marius Bogoevici + */ +public interface AggregateApplication { + + /** + * Retrieves the bindable proxy instance (e.g. + * {@link org.springframework.cloud.stream.messaging.Processor}, + * {@link org.springframework.cloud.stream.messaging.Source}, + * {@link org.springframework.cloud.stream.messaging.Sink} or custom interface) from + * the given namespace. + * @param bindableType the bindable type + * @param namespace the namespace + * @param parameterized bindable type + * @return binding + */ + T getBinding(Class bindableType, String namespace); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationBuilder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationBuilder.java new file mode 100644 index 000000000..4f369572b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationBuilder.java @@ -0,0 +1,531 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binding.BindableProxyFactory; +import org.springframework.cloud.stream.config.ChannelBindingAutoConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Application builder for {@link AggregateApplication}. + * + * @author Dave Syer + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @author Venil Noronha + * @author Janne Valkealahti + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@EnableBinding +public class AggregateApplicationBuilder implements AggregateApplication, + ApplicationContextAware, SmartInitializingSingleton { + + private static final String CHILD_CONTEXT_SUFFIX = ".spring.cloud.stream.context"; + + private static final Bindable> STRING_STRING_MAP = Bindable + .mapOf(String.class, String.class); + + private static final Pattern DOLLAR_ESCAPE_PATTERN = Pattern.compile("\\$"); + + private SourceConfigurer sourceConfigurer; + + private SinkConfigurer sinkConfigurer; + + private List processorConfigurers = new ArrayList<>(); + + private AggregateApplicationBuilder applicationBuilder = this; + + private ConfigurableApplicationContext parentContext; + + private List parentSources = new ArrayList<>(); + + private List parentArgs = new ArrayList<>(); + + private boolean headless = true; + + private boolean webEnvironment = true; + + public AggregateApplicationBuilder(String... args) { + this(new Object[] { ParentConfiguration.class }, args); + } + + public AggregateApplicationBuilder(Object source, String... args) { + this(new Object[] { source }, args); + } + + public AggregateApplicationBuilder(Object[] sources, String[] args) { + addParentSources(sources); + this.parentArgs.addAll(Arrays.asList(args)); + } + + /** + * Adding auto configuration classes to parent sources excluding the configuration + * classes related to binder/binding. + * @param sources sources to which parent sources will be added + */ + private void addParentSources(Object[] sources) { + if (!this.parentSources.contains(ParentConfiguration.class)) { + this.parentSources.add(ParentConfiguration.class); + if (ClassUtils.isPresent( + "org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration", + null)) { + this.parentSources.add(ParentActuatorConfiguration.class); + } + } + this.parentSources.addAll(Arrays.asList(sources)); + } + + public AggregateApplicationBuilder parent(Object source, String... args) { + return parent(new Object[] { source }, args); + } + + public AggregateApplicationBuilder parent(Object[] sources, String[] args) { + addParentSources(sources); + this.parentArgs.addAll(Arrays.asList(args)); + return this; + } + + /** + * Flag to explicitly request a web or non-web environment. + * @param webEnvironment true if the application has a web environment + * @return the AggregateApplicationBuilder being constructed + * @see SpringApplicationBuilder#web(boolean) + */ + public AggregateApplicationBuilder web(boolean webEnvironment) { + this.webEnvironment = webEnvironment; + return this; + } + + /** + * Configures the headless attribute of the build application. + * @param headless true if the application is headless + * @return the AggregateApplicationBuilder being constructed + * @see SpringApplicationBuilder#headless(boolean) + */ + public AggregateApplicationBuilder headless(boolean headless) { + this.headless = headless; + return this; + } + + @Override + public void afterSingletonsInstantiated() { + this.run(); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.parentContext = (ConfigurableApplicationContext) applicationContext; + } + + @Override + public T getBinding(Class bindableType, String namespace) { + if (this.parentContext == null) { + throw new IllegalStateException( + "The aggregate application has not been started yet"); + } + try { + ChildContextHolder contextHolder = this.parentContext + .getBean(namespace + CHILD_CONTEXT_SUFFIX, ChildContextHolder.class); + return contextHolder.getChildContext().getBean(bindableType); + } + catch (BeansException e) { + throw new IllegalStateException("Binding not found for '" + + bindableType.getName() + "' into namespace " + namespace); + } + } + + public SourceConfigurer from(Class app) { + SourceConfigurer sourceConfigurer = new SourceConfigurer(app); + this.sourceConfigurer = sourceConfigurer; + return sourceConfigurer; + } + + public ConfigurableApplicationContext run(String... parentArgs) { + this.parentArgs.addAll(Arrays.asList(parentArgs)); + List> apps = new ArrayList<>(); + if (this.sourceConfigurer != null) { + apps.add(this.sourceConfigurer); + } + if (!this.processorConfigurers.isEmpty()) { + for (ProcessorConfigurer processorConfigurer : this.processorConfigurers) { + apps.add(processorConfigurer); + } + } + if (this.sinkConfigurer != null) { + apps.add(this.sinkConfigurer); + } + LinkedHashMap, String> appsToEmbed = new LinkedHashMap<>(); + LinkedHashMap, String> appConfigurers = new LinkedHashMap<>(); + for (int i = 0; i < apps.size(); i++) { + AppConfigurer appConfigurer = apps.get(i); + Class appToEmbed = appConfigurer.getApp(); + // Always update namespace before preparing SharedChannelRegistry + if (appConfigurer.namespace == null) { + // to remove illegal characters for new properties + // binder + // org.springframework.cloud.stream.aggregation.AggregationTest$TestSource + appConfigurer.namespace = AggregateApplicationUtils.getDefaultNamespace( + DOLLAR_ESCAPE_PATTERN.matcher(appConfigurer.getApp().getName()) + .replaceAll("."), + i); + } + appsToEmbed.put(appToEmbed, appConfigurer.namespace); + appConfigurers.put(appConfigurer, appConfigurer.namespace); + } + if (this.parentContext == null) { + if (Boolean.TRUE.equals(this.webEnvironment)) { + Assert.isTrue( + ClassUtils.isPresent("javax.servlet.ServletRequest", + ClassUtils.getDefaultClassLoader()), + "'webEnvironment' is set to 'true' but 'javax.servlet.*' does not appear to be available in " + + "the classpath. Consider adding `org.springframework.boot:spring-boot-starter-web"); + this.addParentSources( + new Object[] { ServletWebServerFactoryAutoConfiguration.class }); + } + this.parentContext = AggregateApplicationUtils.createParentContext( + this.parentSources.toArray(new Class[0]), + this.parentArgs.toArray(new String[0]), selfContained(), + this.webEnvironment, this.headless); + } + else { + if (BeanFactoryUtils.beansOfTypeIncludingAncestors(this.parentContext, + SharedBindingTargetRegistry.class).size() == 0) { + SharedBindingTargetRegistry sharedBindingTargetRegistry = new SharedBindingTargetRegistry(); + this.parentContext.getBeanFactory().registerSingleton( + "sharedBindingTargetRegistry", sharedBindingTargetRegistry); + } + } + SharedBindingTargetRegistry sharedBindingTargetRegistry = this.parentContext + .getBean(SharedBindingTargetRegistry.class); + AggregateApplicationUtils.prepareSharedBindingTargetRegistry( + sharedBindingTargetRegistry, appsToEmbed); + for (Map.Entry, String> appConfigurerEntry : appConfigurers + .entrySet()) { + + AppConfigurer appConfigurer = appConfigurerEntry.getKey(); + if (appConfigurerEntry.getValue() == null) { + continue; + } + String namespace = appConfigurerEntry.getValue().toLowerCase(); + Set argsToUpdate = new LinkedHashSet<>(); + Set argKeys = new LinkedHashSet<>(); + Map target = bindProperties(namespace, + this.parentContext.getEnvironment()); + + if (!target.isEmpty()) { + for (Map.Entry entry : target.entrySet()) { + String key = entry.getKey(); + argKeys.add(key); + argsToUpdate.add("--" + key + "=" + entry.getValue()); + } + } + + if (!argsToUpdate.isEmpty()) { + appConfigurer.args(argsToUpdate.toArray(new String[0])); + } + } + for (int i = apps.size() - 1; i >= 0; i--) { + AppConfigurer appConfigurer = apps.get(i); + appConfigurer.embed(); + } + if (BeanFactoryUtils.beansOfTypeIncludingAncestors(this.parentContext, + AggregateApplication.class).size() == 0) { + this.parentContext.getBeanFactory() + .registerSingleton("aggregateApplicationAccessor", this); + } + return this.parentContext; + } + + private boolean selfContained() { + return (this.sourceConfigurer != null) && (this.sinkConfigurer != null); + } + + private ChildContextBuilder childContext(Class app, + ConfigurableApplicationContext parentContext, String namespace) { + return new ChildContextBuilder( + AggregateApplicationUtils.embedApp(parentContext, namespace, app)); + } + + private Map bindProperties(String namepace, Environment environment) { + Map target; + BindResult> bindResult = Binder.get(environment) + .bind(namepace, STRING_STRING_MAP); + if (bindResult.isBound()) { + target = bindResult.get(); + } + else { + target = new HashMap<>(); + } + return target; + } + + private static class ChildContextHolder { + + private final ConfigurableApplicationContext childContext; + + ChildContextHolder(ConfigurableApplicationContext childContext) { + Assert.notNull(childContext, "cannot be null"); + this.childContext = childContext; + } + + public ConfigurableApplicationContext getChildContext() { + return this.childContext; + } + + } + + /** + * Auto configuration for {@link SharedBindingTargetRegistry}. + */ + @ImportAutoConfiguration(ChannelBindingAutoConfiguration.class) + @EnableBinding + public static class ParentConfiguration { + + @Bean + @ConditionalOnMissingBean(SharedBindingTargetRegistry.class) + public SharedBindingTargetRegistry sharedBindingTargetRegistry() { + return new SharedBindingTargetRegistry(); + } + + } + + /** + * Auto configuration for {@link EndpointAutoConfiguration}. + */ + @ImportAutoConfiguration(EndpointAutoConfiguration.class) + public static class ParentActuatorConfiguration { + + } + + /** + * Source configurer. + */ + public class SourceConfigurer extends AppConfigurer { + + public SourceConfigurer(Class app) { + this.app = app; + AggregateApplicationBuilder.this.sourceConfigurer = this; + } + + public SinkConfigurer to(Class sink) { + return new SinkConfigurer(sink); + } + + public ProcessorConfigurer via(Class processor) { + return new ProcessorConfigurer(processor); + } + + } + + /** + * Sink configurer. + */ + public class SinkConfigurer extends AppConfigurer { + + public SinkConfigurer(Class app) { + this.app = app; + AggregateApplicationBuilder.this.sinkConfigurer = this; + } + + } + + /** + * Processor configurer. + */ + public class ProcessorConfigurer extends AppConfigurer { + + public ProcessorConfigurer(Class app) { + this.app = app; + AggregateApplicationBuilder.this.processorConfigurers.add(this); + } + + public SinkConfigurer to(Class sink) { + return new SinkConfigurer(sink); + } + + public ProcessorConfigurer via(Class processor) { + return new ProcessorConfigurer(processor); + } + + } + + /** + * Abstraction over configuration of an applciation. + * + * @param type of a configurer + */ + public abstract class AppConfigurer> { + + Class app; + + String[] args; + + String[] names; + + String[] profiles; + + String namespace; + + Class getApp() { + return this.app; + } + + public T as(String... names) { + this.names = names; + return getConfigurer(); + } + + public T args(String... args) { + this.args = args; + return getConfigurer(); + } + + public T profiles(String... profiles) { + this.profiles = profiles; + return getConfigurer(); + } + + @SuppressWarnings("unchecked") + private T getConfigurer() { + return (T) this; + } + + public T namespace(String namespace) { + this.namespace = namespace; + return getConfigurer(); + } + + public ConfigurableApplicationContext run(String... args) { + return AggregateApplicationBuilder.this.applicationBuilder.run(args); + } + + void embed() { + final ConfigurableApplicationContext childContext = childContext(this.app, + AggregateApplicationBuilder.this.parentContext, this.namespace) + .args(this.args).config(this.names).profiles(this.profiles) + .run(); + // Register bindable proxies as beans so they can be queried for later + Map bindableProxies = BeanFactoryUtils + .beansOfTypeIncludingAncestors(childContext.getBeanFactory(), + BindableProxyFactory.class); + for (String bindableProxyName : bindableProxies.keySet()) { + try { + AggregateApplicationBuilder.this.parentContext.getBeanFactory() + .registerSingleton(this.getNamespace() + CHILD_CONTEXT_SUFFIX, + new ChildContextHolder(childContext)); + } + catch (Exception e) { + throw new IllegalStateException( + "Error while trying to register the aggregate bound interface '" + + bindableProxyName + "' into namespace '" + + this.getNamespace() + "'", + e); + } + } + + } + + public AggregateApplication build() { + return AggregateApplicationBuilder.this.applicationBuilder; + } + + public String[] getArgs() { + return this.args; + } + + public String getNamespace() { + return this.namespace; + } + + } + + private final class ChildContextBuilder { + + private SpringApplicationBuilder builder; + + private String configName; + + private String[] args; + + private ChildContextBuilder(SpringApplicationBuilder builder) { + this.builder = builder; + } + + public ChildContextBuilder profiles(String... profiles) { + if (profiles != null) { + this.builder.profiles(profiles); + } + return this; + } + + public ChildContextBuilder config(String... configs) { + if (configs != null) { + this.configName = StringUtils.arrayToCommaDelimitedString(configs); + } + return this; + } + + public ChildContextBuilder args(String... args) { + this.args = args; + return this; + } + + public ConfigurableApplicationContext run() { + List args = new ArrayList(); + if (this.args != null) { + args.addAll(Arrays.asList(this.args)); + } + if (this.configName != null) { + args.add("--spring.config.name=" + this.configName); + } + return this.builder.run(args.toArray(new String[0])); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationUtils.java new file mode 100644 index 000000000..dd7a521c6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplicationUtils.java @@ -0,0 +1,91 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregate; + +import java.util.LinkedHashMap; +import java.util.Map.Entry; + +import org.springframework.boot.Banner.Mode; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.internal.InternalPropertyNames; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.messaging.SubscribableChannel; + +/** + * Utilities for embedding applications in aggregates. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Venil Noronha + * @author Janne Valkealahti + */ +abstract class AggregateApplicationUtils { + + public static final String INPUT_BINDING_NAME = "input"; + + public static final String OUTPUT_BINDING_NAME = "output"; + + static ConfigurableApplicationContext createParentContext(Class[] sources, + String[] args, final boolean selfContained, boolean webEnvironment, + boolean headless) { + SpringApplicationBuilder aggregatorParentConfiguration = new SpringApplicationBuilder(); + aggregatorParentConfiguration.sources(sources).web(WebApplicationType.NONE) + .headless(headless) + .properties("spring.jmx.default-domain=" + + AggregateApplicationBuilder.ParentConfiguration.class.getName(), + InternalPropertyNames.SELF_CONTAINED_APP_PROPERTY_NAME + "=" + + selfContained); + return aggregatorParentConfiguration.run(args); + } + + static String getDefaultNamespace(String appClassName, int index) { + return appClassName + "-" + index; + } + + protected static SpringApplicationBuilder embedApp( + ConfigurableApplicationContext parentContext, String namespace, + Class app) { + return new SpringApplicationBuilder(app).web(WebApplicationType.NONE).main(app) + .bannerMode(Mode.OFF).properties("spring.jmx.default-domain=" + namespace) + .properties( + InternalPropertyNames.NAMESPACE_PROPERTY_NAME + "=" + namespace) + .registerShutdownHook(false).parent(parentContext); + } + + static void prepareSharedBindingTargetRegistry( + SharedBindingTargetRegistry sharedBindingTargetRegistry, + LinkedHashMap, String> appsWithNamespace) { + int i = 0; + SubscribableChannel sharedChannel = null; + for (Entry, String> appEntry : appsWithNamespace.entrySet()) { + String namespace = appEntry.getValue(); + if (i > 0) { + sharedBindingTargetRegistry.register(namespace + "." + INPUT_BINDING_NAME, + sharedChannel); + } + sharedChannel = new DirectChannel(); + if (i < appsWithNamespace.size() - 1) { + sharedBindingTargetRegistry + .register(namespace + "." + OUTPUT_BINDING_NAME, sharedChannel); + } + i++; + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedBindingTargetRegistry.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedBindingTargetRegistry.java new file mode 100644 index 000000000..1c6fffc26 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedBindingTargetRegistry.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregate; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * Stores binding targets shared by the components of an aggregate application. + * + * @author Marius Bogoevici + * @since 1.1.1 + */ +public class SharedBindingTargetRegistry { + + private Map sharedBindingTargets = new ConcurrentSkipListMap<>( + String.CASE_INSENSITIVE_ORDER); + + @SuppressWarnings("unchecked") + public T get(String id, Class bindingTargetType) { + Object sharedBindingTarget = this.sharedBindingTargets.get(id); + if (sharedBindingTarget == null) { + return null; + } + if (!bindingTargetType.isAssignableFrom(sharedBindingTarget.getClass())) { + throw new IllegalArgumentException("A shared " + bindingTargetType.getName() + + " was requested, " + "but the existing shared target with id '" + id + + "' is a " + sharedBindingTarget.getClass()); + } + else { + return (T) sharedBindingTarget; + } + } + + public void register(String id, Object bindingTarget) { + this.sharedBindingTargets.put(id, bindingTarget); + } + + public Map getAll() { + return Collections.unmodifiableMap(this.sharedBindingTargets); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedChannelRegistry.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedChannelRegistry.java new file mode 100644 index 000000000..1c22cdd49 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/SharedChannelRegistry.java @@ -0,0 +1,67 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregate; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.messaging.MessageChannel; + +/** + * Wraps the {@link SharedBindingTargetRegistry} for access to {@link MessageChannel} + * instances. This class is provided as a convenience for users of + * {@link SharedChannelRegistry} in previous versions and will be removed in the future. + * + * @author Marius Bogoevici + * @deprecated in favor of {@link SharedBindingTargetRegistry}. Will be removed in 3.0. + * Not currently used by the framework. + */ +@Deprecated +public class SharedChannelRegistry { + + private final SharedBindingTargetRegistry sharedBindingTargetRegistry; + + public SharedChannelRegistry( + SharedBindingTargetRegistry sharedBindingTargetRegistry) { + this.sharedBindingTargetRegistry = sharedBindingTargetRegistry; + } + + public MessageChannel get(String id) { + return this.sharedBindingTargetRegistry.get(id, MessageChannel.class); + } + + public void register(String id, MessageChannel bindingTarget) { + this.sharedBindingTargetRegistry.register(id, bindingTarget); + } + + public Map getAll() { + Map sharedBindingTargets = this.sharedBindingTargetRegistry + .getAll(); + Map sharedMessageChannels = new HashMap<>(); + for (Map.Entry sharedBindingTargetEntry : sharedBindingTargets + .entrySet()) { + if (MessageChannel.class + .isAssignableFrom(sharedBindingTargetEntry.getValue().getClass())) { + sharedMessageChannels.put(sharedBindingTargetEntry.getKey(), + (MessageChannel) sharedBindingTargetEntry.getValue()); + } + } + return Collections.unmodifiableMap(sharedMessageChannels); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Bindings.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Bindings.java new file mode 100644 index 000000000..d710413ba --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Bindings.java @@ -0,0 +1,48 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; + +/** + * Indicates an instance of an interface containing methods returning bound inputs and + * outputs. + * + * @author Dave Syer + * @author Marius Bogoevici + * @deprecated As of 1.1 for being redundant (beans qualified by it are already uniquely + * identified by their type) + */ + +@Qualifier +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@Deprecated +public @interface Bindings { + + Class value(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/EnableBinding.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/EnableBinding.java new file mode 100644 index 000000000..f5f979c63 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/EnableBinding.java @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.cloud.stream.config.BinderFactoryConfiguration; +import org.springframework.cloud.stream.config.BindingBeansRegistrar; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.integration.config.EnableIntegration; + +/** + * Enables the binding of targets annotated with {@link Input} and {@link Output} to a + * broker, according to the list of interfaces passed as value to the annotation. + * + * @author Dave Syer + * @author Marius Bogoevici + * @author David Turanski + * @author Soby Chacko + */ +@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Configuration +@Import({ BindingBeansRegistrar.class, BinderFactoryConfiguration.class }) +@EnableIntegration +public @interface EnableBinding { + + /** + * A list of interfaces having methods annotated with {@link Input} and/or + * {@link Output} to indicate binding targets. + * @return list of interfaces + */ + Class[] value() default {}; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Input.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Input.java new file mode 100644 index 000000000..1a5635489 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Input.java @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; + +/** + * Indicates that an input binding target will be created by the framework. + * + * @author Dave Syer + * @author Marius Bogoevici + */ + +@Qualifier +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE, + ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Input { + + /** + * Specify the binding target name; used as a bean name for binding target and as a + * destination name by default. + * @return the binding target name + */ + String value() default ""; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Output.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Output.java new file mode 100644 index 000000000..6bc5ba3af --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/Output.java @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; + +/** + * Indicates that an output binding target will be created by the framework. + * + * @author Dave Syer + * @author Marius Bogoevici + * @author Artem Bilan + */ + +@Qualifier +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE, + ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Output { + + /** + * Specify the binding target name; used as a bean name for binding target and as a + * destination name by default. + * @return the binding target name + */ + String value() default ""; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamListener.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamListener.java new file mode 100644 index 000000000..5fa96eb56 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamListener.java @@ -0,0 +1,172 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.cloud.stream.binding.StreamListenerParameterAdapter; +import org.springframework.core.annotation.AliasFor; +import org.springframework.messaging.handler.annotation.MessageMapping; + +/** + * Annotation that marks a method to be a listener to inputs declared via + * {@link EnableBinding} (e.g. channels). + * + * Annotated methods are allowed to have flexible signatures, which determine how the + * method is invoked and how their return results are processed. This annotation can be + * applied for two separate classes of methods. + * + *

    Declarative mode

    + * + * A method is considered declarative if all its method parameter types and return type + * (if not void) are binding targets or conversion targets from binding targets via a + * registered {@link StreamListenerParameterAdapter}. + * + * Only declarative methods can have binding targets or conversion targets as arguments + * and return type. + * + * Declarative methods must specify what inputs and outputs correspond to their arguments + * and return type, and can do this in one of the following ways. + * + *
      + *
    • By using either the {@link Input} or {@link Output} annotation for each of the + * parameters and the {@link Output} annotation on the method for the return type (if + * applicable). The use of annotations in this case is mandatory. In this case the + * {@link StreamListener} annotation must not specify a value.
    • + *
    • By setting an {@link Input} bound target as the annotation value of + * {@link StreamListener} and using + * {@link org.springframework.messaging.handler.annotation.SendTo}
    • on the method for + * the return type (if applicable). In this case the method must have exactly one + * parameter, corresponding to an input. + *
    + * + * An example of declarative method signature using the former idiom is as follows: + * + *
    + * @StreamListener
    + * public @Output("joined") Flux<String> join(
    + *       @Input("input1") Flux<String> input1,
    + *       @Input("input2") Flux<String> input2) {
    + *   // ... join the two input streams via functional operators
    + * }
    + * 
    + * + * An example of declarative method signature using the latter idiom is as follows: + * + *
    + * @StreamListener(Processor.INPUT)
    + * @SendTo(Processor.OUTPUT)
    + * public Flux<String> convert(Flux<String> input) {
    + *     return input.map(String::toUppercase);
    + * }
    + * 
    + * + * Declarative methods are invoked only once, when the context is refreshed. + * + *

    Individual message handler mode

    + * + * Non declarative methods are treated as message handler based, and are invoked for each + * incoming message received from that target. In this case, the method can have a + * flexible signature, as described by {@link MessageMapping}. + * + * If the method returns a {@link org.springframework.messaging.Message}, the result will + * be automatically sent to a binding target, as follows: + *
      + *
    • A result of the type {@link org.springframework.messaging.Message} will be sent + * as-is
    • + *
    • All other results will become the payload of a + * {@link org.springframework.messaging.Message}
    • + *
    + * + * The output binding target where the return message is sent is determined by consulting + * in the following order: + *
      + *
    • The {@link org.springframework.messaging.MessageHeaders} of the resulting + * message.
    • + *
    • The value set on the + * {@link org.springframework.messaging.handler.annotation.SendTo} annotation, if + * present
    • + *
    + * + * An example of individual message handler signature is as follows: + * + *
    + * @StreamListener(Processor.INPUT)
    + * @SendTo(Processor.OUTPUT)
    + * public String convert(String input) {
    + * 		return input.toUppercase();
    + * }
    + * 
    + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @see MessageMapping + * @see EnableBinding + * @see org.springframework.messaging.handler.annotation.SendTo + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@MessageMapping +@Documented +public @interface StreamListener { + + /** + * The name of the binding target (e.g. channel) that the method subscribes to. + * @return the name of the binding target. + */ + @AliasFor("target") + String value() default ""; + + /** + * The name of the binding target (e.g. channel) that the method subscribes to. + * @return the name of the binding target. + */ + @AliasFor("value") + String target() default ""; + + /** + * A condition that must be met by all items that are dispatched to this method. + * @return a SpEL expression that must evaluate to a {@code boolean} value. + */ + String condition() default ""; + + /** + * When "true" (default), and a {@code @SendTo} annotation is present, copy the + * inbound headers to the outbound message (if the header is absent on the outbound + * message). Can be an expression ({@code #{...}}) or property placeholder. Must + * resolve to a boolean or a string that is parsed by {@code Boolean.parseBoolean()}. + * An expression that resolves to {@code null} is interpreted to mean {@code false}. + * + * The expression is evaluated during application initialization, and not for each + * individual message. + * + * Prior to version 1.3.0, the default value used to be "false" and headers were not + * propagated by default. + * + * Starting with version 1.3.0, the default value is "true". + * + * @since 1.2.3 + * @return {@link Boolean} in a String format + */ + String copyHeaders() default "true"; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamMessageConverter.java new file mode 100644 index 000000000..bec70c11d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamMessageConverter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; + +/** + * Marker to tag {@link org.springframework.messaging.converter.MessageConverter} beans + * that will be added to the + * {@link org.springframework.cloud.stream.converter.CompositeMessageConverterFactory}. + * + * @author Vinicius Carvalho + * @author Arten Bilan + */ +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier +@Bean +public @interface StreamMessageConverter { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamRetryTemplate.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamRetryTemplate.java new file mode 100644 index 000000000..444aa01a2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/annotation/StreamRetryTemplate.java @@ -0,0 +1,51 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; + +/** + * Marker to tag an instance of {@link RetryTemplate} to be used by the binder. This + * annotation is also a @Bean to simplify configuration (see below) + * + *
    + * @StreamRetryTemplate
    + * public RetryTemplate myRetryTemplate() {
    + *    return new RetryTemplate();
    + * }
    + * 
    + * + * @author Oleg Zhurakousky + * @since 2.1 + */ + +@Target({ ElementType.FIELD, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Bean +@Qualifier +public @interface StreamRetryTemplate { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractBinder.java new file mode 100644 index 000000000..84ec35266 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractBinder.java @@ -0,0 +1,227 @@ +/* + * Copyright 2013-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.stream.annotation.StreamRetryTemplate; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.expression.EvaluationContext; +import org.springframework.integration.expression.ExpressionUtils; +import org.springframework.messaging.Message; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Base class for {@link Binder} implementations. + * + * @param outbound bind target class + * @param consumer properties class + * @param

    producer properties class + * @author David Turanski + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Mark Fisher + * @author Marius Bogoevici + * @author Soby Chacko + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + * @author Nicolas Homble + */ +public abstract class AbstractBinder + implements ApplicationContextAware, InitializingBean, Binder { + + /** + * The delimiter between a group and index when constructing a binder + * consumer/producer. + */ + private static final String GROUP_INDEX_DELIMITER = "."; + + protected final Log logger = LogFactory.getLog(getClass()); + + private volatile GenericApplicationContext applicationContext; + + private volatile EvaluationContext evaluationContext; + + @Autowired(required = false) + @StreamRetryTemplate + private Map consumerBindingRetryTemplates; + + /** + * For binder implementations that support a prefix, apply the prefix to the name. + * @param prefix the prefix. + * @param name the name. + * @return name with the prefix + */ + public static String applyPrefix(String prefix, String name) { + return prefix + name; + } + + /** + * For binder implementations that support dead lettering, construct the name of the + * dead letter entity for the underlying pipe name. + * @param name the name. + * @return name with DLQ suffix + */ + public static String constructDLQName(String name) { + return name + ".dlq"; + } + + protected AbstractApplicationContext getApplicationContext() { + return this.applicationContext; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + Assert.isInstanceOf(GenericApplicationContext.class, applicationContext); + this.applicationContext = (GenericApplicationContext) applicationContext; + } + + protected ConfigurableListableBeanFactory getBeanFactory() { + return this.applicationContext.getBeanFactory(); + } + + protected EvaluationContext getEvaluationContext() { + return this.evaluationContext; + } + + @Override + public final void afterPropertiesSet() throws Exception { + Assert.notNull(this.applicationContext, + "The 'applicationContext' property must not be null"); + if (this.evaluationContext == null) { + this.evaluationContext = ExpressionUtils + .createStandardEvaluationContext(getBeanFactory()); + } + onInit(); + } + + /** + * Subclasses may implement this method to perform any necessary initialization. It + * will be invoked from {@link #afterPropertiesSet()} which is itself {@code final}. + * @throws Exception when init fails + */ + protected void onInit() throws Exception { + // no-op default + } + + @Override + public final Binding bindConsumer(String name, String group, T target, + C properties) { + if (StringUtils.isEmpty(group)) { + Assert.isTrue(!properties.isPartitioned(), + "A consumer group is required for a partitioned subscription"); + } + return doBindConsumer(name, group, target, properties); + } + + protected abstract Binding doBindConsumer(String name, String group, T inputTarget, + C properties); + + @Override + public final Binding bindProducer(String name, T outboundBindTarget, + P properties) { + return doBindProducer(name, outboundBindTarget, properties); + } + + protected abstract Binding doBindProducer(String name, T outboundBindTarget, + P properties); + + /** + * Construct a name comprised of the name and group. + * @param name the name. + * @param group the group. + * @return the constructed name. + */ + protected final String groupedName(String name, String group) { + return name + GROUP_INDEX_DELIMITER + + (StringUtils.hasText(group) ? group : "default"); + } + + /** + * Deprecated as of v2.0. Doesn't do anything other then returns an instance of + * {@link MessageValues} built from {@link Message}. Remains primarily for backward + * compatibility and will be removed in the next major release. + * @param message message to serialize + * @return wrapped message + */ + @Deprecated + protected final MessageValues serializePayloadIfNecessary(Message message) { + return new MessageValues(message); + } + + /** + * Deprecated as of v2.0. Remains primarily for backward compatibility and will be + * removed in the next major release. + * @param expressionRoot root of the expression + * @return full expression for a header + */ + @Deprecated + protected String buildPartitionRoutingExpression(String expressionRoot) { + return "'" + expressionRoot + "-' + headers['" + BinderHeaders.PARTITION_HEADER + + "']"; + } + + /** + * Create and configure a default retry template unless one has already been provided + * via @Bean by an application. + * @param properties The properties. + * @return The retry template + */ + protected RetryTemplate buildRetryTemplate(ConsumerProperties properties) { + RetryTemplate rt; + if (CollectionUtils.isEmpty(this.consumerBindingRetryTemplates)) { + rt = new RetryTemplate(); + SimpleRetryPolicy retryPolicy = CollectionUtils + .isEmpty(properties.getRetryableExceptions()) + ? new SimpleRetryPolicy(properties.getMaxAttempts()) + : new SimpleRetryPolicy(properties.getMaxAttempts(), + properties.getRetryableExceptions(), true, + properties.isDefaultRetryable()); + + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(properties.getBackOffInitialInterval()); + backOffPolicy.setMultiplier(properties.getBackOffMultiplier()); + backOffPolicy.setMaxInterval(properties.getBackOffMaxInterval()); + rt.setRetryPolicy(retryPolicy); + rt.setBackOffPolicy(backOffPolicy); + } + else { + rt = StringUtils.hasText(properties.getRetryTemplateName()) + ? this.consumerBindingRetryTemplates + .get(properties.getRetryTemplateName()) + : this.consumerBindingRetryTemplates.values().iterator().next(); + } + return rt; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractExtendedBindingProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractExtendedBindingProperties.java new file mode 100644 index 000000000..e9e9b1870 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractExtendedBindingProperties.java @@ -0,0 +1,105 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.integration.support.utils.IntegrationUtils; + +/** + * Base implementation of {@link ExtendedBindingProperties}. + * + * @param - consumer properties type + * @param

    - producer properties type + * @param - type which provides the consumer and producer properties + * @author Oleg Zhurakousky + * @since 2.1 + */ +public abstract class AbstractExtendedBindingProperties + implements ExtendedBindingProperties, ApplicationContextAware { + + private final Map bindings = new HashMap<>(); + + private ConfigurableApplicationContext applicationContext = new GenericApplicationContext(); + + public void setBindings(Map bindings) { + this.bindings.putAll(bindings); + } + + @SuppressWarnings("unchecked") + @Override + public C getExtendedConsumerProperties(String binding) { + this.bindIfNecessary(binding); + return (C) this.bindings.get(binding).getConsumer(); + } + + @SuppressWarnings("unchecked") + @Override + public P getExtendedProducerProperties(String binding) { + this.bindIfNecessary(binding); + return (P) this.bindings.get(binding).getProducer(); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + } + + /* + * The "necessary" implies the scenario where only defaults are defined. + */ + private void bindIfNecessary(String bindingName) { + if (!this.bindings.containsKey(bindingName)) { + this.bindToDefault(bindingName); + } + } + + @SuppressWarnings("unchecked") + private void bindToDefault(String binding) { + T extendedBindingPropertiesTarget = (T) BeanUtils + .instantiateClass(this.getExtendedPropertiesEntryClass()); + Binder binder = new Binder( + ConfigurationPropertySources + .get(this.applicationContext.getEnvironment()), + new PropertySourcesPlaceholdersResolver( + this.applicationContext.getEnvironment()), + IntegrationUtils.getConversionService( + this.applicationContext.getBeanFactory()), + null); + binder.bind(this.getDefaultsPrefix(), + Bindable.ofInstance(extendedBindingPropertiesTarget)); + this.bindings.put(binding, extendedBindingPropertiesTarget); + } + + protected Map doGetBindings() { + return Collections.unmodifiableMap(this.bindings); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinder.java new file mode 100644 index 000000000..feb1cea3c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinder.java @@ -0,0 +1,1172 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.apache.commons.logging.Log; +import org.reactivestreams.Publisher; + +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; +import org.springframework.cloud.stream.config.ListenerContainerCustomizer; +import org.springframework.cloud.stream.config.MessageSourceCustomizer; +import org.springframework.cloud.stream.function.IntegrationFlowFunctionSupport; +import org.springframework.cloud.stream.function.StreamFunctionProperties; +import org.springframework.cloud.stream.provisioning.ConsumerDestination; +import org.springframework.cloud.stream.provisioning.ProducerDestination; +import org.springframework.cloud.stream.provisioning.ProvisioningException; +import org.springframework.cloud.stream.provisioning.ProvisioningProvider; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.context.Lifecycle; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.expression.Expression; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.integration.channel.AbstractSubscribableChannel; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.MessageChannelReactiveUtils; +import org.springframework.integration.channel.PublishSubscribeChannel; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.integration.core.MessageProducer; +import org.springframework.integration.core.MessageSource; +import org.springframework.integration.dsl.IntegrationFlowBuilder; +import org.springframework.integration.dsl.IntegrationFlows; +import org.springframework.integration.handler.AbstractMessageHandler; +import org.springframework.integration.handler.BridgeHandler; +import org.springframework.integration.handler.advice.ErrorMessageSendingRecoverer; +import org.springframework.integration.support.ErrorMessageStrategy; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.retry.RecoveryCallback; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link AbstractBinder} that serves as base class for {@link MessageChannel} binders. + * Implementors must implement the following methods: + *

      + *
    • {@link #createProducerMessageHandler(ProducerDestination, ProducerProperties, MessageChannel)}
    • + *
    • {@link #createConsumerEndpoint(ConsumerDestination, String, ConsumerProperties)} + *
    • + *
    + * + * @param the consumer properties type + * @param

    the producer properties type + * @param the provisioning producer properties type + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Soby Chacko + * @author Oleg Zhurakousky + * @author Artem Bilan + * @author Gary Russell + * @since 1.1 + */ +// @checkstyle:off +public abstract class AbstractMessageChannelBinder> + extends AbstractBinder implements + PollableConsumerBinder, ApplicationEventPublisherAware { + + // @checkstyle:on + + /** + * {@link ProvisioningProvider} delegated by the downstream binder implementations. + */ + protected final PP provisioningProvider; + + // @checkstyle:off + private final EmbeddedHeadersChannelInterceptor embeddedHeadersChannelInterceptor = new EmbeddedHeadersChannelInterceptor( + this.logger); + + // @checkstyle:on + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Indicates which headers are to be embedded in the payload if a binding requires + * embedding headers. + */ + private final String[] headersToEmbed; + + private final ListenerContainerCustomizer containerCustomizer; + + private MessageSourceCustomizer sourceCustomizer; + + private ApplicationEventPublisher applicationEventPublisher; + + @Autowired(required = false) + private IntegrationFlowFunctionSupport integrationFlowFunctionSupport; + + @Autowired(required = false) + private StreamFunctionProperties streamFunctionProperties; + + private boolean producerBindingExist; + + public AbstractMessageChannelBinder(String[] headersToEmbed, + PP provisioningProvider) { + this(headersToEmbed, provisioningProvider, null); + } + + @Deprecated + public AbstractMessageChannelBinder(String[] headersToEmbed, PP provisioningProvider, + ListenerContainerCustomizer containerCustomizer) { + + this(headersToEmbed, provisioningProvider, containerCustomizer, null); + } + + public AbstractMessageChannelBinder(String[] headersToEmbed, PP provisioningProvider, + @Nullable ListenerContainerCustomizer containerCustomizer, + @Nullable MessageSourceCustomizer sourceCustomizer) { + + SimpleModule module = new SimpleModule(); + module.addSerializer(Expression.class, new ExpressionSerializer(Expression.class)); + objectMapper.registerModule(module); + + this.headersToEmbed = headersToEmbed == null ? new String[0] : headersToEmbed; + this.provisioningProvider = provisioningProvider; + this.containerCustomizer = containerCustomizer == null ? (c, q, g) -> { + } : containerCustomizer; + this.sourceCustomizer = sourceCustomizer == null ? (s, q, g) -> { + } : sourceCustomizer; + } + + protected ApplicationEventPublisher getApplicationEventPublisher() { + return this.applicationEventPublisher; + } + + @Override + public void setApplicationEventPublisher( + ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + @SuppressWarnings("unchecked") + protected ListenerContainerCustomizer getContainerCustomizer() { + return (ListenerContainerCustomizer) this.containerCustomizer; + } + + @SuppressWarnings("unchecked") + protected MessageSourceCustomizer getMessageSourceCustomizer() { + return (MessageSourceCustomizer) this.sourceCustomizer; + } + + /** + * Binds an outbound channel to a given destination. The implementation delegates to + * {@link ProvisioningProvider#provisionProducerDestination(String, ProducerProperties)} + * and + * {@link #createProducerMessageHandler(ProducerDestination, ProducerProperties, MessageChannel)} + * for handling the middleware specific logic. If the returned producer message + * handler is an {@link InitializingBean} then + * {@link InitializingBean#afterPropertiesSet()} will be called on it. Similarly, if + * the returned producer message handler endpoint is a {@link Lifecycle}, then + * {@link Lifecycle#start()} will be called on it. + * @param destination the name of the destination + * @param outputChannel the channel to be bound + * @param producerProperties the {@link ProducerProperties} of the binding + * @return the Binding for the channel + * @throws BinderException on internal errors during binding + */ + @Override + public final Binding doBindProducer(final String destination, + MessageChannel outputChannel, final P producerProperties) + throws BinderException { + Assert.isInstanceOf(SubscribableChannel.class, outputChannel, + "Binding is supported only for SubscribableChannel instances"); + final MessageHandler producerMessageHandler; + final ProducerDestination producerDestination; + try { + producerDestination = this.provisioningProvider + .provisionProducerDestination(destination, producerProperties); + SubscribableChannel errorChannel = producerProperties.isErrorChannelEnabled() + ? registerErrorInfrastructure(producerDestination) : null; + producerMessageHandler = createProducerMessageHandler(producerDestination, + producerProperties, outputChannel, errorChannel); + if (producerMessageHandler instanceof InitializingBean) { + ((InitializingBean) producerMessageHandler).afterPropertiesSet(); + } + } + catch (Exception e) { + if (e instanceof BinderException) { + throw (BinderException) e; + } + else if (e instanceof ProvisioningException) { + throw (ProvisioningException) e; + } + else { + throw new BinderException( + "Exception thrown while building outbound endpoint", e); + } + } + + if (producerProperties.isAutoStartup() + && producerMessageHandler instanceof Lifecycle) { + ((Lifecycle) producerMessageHandler).start(); + } + this.postProcessOutputChannel(outputChannel, producerProperties); + + if (shouldWireDunctionToChannel(true)) { + outputChannel = this.postProcessOutboundChannelForFunction(outputChannel, + producerProperties); + } + + ((SubscribableChannel) outputChannel) + .subscribe(new SendingHandler(producerMessageHandler, + HeaderMode.embeddedHeaders + .equals(producerProperties.getHeaderMode()), + this.headersToEmbed, useNativeEncoding(producerProperties))); + + Binding binding = new DefaultBinding(destination, + outputChannel, producerMessageHandler instanceof Lifecycle + ? (Lifecycle) producerMessageHandler : null) { + + @Override + public Map getExtendedInfo() { + return doGetExtendedInfo(destination, producerProperties); + } + + @Override + public boolean isInput() { + return false; + } + + @Override + public void afterUnbind() { + try { + destroyErrorInfrastructure(producerDestination); + if (producerMessageHandler instanceof DisposableBean) { + ((DisposableBean) producerMessageHandler).destroy(); + } + } + catch (Exception e) { + AbstractMessageChannelBinder.this.logger + .error("Exception thrown while unbinding " + toString(), e); + } + afterUnbindProducer(producerDestination, producerProperties); + } + }; + + doPublishEvent(new BindingCreatedEvent(binding)); + this.producerBindingExist = true; + return binding; + } + + /** + * Whether the producer for the destination being created should be configured to use + * native encoding which may, or may not, be determined from the properties. For + * example, a transactional kafka binder uses a common producer for all destinations. + * The default implementation returns {@link P#isUseNativeEncoding()}. + * @param producerProperties the properties. + * @return true to use native encoding. + */ + protected boolean useNativeEncoding(P producerProperties) { + return producerProperties.isUseNativeEncoding(); + } + + /** + * Allows subclasses to perform post processing on the channel - for example to add + * more interceptors. + * @param outputChannel the channel. + * @param producerProperties the producer properties. + */ + protected void postProcessOutputChannel(MessageChannel outputChannel, + P producerProperties) { + // default no-op + } + + /** + * Create a {@link MessageHandler} with the ability to send data to the target + * middleware. If the returned instance is also a {@link Lifecycle}, it will be + * stopped automatically by the binder. + *

    + * In order to be fully compliant, the {@link MessageHandler} of the binder must + * observe the following headers: + *

      + *
    • {@link BinderHeaders#PARTITION_HEADER} - indicates the target partition where + * the message must be sent
    • + *
    + *

    + * @param destination the name of the target destination. + * @param producerProperties the producer properties. + * @param channel the channel to bind. + * @param errorChannel the error channel (if enabled, otherwise null). If not null, + * the binder must wire this channel into the producer endpoint so that errors are + * forwarded to it. + * @return the message handler for sending data to the target middleware + * @throws Exception when producer messsage handler failed to be created + */ + protected MessageHandler createProducerMessageHandler(ProducerDestination destination, + P producerProperties, MessageChannel channel, MessageChannel errorChannel) + throws Exception { + return createProducerMessageHandler(destination, producerProperties, + errorChannel); + } + + /** + * Create a {@link MessageHandler} with the ability to send data to the target + * middleware. If the returned instance is also a {@link Lifecycle}, it will be + * stopped automatically by the binder. + *

    + * In order to be fully compliant, the {@link MessageHandler} of the binder must + * observe the following headers: + *

      + *
    • {@link BinderHeaders#PARTITION_HEADER} - indicates the target partition where + * the message must be sent
    • + *
    + *

    + * @param destination the name of the target destination + * @param producerProperties the producer properties + * @param errorChannel the error channel (if enabled, otherwise null). If not null, + * the binder must wire this channel into the producer endpoint so that errors are + * forwarded to it. + * @return the message handler for sending data to the target middleware + * @throws Exception upon failure to create the producer message handler + */ + protected abstract MessageHandler createProducerMessageHandler( + ProducerDestination destination, P producerProperties, + MessageChannel errorChannel) throws Exception; + + /** + * Invoked after the unbinding of a producer. Subclasses may override this to provide + * their own logic for dealing with unbinding. + * @param destination the bound destination + * @param producerProperties the producer properties + */ + protected void afterUnbindProducer(ProducerDestination destination, + P producerProperties) { + } + + /** + * Binds an inbound channel to a given destination. The implementation delegates to + * {@link ProvisioningProvider#provisionConsumerDestination(String, String, ConsumerProperties)} + * and + * {@link #createConsumerEndpoint(ConsumerDestination, String, ConsumerProperties)} + * for handling middleware-specific logic. If the returned consumer endpoint is an + * {@link InitializingBean} then {@link InitializingBean#afterPropertiesSet()} will be + * called on it. Similarly, if the returned consumer endpoint is a {@link Lifecycle}, + * then {@link Lifecycle#start()} will be called on it. + * @param name the name of the destination + * @param group the consumer group + * @param inputChannel the channel to be bound + * @param properties the {@link ConsumerProperties} of the binding + * @return the Binding for the channel + * @throws BinderException on internal errors during binding + */ + @Override + public final Binding doBindConsumer(String name, String group, + MessageChannel inputChannel, final C properties) throws BinderException { + MessageProducer consumerEndpoint = null; + try { + ConsumerDestination destination = this.provisioningProvider + .provisionConsumerDestination(name, group, properties); + // the function support for the inbound channel is only for Sink + if (shouldWireDunctionToChannel(false)) { + inputChannel = this.postProcessInboundChannelForFunction(inputChannel, + properties); + } + if (HeaderMode.embeddedHeaders.equals(properties.getHeaderMode())) { + enhanceMessageChannel(inputChannel); + } + consumerEndpoint = createConsumerEndpoint(destination, group, properties); + consumerEndpoint.setOutputChannel(inputChannel); + if (consumerEndpoint instanceof InitializingBean) { + ((InitializingBean) consumerEndpoint).afterPropertiesSet(); + } + if (properties.isAutoStartup() && consumerEndpoint instanceof Lifecycle) { + ((Lifecycle) consumerEndpoint).start(); + } + + Binding binding = new DefaultBinding(name, + group, inputChannel, consumerEndpoint instanceof Lifecycle + ? (Lifecycle) consumerEndpoint : null) { + + @Override + public Map getExtendedInfo() { + return doGetExtendedInfo(destination, properties); + } + + @Override + public boolean isInput() { + return true; + } + + @Override + protected void afterUnbind() { + try { + if (getEndpoint() instanceof DisposableBean) { + ((DisposableBean) getEndpoint()).destroy(); + } + } + catch (Exception e) { + AbstractMessageChannelBinder.this.logger.error( + "Exception thrown while unbinding " + toString(), e); + } + afterUnbindConsumer(destination, this.group, properties); + destroyErrorInfrastructure(destination, this.group, properties); + } + + }; + doPublishEvent(new BindingCreatedEvent(binding)); + return binding; + } + catch (Exception e) { + if (consumerEndpoint instanceof Lifecycle) { + ((Lifecycle) consumerEndpoint).stop(); + } + if (e instanceof BinderException) { + throw (BinderException) e; + } + else if (e instanceof ProvisioningException) { + throw (ProvisioningException) e; + } + else { + throw new BinderException("Exception thrown while starting consumer: ", + e); + } + } + } + + @Override + public Binding> bindPollableConsumer(String name, + String group, final PollableSource inboundBindTarget, + C properties) { + Assert.isInstanceOf(DefaultPollableMessageSource.class, inboundBindTarget); + DefaultPollableMessageSource bindingTarget = (DefaultPollableMessageSource) inboundBindTarget; + ConsumerDestination destination = this.provisioningProvider + .provisionConsumerDestination(name, group, properties); + if (HeaderMode.embeddedHeaders.equals(properties.getHeaderMode())) { + bindingTarget.addInterceptor(0, this.embeddedHeadersChannelInterceptor); + } + final PolledConsumerResources resources = createPolledConsumerResources(name, + group, destination, properties); + + MessageSource messageSource = resources.getSource(); + if (messageSource instanceof BeanFactoryAware) { + ((BeanFactoryAware) messageSource).setBeanFactory(getApplicationContext().getBeanFactory()); + } + bindingTarget.setSource(messageSource); + if (resources.getErrorInfrastructure() != null) { + if (resources.getErrorInfrastructure().getErrorChannel() != null) { + bindingTarget.setErrorChannel( + resources.getErrorInfrastructure().getErrorChannel()); + } + ErrorMessageStrategy ems = getErrorMessageStrategy(); + if (ems != null) { + bindingTarget.setErrorMessageStrategy(ems); + } + } + if (properties.getMaxAttempts() > 1) { + bindingTarget.setRetryTemplate(buildRetryTemplate(properties)); + bindingTarget.setRecoveryCallback(getPolledConsumerRecoveryCallback( + resources.getErrorInfrastructure(), properties)); + } + postProcessPollableSource(bindingTarget); + if (resources.getSource() instanceof Lifecycle) { + ((Lifecycle) resources.getSource()).start(); + } + Binding> binding = new DefaultBinding>( + name, group, inboundBindTarget, resources.getSource() instanceof Lifecycle + ? (Lifecycle) resources.getSource() : null) { + + @Override + public Map getExtendedInfo() { + return doGetExtendedInfo(destination, properties); + } + + @Override + public boolean isInput() { + return true; + } + + @Override + public void afterUnbind() { + afterUnbindConsumer(destination, this.group, properties); + destroyErrorInfrastructure(destination, this.group, properties); + } + + }; + + doPublishEvent(new BindingCreatedEvent(binding)); + return binding; + } + + protected void postProcessPollableSource(DefaultPollableMessageSource bindingTarget) { + } + + /** + * Implementations can override the default {@link ErrorMessageSendingRecoverer}. + * @param errorInfrastructure the infrastructure. + * @param properties the consumer properties. + * @return the recoverer. + */ + protected RecoveryCallback getPolledConsumerRecoveryCallback( + ErrorInfrastructure errorInfrastructure, C properties) { + return errorInfrastructure.getRecoverer(); + } + + protected PolledConsumerResources createPolledConsumerResources(String name, + String group, ConsumerDestination destination, C consumerProperties) { + throw new UnsupportedOperationException( + "This binder does not support pollable consumers"); + } + + private void enhanceMessageChannel(MessageChannel inputChannel) { + ((AbstractMessageChannel) inputChannel).addInterceptor(0, + this.embeddedHeadersChannelInterceptor); + } + + /** + * Creates {@link MessageProducer} that receives data from the consumer destination. + * will be started and stopped by the binder. + * @param group the consumer group + * @param destination reference to the consumer destination + * @param properties the consumer properties + * @return the consumer endpoint. + * @throws Exception when consumer endpoint creation failed. + */ + protected abstract MessageProducer createConsumerEndpoint( + ConsumerDestination destination, String group, C properties) throws Exception; + + /** + * Invoked after the unbinding of a consumer. The binder implementation can override + * this method to provide their own logic (e.g. for cleaning up destinations). + * @param destination the consumer destination + * @param group the consumer group + * @param consumerProperties the consumer properties + */ + protected void afterUnbindConsumer(ConsumerDestination destination, String group, + C consumerProperties) { + } + + /** + * Register an error channel for the destination when an async send error is received. + * Bridge the channel to the global error channel (if present). + * @param destination the destination. + * @return the channel. + */ + private SubscribableChannel registerErrorInfrastructure( + ProducerDestination destination) { + + String errorChannelName = errorsBaseName(destination); + SubscribableChannel errorChannel; + if (getApplicationContext().containsBean(errorChannelName)) { + Object errorChannelObject = getApplicationContext().getBean(errorChannelName); + if (!(errorChannelObject instanceof SubscribableChannel)) { + throw new IllegalStateException("Error channel '" + errorChannelName + + "' must be a SubscribableChannel"); + } + errorChannel = (SubscribableChannel) errorChannelObject; + } + else { + errorChannel = new PublishSubscribeChannel(); + ((GenericApplicationContext) getApplicationContext()).registerBean( + errorChannelName, SubscribableChannel.class, () -> errorChannel); + } + MessageChannel defaultErrorChannel = null; + if (getApplicationContext() + .containsBean(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME)) { + defaultErrorChannel = getApplicationContext().getBean( + IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME, + MessageChannel.class); + } + if (defaultErrorChannel != null) { + BridgeHandler errorBridge = new BridgeHandler(); + errorBridge.setOutputChannel(defaultErrorChannel); + errorChannel.subscribe(errorBridge); + String errorBridgeHandlerName = getErrorBridgeName(destination); + ((GenericApplicationContext) getApplicationContext()).registerBean( + errorBridgeHandlerName, BridgeHandler.class, () -> errorBridge); + } + return errorChannel; + } + + /** + * Build an errorChannelRecoverer that writes to a pub/sub channel for the destination + * when an exception is thrown to a consumer. + * @param destination the destination. + * @param group the group. + * @param consumerProperties the properties. + * @return the ErrorInfrastructure which is a holder for the error channel, the + * recoverer and the message handler that is subscribed to the channel. + */ + protected final ErrorInfrastructure registerErrorInfrastructure( + ConsumerDestination destination, String group, C consumerProperties) { + + return registerErrorInfrastructure(destination, group, consumerProperties, false); + } + + /** + * Build an errorChannelRecoverer that writes to a pub/sub channel for the destination + * when an exception is thrown to a consumer. + * @param destination the destination. + * @param group the group. + * @param consumerProperties the properties. + * @param polled true if this is for a polled consumer. + * @return the ErrorInfrastructure which is a holder for the error channel, the + * recoverer and the message handler that is subscribed to the channel. + */ + protected final ErrorInfrastructure registerErrorInfrastructure( + ConsumerDestination destination, String group, C consumerProperties, + boolean polled) { + + ErrorMessageStrategy errorMessageStrategy = getErrorMessageStrategy(); + + String errorChannelName = errorsBaseName(destination, group, consumerProperties); + SubscribableChannel errorChannel; + if (getApplicationContext().containsBean(errorChannelName)) { + Object errorChannelObject = getApplicationContext().getBean(errorChannelName); + + Assert.isInstanceOf(SubscribableChannel.class, errorChannelObject, + "Error channel '" + errorChannelName + + "' must be a SubscribableChannel"); + errorChannel = (SubscribableChannel) errorChannelObject; + } + else { + errorChannel = new BinderErrorChannel(); + + ((GenericApplicationContext) getApplicationContext()).registerBean( + errorChannelName, SubscribableChannel.class, () -> errorChannel); + } + ErrorMessageSendingRecoverer recoverer; + if (errorMessageStrategy == null) { + recoverer = new ErrorMessageSendingRecoverer(errorChannel); + } + else { + recoverer = new ErrorMessageSendingRecoverer(errorChannel, + errorMessageStrategy); + } + + String recovererBeanName = getErrorRecovererName(destination, group, + consumerProperties); + ((GenericApplicationContext) getApplicationContext()).registerBean( + recovererBeanName, ErrorMessageSendingRecoverer.class, () -> recoverer); + MessageHandler handler; + if (polled) { + handler = getPolledConsumerErrorMessageHandler(destination, group, + consumerProperties); + } + else { + handler = getErrorMessageHandler(destination, group, consumerProperties); + } + MessageChannel defaultErrorChannel = null; + if (getApplicationContext() + .containsBean(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME)) { + defaultErrorChannel = getApplicationContext().getBean( + IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME, + MessageChannel.class); + } + if (handler == null && errorChannel instanceof LastSubscriberAwareChannel) { + handler = getDefaultErrorMessageHandler( + (LastSubscriberAwareChannel) errorChannel, + defaultErrorChannel != null); + } + String errorMessageHandlerName = getErrorMessageHandlerName(destination, group, + consumerProperties); + + if (handler != null) { + if (this.isSubscribable(errorChannel)) { + MessageHandler errorHandler = handler; + ((GenericApplicationContext) getApplicationContext()).registerBean( + errorMessageHandlerName, MessageHandler.class, + () -> errorHandler); + errorChannel.subscribe(handler); + } + else { + this.logger.warn("The provided errorChannel '" + errorChannelName + + "' is an instance of DirectChannel, " + + "so no more subscribers could be added which may affect DLQ processing. " + + "Resolution: Configure your own errorChannel as " + + "an instance of PublishSubscribeChannel"); + } + } + + if (defaultErrorChannel != null) { + if (this.isSubscribable(errorChannel)) { + BridgeHandler errorBridge = new BridgeHandler(); + errorBridge.setOutputChannel(defaultErrorChannel); + errorChannel.subscribe(errorBridge); + + String errorBridgeHandlerName = getErrorBridgeName(destination, group, + consumerProperties); + ((GenericApplicationContext) getApplicationContext()).registerBean( + errorBridgeHandlerName, BridgeHandler.class, () -> errorBridge); + } + else { + this.logger.warn("The provided errorChannel '" + errorChannelName + + "' is an instance of DirectChannel, " + + "so no more subscribers could be added and no error messages will be sent to global error channel. " + + "Resolution: Configure your own errorChannel as " + + "an instance of PublishSubscribeChannel"); + } + } + return new ErrorInfrastructure(errorChannel, recoverer, handler); + } + + private boolean isSubscribable(SubscribableChannel errorChannel) { + if (errorChannel instanceof PublishSubscribeChannel) { + return true; + } + return errorChannel instanceof AbstractSubscribableChannel + ? ((AbstractSubscribableChannel) errorChannel).getSubscriberCount() == 0 + : true; + } + + private void destroyErrorInfrastructure(ProducerDestination destination) { + String errorChannelName = errorsBaseName(destination); + String errorBridgeHandlerName = getErrorBridgeName(destination); + MessageHandler bridgeHandler = null; + if (getApplicationContext().containsBean(errorBridgeHandlerName)) { + bridgeHandler = getApplicationContext().getBean(errorBridgeHandlerName, + MessageHandler.class); + } + if (getApplicationContext().containsBean(errorChannelName)) { + SubscribableChannel channel = getApplicationContext() + .getBean(errorChannelName, SubscribableChannel.class); + if (bridgeHandler != null) { + channel.unsubscribe(bridgeHandler); + ((DefaultSingletonBeanRegistry) getApplicationContext().getBeanFactory()) + .destroySingleton(errorBridgeHandlerName); + } + ((DefaultSingletonBeanRegistry) getApplicationContext().getBeanFactory()) + .destroySingleton(errorChannelName); + } + } + + private void destroyErrorInfrastructure(ConsumerDestination destination, String group, + C properties) { + try { + String recoverer = getErrorRecovererName(destination, group, properties); + + destroyBean(recoverer); + + String errorChannelName = errorsBaseName(destination, group, properties); + String errorMessageHandlerName = getErrorMessageHandlerName(destination, + group, properties); + String errorBridgeHandlerName = getErrorBridgeName(destination, group, + properties); + MessageHandler bridgeHandler = null; + if (getApplicationContext().containsBean(errorBridgeHandlerName)) { + bridgeHandler = getApplicationContext().getBean(errorBridgeHandlerName, + MessageHandler.class); + } + MessageHandler handler = null; + if (getApplicationContext().containsBean(errorMessageHandlerName)) { + handler = getApplicationContext().getBean(errorMessageHandlerName, + MessageHandler.class); + } + if (getApplicationContext().containsBean(errorChannelName)) { + SubscribableChannel channel = getApplicationContext() + .getBean(errorChannelName, SubscribableChannel.class); + if (bridgeHandler != null) { + channel.unsubscribe(bridgeHandler); + destroyBean(errorBridgeHandlerName); + } + if (handler != null) { + channel.unsubscribe(handler); + destroyBean(errorMessageHandlerName); + } + destroyBean(errorChannelName); + } + } + catch (IllegalStateException e) { + // context is shutting down. + } + } + + private void destroyBean(String beanName) { + if (getApplicationContext().containsBean(beanName)) { + ((DefaultSingletonBeanRegistry) getApplicationContext().getBeanFactory()) + .destroySingleton(beanName); + ((GenericApplicationContext) getApplicationContext()) + .removeBeanDefinition(beanName); + } + } + + /** + * Binders can return a message handler to be subscribed to the error channel. + * Examples might be if the user wishes to (re)publish messages to a DLQ. + * @param destination the destination. + * @param group the group. + * @param consumerProperties the properties. + * @return the handler (may be null, which is the default, causing the exception to be + * rethrown). + */ + protected MessageHandler getErrorMessageHandler(ConsumerDestination destination, + String group, C consumerProperties) { + return null; + } + + /** + * Binders can return a message handler to be subscribed to the error channel. + * Examples might be if the user wishes to (re)publish messages to a DLQ. + * @param destination the destination. + * @param group the group. + * @param consumerProperties the properties. + * @return the handler (may be null, which is the default, causing the exception to be + * rethrown). + */ + protected MessageHandler getPolledConsumerErrorMessageHandler( + ConsumerDestination destination, String group, C consumerProperties) { + return null; + } + + /** + * Return the default error message handler, which throws the error message payload to + * the caller if there are no user handlers subscribed. The handler is ordered so it + * runs after any user-defined handlers that are subscribed. + * @param errorChannel the error channel. + * @param defaultErrorChannelPresent true if the context has a default 'errorChannel'. + * @return the handler. + */ + protected MessageHandler getDefaultErrorMessageHandler( + LastSubscriberAwareChannel errorChannel, boolean defaultErrorChannelPresent) { + return new FinalRethrowingErrorMessageHandler(errorChannel, + defaultErrorChannelPresent); + } + + /** + * Binders can return an {@link ErrorMessageStrategy} for building error messages; + * binder implementations typically might add extra headers to the error message. + * @return the implementation - may be null. + */ + protected ErrorMessageStrategy getErrorMessageStrategy() { + return null; + } + + protected String getErrorRecovererName(ConsumerDestination destination, String group, + C consumerProperties) { + return errorsBaseName(destination, group, consumerProperties) + ".recoverer"; + } + + protected String getErrorMessageHandlerName(ConsumerDestination destination, + String group, C consumerProperties) { + return errorsBaseName(destination, group, consumerProperties) + ".handler"; + } + + protected String getErrorBridgeName(ConsumerDestination destination, String group, + C consumerProperties) { + return errorsBaseName(destination, group, consumerProperties) + ".bridge"; + } + + protected String errorsBaseName(ConsumerDestination destination, String group, + C consumerProperties) { + return destination.getName() + "." + group + ".errors"; + } + + protected String getErrorBridgeName(ProducerDestination destination) { + return errorsBaseName(destination) + ".bridge"; + } + + protected String errorsBaseName(ProducerDestination destination) { + return destination.getName() + ".errors"; + } + + private Map doGetExtendedInfo(Object destination, Object properties) { + Map extendedInfo = new LinkedHashMap<>(); + extendedInfo.put("bindingDestination", destination.toString()); + extendedInfo.put(properties.getClass().getSimpleName(), + this.objectMapper.convertValue(properties, Map.class)); + return extendedInfo; + } + + private void doPublishEvent(ApplicationEvent event) { + if (this.applicationEventPublisher != null) { + this.applicationEventPublisher.publishEvent(event); + } + } + + /* + * FUNCTION-TO-EXISTING-APP section + * + * To support composing functions into the existing apps. These methods do/should not + * participate in any way with general function bootstrap (e.g. brand new function + * based app). For that please see FunctionConfiguration.integrationFlowCreator + */ + private boolean shouldWireDunctionToChannel(boolean producer) { + if (!producer && this.producerBindingExist) { + return false; + } + else { + return this.streamFunctionProperties != null + && StringUtils.hasText(this.streamFunctionProperties.getDefinition()) + && (!this.getApplicationContext() + .containsBean("integrationFlowCreator") + || this.getApplicationContext() + .getBean("integrationFlowCreator").equals(null)); + } + } + + private SubscribableChannel postProcessOutboundChannelForFunction( + MessageChannel outputChannel, ProducerProperties producerProperties) { + if (this.integrationFlowFunctionSupport != null) { + Publisher publisher = MessageChannelReactiveUtils + .toPublisher(outputChannel); + // If the app has an explicit Supplier bean defined, make that as the + // publisher + if (this.integrationFlowFunctionSupport.containsFunction(Supplier.class)) { + IntegrationFlowBuilder integrationFlowBuilder = IntegrationFlows + .from(outputChannel).bridge(); + publisher = integrationFlowBuilder.toReactivePublisher(); + } + if (this.integrationFlowFunctionSupport.containsFunction(Function.class, + this.streamFunctionProperties.getDefinition())) { + DirectChannel actualOutputChannel = new DirectChannel(); + if (outputChannel instanceof AbstractMessageChannel) { + moveChannelInterceptors((AbstractMessageChannel) outputChannel, + actualOutputChannel); + } + this.integrationFlowFunctionSupport.andThenFunction(publisher, + actualOutputChannel, this.streamFunctionProperties); + return actualOutputChannel; + } + } + return (SubscribableChannel) outputChannel; + } + + private SubscribableChannel postProcessInboundChannelForFunction( + MessageChannel inputChannel, ConsumerProperties consumerProperties) { + if (this.integrationFlowFunctionSupport != null + && (this.integrationFlowFunctionSupport.containsFunction(Consumer.class) + || this.integrationFlowFunctionSupport + .containsFunction(Function.class))) { + DirectChannel actualInputChannel = new DirectChannel(); + if (inputChannel instanceof AbstractMessageChannel) { + moveChannelInterceptors((AbstractMessageChannel) inputChannel, + actualInputChannel); + } + + this.integrationFlowFunctionSupport.andThenFunction( + MessageChannelReactiveUtils.toPublisher(actualInputChannel), + inputChannel, this.streamFunctionProperties); + return actualInputChannel; + } + return (SubscribableChannel) inputChannel; + } + + private void moveChannelInterceptors(AbstractMessageChannel existingMessageChannel, + AbstractMessageChannel actualMessageChannel) { + for (ChannelInterceptor channelInterceptor : existingMessageChannel + .getChannelInterceptors()) { + actualMessageChannel.addInterceptor(channelInterceptor); + existingMessageChannel.removeInterceptor(channelInterceptor); + } + } + + // END FUNCTION-TO-EXISTING-APP section + + protected static class ErrorInfrastructure { + + private final SubscribableChannel errorChannel; + + private final ErrorMessageSendingRecoverer recoverer; + + private final MessageHandler handler; + + ErrorInfrastructure(SubscribableChannel errorChannel, + ErrorMessageSendingRecoverer recoverer, MessageHandler handler) { + this.errorChannel = errorChannel; + this.recoverer = recoverer; + this.handler = handler; + } + + public SubscribableChannel getErrorChannel() { + return this.errorChannel; + } + + public ErrorMessageSendingRecoverer getRecoverer() { + return this.recoverer; + } + + public MessageHandler getHandler() { + return this.handler; + } + + } + + private static final class EmbeddedHeadersChannelInterceptor + implements ChannelInterceptor { + + protected final Log logger; + + EmbeddedHeadersChannelInterceptor(Log logger) { + this.logger = logger; + } + + @Override + @SuppressWarnings("unchecked") + public Message preSend(Message message, MessageChannel channel) { + if (message.getPayload() instanceof byte[] + && !message.getHeaders() + .containsKey(BinderHeaders.NATIVE_HEADERS_PRESENT) + && EmbeddedHeaderUtils + .mayHaveEmbeddedHeaders((byte[]) message.getPayload())) { + + MessageValues messageValues; + try { + messageValues = EmbeddedHeaderUtils + .extractHeaders((Message) message, true); + } + catch (Exception e) { + /* + * debug() rather then error() since we don't know for sure that it + * really is a message with embedded headers, it just meets the + * criteria in EmbeddedHeaderUtils.mayHaveEmbeddedHeaders(). + */ + if (this.logger.isDebugEnabled()) { + this.logger.debug( + EmbeddedHeaderUtils.decodeExceptionMessage(message), e); + } + messageValues = new MessageValues(message); + } + return messageValues.toMessage(); + } + return message; + } + + } + + protected static class PolledConsumerResources { + + private final MessageSource source; + + private final ErrorInfrastructure errorInfrastructure; + + public PolledConsumerResources(MessageSource source, + ErrorInfrastructure errorInfrastructure) { + this.source = source; + this.errorInfrastructure = errorInfrastructure; + } + + MessageSource getSource() { + return this.source; + } + + ErrorInfrastructure getErrorInfrastructure() { + return this.errorInfrastructure; + } + + } + + private final class SendingHandler extends AbstractMessageHandler + implements Lifecycle { + + private final boolean embedHeaders; + + private final String[] embeddedHeaders; + + private final MessageHandler delegate; + + private final boolean useNativeEncoding; + + private SendingHandler(MessageHandler delegate, boolean embedHeaders, + String[] headersToEmbed, boolean useNativeEncoding) { + this.delegate = delegate; + setBeanFactory(AbstractMessageChannelBinder.this.getBeanFactory()); + this.embedHeaders = embedHeaders; + this.embeddedHeaders = headersToEmbed; + this.useNativeEncoding = useNativeEncoding; + } + + @Override + protected void handleMessageInternal(Message message) throws Exception { + Message messageToSend = (this.useNativeEncoding) ? message + : serializeAndEmbedHeadersIfApplicable(message); + this.delegate.handleMessage(messageToSend); + } + + @SuppressWarnings("deprecation") + private Message serializeAndEmbedHeadersIfApplicable(Message message) + throws Exception { + MessageValues transformed = serializePayloadIfNecessary(message); + Object payload; + if (this.embedHeaders) { + Object contentType = transformed.get(MessageHeaders.CONTENT_TYPE); + // transform content type headers to String, so that they can be properly + // embedded in JSON + if (contentType != null) { + transformed.put(MessageHeaders.CONTENT_TYPE, contentType.toString()); + } + Object originalContentType = transformed + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE); + if (originalContentType != null) { + transformed.put(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE, + originalContentType.toString()); + } + payload = EmbeddedHeaderUtils.embedHeaders(transformed, + this.embeddedHeaders); + } + else { + payload = transformed.getPayload(); + } + return getMessageBuilderFactory().withPayload(payload) + .copyHeaders(transformed.getHeaders()).build(); + } + + @Override + public void start() { + if (this.delegate instanceof Lifecycle) { + ((Lifecycle) this.delegate).start(); + } + } + + @Override + public void stop() { + if (this.delegate instanceof Lifecycle) { + ((Lifecycle) this.delegate).stop(); + } + } + + @Override + public boolean isRunning() { + return this.delegate instanceof Lifecycle + && ((Lifecycle) this.delegate).isRunning(); + } + + } + + @SuppressWarnings("serial") + private static class ExpressionSerializer extends StdSerializer { + + protected ExpressionSerializer(Class t) { + super(t); + } + + @Override + public void serialize(Expression value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeString(value.getExpressionString()); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binder.java new file mode 100644 index 000000000..0e8c5ba8a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binder.java @@ -0,0 +1,62 @@ +/* + * Copyright 2013-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * A strategy interface used to bind an app interface to a logical name. The name is + * intended to identify a logical consumer or producer of messages. This may be a queue, a + * channel adapter, another message channel, a Spring bean, etc. + * + * @param the primary binding type (e.g. MessageChannel). + * @param the consumer properties type. + * @param

    the producer properties type. + * @author Mark Fisher + * @author David Turanski + * @author Gary Russell + * @author Jennifer Hickey + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @since 1.0 + */ +public interface Binder { + + /** + * Bind the target component as a message consumer to the logical entity identified by + * the name. + * @param name the logical identity of the message source + * @param group the consumer group to which this consumer belongs - subscriptions are + * shared among consumers in the same group (a null or empty String, must + * be treated as an anonymous group that doesn't share the subscription with any other + * consumer) + * @param inboundBindTarget the app interface to be bound as a consumer + * @param consumerProperties the consumer properties + * @return the setup binding + */ + Binding bindConsumer(String name, String group, T inboundBindTarget, + C consumerProperties); + + /** + * Bind the target component as a message producer to the logical entity identified by + * the name. + * @param name the logical identity of the message target + * @param outboundBindTarget the app interface to be bound as a producer + * @param producerProperties the producer properties + * @return the setup binding + */ + Binding bindProducer(String name, T outboundBindTarget, P producerProperties); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderConfiguration.java new file mode 100644 index 000000000..4aa298cee --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Map; +import java.util.Properties; + +/** + * Configuration for a binder instance, associating a {@link BinderType} with its + * configuration {@link Properties}. An application may contain multiple + * {@link BinderConfiguration}s per {@link BinderType}, when connecting to multiple + * systems of the same type. + * + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +public class BinderConfiguration { + + private final String binderType; + + private final Map properties; + + private final boolean inheritEnvironment; + + private final boolean defaultCandidate; + + /** + * @param binderType the binder type used by this configuration + * @param properties the properties for setting up the binder + * @param inheritEnvironment whether the binder should inherit the environment of the + * application + * @param defaultCandidate whether the binder should be considered as a candidate when + * determining a default + */ + public BinderConfiguration(String binderType, Map properties, + boolean inheritEnvironment, boolean defaultCandidate) { + this.binderType = binderType; + this.properties = properties; + this.inheritEnvironment = inheritEnvironment; + this.defaultCandidate = defaultCandidate; + } + + public String getBinderType() { + return this.binderType; + } + + public Map getProperties() { + return this.properties; + } + + public boolean isInheritEnvironment() { + return this.inheritEnvironment; + } + + public boolean isDefaultCandidate() { + return this.defaultCandidate; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderErrorChannel.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderErrorChannel.java new file mode 100644 index 000000000..78f55f32b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderErrorChannel.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.integration.channel.PublishSubscribeChannel; +import org.springframework.messaging.MessageHandler; + +/** + * A channel for errors. If it has a {@link LastSubscriberMessageHandler} subscriber, it + * can only have one and it will always be the last subscriber. + * + * @author Gary Russell + * @since 1.3 + * + */ +class BinderErrorChannel extends PublishSubscribeChannel + implements LastSubscriberAwareChannel { + + private final AtomicInteger subscribers = new AtomicInteger(); + + private volatile LastSubscriberMessageHandler finalHandler; + + @Override + public boolean subscribe(MessageHandler handler) { + this.subscribers.incrementAndGet(); + if (handler instanceof LastSubscriberMessageHandler + && this.finalHandler != null) { + throw new IllegalStateException( + "Only one LastSubscriberMessageHandler is allowed"); + } + if (this.finalHandler != null) { + unsubscribe(this.finalHandler); + } + boolean result = super.subscribe(handler); + if (this.finalHandler != null) { + super.subscribe(this.finalHandler); + } + if (handler instanceof LastSubscriberMessageHandler + && this.finalHandler == null) { + this.finalHandler = (LastSubscriberMessageHandler) handler; + } + return result; + } + + @Override + public boolean unsubscribe(MessageHandler handler) { + this.subscribers.decrementAndGet(); + return super.unsubscribe(handler); + } + + @Override + public int subscribers() { + return this.subscribers.get(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderException.java new file mode 100644 index 000000000..894d8bbd7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2014-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * Exception thrown to indicate a binder error (most likely a configuration error). + * + * @author Gary Russell + * @author Marius Bogoevici + */ +@SuppressWarnings("serial") +public class BinderException extends RuntimeException { + + public BinderException(String message) { + super(message); + } + + public BinderException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderFactory.java new file mode 100644 index 000000000..fe16b24ca --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderFactory.java @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * @author Marius Bogoevici + */ +public interface BinderFactory { + + /** + * Returns the binder instance associated with the given configuration name. Instance + * caching is a requirement, and implementations must return the same instance on + * subsequent invocations with the same arguments. + * @param the primary binding type + * @param configurationName the name of a binder configuration + * @param bindableType binding target type + * @return the binder instance + */ + Binder getBinder( + String configurationName, Class bindableType); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderHeaders.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderHeaders.java new file mode 100644 index 000000000..65312e853 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderHeaders.java @@ -0,0 +1,81 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.integration.IntegrationMessageHeaderAccessor; +import org.springframework.messaging.MessageHeaders; + +/** + * Spring Integration message headers for Spring Cloud Stream. + * + * @author Gary Russell + * @author David Turanski + * @author Soby Chacko + */ +public final class BinderHeaders { + + /** + * Indicates the original content type of a message that has been transformed in a + * native transport format. + */ + public static final String BINDER_ORIGINAL_CONTENT_TYPE = "originalContentType"; + + /** + * The headers that will be propagated, by default, by binder implementations that + * have no inherent header support (by embedding the headers in the payload). + */ + public static final String[] STANDARD_HEADERS = new String[] { + IntegrationMessageHeaderAccessor.CORRELATION_ID, + IntegrationMessageHeaderAccessor.SEQUENCE_SIZE, + IntegrationMessageHeaderAccessor.SEQUENCE_NUMBER, MessageHeaders.CONTENT_TYPE, + BINDER_ORIGINAL_CONTENT_TYPE }; + + private static final String PREFIX = "scst_"; + + /** + * Indicates the target partition of an outbound message. Binders must observe this + * value when sending data on the transport. This header is internally set by the + * framework when partitioning is configured. It may be overridden by + * {@link BinderHeaders#PARTITION_OVERRIDE} if set by the user. + */ + public static final String PARTITION_HEADER = PREFIX + "partition"; + + /** + * Indicates the target partition of an outbound message. Overrides any partition + * selected by the binder. This header takes precedence over + * {@link BinderHeaders#PARTITION_HEADER}. + */ + public static final String PARTITION_OVERRIDE = PREFIX + "partitionOverride"; + + /** + * Indicates that an incoming message has native headers. + * @since 2.0 + */ + public static final String NATIVE_HEADERS_PRESENT = PREFIX + "nativeHeadersPresent"; + + /** + * Indicates the Spring Cloud Stream version. Used for determining if legacy content + * type check supported or not. + * @since 2.0 + */ + public static final String SCST_VERSION = PREFIX + "version"; + + private BinderHeaders() { + super(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderSpecificPropertiesProvider.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderSpecificPropertiesProvider.java new file mode 100644 index 000000000..5c3b95c01 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderSpecificPropertiesProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * @author Oleg Zhurakousky + * @since 2.1 + * + */ +public interface BinderSpecificPropertiesProvider { + + Object getConsumer(); + + Object getProducer(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderType.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderType.java new file mode 100644 index 000000000..827f876cc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderType.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Arrays; + +/** + * References one or more + * {@link org.springframework.context.annotation.Configuration}-annotated classes which + * provide a context definition which contains exactly one {@link Binder}, typically for a + * given type of system (e.g. Rabbit, Kafka, Redis, etc.). An application may contain + * multiple instances of a given {@link BinderType}, when connecting to multiple systems + * of the same type. + * + * @author Marius Bogoevici + */ +public class BinderType { + + private final String defaultName; + + private final Class[] configurationClasses; + + public BinderType(String defaultName, Class[] configurationClasses) { + this.defaultName = defaultName; + this.configurationClasses = configurationClasses; + } + + public String getDefaultName() { + return this.defaultName; + } + + public Class[] getConfigurationClasses() { + return this.configurationClasses; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BinderType that = (BinderType) o; + if (!this.defaultName.equals(that.defaultName)) { + return false; + } + return Arrays.equals(this.configurationClasses, that.configurationClasses); + + } + + @Override + public int hashCode() { + int result = this.defaultName.hashCode(); + result = 31 * result + Arrays.hashCode(this.configurationClasses); + return result; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderTypeRegistry.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderTypeRegistry.java new file mode 100644 index 000000000..cca64c010 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderTypeRegistry.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Map; + +/** + * A registry of {@link BinderType}s, indexed by name. A {@link BinderTypeRegistry} bean + * is created automatically based on information found in the + * {@literal META-INF/spring.binders} files provided by binder implementors. + * + * @author Marius Bogoevici + */ +public interface BinderTypeRegistry { + + BinderType get(String name); + + Map getAll(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binding.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binding.java new file mode 100644 index 000000000..4400284b2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/Binding.java @@ -0,0 +1,134 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.cloud.stream.endpoint.BindingsEndpoint; +import org.springframework.integration.endpoint.Pausable; + +/** + * Represents a binding between an input or output and an adapter endpoint that connects + * via a Binder. The binding could be for a consumer or a producer. A consumer binding + * represents a connection from an adapter to an input. A producer binding represents a + * connection from an output to an adapter. + * + * @param type of a binding + * @author Jennifer Hickey + * @author Mark Fisher + * @author Gary Russell + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @see org.springframework.cloud.stream.annotation.EnableBinding + */ +public interface Binding extends Pausable { + + default Map getExtendedInfo() { + return Collections.emptyMap(); + } + + /** + * Stops the target component represented by this instance. NOTE: At the time the + * instance is created the component is already started. This operation is typically + * used by actuator to re-bind/re-start. + * + * @see BindingsEndpoint + */ + default void start() { + } + + /** + * Starts the target component represented by this instance. NOTE: At the time the + * instance is created the component is already started. This operation is typically + * used by actuator to re-bind/re-start. + * + * @see BindingsEndpoint + */ + default void stop() { + } + + /** + * Pauses the target component represented by this instance if and only if the + * component implements {@link Pausable} interface NOTE: At the time the instance is + * created the component is already started and active. This operation is typically + * used by actuator to pause/resume. + * + * @see BindingsEndpoint + */ + default void pause() { + this.stop(); + } + + /** + * Resumes the target component represented by this instance if and only if the + * component implements {@link Pausable} interface NOTE: At the time the instance is + * created the component is already started and active. This operation is typically + * used by actuator to pause/resume. + * + * @see BindingsEndpoint + */ + default void resume() { + this.start(); + } + + /** + * @return 'true' if the target component represented by this instance is running. + */ + default boolean isRunning() { + return false; + } + + /** + * Returns the name of the destination for this binding. + * @return destination name + */ + default String getName() { + return null; + } + + /** + * Returns the name of the target for this binding (i.e., channel name). + * @return binding name + * + * @since 2.2 + */ + default String getBindingName() { + return null; + } + + /** + * Unbinds the target component represented by this instance and stops any active + * components. Implementations must be idempotent. After this method is invoked, the + * target is not expected to receive any messages; this instance should be discarded, + * and a new Binding should be created instead. + */ + void unbind(); + + /** + * Returns boolean flag representing this binding's type. If 'true' this binding is an + * 'input' binding otherwise it is 'output' (as in binding annotated with + * either @Input or @Output). + * @return 'true' if this binding represents an input binding. + */ + default boolean isInput() { + throw new UnsupportedOperationException( + "Binding implementation `" + this.getClass().getName() + + "` must implement this operation before it is called"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCleaner.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCleaner.java new file mode 100644 index 000000000..f1b512c90 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCleaner.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.List; +import java.util.Map; + +/** + * Interface for implementations that perform cleanup for binders. + * + * @author Gary Russell + * @since 1.2 + */ +public interface BindingCleaner { + + /** + * Clean up all resources for the supplied stream/job. + * @param entity the stream or job; may be terminated with a simple wild card '*', in + * which case all streams with names starting with the characters before the '*' will + * be cleaned. + * @param isJob true if the entity is a job. + * @return a map of lists of resources removed. + */ + Map> clean(String entity, boolean isJob); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCreatedEvent.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCreatedEvent.java new file mode 100644 index 000000000..9f9dcb855 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BindingCreatedEvent.java @@ -0,0 +1,36 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.context.ApplicationEvent; + +/** + * ApplicationEvent fired whenever the the Binding is created. + * + * @author Oleg Zhurakousky + * @since 2.0 + * + * #see AbstractMessageChannelBinder + */ +@SuppressWarnings("serial") +public class BindingCreatedEvent extends ApplicationEvent { + + public BindingCreatedEvent(Binding source) { + super(source); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ConsumerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ConsumerProperties.java new file mode 100644 index 000000000..dfed76167 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ConsumerProperties.java @@ -0,0 +1,288 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.validation.constraints.Min; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Common consumer properties - spring.cloud.stream.bindings.[destinationName].consumer. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Soby Chacko + * @author Oleg Zhurakousky + * @author Nicolas Homble + */ +@JsonInclude(JsonInclude.Include.NON_DEFAULT) +public class ConsumerProperties { + + /** + * Signals if this consumer needs to be started automatically. + * + * Default: true + */ + private boolean autoStartup = true; + + /** + * The concurrency setting of the consumer. Default: 1. + */ + private int concurrency = 1; + + /** + * Whether the consumer receives data from a partitioned producer. Default: 'false'. + */ + private boolean partitioned; + + /** + * When set to a value greater than equal to zero, allows customizing the instance + * count of this consumer (if different from spring.cloud.stream.instanceCount). When + * set to a negative value, it will default to spring.cloud.stream.instanceCount. See + * that property for more information. Default: -1 NOTE: This setting will override + * the one set in 'spring.cloud.stream.instance-count' + */ + private int instanceCount = -1; + + /** + * When set to a value greater than equal to zero, allows customizing the instance + * index of this consumer (if different from spring.cloud.stream.instanceIndex). When + * set to a negative value, it will default to spring.cloud.stream.instanceIndex. See + * that property for more information. Default: -1 NOTE: This setting will override + * the one set in 'spring.cloud.stream.instance-index' + */ + private int instanceIndex = -1; + + /** + * The number of attempts to process the message (including the first) in the event of + * processing failures. This is a RetryTemplate configuration which is provided by the + * framework. Default: 3. Set to 1 to disable retry. You can also provide custom + * RetryTemplate in the event you want to take complete control of the RetryTemplate. + * Simply configure it as @Bean inside your application configuration. + */ + private int maxAttempts = 3; + + /** + * The backoff initial interval on retry. This is a RetryTemplate configuration which + * is provided by the framework. Default: 1000 ms. You can also provide custom + * RetryTemplate in the event you want to take complete control of the RetryTemplate. + * Simply configure it as @Bean inside your application configuration. + */ + private int backOffInitialInterval = 1000; + + /** + * The maximum backoff interval. This is a RetryTemplate configuration which is + * provided by the framework. Default: 10000 ms. You can also provide custom + * RetryTemplate in the event you want to take complete control of the RetryTemplate. + * Simply configure it as @Bean inside your application configuration. + */ + private int backOffMaxInterval = 10000; + + /** + * The backoff multiplier.This is a RetryTemplate configuration which is provided by + * the framework. Default: 2.0. You can also provide custom RetryTemplate in the event + * you want to take complete control of the RetryTemplate. Simply configure it + * as @Bean inside your application configuration. + */ + private double backOffMultiplier = 2.0; + + /** + * Whether exceptions thrown by the listener that are not listed in the + * 'retryableExceptions' are retryable. + */ + private boolean defaultRetryable = true; + + /** + * Allows you to further qualify which RetryTemplate to use for a specific consumer + * binding.. + */ + private String retryTemplateName; + + /** + * A map of Throwable class names in the key and a boolean in the value. Specify those + * exceptions (and subclasses) that will or won't be retried. + */ + private Map, Boolean> retryableExceptions = new LinkedHashMap<>(); + + /** + * When set to none, disables header parsing on input. Effective only for messaging + * middleware that does not support message headers natively and requires header + * embedding. This option is useful when consuming data from non-Spring Cloud Stream + * applications when native headers are not supported. When set to headers, uses the + * middleware’s native header mechanism. When set to 'embeddedHeaders', embeds headers + * into the message payload. Default: depends on binder implementation. Rabbit and + * Kafka binders currently distributed with spring cloud stream support headers + * natively. + */ + private HeaderMode headerMode; + + /** + * When set to true, the inbound message is deserialized directly by client library, + * which must be configured correspondingly (e.g. setting an appropriate Kafka + * producer value serializer). NOTE: This is binder specific setting which has no + * effect if binder does not support native serialization/deserialization. Currently + * only Kafka binder supports it. Default: 'false' + */ + private boolean useNativeDecoding; + + /** + * When set to true, the underlying binder will natively multiplex destinations on the + * same input binding. For example, in the case of a comma separated multiple + * destinations, the core framework will skip binding them individually if this is set + * to true, but delegate that responsibility to the binder. + * + * By default this property is set to `false` and the binder will individually bind + * each destinations in case of a comma separated multi destination list. The + * individual binder implementations that need to support multiple input bindings + * natively (multiplex) can enable this property. Under normal circumstances, the end + * users are not expected to enable or disable this property directly. + */ + private boolean multiplex; + + public String getRetryTemplateName() { + return retryTemplateName; + } + + public void setRetryTemplateName(String retryTemplateName) { + this.retryTemplateName = retryTemplateName; + } + + @Min(value = 1, message = "Concurrency should be greater than zero.") + public int getConcurrency() { + return this.concurrency; + } + + public void setConcurrency(int concurrency) { + this.concurrency = concurrency; + } + + public boolean isPartitioned() { + return this.partitioned; + } + + public void setPartitioned(boolean partitioned) { + this.partitioned = partitioned; + } + + @Min(value = -1, message = "Instance count should be greater than or equal to -1.") + public int getInstanceCount() { + return this.instanceCount; + } + + public void setInstanceCount(int instanceCount) { + this.instanceCount = instanceCount; + } + + @Min(value = -1, message = "Instance index should be greater than or equal to -1") + public int getInstanceIndex() { + return this.instanceIndex; + } + + public void setInstanceIndex(int instanceIndex) { + this.instanceIndex = instanceIndex; + } + + @Min(value = 1, message = "Max attempts should be greater than zero.") + public int getMaxAttempts() { + return this.maxAttempts; + } + + public void setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + @Min(value = 1, message = "Backoff initial interval should be greater than zero.") + public int getBackOffInitialInterval() { + return this.backOffInitialInterval; + } + + public void setBackOffInitialInterval(int backOffInitialInterval) { + this.backOffInitialInterval = backOffInitialInterval; + } + + @Min(value = 1, message = "Backoff max interval should be greater than zero.") + public int getBackOffMaxInterval() { + return this.backOffMaxInterval; + } + + public void setBackOffMaxInterval(int backOffMaxInterval) { + this.backOffMaxInterval = backOffMaxInterval; + } + + @Min(value = 1, message = "Backoff multiplier should be greater than zero.") + public double getBackOffMultiplier() { + return this.backOffMultiplier; + } + + public void setBackOffMultiplier(double backOffMultiplier) { + this.backOffMultiplier = backOffMultiplier; + } + + public boolean isDefaultRetryable() { + return this.defaultRetryable; + } + + public void setDefaultRetryable(boolean defaultRetryable) { + this.defaultRetryable = defaultRetryable; + } + + public Map, Boolean> getRetryableExceptions() { + return this.retryableExceptions; + } + + public void setRetryableExceptions( + Map, Boolean> retryableExceptions) { + this.retryableExceptions = retryableExceptions; + } + + public HeaderMode getHeaderMode() { + return this.headerMode; + } + + public void setHeaderMode(HeaderMode headerMode) { + this.headerMode = headerMode; + } + + public boolean isUseNativeDecoding() { + return this.useNativeDecoding; + } + + public void setUseNativeDecoding(boolean useNativeDecoding) { + this.useNativeDecoding = useNativeDecoding; + } + + public boolean isMultiplex() { + return this.multiplex; + } + + public void setMultiplex(boolean multiplex) { + this.multiplex = multiplex; + } + + public boolean isAutoStartup() { + return this.autoStartup; + } + + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java new file mode 100644 index 000000000..aeb18fc0e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java @@ -0,0 +1,372 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.Banner.Mode; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.config.SpelExpressionConverterConfiguration; +import org.springframework.cloud.stream.reflection.GenericsUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Default {@link BinderFactory} implementation. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Oleg Zhurakousky + * @author Soby Chacko + * @author Artem Bilan + */ +public class DefaultBinderFactory + implements BinderFactory, DisposableBean, ApplicationContextAware { + + private final Map binderConfigurations; + + // @checkstyle:off + private final Map, ConfigurableApplicationContext>> binderInstanceCache = new HashMap<>(); + + // @checkstyle:on + + private final Map defaultBinderForBindingTargetType = new HashMap<>(); + + private final BinderTypeRegistry binderTypeRegistry; + + private volatile ConfigurableApplicationContext context; + + private Collection listeners; + + private volatile String defaultBinder; + + public DefaultBinderFactory(Map binderConfigurations, + BinderTypeRegistry binderTypeRegistry) { + this.binderConfigurations = new HashMap<>(binderConfigurations); + this.binderTypeRegistry = binderTypeRegistry; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + Assert.isInstanceOf(ConfigurableApplicationContext.class, applicationContext); + this.context = (ConfigurableApplicationContext) applicationContext; + } + + public void setDefaultBinder(String defaultBinder) { + this.defaultBinder = defaultBinder; + } + + public void setListeners(Collection listeners) { + this.listeners = listeners; + } + + @Override + public void destroy() { + this.binderInstanceCache.values().stream().map(Entry::getValue) + .forEach(ConfigurableApplicationContext::close); + this.defaultBinderForBindingTargetType.clear(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public synchronized Binder getBinder(String name, + Class bindingTargetType) { + + String binderName = StringUtils.hasText(name) ? name : this.defaultBinder; + + Map binders = this.context == null ? Collections.emptyMap() + : this.context.getBeansOfType(Binder.class); + Binder binder; + if (StringUtils.hasText(binderName) && binders.containsKey(binderName)) { + binder = (Binder) this.context + .getBean(binderName); + } + else if (binders.size() == 1) { + binder = binders.values().iterator().next(); + } + else if (binders.size() > 1) { + throw new IllegalStateException( + "Multiple binders are available, however neither default nor " + + "per-destination binder name is provided. Available binders are " + + binders.keySet()); + } + else { + /* + * This is the fall back to the old bootstrap that relies on spring.binders. + */ + binder = this.doGetBinder(binderName, bindingTargetType); + } + return binder; + } + + private Binder doGetBinder(String name, + Class bindingTargetType) { + String configurationName; + // Fall back to a default if no argument is provided + if (StringUtils.isEmpty(name)) { + Assert.notEmpty(this.binderConfigurations, + "A default binder has been requested, but there is no binder available"); + if (!StringUtils.hasText(this.defaultBinder)) { + Set defaultCandidateConfigurations = new HashSet<>(); + for (Map.Entry binderConfigurationEntry : this.binderConfigurations + .entrySet()) { + if (binderConfigurationEntry.getValue().isDefaultCandidate()) { + defaultCandidateConfigurations + .add(binderConfigurationEntry.getKey()); + } + } + if (defaultCandidateConfigurations.size() == 1) { + configurationName = defaultCandidateConfigurations.iterator().next(); + this.defaultBinderForBindingTargetType + .put(bindingTargetType.getName(), configurationName); + } + else { + List candidatesForBindableType = new ArrayList<>(); + for (String defaultCandidateConfiguration : defaultCandidateConfigurations) { + Binder binderInstance = getBinderInstance( + defaultCandidateConfiguration); + Class binderType = GenericsUtils.getParameterType( + binderInstance.getClass(), Binder.class, 0); + if (binderType.isAssignableFrom(bindingTargetType)) { + candidatesForBindableType.add(defaultCandidateConfiguration); + } + } + if (candidatesForBindableType.size() == 1) { + configurationName = candidatesForBindableType.iterator().next(); + this.defaultBinderForBindingTargetType + .put(bindingTargetType.getName(), configurationName); + } + else { + String countMsg = (candidatesForBindableType.size() == 0) + ? "are no binders" : "is more than one binder"; + throw new IllegalStateException( + "A default binder has been requested, but there " + + countMsg + " available for '" + + bindingTargetType.getName() + "' : " + + StringUtils.collectionToCommaDelimitedString( + candidatesForBindableType) + + ", and no default binder has been set."); + } + } + } + else { + configurationName = this.defaultBinder; + } + } + else { + configurationName = name; + } + Binder binderInstance = getBinderInstance( + configurationName); + Assert.state(verifyBinderTypeMatchesTarget(binderInstance, bindingTargetType), + "The binder '" + configurationName + "' cannot bind a " + + bindingTargetType.getName()); + return binderInstance; + } + + /** + * Return true if the binder is a {@link PollableConsumerBinder} and the target type + * is a {@link PollableSource} and their generic types match (e.g. MessageHandler), OR + * if it's a {@link Binder} and the target matches the binder's generic type. + * @param bindng target type + * @param binderInstance the binder. + * @param bindingTargetType the binding target type. + * @return true if the conditions match. + */ + private boolean verifyBinderTypeMatchesTarget(Binder binderInstance, + Class bindingTargetType) { + return (binderInstance instanceof PollableConsumerBinder && GenericsUtils + .checkCompatiblePollableBinder(binderInstance, bindingTargetType)) + || GenericsUtils + .getParameterType(binderInstance.getClass(), Binder.class, 0) + .isAssignableFrom(bindingTargetType); + } + + @SuppressWarnings("unchecked") + private Binder getBinderInstance( + String configurationName) { + if (!this.binderInstanceCache.containsKey(configurationName)) { + BinderConfiguration binderConfiguration = this.binderConfigurations + .get(configurationName); + Assert.state(binderConfiguration != null, + "Unknown binder configuration: " + configurationName); + BinderType binderType = this.binderTypeRegistry + .get(binderConfiguration.getBinderType()); + Assert.notNull(binderType, "Binder type " + + binderConfiguration.getBinderType() + " is not defined"); + + Map binderProperties = new HashMap<>(); + this.flatten(null, binderConfiguration.getProperties(), binderProperties); + + // Convert all properties to arguments, so that they receive maximum + // precedence + ArrayList args = new ArrayList<>(); + for (Map.Entry property : binderProperties.entrySet()) { + args.add( + String.format("--%s=%s", property.getKey(), property.getValue())); + } + // Initialize the domain with a unique name based on the bootstrapping context + // setting + ConfigurableEnvironment environment = this.context != null + ? this.context.getEnvironment() : null; + String defaultDomain = environment != null + ? environment.getProperty("spring.jmx.default-domain") : ""; + args.add("--spring.jmx.default-domain=" + defaultDomain + "binder." + + configurationName); + // Initializing SpringApplicationBuilder with + // SpelExpressionConverterConfiguration due to the fact that + // infrastructure related configuration is not propagated in a multi binder + // scenario. + // See this GH issue for more details: + // https://github.com/spring-cloud/spring-cloud-stream/issues/1412 + // and the associated PR: + // https://github.com/spring-cloud/spring-cloud-stream/pull/1413 + SpringApplicationBuilder springApplicationBuilder = new SpringApplicationBuilder( + SpelExpressionConverterConfiguration.class) + .sources(binderType.getConfigurationClasses()) + .bannerMode(Mode.OFF).logStartupInfo(false) + .web(WebApplicationType.NONE); + // If the environment is not customized and a main context is available, we + // will set the latter as parent. + // This ensures that the defaults and user-defined customizations (e.g. custom + // connection factory beans) + // are propagated to the binder context. If the environment is customized, + // then the binder context should + // not inherit any beans from the parent + boolean useApplicationContextAsParent = binderProperties.isEmpty() + && this.context != null; + if (useApplicationContextAsParent) { + springApplicationBuilder.parent(this.context); + } + // If the current application context is not set as parent and the environment + // is set, + // provide the current context as an additional bean in the BeanFactory. + if (environment != null && !useApplicationContextAsParent) { + springApplicationBuilder + .initializers(new InitializerWithOuterContext(this.context)); + } + + if (environment != null && (useApplicationContextAsParent + || binderConfiguration.isInheritEnvironment())) { + StandardEnvironment binderEnvironment = new StandardEnvironment(); + binderEnvironment.merge(environment); + // See ConfigurationPropertySources.ATTACHED_PROPERTY_SOURCE_NAME + binderEnvironment.getPropertySources().remove("configurationProperties"); + springApplicationBuilder.environment(binderEnvironment); + } + + ConfigurableApplicationContext binderProducingContext = springApplicationBuilder + .run(args.toArray(new String[0])); + + Binder binder = binderProducingContext.getBean(Binder.class); + /* + * This will ensure that application defined errorChannel and other beans are + * accessible within binder's context (see + * https://github.com/spring-cloud/spring-cloud-stream/issues/1384) + */ + if (this.context != null && binder instanceof ApplicationContextAware) { + ((ApplicationContextAware) binder).setApplicationContext(this.context); + } + if (!CollectionUtils.isEmpty(this.listeners)) { + for (Listener binderFactoryListener : this.listeners) { + binderFactoryListener.afterBinderContextInitialized(configurationName, + binderProducingContext); + } + } + this.binderInstanceCache.put(configurationName, + new SimpleImmutableEntry<>(binder, binderProducingContext)); + } + return (Binder) this.binderInstanceCache + .get(configurationName).getKey(); + } + + /** + * Ensures that nested properties are flattened (i.e., foo.bar=baz instead of + * foo={bar=baz}). + * @param propertyName property name to flatten + * @param value value that contains the property name + * @param flattenedProperties map to which we'll add the falttened property + */ + @SuppressWarnings("unchecked") + private void flatten(String propertyName, Object value, + Map flattenedProperties) { + if (value instanceof Map) { + ((Map) value).forEach((k, v) -> flatten( + (propertyName != null ? propertyName + "." : "") + k, v, + flattenedProperties)); + } + else { + flattenedProperties.put(propertyName, value.toString()); + } + } + + /** + * A listener that can be registered with the {@link DefaultBinderFactory} that allows + * the registration of additional configuration. + * + * @author Ilayaperumal Gopinathan + */ + public interface Listener { + + /** + * Applying additional capabilities to the binder after the binder context has + * been initialized. + * @param configurationName the binder configuration name + * @param binderContext the application context of the binder + */ + void afterBinderContextInitialized(String configurationName, + ConfigurableApplicationContext binderContext); + + } + + private static class InitializerWithOuterContext + implements ApplicationContextInitializer { + + private final ApplicationContext context; + + InitializerWithOuterContext(ApplicationContext context) { + this.context = context; + } + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.getBeanFactory().registerSingleton("outerContext", + this.context); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderTypeRegistry.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderTypeRegistry.java new file mode 100644 index 000000000..c4055621e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderTypeRegistry.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Default implementation of a {@link BinderTypeRegistry}. + * + * @author Marius Bogoevici + */ +public class DefaultBinderTypeRegistry implements BinderTypeRegistry { + + private final Map binderTypes; + + public DefaultBinderTypeRegistry(Map binderTypes) { + this.binderTypes = Collections.unmodifiableMap(new HashMap<>(binderTypes)); + } + + @Override + public BinderType get(String name) { + return this.binderTypes.get(name); + } + + @Override + public Map getAll() { + return this.binderTypes; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinding.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinding.java new file mode 100644 index 000000000..5d84de5e2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinding.java @@ -0,0 +1,195 @@ +/* + * Copyright 2013-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.Lifecycle; +import org.springframework.integration.context.IntegrationObjectSupport; +import org.springframework.integration.endpoint.Pausable; +import org.springframework.integration.support.context.NamedComponent; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Default implementation for a {@link Binding}. + * + * @param type of binding + * @author Jennifer Hickey + * @author Mark Fisher + * @author Gary Russell + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @see org.springframework.cloud.stream.annotation.EnableBinding + */ + +@JsonPropertyOrder({ "bindingName", "name", "group", "pausable", "state" }) +@JsonIgnoreProperties("running") +public class DefaultBinding implements Binding { + + protected final String name; + + protected final String group; + + protected final T target; + + protected final Lifecycle lifecycle; + + private final Log logger = LogFactory.getLog(this.getClass().getName()); + + private boolean paused; + + private boolean restartable; + + /** + * Creates an instance that associates a given name, group and binding target with an + * optional {@link Lifecycle} component, which will be stopped during unbinding. + * @param name the name of the binding target + * @param group the group (only for input targets) + * @param target the binding target + * @param lifecycle {@link Lifecycle} that runs while the binding is active and will + * be stopped during unbinding + */ + public DefaultBinding(String name, String group, T target, Lifecycle lifecycle) { + Assert.notNull(target, "target must not be null"); + this.name = name; + this.group = group; + this.target = target; + this.lifecycle = lifecycle; + this.restartable = StringUtils.hasText(group); + } + + public DefaultBinding(String name, T target, Lifecycle lifecycle) { + this(name, null, target, lifecycle); + this.restartable = true; + } + + public String getName() { + return this.name; + } + + public String getBindingName() { + String resolvedName = (this.target instanceof IntegrationObjectSupport) + ? ((IntegrationObjectSupport) this.target).getComponentName() : getName(); + return resolvedName == null ? getName() : resolvedName; + } + + public String getGroup() { + return this.group; + } + + public String getState() { + String state = "N/A"; + if (this.lifecycle != null) { + if (isPausable()) { + state = this.paused ? "paused" : this.getRunningState(); + } + else { + state = this.getRunningState(); + } + } + return state; + } + + public boolean isRunning() { + return this.lifecycle != null && this.lifecycle.isRunning(); + } + + public boolean isPausable() { + return this.lifecycle instanceof Pausable; + } + + @Override + public final synchronized void start() { + if (!this.isRunning()) { + if (this.lifecycle != null && this.restartable) { + this.lifecycle.start(); + } + else { + this.logger.warn("Can not re-bind an anonymous binding"); + } + } + } + + @Override + public final synchronized void stop() { + if (this.isRunning()) { + this.lifecycle.stop(); + } + } + + @Override + public final synchronized void pause() { + if (this.lifecycle instanceof Pausable) { + ((Pausable) this.lifecycle).pause(); + this.paused = true; + } + else { + this.logger + .warn("Attempted to pause a component that does not support Pausable " + + this.lifecycle); + } + } + + @Override + public final synchronized void resume() { + if (this.lifecycle instanceof Pausable) { + ((Pausable) this.lifecycle).resume(); + this.paused = false; + } + else { + this.logger.warn( + "Attempted to resume a component that does not support Pausable " + + this.lifecycle); + } + } + + @Override + public final void unbind() { + this.stop(); + afterUnbind(); + } + + Lifecycle getEndpoint() { + return this.lifecycle; + } + + @Override + public String toString() { + return " Binding [name=" + this.name + ", target=" + this.target + ", lifecycle=" + + ((this.lifecycle instanceof NamedComponent) + ? ((NamedComponent) this.lifecycle).getComponentName() + : ObjectUtils.nullSafeToString(this.lifecycle)) + + "]"; + } + + /** + * Listener method that executes after unbinding. Subclasses can implement their own + * behaviour on unbinding by overriding this method. + */ + protected void afterUnbind() { + } + + private String getRunningState() { + return isRunning() ? "running" : "stopped"; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultPollableMessageSource.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultPollableMessageSource.java new file mode 100644 index 000000000..96220d46d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultPollableMessageSource.java @@ -0,0 +1,343 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.support.NameMatchMethodPointcutAdvisor; +import org.springframework.context.Lifecycle; +import org.springframework.core.AttributeAccessor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.integration.StaticMessageHeaderAccessor; +import org.springframework.integration.acks.AckUtils; +import org.springframework.integration.acks.AcknowledgmentCallback; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.core.MessageSource; +import org.springframework.integration.core.MessagingTemplate; +import org.springframework.integration.support.DefaultErrorMessageStrategy; +import org.springframework.integration.support.ErrorMessageStrategy; +import org.springframework.integration.support.ErrorMessageUtils; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.retry.RecoveryCallback; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +/** + * The default implementation of a {@link PollableMessageSource}. + * + * @author Gary Russell + * @author Oleg Zhurakousky + * @since 2.0 + * + */ +public class DefaultPollableMessageSource + implements PollableMessageSource, Lifecycle, RetryListener { + + protected static final ThreadLocal attributesHolder = new ThreadLocal(); + + private static final DirectChannel dummyChannel = new DirectChannel(); + + static { + dummyChannel.setBeanName("dummy.required.by.nonnull.api"); + } + + private final List interceptors = new ArrayList<>(); + + private final MessagingTemplate messagingTemplate = new MessagingTemplate(); + + private final SmartMessageConverter messageConverter; + + private MessageSource source; + + private RetryTemplate retryTemplate; + + private RecoveryCallback recoveryCallback; + + private MessageChannel errorChannel; + + private ErrorMessageStrategy errorMessageStrategy = new DefaultErrorMessageStrategy(); + + private BiConsumer> attributesProvider; + + private boolean running; + + /** + * @param messageConverter instance of {@link SmartMessageConverter}. Can be null. + */ + public DefaultPollableMessageSource( + @Nullable SmartMessageConverter messageConverter) { + this.messageConverter = messageConverter; + } + + public void setSource(MessageSource source) { + ProxyFactory pf = new ProxyFactory(source); + class ReceiveAdvice implements MethodInterceptor { + + private final List interceptors = new ArrayList<>(); + + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + Object result = invocation.proceed(); + if (result instanceof Message) { + Message received = (Message) result; + for (ChannelInterceptor interceptor : this.interceptors) { + received = interceptor.preSend(received, dummyChannel); + if (received == null) { + return null; + } + } + return received; + } + return result; + } + + } + final ReceiveAdvice advice = new ReceiveAdvice(); + advice.interceptors.addAll(this.interceptors); + NameMatchMethodPointcutAdvisor sourceAdvisor = new NameMatchMethodPointcutAdvisor( + advice); + sourceAdvisor.addMethodName("receive"); + pf.addAdvisor(sourceAdvisor); + this.source = (MessageSource) pf.getProxy(); + } + + public void setRetryTemplate(RetryTemplate retryTemplate) { + retryTemplate.registerListener(this); + this.retryTemplate = retryTemplate; + } + + public void setRecoveryCallback(RecoveryCallback recoveryCallback) { + this.recoveryCallback = context -> { + if (!shouldRequeue((MessagingException) context.getLastThrowable())) { + return recoveryCallback.recover(context); + } + throw (MessagingException) context.getLastThrowable(); + }; + } + + public void setErrorChannel(MessageChannel errorChannel) { + this.errorChannel = errorChannel; + } + + public void setErrorMessageStrategy(ErrorMessageStrategy errorMessageStrategy) { + Assert.notNull(errorMessageStrategy, "'errorMessageStrategy' cannot be null"); + this.errorMessageStrategy = errorMessageStrategy; + } + + public void setAttributesProvider( + BiConsumer> attributesProvider) { + this.attributesProvider = attributesProvider; + } + + public void addInterceptor(ChannelInterceptor interceptor) { + this.interceptors.add(interceptor); + } + + public void addInterceptor(int index, ChannelInterceptor interceptor) { + this.interceptors.add(index, interceptor); + } + + @Override + public synchronized boolean isRunning() { + return this.running; + } + + @Override + public synchronized void start() { + if (!this.running && this.source instanceof Lifecycle) { + ((Lifecycle) this.source).start(); + } + this.running = true; + } + + @Override + public synchronized void stop() { + if (this.running && this.source instanceof Lifecycle) { + ((Lifecycle) this.source).stop(); + } + this.running = false; + } + + @Override + public boolean poll(MessageHandler handler) { + return poll(handler, null); + } + + @Override + public boolean poll(MessageHandler handler, ParameterizedTypeReference type) { + Message message = this.receive(type); + if (message == null) { + return false; + } + + AcknowledgmentCallback ackCallback = StaticMessageHeaderAccessor + .getAcknowledgmentCallback(message); + try { + if (this.retryTemplate == null) { + this.handle(message, handler); + } + else { + this.retryTemplate.execute(context -> { + this.handle(message, handler); + return null; + }, this.recoveryCallback); + } + return true; + } + catch (MessagingException e) { + if (this.retryTemplate == null && !shouldRequeue(e)) { + this.messagingTemplate.send(this.errorChannel, this.errorMessageStrategy + .buildErrorMessage(e, attributesHolder.get())); + return true; + } + else if (!ackCallback.isAcknowledged() && shouldRequeue(e)) { + AckUtils.requeue(ackCallback); + return true; + } + else { + AckUtils.autoNack(ackCallback); + } + if (e.getFailedMessage().equals(message)) { + throw e; + } + throw new MessageHandlingException(message, e); + } + catch (Exception e) { + AckUtils.autoNack(ackCallback); + if (e instanceof MessageHandlingException && ((MessageHandlingException) e) + .getFailedMessage().equals(message)) { + throw (MessageHandlingException) e; + } + throw new MessageHandlingException(message, e); + } + finally { + AckUtils.autoAck(ackCallback); + } + } + + protected boolean shouldRequeue(Exception e) { + boolean requeue = false; + Throwable t = e.getCause(); + while (t != null && !requeue) { + requeue = t instanceof RequeueCurrentMessageException; + t = t.getCause(); + } + return requeue; + } + + @Override + public boolean open(RetryContext context, + RetryCallback callback) { + if (DefaultPollableMessageSource.this.recoveryCallback != null) { + attributesHolder.set(context); + } + return true; + } + + @Override + public void close(RetryContext context, + RetryCallback callback, Throwable throwable) { + attributesHolder.remove(); + } + + @Override + public void onError(RetryContext context, + RetryCallback callback, Throwable throwable) { + // Empty + } + + /** + * Receives Message from the source and converts its payload to a provided type. Can + * return null + * @param type type reference + * @return received message + */ + private Message receive(ParameterizedTypeReference type) { + Message message = this.source.receive(); + if (message != null && type != null && this.messageConverter != null) { + Class targetType = type == null ? Object.class + : type.getType() instanceof Class ? (Class) type.getType() + : Object.class; + Object payload = this.messageConverter.fromMessage(message, targetType, type); + if (payload == null) { + throw new MessageConversionException(message, + "No converter could convert Message"); + } + message = MessageBuilder.withPayload(payload) + .copyHeaders(message.getHeaders()).build(); + } + return message; + } + + private void doHandleMessage(MessageHandler handler, Message message) { + try { + handler.handleMessage(message); + } + catch (Throwable t) { // NOSONAR + throw new MessageHandlingException(message, t); + } + } + + /** + * If there's a retry template, it will set the attributes holder via the listener. If + * there's no retry template, but there's an error channel, we create a new attributes + * holder here. If an attributes holder exists (by either method), we set the + * attributes for use by the {@link ErrorMessageStrategy}. + * @param message the Spring Messaging message to use. + */ + private void setAttributesIfNecessary(Message message) { + boolean needHolder = this.errorChannel != null && this.retryTemplate == null; + boolean needAttributes = needHolder || this.retryTemplate != null; + if (needHolder) { + attributesHolder.set(ErrorMessageUtils.getAttributeAccessor(null, null)); + } + if (needAttributes) { + AttributeAccessor attributes = attributesHolder.get(); + if (attributes != null) { + attributes.setAttribute(ErrorMessageUtils.INPUT_MESSAGE_CONTEXT_KEY, + message); + if (this.attributesProvider != null) { + this.attributesProvider.accept(attributes, message); + } + } + } + } + + private void handle(Message message, MessageHandler handler) { + setAttributesIfNecessary(message); + doHandleMessage(handler, message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DirectHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DirectHandler.java new file mode 100644 index 000000000..ef8b4c536 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DirectHandler.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; + +/** + * @author Marius Bogoevici + */ +public class DirectHandler implements MessageHandler { + + private final MessageChannel outputChannel; + + public DirectHandler(MessageChannel outputChannel) { + this.outputChannel = outputChannel; + } + + @Override + public void handleMessage(Message message) throws MessagingException { + this.outputChannel.send(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/EmbeddedHeaderUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/EmbeddedHeaderUtils.java new file mode 100644 index 000000000..472967771 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/EmbeddedHeaderUtils.java @@ -0,0 +1,195 @@ +/* + * Copyright 2014-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import javax.xml.bind.DatatypeConverter; + +import org.springframework.integration.support.json.Jackson2JsonObjectMapper; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.ObjectUtils; + +/** + * Encodes requested headers into payload with format + * {@code 0xff, n(1), [ [lenHdr(1), hdr, lenValue(4), value] ... ]}. The 0xff indicates + * this new format; n is number of headers (max 255); for each header, the name length (1 + * byte) is followed by the name, followed by the value length (int) followed by the value + * (json). + *

    + * Previously, there was no leading 0xff; the value length was 1 byte and only String + * header values were supported (no JSON conversion). + * + * @author Eric Bottard + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @since 1.2 + */ +public abstract class EmbeddedHeaderUtils { + + private static final Jackson2JsonObjectMapper objectMapper = new Jackson2JsonObjectMapper(); + + public static String decodeExceptionMessage(Message requestMessage) { + return "Could not convert message: " + + DatatypeConverter.printHexBinary((byte[]) requestMessage.getPayload()); + } + + /** + * Return a new message where some of the original headers of {@code original} have + * been embedded into the new message payload. + * @param original original message + * @param headers headers to embedd + * @return a new message + * @throws Exception when message couldn't be generated + */ + public static byte[] embedHeaders(MessageValues original, String... headers) + throws Exception { + byte[][] headerValues = new byte[headers.length][]; + int n = 0; + int headerCount = 0; + int headersLength = 0; + for (String header : headers) { + Object value = original.get(header); + if (value != null) { + String json = objectMapper.toJson(value); + headerValues[n] = json.getBytes("UTF-8"); + headerCount++; + headersLength += header.length() + headerValues[n++].length; + } + else { + headerValues[n++] = null; + } + } + // 0xff, n(1), [ [lenHdr(1), hdr, lenValue(4), value] ... ] + byte[] newPayload = new byte[((byte[]) original.getPayload()).length + + headersLength + headerCount * 5 + 2]; + ByteBuffer byteBuffer = ByteBuffer.wrap(newPayload); + byteBuffer.put((byte) 0xff); // signal new format + byteBuffer.put((byte) headerCount); + for (int i = 0; i < headers.length; i++) { + if (headerValues[i] != null) { + byteBuffer.put((byte) headers[i].length()); + byteBuffer.put(headers[i].getBytes("UTF-8")); + byteBuffer.putInt(headerValues[i].length); + byteBuffer.put(headerValues[i]); + } + } + + byteBuffer.put((byte[]) original.getPayload()); + return byteBuffer.array(); + } + + /** + * Return a message where headers, that were originally embedded into the payload, + * have been promoted back to actual headers. The new payload is now the original + * payload. + * @param message the message to extract headers + * @param copyRequestHeaders boolean value to specify if the request headers should be + * copied + * @return wrapped message values + * @throws Exception when extraction failed + */ + public static MessageValues extractHeaders(Message message, + boolean copyRequestHeaders) throws Exception { + return extractHeaders(message.getPayload(), copyRequestHeaders, + message.getHeaders()); + } + + private static MessageValues extractHeaders(byte[] payload, + boolean copyRequestHeaders, MessageHeaders requestHeaders) throws Exception { + ByteBuffer byteBuffer = ByteBuffer.wrap(payload); + int headerCount = byteBuffer.get() & 0xff; + if (headerCount == 0xff) { + headerCount = byteBuffer.get() & 0xff; + Map headers = new HashMap(); + for (int i = 0; i < headerCount; i++) { + int len = byteBuffer.get() & 0xff; + String headerName = new String(payload, byteBuffer.position(), len, + "UTF-8"); + byteBuffer.position(byteBuffer.position() + len); + len = byteBuffer.getInt(); + String headerValue = new String(payload, byteBuffer.position(), len, + "UTF-8"); + Object headerContent = objectMapper.fromJson(headerValue, Object.class); + headers.put(headerName, headerContent); + byteBuffer.position(byteBuffer.position() + len); + } + byte[] newPayload = new byte[byteBuffer.remaining()]; + byteBuffer.get(newPayload); + return buildMessageValues(newPayload, headers, copyRequestHeaders, + requestHeaders); + } + else { + return buildMessageValues(payload, new HashMap<>(), copyRequestHeaders, + requestHeaders); + } + } + + /** + * Return a message where headers, that were originally embedded into the payload, + * have been promoted back to actual headers. The new payload is now the original + * payload. + * @param payload the message payload + * @return the message with extracted headers + * @throws Exception when extraction failed + */ + public static MessageValues extractHeaders(byte[] payload) throws Exception { + return extractHeaders(payload, false, null); + } + + private static MessageValues buildMessageValues(byte[] payload, + Map headers, boolean copyRequestHeaders, + MessageHeaders requestHeaders) { + MessageValues messageValues = new MessageValues(payload, headers); + if (copyRequestHeaders && requestHeaders != null) { + messageValues.copyHeadersIfAbsent(requestHeaders); + } + return messageValues; + } + + public static String[] headersToEmbed(String[] configuredHeaders) { + String[] headersToMap; + if (ObjectUtils.isEmpty(configuredHeaders)) { + headersToMap = BinderHeaders.STANDARD_HEADERS; + } + else { + String[] combinedHeadersToMap = Arrays.copyOfRange( + BinderHeaders.STANDARD_HEADERS, 0, + BinderHeaders.STANDARD_HEADERS.length + configuredHeaders.length); + System.arraycopy(configuredHeaders, 0, combinedHeadersToMap, + BinderHeaders.STANDARD_HEADERS.length, configuredHeaders.length); + headersToMap = combinedHeadersToMap; + } + return headersToMap; + } + + /** + * Return true if the bytes might have embedded headers. (First byte is 0xff and long + * enough for at least one header). + * @param bytes the array. + * @return true if it may have embedded headers. + */ + public static boolean mayHaveEmbeddedHeaders(byte[] bytes) { + return bytes.length > 8 && (bytes[0] & 0xff) == 0xff; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedBindingProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedBindingProperties.java new file mode 100644 index 000000000..23dc5b4b8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedBindingProperties.java @@ -0,0 +1,68 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.Map; + +/** + * Properties that extend the common binding properties for a particular binder + * implementation. + * + * @param consumer properties type + * @param

    producer properties type + * @author Marius Bogoevici + * @author Mark Fisher + * @author Soby Chacko + */ +public interface ExtendedBindingProperties { + + C getExtendedConsumerProperties(String channelName); + + P getExtendedProducerProperties(String channelName); + + default Map getBindings() { + return Collections.emptyMap(); + } + + /** + * Extended binding properties can define a default prefix to place all the extended + * common producer and consumer properties. For example, if the binder type is foo it + * is convenient to specify common extended properties for the producer or consumer + * across multiple bindings in the form of + * `spring.cloud.stream.foo.default.producer.x=y` or + * `spring.cloud.stream.foo.default.consumer.x=y`. + * + * The binding process will use this defaults prefix to resolve any common extended + * producer and consumer properties. + * @return default prefix for extended properties + * @since 2.1.0 + */ + String getDefaultsPrefix(); + + /** + * + * Extended properties class which should be a subclass of + * {@link BinderSpecificPropertiesProvider} against which default extended producer + * and consumer properties are resolved. + * @return extended properties class that contains extended producer/consumer + * properties + * @since 2.1.0 + */ + Class getExtendedPropertiesEntryClass(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedConsumerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedConsumerProperties.java new file mode 100644 index 000000000..03c491c07 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedConsumerProperties.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * Extension of {@link ConsumerProperties} to be used with an + * {@link ExtendedPropertiesBinder}. + * + * @param extension type + * @author Marius Bogoevici + */ +public class ExtendedConsumerProperties extends ConsumerProperties { + + private T extension; + + public ExtendedConsumerProperties(T extension) { + this.extension = extension; + } + + public T getExtension() { + return this.extension; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedProducerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedProducerProperties.java new file mode 100644 index 000000000..c01b67193 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedProducerProperties.java @@ -0,0 +1,35 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * @param extension type + * @author Marius Bogoevici + */ +public class ExtendedProducerProperties extends ProducerProperties { + + private T extension; + + public ExtendedProducerProperties(T extension) { + this.extension = extension; + } + + public T getExtension() { + return this.extension; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinder.java new file mode 100644 index 000000000..f440c143d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinder.java @@ -0,0 +1,34 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * Extension of {@link Binder} that takes {@link ExtendedConsumerProperties} and + * {@link ExtendedProducerProperties} as arguments. In addition to supporting binding + * operations, it allows the binder to provide values for the additional properties it + * expects on the bindings. + * + * @param binder type + * @param consumer type + * @param

    producer type + * @author Marius Bogoevici + */ +public interface ExtendedPropertiesBinder + extends Binder, ExtendedProducerProperties

    >, + ExtendedBindingProperties { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/FinalRethrowingErrorMessageHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/FinalRethrowingErrorMessageHandler.java new file mode 100644 index 000000000..cf4e2d393 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/FinalRethrowingErrorMessageHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; + +/** + * A MessageHandler that always is the last subscriber (on a {@link BinderErrorChannel}) + * that throws an exception if it the only subscriber (aside from the bridge to the global + * error channel). It is typically only used if a binder implementation does not return a + * handled from {@code getErrorMessageHandler()}. + * + * @author Gary Russell + * @since 1.3 + * + */ +class FinalRethrowingErrorMessageHandler + implements MessageHandler, LastSubscriberMessageHandler { + + private final LastSubscriberAwareChannel errorChannel; + + private final boolean defaultErrorChannelPresent; + + FinalRethrowingErrorMessageHandler(LastSubscriberAwareChannel errorChannel, + boolean defaultErrorChannelPresent) { + this.errorChannel = errorChannel; + this.defaultErrorChannelPresent = defaultErrorChannelPresent; + } + + @Override + public void handleMessage(Message message) throws MessagingException { + if (this.errorChannel.subscribers() > (this.defaultErrorChannelPresent ? 2 : 1)) { + // user has subscribed; default is 2, this and the bridge to the + // errorChannel + return; + } + if (message.getPayload() instanceof MessagingException) { + throw (MessagingException) message.getPayload(); + } + else { + throw new MessagingException((Message) null, + (Throwable) message.getPayload()); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/HeaderMode.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/HeaderMode.java new file mode 100644 index 000000000..96a120578 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/HeaderMode.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * @author Marius Bogoevici + * @author Gary Russell + */ +public enum HeaderMode { + + /** + * @deprecated - use {@link #none}. + */ + raw, + + /** + * No headers. + */ + none, + + /** + * Native headers. + */ + headers, + + /** + * Headers embedded in payload - e.g. kafka < 0.11 + */ + embeddedHeaders + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/JavaClassMimeTypeUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/JavaClassMimeTypeUtils.java new file mode 100644 index 000000000..eced820c0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/JavaClassMimeTypeUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Handles representing any java class as a {@link MimeType}. + * + * @author David Turanski + * @author Ilayaperumal Gopinathan + * @author Soby Chacko + */ +public abstract class JavaClassMimeTypeUtils { + + private static ConcurrentMap mimeTypesCache = new ConcurrentHashMap<>(); + + /** + * Convert payload to {@link MimeType} based on the content type on the message. + * @param payload the payload to convert + * @param originalContentType content type on the message + * @return converted {@link MimeType} + */ + public static MimeType mimeTypeFromObject(Object payload, + String originalContentType) { + Assert.notNull(payload, "payload object cannot be null."); + if (payload instanceof byte[]) { + return MimeTypeUtils.APPLICATION_OCTET_STREAM; + } + if (payload instanceof String) { + return MimeTypeUtils.APPLICATION_JSON_VALUE.equals(originalContentType) + ? MimeTypeUtils.APPLICATION_JSON : MimeTypeUtils.TEXT_PLAIN; + } + String className = payload.getClass().getName(); + MimeType mimeType = mimeTypesCache.get(className); + if (mimeType == null) { + String modifiedClassName = className; + if (payload.getClass().isArray()) { + // Need to remove trailing ';' for an object array, e.g. + // "[Ljava.lang.String;" or multi-dimensional + // "[[[Ljava.lang.String;" + if (modifiedClassName.endsWith(";")) { + modifiedClassName = modifiedClassName.substring(0, + modifiedClassName.length() - 1); + } + // Wrap in quotes to handle the illegal '[' character + modifiedClassName = "\"" + modifiedClassName + "\""; + } + mimeType = MimeType + .valueOf("application/x-java-object;type=" + modifiedClassName); + mimeTypesCache.put(className, mimeType); + } + return mimeType; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberAwareChannel.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberAwareChannel.java new file mode 100644 index 000000000..4d4609dfb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberAwareChannel.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.SubscribableChannel; + +/** + * A channel that can ensure a {@code LastSubscriberMessageHandler} is always the last + * subscriber. + * + * @author Gary Russell + * @since 1.3 + * + */ +interface LastSubscriberAwareChannel extends SubscribableChannel { + + /** + * Return the current subscribers. + * @return the subscribers. + */ + int subscribers(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberMessageHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberMessageHandler.java new file mode 100644 index 000000000..e1fa2fd44 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/LastSubscriberMessageHandler.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.MessageHandler; + +/** + * A marker interface designating a subscriber that must be the last. + * + * @author Gary Russell + * @since 1.3 + * + */ +public interface LastSubscriberMessageHandler extends MessageHandler { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/MessageValues.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/MessageValues.java new file mode 100644 index 000000000..772136fb8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/MessageValues.java @@ -0,0 +1,166 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.integration.support.MessageBuilder; +import org.springframework.integration.support.MessageBuilderFactory; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * A mutable type for allowing {@link Binder} implementations to transform and enrich + * message content more efficiently. + * + * @author David Turanski + * @author Marius Bogoevici + */ +public class MessageValues implements Map { + + private Map headers = new HashMap<>(); + + private Object payload; + + /** + * Create an instance from a {@link Message}. + * @param message the message + */ + public MessageValues(Message message) { + this.payload = message.getPayload(); + for (Map.Entry header : message.getHeaders().entrySet()) { + this.headers.put(header.getKey(), header.getValue()); + } + } + + public MessageValues(Object payload, Map headers) { + this.payload = payload; + this.headers.putAll(headers); + } + + /** + * @return the payload + */ + public Object getPayload() { + return this.payload; + } + + /** + * Set the payload. + * @param payload any non null object. + */ + public void setPayload(Object payload) { + Assert.notNull(payload, "'payload' cannot be null"); + this.payload = payload; + } + + public Map getHeaders() { + return this.headers; + } + + /** + * Convert to a {@link Message} using a + * {@link org.springframework.integration.support.MessageBuilderFactory}. + * @param messageBuilderFactory the MessageBuilderFactory + * @return the Message + */ + public Message toMessage(MessageBuilderFactory messageBuilderFactory) { + return messageBuilderFactory.withPayload(this.payload).copyHeaders(this.headers) + .build(); + } + + /** + * Convert to a {@link Message} using a the default + * {@link org.springframework.integration.support.MessageBuilder}. + * @return the Message + */ + public Message toMessage() { + return MessageBuilder.withPayload(this.payload).copyHeaders(this.headers).build(); + } + + @Override + public int size() { + return this.headers.size(); + } + + @Override + public boolean isEmpty() { + return this.headers.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return this.headers.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return this.headers.containsValue(value); + } + + @Override + public Object get(Object key) { + return this.headers.get(key); + } + + @Override + public Object put(String key, Object value) { + return this.headers.put(key, value); + } + + @Override + public Object remove(Object key) { + return this.headers.remove(key); + } + + @Override + public void putAll(Map m) { + this.headers.putAll(m); + } + + @Override + public void clear() { + this.headers.clear(); + } + + @Override + public Set keySet() { + return this.headers.keySet(); + } + + @Override + public Collection values() { + return this.headers.values(); + } + + @Override + public Set> entrySet() { + return this.headers.entrySet(); + } + + public void copyHeadersIfAbsent(Map headersToCopy) { + for (Entry headersToCopyEntry : headersToCopy.entrySet()) { + if (!containsKey(headersToCopyEntry.getKey())) { + put(headersToCopyEntry.getKey(), headersToCopyEntry.getValue()); + } + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionHandler.java new file mode 100644 index 000000000..34010c2cd --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionHandler.java @@ -0,0 +1,123 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.expression.EvaluationContext; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * Utility class to determine if a binding is configured for partitioning (based on the + * binder properties provided in the constructor) and what partition a message should be + * delivered to. + * + * @author Patrick Peralta + * @author David Turanski + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Mark Fisher + * @author Marius Bogoevici + */ +public class PartitionHandler { + + private final EvaluationContext evaluationContext; + + private final ProducerProperties producerProperties; + + private final PartitionKeyExtractorStrategy partitionKeyExtractorStrategy; + + private final PartitionSelectorStrategy partitionSelectorStrategy; + + private volatile int partitionCount; + + /** + * Construct a {@code PartitionHandler}. + * @param evaluationContext evaluation context for binder + * @param properties binder properties + * @param partitionKeyExtractorStrategy PartitionKeyExtractor strategy + * @param partitionSelectorStrategy PartitionSelector strategy + */ + public PartitionHandler(EvaluationContext evaluationContext, + ProducerProperties properties, + PartitionKeyExtractorStrategy partitionKeyExtractorStrategy, + PartitionSelectorStrategy partitionSelectorStrategy) { + this.evaluationContext = evaluationContext; + this.producerProperties = properties; + this.partitionKeyExtractorStrategy = partitionKeyExtractorStrategy; + this.partitionSelectorStrategy = partitionSelectorStrategy; + this.partitionCount = this.producerProperties.getPartitionCount(); + } + + /** + * Set the actual partition count (if different to the configured count). + * @param partitionCount the count. + */ + public void setPartitionCount(int partitionCount) { + this.partitionCount = partitionCount; + } + + /** + * Determine the partition to which to send this message. + *

    + * If a partition key extractor class is provided, it is invoked to determine the key. + * Otherwise, the partition key expression is evaluated to obtain the key value. + *

    + * If a partition selector class is provided, it will be invoked to determine the + * partition. Otherwise, if the partition expression is not null, it is evaluated + * against the key and is expected to return an integer to which the modulo function + * will be applied, using the {@code partitionCount} as the divisor. If no partition + * expression is provided, the key will be passed to the binder partition strategy + * along with the {@code partitionCount}. The default partition strategy uses + * {@code key.hashCode()}, and the result will be the mod of that value. + * @param message the message. + * @return the partition + */ + public int determinePartition(Message message) { + Object key = extractKey(message); + + int partition; + if (this.producerProperties.getPartitionSelectorExpression() != null) { + partition = this.producerProperties.getPartitionSelectorExpression() + .getValue(this.evaluationContext, key, Integer.class); + } + else { + partition = this.partitionSelectorStrategy.selectPartition(key, + this.partitionCount); + } + // protection in case a user selector returns a negative. + return Math.abs(partition % this.partitionCount); + } + + private Object extractKey(Message message) { + Object key = invokeKeyExtractor(message); + if (key == null && this.producerProperties.getPartitionKeyExpression() != null) { + key = this.producerProperties.getPartitionKeyExpression() + .getValue(this.evaluationContext, message); + } + Assert.notNull(key, "Partition key cannot be null"); + + return key; + } + + private Object invokeKeyExtractor(Message message) { + if (this.partitionKeyExtractorStrategy != null) { + return this.partitionKeyExtractorStrategy.extractKey(message); + } + return null; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionKeyExtractorStrategy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionKeyExtractorStrategy.java new file mode 100644 index 000000000..e842bbd0c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionKeyExtractorStrategy.java @@ -0,0 +1,30 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.Message; + +/** + * Strategy for extracting a partition key from a Message. + * + * @author Gary Russell + */ +public interface PartitionKeyExtractorStrategy { + + Object extractKey(Message message); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionSelectorStrategy.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionSelectorStrategy.java new file mode 100644 index 000000000..c63b3fb79 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PartitionSelectorStrategy.java @@ -0,0 +1,38 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * Strategy for determining the partition to which a message should be sent. + * + * @author Gary Russell + */ +public interface PartitionSelectorStrategy { + + /** + * Determine the partition based on a key. The partitionCount is 1 greater than the + * maximum value of a valid partition. Typical implementations will return + * {@code someValue % partitionCount}. The caller will apply that same modulo + * operation (as well as enforcing absolute value) if the value exceeds partitionCount + * - 1. + * @param key the key + * @param partitionCount the number of partitions + * @return the partition + */ + int selectPartition(Object key, int partitionCount); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableConsumerBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableConsumerBinder.java new file mode 100644 index 000000000..94ed3ef54 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableConsumerBinder.java @@ -0,0 +1,44 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * A binder that supports pollable message sources. + * + * @param the polled consumer handler type. + * @param the consumer properties type. + * @author Gary Russell + * @since 2.0 + * + */ +public interface PollableConsumerBinder { + + /** + * Configure a binding for a pollable message source. + * @param name the binding name. + * @param group the consumer group. + * @param inboundBindTarget the binding target. + * @param consumerProperties the consumer properties. + * @return the binding. + */ + default Binding> bindPollableConsumer(String name, String group, + PollableSource inboundBindTarget, C consumerProperties) { + throw new UnsupportedOperationException( + "This binder does not support pollable consumers"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableMessageSource.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableMessageSource.java new file mode 100644 index 000000000..17ee952cb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableMessageSource.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.messaging.MessageHandler; + +/** + * A {@link PollableSource} that calls a {@link MessageHandler} with a + * {@link org.springframework.messaging.Message}. + * + * @author Gary Russell + * @since 2.0 + * + */ +public interface PollableMessageSource extends PollableSource { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableSource.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableSource.java new file mode 100644 index 000000000..d2772d413 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/PollableSource.java @@ -0,0 +1,51 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.core.ParameterizedTypeReference; + +/** + * A mechanism to poll a consumer. + * + * @param the handler type to process the result of the poll. + * @author Gary Russell + * @since 2.0 + * + */ +@FunctionalInterface +public interface PollableSource { + + /** + * Poll the consumer. + * @param handler the handler. + * @return true if a message was handled. + */ + boolean poll(H handler); + + /** + * Poll the consumer and convert the payload to the type. Throw a + * {@code RequeueCurrentMessageException} to force the current message to be requeued + * in the broker (after retries are exhausted, if configured). + * @param handler the handler. + * @param type the type. + * @return true if a message was handled. + */ + default boolean poll(H handler, ParameterizedTypeReference type) { + return poll(handler); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ProducerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ProducerProperties.java new file mode 100644 index 000000000..de9971630 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/ProducerProperties.java @@ -0,0 +1,222 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.IOException; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.Min; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.springframework.expression.Expression; + +/** + * Common producer properties. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Oleg Zhurakousky + */ +@JsonInclude(Include.NON_DEFAULT) +public class ProducerProperties { + + /** + * Signals if this producer needs to be started automatically. Default: true + */ + private boolean autoStartup = true; + + @JsonSerialize(using = ExpressionSerializer.class) + private Expression partitionKeyExpression; + + /** + * @deprecated in favor of 'partitionKeyExtractorName' + */ + @Deprecated + private Class partitionKeyExtractorClass; + + /** + * The name of the bean that implements {@link PartitionKeyExtractorStrategy}\. Used + * to extract a key used to compute the partition id (see 'partitionSelector*')
    + * Mutually exclusive with 'partitionKeyExpression'. + */ + private String partitionKeyExtractorName; + + /** + * @deprecated in favor of 'partitionSelectorName' + */ + @Deprecated + private Class partitionSelectorClass; + + /** + * The name of the bean that implements {@link PartitionSelectorStrategy}\. Used to + * determine partition id based on partition key (see 'partitionKeyExtractor*').
    + * Mutually exclusive with 'partitionSelectorExpression'. + */ + private String partitionSelectorName; + + @JsonSerialize(using = ExpressionSerializer.class) + private Expression partitionSelectorExpression; + + private int partitionCount = 1; + + private String[] requiredGroups = new String[] {}; + + private HeaderMode headerMode; + + private boolean useNativeEncoding = false; + + private boolean errorChannelEnabled = false; + + public Expression getPartitionKeyExpression() { + return this.partitionKeyExpression; + } + + public void setPartitionKeyExpression(Expression partitionKeyExpression) { + this.partitionKeyExpression = partitionKeyExpression; + } + + @Deprecated + public Class getPartitionKeyExtractorClass() { + return this.partitionKeyExtractorClass; + } + + @Deprecated + public void setPartitionKeyExtractorClass(Class partitionKeyExtractorClass) { + this.partitionKeyExtractorClass = partitionKeyExtractorClass; + } + + public boolean isPartitioned() { + return this.partitionKeyExpression != null + || this.partitionKeyExtractorName != null + || this.partitionKeyExtractorClass != null; + } + + @Deprecated + public Class getPartitionSelectorClass() { + return this.partitionSelectorClass; + } + + @Deprecated + public void setPartitionSelectorClass(Class partitionSelectorClass) { + this.partitionSelectorClass = partitionSelectorClass; + } + + public Expression getPartitionSelectorExpression() { + return this.partitionSelectorExpression; + } + + public void setPartitionSelectorExpression(Expression partitionSelectorExpression) { + this.partitionSelectorExpression = partitionSelectorExpression; + } + + @Min(value = 1, message = "Partition count should be greater than zero.") + public int getPartitionCount() { + return this.partitionCount; + } + + public void setPartitionCount(int partitionCount) { + this.partitionCount = partitionCount; + } + + public String[] getRequiredGroups() { + return this.requiredGroups; + } + + public void setRequiredGroups(String... requiredGroups) { + this.requiredGroups = requiredGroups; + } + + @AssertTrue(message = "Partition key expression and partition key extractor class properties are mutually exclusive.") + public boolean isValidPartitionKeyProperty() { + return (this.partitionKeyExpression == null) + || (this.partitionKeyExtractorClass == null); + } + + @AssertTrue(message = "Partition selector class and partition selector expression properties are mutually exclusive.") + public boolean isValidPartitionSelectorProperty() { + return (this.partitionSelectorClass == null) + || (this.partitionSelectorExpression == null); + } + + public HeaderMode getHeaderMode() { + return this.headerMode; + } + + public void setHeaderMode(HeaderMode headerMode) { + this.headerMode = headerMode; + } + + public boolean isUseNativeEncoding() { + return this.useNativeEncoding; + } + + public void setUseNativeEncoding(boolean useNativeEncoding) { + this.useNativeEncoding = useNativeEncoding; + } + + public boolean isErrorChannelEnabled() { + return this.errorChannelEnabled; + } + + public void setErrorChannelEnabled(boolean errorChannelEnabled) { + this.errorChannelEnabled = errorChannelEnabled; + } + + public String getPartitionKeyExtractorName() { + return this.partitionKeyExtractorName; + } + + public void setPartitionKeyExtractorName(String partitionKeyExtractorName) { + this.partitionKeyExtractorName = partitionKeyExtractorName; + } + + public String getPartitionSelectorName() { + return this.partitionSelectorName; + } + + public void setPartitionSelectorName(String partitionSelectorName) { + this.partitionSelectorName = partitionSelectorName; + } + + public boolean isAutoStartup() { + return this.autoStartup; + } + + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + + static class ExpressionSerializer extends JsonSerializer { + + @Override + public void serialize(Expression expression, JsonGenerator jsonGenerator, + SerializerProvider serializerProvider) throws IOException { + if (expression != null) { + jsonGenerator.writeString(expression.getExpressionString()); + } + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/RequeueCurrentMessageException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/RequeueCurrentMessageException.java new file mode 100644 index 000000000..eb9f162eb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/RequeueCurrentMessageException.java @@ -0,0 +1,47 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +/** + * When using a {@code PollableMessageSource} throw this exception to cause the current + * message to be requeued in the broker so that it will be redelivered on the next poll. + * + * @author Gary Russell + * @since 2.1 + * + */ +public class RequeueCurrentMessageException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public RequeueCurrentMessageException() { + super(); + } + + public RequeueCurrentMessageException(String message, Throwable cause) { + super(message, cause); + } + + public RequeueCurrentMessageException(String message) { + super(message); + } + + public RequeueCurrentMessageException(Throwable cause) { + super(cause); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingLifecycle.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingLifecycle.java new file mode 100644 index 000000000..83e0a436b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingLifecycle.java @@ -0,0 +1,83 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Map; + +import org.springframework.context.SmartLifecycle; + +/** + * Base implementation of lifecycle operations for {@link BindingService} aware + * {@link Bindable}s. + * + * @author Oleg Zhurakousky + * @see InputBindingLifecycle + * @see OutputBindingLifecycle + */ +abstract class AbstractBindingLifecycle implements SmartLifecycle { + + final BindingService bindingService; + + private final Map bindables; + + private volatile boolean running; + + AbstractBindingLifecycle(BindingService bindingService, + Map bindables) { + this.bindingService = bindingService; + this.bindables = bindables; + } + + @Override + public void start() { + if (!this.running) { + this.bindables.values().forEach(this::doStartWithBindable); + this.running = true; + } + } + + @Override + public void stop() { + if (this.running) { + this.bindables.values().forEach(this::doStopWithBindable); + this.running = false; + } + } + + @Override + public boolean isRunning() { + return this.running; + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void stop(Runnable callback) { + stop(); + if (callback != null) { + callback.run(); + } + } + + abstract void doStartWithBindable(Bindable bindable); + + abstract void doStopWithBindable(Bindable bindable); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingTargetFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingTargetFactory.java new file mode 100644 index 000000000..a222240b6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/AbstractBindingTargetFactory.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.util.Assert; + +/** + * A {@link BindingTargetFactory} implementation that restricts the type of binding target + * to a specified class and its supertypes. + * + * @param type of binding target + * @author Marius Bogoevici + */ +public abstract class AbstractBindingTargetFactory implements BindingTargetFactory { + + private final Class bindingTargetType; + + protected AbstractBindingTargetFactory(Class bindingTargetType) { + Assert.notNull(bindingTargetType, "The binding target type cannot be null"); + this.bindingTargetType = bindingTargetType; + } + + @Override + public final boolean canCreate(Class clazz) { + return clazz.isAssignableFrom(this.bindingTargetType); + } + + @Override + public abstract T createInput(String name); + + @Override + public abstract T createOutput(String name); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/Bindable.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/Bindable.java new file mode 100644 index 000000000..07cb4b657 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/Bindable.java @@ -0,0 +1,106 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import org.springframework.cloud.stream.binder.Binding; + +/** + * Marker interface for instances that can bind/unbind groups of inputs and outputs. + * + * Intended for internal use. + * + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +public interface Bindable { + + /** + * Binds all the inputs associated with this instance. + * @deprecated as of 2.0 in favor of {@link #createAndBindInputs(BindingService)} + * @param adapter binding service + */ + @Deprecated + default void bindInputs(BindingService adapter) { + this.createAndBindInputs(adapter); + } + + /** + * Binds all the inputs associated with this instance. + * @param adapter instance of {@link BindingService} + * @return collection of {@link Binding}s + * + * @since 2.0 + */ + default Collection> createAndBindInputs(BindingService adapter) { + return Collections.>emptyList(); + } + + /** + * Binds all the outputs associated with this instance. + * @deprecated as of 2.0 in favor of {@link #createAndBindOutputs(BindingService)} + * @param adapter binding service + */ + @Deprecated + default void bindOutputs(BindingService adapter) { + } + + /** + * Binds all the outputs associated with this instance. + * @param adapter instance of {@link BindingService} + * @return collection of {@link Binding}s + * + * @since 2.0 + */ + default Collection> createAndBindOutputs(BindingService adapter) { + return Collections.>emptyList(); + } + + /** + * Unbinds all the inputs associated with this instance. + * @param adapter binding service + */ + default void unbindInputs(BindingService adapter) { + } + + /** + * Unbinds all the outputs associated with this instance. + * @param adapter binding service + */ + default void unbindOutputs(BindingService adapter) { + } + + /** + * Enumerates all the input binding names. + * @return input binding names + */ + default Set getInputs() { + return Collections.emptySet(); + } + + /** + * Enumerates all the output binding names. + * @return output binding names + */ + default Set getOutputs() { + return Collections.emptySet(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindableProxyFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindableProxyFactory.java new file mode 100644 index 000000000..b30b81337 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindableProxyFactory.java @@ -0,0 +1,365 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.stream.aggregate.SharedBindingTargetRegistry; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.internal.InternalPropertyNames; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * {@link FactoryBean} for instantiating the interfaces specified via + * {@link EnableBinding}. + * + * @author Marius Bogoevici + * @author David Syer + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + * @see EnableBinding + */ +public class BindableProxyFactory + implements MethodInterceptor, FactoryBean, Bindable, InitializingBean { + + private static Log log = LogFactory.getLog(BindableProxyFactory.class); + + private final Map targetCache = new HashMap<>(2); + + @Value("${" + InternalPropertyNames.NAMESPACE_PROPERTY_NAME + ":}") + private String namespace; + + @Autowired(required = false) + private SharedBindingTargetRegistry sharedBindingTargetRegistry; + + @Autowired + private Map bindingTargetFactories; + + private Class type; + + private Object proxy; + + private Map inputHolders = new HashMap<>(); + + private Map outputHolders = new HashMap<>(); + + public BindableProxyFactory(Class type) { + this.type = type; + } + + @Override + public synchronized Object invoke(MethodInvocation invocation) throws Throwable { + Method method = invocation.getMethod(); + + // try to use cached target + Object boundTarget = this.targetCache.get(method); + if (boundTarget != null) { + return boundTarget; + } + + Input input = AnnotationUtils.findAnnotation(method, Input.class); + if (input != null) { + String name = BindingBeanDefinitionRegistryUtils.getBindingTargetName(input, + method); + boundTarget = this.inputHolders.get(name).getBoundTarget(); + this.targetCache.put(method, boundTarget); + return boundTarget; + } + else { + Output output = AnnotationUtils.findAnnotation(method, Output.class); + if (output != null) { + String name = BindingBeanDefinitionRegistryUtils + .getBindingTargetName(output, method); + boundTarget = this.outputHolders.get(name).getBoundTarget(); + this.targetCache.put(method, boundTarget); + return boundTarget; + } + } + return null; + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notEmpty(BindableProxyFactory.this.bindingTargetFactories, + "'bindingTargetFactories' cannot be empty"); + ReflectionUtils.doWithMethods(this.type, new ReflectionUtils.MethodCallback() { + @Override + public void doWith(Method method) throws IllegalArgumentException { + Input input = AnnotationUtils.findAnnotation(method, Input.class); + if (input != null) { + String name = BindingBeanDefinitionRegistryUtils + .getBindingTargetName(input, method); + Class returnType = method.getReturnType(); + Object sharedBindingTarget = locateSharedBindingTarget(name, + returnType); + if (sharedBindingTarget != null) { + BindableProxyFactory.this.inputHolders.put(name, + new BoundTargetHolder(sharedBindingTarget, false)); + } + else { + BindableProxyFactory.this.inputHolders.put(name, + new BoundTargetHolder(getBindingTargetFactory(returnType) + .createInput(name), true)); + } + } + } + }); + ReflectionUtils.doWithMethods(this.type, new ReflectionUtils.MethodCallback() { + @Override + public void doWith(Method method) throws IllegalArgumentException { + Output output = AnnotationUtils.findAnnotation(method, Output.class); + if (output != null) { + String name = BindingBeanDefinitionRegistryUtils + .getBindingTargetName(output, method); + Class returnType = method.getReturnType(); + Object sharedBindingTarget = locateSharedBindingTarget(name, + returnType); + if (sharedBindingTarget != null) { + BindableProxyFactory.this.outputHolders.put(name, + new BoundTargetHolder(sharedBindingTarget, false)); + } + else { + BindableProxyFactory.this.outputHolders.put(name, + new BoundTargetHolder(getBindingTargetFactory(returnType) + .createOutput(name), true)); + } + } + } + }); + } + + private BindingTargetFactory getBindingTargetFactory(Class bindingTargetType) { + List candidateBindingTargetFactories = new ArrayList<>(); + for (Map.Entry bindingTargetFactoryEntry : this.bindingTargetFactories + .entrySet()) { + if (bindingTargetFactoryEntry.getValue().canCreate(bindingTargetType)) { + candidateBindingTargetFactories.add(bindingTargetFactoryEntry.getKey()); + } + } + if (candidateBindingTargetFactories.size() == 1) { + return this.bindingTargetFactories + .get(candidateBindingTargetFactories.get(0)); + } + else { + if (candidateBindingTargetFactories.size() == 0) { + throw new IllegalStateException( + "No factory found for binding target type: " + + bindingTargetType.getName() + + " among registered factories: " + + StringUtils.collectionToCommaDelimitedString( + this.bindingTargetFactories.keySet())); + } + else { + throw new IllegalStateException( + "Multiple factories found for binding target type: " + + bindingTargetType.getName() + ": " + + StringUtils.collectionToCommaDelimitedString( + candidateBindingTargetFactories)); + } + } + } + + private T locateSharedBindingTarget(String name, Class bindingTargetType) { + return this.sharedBindingTargetRegistry != null + ? this.sharedBindingTargetRegistry.get( + getNamespacePrefixedBindingTargetName(name), bindingTargetType) + : null; + } + + private String getNamespacePrefixedBindingTargetName(String name) { + return this.namespace + "." + name; + } + + @Override + public synchronized Object getObject() throws Exception { + if (this.proxy == null) { + ProxyFactory factory = new ProxyFactory(this.type, this); + this.proxy = factory.getProxy(); + } + return this.proxy; + } + + @Override + public Class getObjectType() { + return this.type; + } + + @Override + public boolean isSingleton() { + return true; + } + + /** + * @deprecated in favor of {@link #createAndBindInputs(BindingService)} + */ + @Override + @Deprecated + public void bindInputs(BindingService bindingService) { + this.createAndBindInputs(bindingService); + } + + @Override + public Collection> createAndBindInputs( + BindingService bindingService) { + List> bindings = new ArrayList<>(); + if (log.isDebugEnabled()) { + log.debug( + String.format("Binding inputs for %s:%s", this.namespace, this.type)); + } + for (Map.Entry boundTargetHolderEntry : this.inputHolders + .entrySet()) { + String inputTargetName = boundTargetHolderEntry.getKey(); + BoundTargetHolder boundTargetHolder = boundTargetHolderEntry.getValue(); + if (boundTargetHolder.isBindable()) { + if (log.isDebugEnabled()) { + log.debug(String.format("Binding %s:%s:%s", this.namespace, this.type, + inputTargetName)); + } + bindings.addAll(bindingService.bindConsumer( + boundTargetHolder.getBoundTarget(), inputTargetName)); + } + } + return bindings; + } + + /** + * @deprecated in favor of {@link #createAndBindOutputs(BindingService)} + */ + @Override + @Deprecated + public void bindOutputs(BindingService bindingService) { + this.createAndBindOutputs(bindingService); + } + + @Override + public Collection> createAndBindOutputs( + BindingService bindingService) { + List> bindings = new ArrayList<>(); + if (log.isDebugEnabled()) { + log.debug(String.format("Binding outputs for %s:%s", this.namespace, + this.type)); + } + for (Map.Entry boundTargetHolderEntry : this.outputHolders + .entrySet()) { + BoundTargetHolder boundTargetHolder = boundTargetHolderEntry.getValue(); + String outputTargetName = boundTargetHolderEntry.getKey(); + if (boundTargetHolderEntry.getValue().isBindable()) { + if (log.isDebugEnabled()) { + log.debug(String.format("Binding %s:%s:%s", this.namespace, this.type, + outputTargetName)); + } + bindings.add(bindingService.bindProducer( + boundTargetHolder.getBoundTarget(), outputTargetName)); + } + } + return bindings; + } + + @Override + public void unbindInputs(BindingService bindingService) { + if (log.isDebugEnabled()) { + log.debug(String.format("Unbinding inputs for %s:%s", this.namespace, + this.type)); + } + for (Map.Entry boundTargetHolderEntry : this.inputHolders + .entrySet()) { + if (boundTargetHolderEntry.getValue().isBindable()) { + if (log.isDebugEnabled()) { + log.debug(String.format("Unbinding %s:%s:%s", this.namespace, + this.type, boundTargetHolderEntry.getKey())); + } + bindingService.unbindConsumers(boundTargetHolderEntry.getKey()); + } + } + } + + @Override + public void unbindOutputs(BindingService bindingService) { + if (log.isDebugEnabled()) { + log.debug(String.format("Unbinding outputs for %s:%s", this.namespace, + this.type)); + } + for (Map.Entry boundTargetHolderEntry : this.outputHolders + .entrySet()) { + if (boundTargetHolderEntry.getValue().isBindable()) { + if (log.isDebugEnabled()) { + log.debug(String.format("Binding %s:%s:%s", this.namespace, this.type, + boundTargetHolderEntry.getKey())); + } + bindingService.unbindProducers(boundTargetHolderEntry.getKey()); + } + } + } + + @Override + public Set getInputs() { + return this.inputHolders.keySet(); + } + + @Override + public Set getOutputs() { + return this.outputHolders.keySet(); + } + + /** + * Holds information about the binding targets exposed by the interface proxy, as well + * as their status. + */ + private final class BoundTargetHolder { + + private Object boundTarget; + + private boolean bindable; + + private BoundTargetHolder(Object boundTarget, boolean bindable) { + this.boundTarget = boundTarget; + this.bindable = bindable; + } + + public Object getBoundTarget() { + return this.boundTarget; + } + + public boolean isBindable() { + return this.bindable; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareChannelResolver.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareChannelResolver.java new file mode 100644 index 000000000..f2b2c046b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareChannelResolver.java @@ -0,0 +1,175 @@ +/* + * Copyright 2013-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.integration.config.GlobalChannelInterceptorProcessor; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.core.BeanFactoryMessageChannelDestinationResolver; +import org.springframework.messaging.core.DestinationResolutionException; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * A {@link org.springframework.messaging.core.DestinationResolver} implementation that + * resolves the channel from the bean factory and, if not present, creates a new channel + * and adds it to the factory after binding it to the binder. + * + * @author Mark Fisher + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class BinderAwareChannelResolver + extends BeanFactoryMessageChannelDestinationResolver { + + private final BindingService bindingService; + + private final AbstractBindingTargetFactory bindingTargetFactory; + + private final DynamicDestinationsBindable dynamicDestinationsBindable; + + @SuppressWarnings("rawtypes") + private final NewDestinationBindingCallback newBindingCallback; + + private ConfigurableListableBeanFactory beanFactory; + + public BinderAwareChannelResolver(BindingService bindingService, + AbstractBindingTargetFactory bindingTargetFactory, + DynamicDestinationsBindable dynamicDestinationsBindable) { + this(bindingService, bindingTargetFactory, dynamicDestinationsBindable, null, + null); + } + + @SuppressWarnings("rawtypes") + public BinderAwareChannelResolver(BindingService bindingService, + AbstractBindingTargetFactory bindingTargetFactory, + DynamicDestinationsBindable dynamicDestinationsBindable, + NewDestinationBindingCallback callback) { + this(bindingService, bindingTargetFactory, dynamicDestinationsBindable, callback, + null); + } + + /** + * @deprecated since GlobalChannelInterceptorProcessor is no longer used + * @param bindingService service to bind inputs and outputs + * @param bindingTargetFactory implementation that restricts the type of binding + * target to a specified class and its supertypes + * @param dynamicDestinationsBindable stores the dynamic destination names and handles + * their unbinding. + * @param callback used to configure a new destination before it is bound. + * @param globalChannelInterceptorProcessor applies global interceptors to message + * channel beans + */ + @SuppressWarnings("rawtypes") + @Deprecated + public BinderAwareChannelResolver(BindingService bindingService, + AbstractBindingTargetFactory bindingTargetFactory, + DynamicDestinationsBindable dynamicDestinationsBindable, + NewDestinationBindingCallback callback, + GlobalChannelInterceptorProcessor globalChannelInterceptorProcessor) { + this.dynamicDestinationsBindable = dynamicDestinationsBindable; + Assert.notNull(bindingService, "'bindingService' cannot be null"); + Assert.notNull(bindingTargetFactory, "'bindingTargetFactory' cannot be null"); + this.bindingService = bindingService; + this.bindingTargetFactory = bindingTargetFactory; + this.newBindingCallback = callback; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) { + super.setBeanFactory(beanFactory); + Assert.isTrue(beanFactory instanceof ConfigurableListableBeanFactory, + "'beanFactory' must be an instance of ConfigurableListableBeanFactory"); + this.beanFactory = (ConfigurableListableBeanFactory) beanFactory; + } + + /* + * See the following for more discussion on it as well as demo reproducing it, thanks + * to Anshul Mehra (@Walliee) + * https://github.com/spring-cloud/spring-cloud-stream/issues/1603 + */ + @SuppressWarnings("unchecked") + @Override + public synchronized MessageChannel resolveDestination(String channelName) { + BindingServiceProperties bindingServiceProperties = this.bindingService + .getBindingServiceProperties(); + String[] dynamicDestinations = bindingServiceProperties.getDynamicDestinations(); + + MessageChannel channel; + boolean dynamicAllowed = ObjectUtils.isEmpty(dynamicDestinations) + || ObjectUtils.containsElement(dynamicDestinations, channelName); + try { + channel = super.resolveDestination(channelName); + } + catch (DestinationResolutionException e) { + if (!dynamicAllowed) { + throw e; + } + else { + channel = this.bindingTargetFactory.createOutput(channelName); + this.beanFactory.registerSingleton(channelName, channel); + channel = (MessageChannel) this.beanFactory.initializeBean(channel, + channelName); + if (this.newBindingCallback != null) { + ProducerProperties producerProperties = bindingServiceProperties + .getProducerProperties(channelName); + Object extendedProducerProperties = this.bindingService + .getExtendedProducerProperties(channel, channelName); + this.newBindingCallback.configure(channelName, channel, + producerProperties, extendedProducerProperties); + bindingServiceProperties.updateProducerProperties(channelName, + producerProperties); + } + Binding binding = this.bindingService + .bindProducer(channel, channelName); + this.dynamicDestinationsBindable.addOutputBinding(channelName, binding); + } + } + return channel; + } + + /** + * Configure a new destination before it is bound. + * + * @param the extended properties type. If you need to support dynamic binding + * with multiple binders, use {@link Object} and cast as needed. + * @since 2.0 + * + */ + @FunctionalInterface + public interface NewDestinationBindingCallback { + + /** + * Configure the properties or channel before binding. + * @param channelName the name of the new channel. + * @param channel the channel that is about to be bound. + * @param producerProperties the producer properties. + * @param extendedProducerProperties the extended producer properties (type + * depends on binder type and may be null if the binder doesn't support extended + * properties). + */ + void configure(String channelName, MessageChannel channel, + ProducerProperties producerProperties, T extendedProducerProperties); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareRouterBeanPostProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareRouterBeanPostProcessor.java new file mode 100644 index 000000000..e91a5e5e5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BinderAwareRouterBeanPostProcessor.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.integration.router.AbstractMappingMessageRouter; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.core.DestinationResolver; + +/** + * A {@link BeanPostProcessor} that sets a {@link BinderAwareChannelResolver} on any bean + * of type {@link AbstractMappingMessageRouter} within the context. + * + * @author Mark Fisher + * @author Gary Russell + * @author Oleg Zhurakousky + * @deprecated as of 2.0, will be renamed/replaced as it is no longer a BPP and naming is + * a bit confusing + */ +@Deprecated +public class BinderAwareRouterBeanPostProcessor { + + public BinderAwareRouterBeanPostProcessor(AbstractMappingMessageRouter[] routers, + DestinationResolver channelResolver) { + if (routers != null) { + for (AbstractMappingMessageRouter router : routers) { + router.setChannelResolver(channelResolver); + } + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingBeanDefinitionRegistryUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingBeanDefinitionRegistryUtils.java new file mode 100644 index 000000000..6d07f75a1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingBeanDefinitionRegistryUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Map; + +import org.springframework.beans.factory.BeanDefinitionStoreException; +import org.springframework.beans.factory.support.AutowireCandidateQualifier; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.cloud.stream.annotation.Bindings; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * Utility class for registering bean definitions for binding targets. + * + * @author Marius Bogoevici + * @author Dave Syer + * @author Artem Bilan + */ +@SuppressWarnings("deprecation") +public abstract class BindingBeanDefinitionRegistryUtils { + + public static void registerInputBindingTargetBeanDefinition(String qualifierValue, + String name, String bindingTargetInterfaceBeanName, + String bindingTargetInterfaceMethodName, BeanDefinitionRegistry registry) { + registerBindingTargetBeanDefinition(Input.class, qualifierValue, name, + bindingTargetInterfaceBeanName, bindingTargetInterfaceMethodName, + registry); + } + + public static void registerOutputBindingTargetBeanDefinition(String qualifierValue, + String name, String bindingTargetInterfaceBeanName, + String bindingTargetInterfaceMethodName, BeanDefinitionRegistry registry) { + registerBindingTargetBeanDefinition(Output.class, qualifierValue, name, + bindingTargetInterfaceBeanName, bindingTargetInterfaceMethodName, + registry); + } + + private static void registerBindingTargetBeanDefinition( + Class qualifier, String qualifierValue, String name, + String bindingTargetInterfaceBeanName, + String bindingTargetInterfaceMethodName, BeanDefinitionRegistry registry) { + + if (registry.containsBeanDefinition(name)) { + throw new BeanDefinitionStoreException(bindingTargetInterfaceBeanName, name, + "bean definition with this name already exists - " + + registry.getBeanDefinition(name)); + } + + RootBeanDefinition rootBeanDefinition = new RootBeanDefinition(); + rootBeanDefinition.setFactoryBeanName(bindingTargetInterfaceBeanName); + rootBeanDefinition.setUniqueFactoryMethodName(bindingTargetInterfaceMethodName); + rootBeanDefinition + .addQualifier(new AutowireCandidateQualifier(qualifier, qualifierValue)); + registry.registerBeanDefinition(name, rootBeanDefinition); + } + + public static void registerBindingTargetBeanDefinitions(Class type, + final String bindingTargetInterfaceBeanName, + final BeanDefinitionRegistry registry) { + ReflectionUtils.doWithMethods(type, method -> { + Input input = AnnotationUtils.findAnnotation(method, Input.class); + if (input != null) { + String name = getBindingTargetName(input, method); + registerInputBindingTargetBeanDefinition(input.value(), name, + bindingTargetInterfaceBeanName, method.getName(), registry); + } + Output output = AnnotationUtils.findAnnotation(method, Output.class); + if (output != null) { + String name = getBindingTargetName(output, method); + registerOutputBindingTargetBeanDefinition(output.value(), name, + bindingTargetInterfaceBeanName, method.getName(), registry); + } + }); + } + + public static void registerBindingTargetsQualifiedBeanDefinitions(Class parent, + Class type, final BeanDefinitionRegistry registry) { + + if (type.isInterface()) { + RootBeanDefinition rootBeanDefinition = new RootBeanDefinition( + BindableProxyFactory.class); + rootBeanDefinition + .addQualifier(new AutowireCandidateQualifier(Bindings.class, parent)); + rootBeanDefinition.getConstructorArgumentValues() + .addGenericArgumentValue(type); + registry.registerBeanDefinition(type.getName(), rootBeanDefinition); + } + else { + RootBeanDefinition rootBeanDefinition = new RootBeanDefinition(type); + rootBeanDefinition + .addQualifier(new AutowireCandidateQualifier(Bindings.class, parent)); + registry.registerBeanDefinition(type.getName(), rootBeanDefinition); + } + } + + public static String getBindingTargetName(Annotation annotation, Method method) { + Map attrs = AnnotationUtils.getAnnotationAttributes(annotation, + false); + if (attrs.containsKey("value") + && StringUtils.hasText((CharSequence) attrs.get("value"))) { + return (String) attrs.get("value"); + } + return method.getName(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingService.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingService.java new file mode 100644 index 000000000..554c675b8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingService.java @@ -0,0 +1,396 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.sql.Date; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeanUtils; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ExtendedConsumerProperties; +import org.springframework.cloud.stream.binder.ExtendedProducerProperties; +import org.springframework.cloud.stream.binder.ExtendedPropertiesBinder; +import org.springframework.cloud.stream.binder.PollableConsumerBinder; +import org.springframework.cloud.stream.binder.PollableSource; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.DataBinder; +import org.springframework.validation.beanvalidation.CustomValidatorBean; + +/** + * Handles binding of input/output targets by delegating to an underlying {@link Binder}. + * + * @author Mark Fisher + * @author Dave Syer + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Janne Valkealahti + * @author Soby Chacko + */ +public class BindingService { + + private final CustomValidatorBean validator; + + private final Log log = LogFactory.getLog(BindingService.class); + + private final BindingServiceProperties bindingServiceProperties; + + private final Map> producerBindings = new HashMap<>(); + + private final Map>> consumerBindings = new HashMap<>(); + + private final TaskScheduler taskScheduler; + + private final BinderFactory binderFactory; + + public BindingService(BindingServiceProperties bindingServiceProperties, + BinderFactory binderFactory) { + this(bindingServiceProperties, binderFactory, null); + } + + public BindingService(BindingServiceProperties bindingServiceProperties, + BinderFactory binderFactory, TaskScheduler taskScheduler) { + this.bindingServiceProperties = bindingServiceProperties; + this.binderFactory = binderFactory; + this.validator = new CustomValidatorBean(); + this.validator.afterPropertiesSet(); + this.taskScheduler = taskScheduler; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Collection> bindConsumer(T input, String inputName) { + Collection> bindings = new ArrayList<>(); + Binder binder = (Binder) getBinder( + inputName, input.getClass()); + ConsumerProperties consumerProperties = this.bindingServiceProperties + .getConsumerProperties(inputName); + if (binder instanceof ExtendedPropertiesBinder) { + Object extension = ((ExtendedPropertiesBinder) binder) + .getExtendedConsumerProperties(inputName); + ExtendedConsumerProperties extendedConsumerProperties = new ExtendedConsumerProperties( + extension); + BeanUtils.copyProperties(consumerProperties, extendedConsumerProperties); + + consumerProperties = extendedConsumerProperties; + } + + validate(consumerProperties); + + String bindingTarget = this.bindingServiceProperties + .getBindingDestination(inputName); + + if (consumerProperties.isMultiplex()) { + bindings.add(doBindConsumer(input, inputName, binder, consumerProperties, + bindingTarget)); + } + else { + String[] bindingTargets = StringUtils + .commaDelimitedListToStringArray(bindingTarget); + for (String target : bindingTargets) { + Binding binding = input instanceof PollableSource + ? doBindPollableConsumer(input, inputName, binder, + consumerProperties, target) + : doBindConsumer(input, inputName, binder, consumerProperties, + target); + + bindings.add(binding); + } + } + bindings = Collections.unmodifiableCollection(bindings); + this.consumerBindings.put(inputName, new ArrayList<>(bindings)); + return bindings; + } + + public Binding doBindConsumer(T input, String inputName, + Binder binder, + ConsumerProperties consumerProperties, String target) { + if (this.taskScheduler == null + || this.bindingServiceProperties.getBindingRetryInterval() <= 0) { + return binder.bindConsumer(target, + this.bindingServiceProperties.getGroup(inputName), input, + consumerProperties); + } + else { + try { + return binder.bindConsumer(target, + this.bindingServiceProperties.getGroup(inputName), input, + consumerProperties); + } + catch (RuntimeException e) { + LateBinding late = new LateBinding(); + rescheduleConsumerBinding(input, inputName, binder, consumerProperties, + target, late, e); + return late; + } + } + } + + public void rescheduleConsumerBinding(final T input, final String inputName, + final Binder binder, + final ConsumerProperties consumerProperties, final String target, + final LateBinding late, RuntimeException exception) { + assertNotIllegalException(exception); + this.log.error("Failed to create consumer binding; retrying in " + + this.bindingServiceProperties.getBindingRetryInterval() + " seconds", + exception); + this.scheduleTask(() -> { + try { + late.setDelegate(binder.bindConsumer(target, + this.bindingServiceProperties.getGroup(inputName), input, + consumerProperties)); + } + catch (RuntimeException e) { + rescheduleConsumerBinding(input, inputName, binder, consumerProperties, + target, late, e); + } + }); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Binding doBindPollableConsumer(T input, String inputName, + Binder binder, + ConsumerProperties consumerProperties, String target) { + if (this.taskScheduler == null + || this.bindingServiceProperties.getBindingRetryInterval() <= 0) { + return ((PollableConsumerBinder) binder).bindPollableConsumer(target, + this.bindingServiceProperties.getGroup(inputName), + (PollableSource) input, consumerProperties); + } + else { + try { + return ((PollableConsumerBinder) binder).bindPollableConsumer(target, + this.bindingServiceProperties.getGroup(inputName), + (PollableSource) input, consumerProperties); + } + catch (RuntimeException e) { + LateBinding late = new LateBinding(); + reschedulePollableConsumerBinding(input, inputName, binder, + consumerProperties, target, late, e); + return late; + } + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void reschedulePollableConsumerBinding(final T input, + final String inputName, final Binder binder, + final ConsumerProperties consumerProperties, final String target, + final LateBinding late, RuntimeException exception) { + assertNotIllegalException(exception); + this.log.error("Failed to create consumer binding; retrying in " + + this.bindingServiceProperties.getBindingRetryInterval() + " seconds", + exception); + this.scheduleTask(() -> { + try { + late.setDelegate(((PollableConsumerBinder) binder).bindPollableConsumer( + target, this.bindingServiceProperties.getGroup(inputName), + (PollableSource) input, consumerProperties)); + } + catch (RuntimeException e) { + reschedulePollableConsumerBinding(input, inputName, binder, + consumerProperties, target, late, e); + } + }); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Binding bindProducer(T output, String outputName) { + String bindingTarget = this.bindingServiceProperties + .getBindingDestination(outputName); + Binder binder = (Binder) getBinder( + outputName, output.getClass()); + ProducerProperties producerProperties = this.bindingServiceProperties + .getProducerProperties(outputName); + if (binder instanceof ExtendedPropertiesBinder) { + Object extension = ((ExtendedPropertiesBinder) binder) + .getExtendedProducerProperties(outputName); + ExtendedProducerProperties extendedProducerProperties = new ExtendedProducerProperties<>( + extension); + BeanUtils.copyProperties(producerProperties, extendedProducerProperties); + + producerProperties = extendedProducerProperties; + } + validate(producerProperties); + Binding binding = doBindProducer(output, bindingTarget, binder, + producerProperties); + this.producerBindings.put(outputName, binding); + return binding; + } + + @SuppressWarnings("rawtypes") + public Object getExtendedProducerProperties(Object output, String outputName) { + Binder binder = getBinder(outputName, output.getClass()); + if (binder instanceof ExtendedPropertiesBinder) { + return ((ExtendedPropertiesBinder) binder) + .getExtendedProducerProperties(outputName); + } + return null; + } + + public Binding doBindProducer(T output, String bindingTarget, + Binder binder, + ProducerProperties producerProperties) { + if (this.taskScheduler == null + || this.bindingServiceProperties.getBindingRetryInterval() <= 0) { + return binder.bindProducer(bindingTarget, output, producerProperties); + } + else { + try { + return binder.bindProducer(bindingTarget, output, producerProperties); + } + catch (RuntimeException e) { + LateBinding late = new LateBinding(); + rescheduleProducerBinding(output, bindingTarget, binder, + producerProperties, late, e); + return late; + } + } + } + + public void rescheduleProducerBinding(final T output, final String bindingTarget, + final Binder binder, + final ProducerProperties producerProperties, final LateBinding late, + final RuntimeException exception) { + assertNotIllegalException(exception); + this.log.error("Failed to create producer binding; retrying in " + + this.bindingServiceProperties.getBindingRetryInterval() + " seconds", + exception); + this.scheduleTask(() -> { + try { + late.setDelegate( + binder.bindProducer(bindingTarget, output, producerProperties)); + } + catch (RuntimeException e) { + rescheduleProducerBinding(output, bindingTarget, binder, + producerProperties, late, e); + } + }); + } + + public void unbindConsumers(String inputName) { + List> bindings = this.consumerBindings.remove(inputName); + if (bindings != null && !CollectionUtils.isEmpty(bindings)) { + for (Binding binding : bindings) { + binding.unbind(); + } + } + else if (this.log.isWarnEnabled()) { + this.log.warn("Trying to unbind '" + inputName + "', but no binding found."); + } + } + + public void unbindProducers(String outputName) { + Binding binding = this.producerBindings.remove(outputName); + if (binding != null) { + binding.unbind(); + } + else if (this.log.isWarnEnabled()) { + this.log.warn("Trying to unbind '" + outputName + "', but no binding found."); + } + } + + /** + * Provided for backwards compatibility. Will be removed in a future version. + * @return {@link BindingServiceProperties} + */ + @Deprecated + public BindingServiceProperties getChannelBindingServiceProperties() { + return this.bindingServiceProperties; + } + + public BindingServiceProperties getBindingServiceProperties() { + return this.bindingServiceProperties; + } + + protected Binder getBinder(String channelName, Class bindableType) { + String binderConfigurationName = this.bindingServiceProperties + .getBinder(channelName); + return this.binderFactory.getBinder(binderConfigurationName, bindableType); + } + + private void validate(Object properties) { + DataBinder dataBinder = new DataBinder(properties); + dataBinder.setValidator(this.validator); + dataBinder.validate(); + if (dataBinder.getBindingResult().hasErrors()) { + throw new IllegalStateException(dataBinder.getBindingResult().toString()); + } + } + + private void scheduleTask(Runnable task) { + this.taskScheduler.schedule(task, new Date(System.currentTimeMillis() + + this.bindingServiceProperties.getBindingRetryInterval() * 1_000)); + } + + private void assertNotIllegalException(RuntimeException exception) + throws RuntimeException { + if (exception instanceof IllegalStateException + || exception instanceof IllegalArgumentException) { + throw exception; + } + } + + private static class LateBinding implements Binding { + + private volatile Binding delegate; + + private volatile boolean unbound; + + LateBinding() { + super(); + } + + public synchronized void setDelegate(Binding delegate) { + if (this.unbound) { + delegate.unbind(); + } + else { + this.delegate = delegate; + } + } + + @Override + public synchronized void unbind() { + this.unbound = true; + if (this.delegate != null) { + this.delegate.unbind(); + } + } + + @Override + public String toString() { + return "LateBinding [delegate=" + this.delegate + "]"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingTargetFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingTargetFactory.java new file mode 100644 index 000000000..43d16c8a4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/BindingTargetFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +/** + * Defines methods to create/configure the binding targets defined by + * {@link org.springframework.cloud.stream.annotation.EnableBinding}. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +public interface BindingTargetFactory { + + /** + * Checks whether a specific binding target type can be created by this factory. + * @param clazz the binding target type + * @return true if the binding target can be created + */ + boolean canCreate(Class clazz); + + /** + * Create an input binding target that will be bound via a corresponding + * {@link org.springframework.cloud.stream.binder.Binder}. + * @param name name of the binding target + * @return binding target + */ + Object createInput(String name); + + /** + * Create an output binding target that will be bound via a corresponding + * {@link org.springframework.cloud.stream.binder.Binder}. + * @param name name of the binding target + * @return binding target + */ + Object createOutput(String name); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/CompositeMessageChannelConfigurer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/CompositeMessageChannelConfigurer.java new file mode 100644 index 000000000..3cf40be90 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/CompositeMessageChannelConfigurer.java @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.List; + +import org.springframework.cloud.stream.binder.PollableMessageSource; +import org.springframework.messaging.MessageChannel; + +/** + * {@link MessageChannelConfigurer} that composes all the message channel configurers. + * + * @author Ilayaperumal Gopinathan + */ +public class CompositeMessageChannelConfigurer + implements MessageChannelAndSourceConfigurer { + + private final List messageChannelConfigurers; + + public CompositeMessageChannelConfigurer( + List messageChannelConfigurers) { + this.messageChannelConfigurers = messageChannelConfigurers; + } + + @Override + public void configureInputChannel(MessageChannel messageChannel, String channelName) { + for (MessageChannelConfigurer messageChannelConfigurer : this.messageChannelConfigurers) { + messageChannelConfigurer.configureInputChannel(messageChannel, channelName); + } + } + + @Override + public void configureOutputChannel(MessageChannel messageChannel, + String channelName) { + for (MessageChannelConfigurer messageChannelConfigurer : this.messageChannelConfigurers) { + messageChannelConfigurer.configureOutputChannel(messageChannel, channelName); + } + } + + @Override + public void configurePolledMessageSource(PollableMessageSource binding, String name) { + this.messageChannelConfigurers.forEach(cconfigurer -> { + if (cconfigurer instanceof MessageChannelAndSourceConfigurer) { + ((MessageChannelAndSourceConfigurer) cconfigurer) + .configurePolledMessageSource(binding, name); + } + }); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/ContextStartAfterRefreshListener.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/ContextStartAfterRefreshListener.java new file mode 100644 index 000000000..0785bd153 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/ContextStartAfterRefreshListener.java @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.event.ContextRefreshedEvent; + +/** + * Automatically starts the context after a refresh. + * + * @author Marius Bogoevici + */ +public class ContextStartAfterRefreshListener + implements ApplicationListener, ApplicationContextAware { + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + ConfigurableApplicationContext source = (ConfigurableApplicationContext) event + .getSource(); + if (source == this.applicationContext && !source.isRunning()) { + source.start(); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DispatchingStreamListenerMessageHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DispatchingStreamListenerMessageHandler.java new file mode 100644 index 000000000..9efd38543 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DispatchingStreamListenerMessageHandler.java @@ -0,0 +1,149 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * An {@link AbstractReplyProducingMessageHandler} that delegates to a collection of + * internal {@link ConditionalStreamListenerMessageHandlerWrapper} instances, executing + * the ones that match the given expression. + * + * @author Marius Bogoevici + * @since 1.2 + */ +final class DispatchingStreamListenerMessageHandler + extends AbstractReplyProducingMessageHandler { + + private final List handlerMethods; + + private final boolean evaluateExpressions; + + private final EvaluationContext evaluationContext; + + DispatchingStreamListenerMessageHandler( + Collection handlerMethods, + EvaluationContext evaluationContext) { + Assert.notEmpty(handlerMethods, "'handlerMethods' cannot be empty"); + this.handlerMethods = Collections + .unmodifiableList(new ArrayList<>(handlerMethods)); + boolean evaluateExpressions = false; + for (ConditionalStreamListenerMessageHandlerWrapper handlerMethod : handlerMethods) { + if (handlerMethod.getCondition() != null) { + evaluateExpressions = true; + break; + } + } + this.evaluateExpressions = evaluateExpressions; + if (evaluateExpressions) { + Assert.notNull(evaluationContext, + "'evaluationContext' cannot be null if conditions are used"); + } + this.evaluationContext = evaluationContext; + } + + @Override + protected boolean shouldCopyRequestHeaders() { + return false; + } + + @Override + protected Object handleRequestMessage(Message requestMessage) { + List matchingHandlers = this.evaluateExpressions + ? findMatchingHandlers(requestMessage) : this.handlerMethods; + if (matchingHandlers.size() == 0) { + if (this.logger.isWarnEnabled()) { + this.logger.warn( + "Cannot find a @StreamListener matching for message with id: " + + requestMessage.getHeaders().getId()); + } + return null; + } + else if (matchingHandlers.size() > 1) { + for (ConditionalStreamListenerMessageHandlerWrapper matchingMethod : matchingHandlers) { + matchingMethod.getStreamListenerMessageHandler() + .handleMessage(requestMessage); + } + return null; + } + else { + final ConditionalStreamListenerMessageHandlerWrapper singleMatchingHandler = matchingHandlers + .get(0); + singleMatchingHandler.getStreamListenerMessageHandler() + .handleMessage(requestMessage); + return null; + } + } + + private List findMatchingHandlers( + Message message) { + ArrayList matchingMethods = new ArrayList<>(); + for (ConditionalStreamListenerMessageHandlerWrapper wrapper : this.handlerMethods) { + if (wrapper.getCondition() == null) { + matchingMethods.add(wrapper); + } + else { + boolean conditionMetOnMessage = wrapper.getCondition() + .getValue(this.evaluationContext, message, Boolean.class); + if (conditionMetOnMessage) { + matchingMethods.add(wrapper); + } + } + } + return matchingMethods; + } + + static class ConditionalStreamListenerMessageHandlerWrapper { + + private final Expression condition; + + private final StreamListenerMessageHandler streamListenerMessageHandler; + + ConditionalStreamListenerMessageHandlerWrapper(Expression condition, + StreamListenerMessageHandler streamListenerMessageHandler) { + Assert.notNull(streamListenerMessageHandler, + "the message handler cannot be null"); + Assert.isTrue(condition == null || streamListenerMessageHandler.isVoid(), + "cannot specify a condition and a return value at the same time"); + this.condition = condition; + this.streamListenerMessageHandler = streamListenerMessageHandler; + } + + public Expression getCondition() { + return this.condition; + } + + public boolean isVoid() { + return this.streamListenerMessageHandler.isVoid(); + } + + public StreamListenerMessageHandler getStreamListenerMessageHandler() { + return this.streamListenerMessageHandler; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DynamicDestinationsBindable.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DynamicDestinationsBindable.java new file mode 100644 index 000000000..b0658ab46 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/DynamicDestinationsBindable.java @@ -0,0 +1,57 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.cloud.stream.binder.Binding; + +/** + * A {@link Bindable} that stores the dynamic destination names and handles their + * unbinding. + * + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public final class DynamicDestinationsBindable implements Bindable { + + /** + * Map containing dynamic channel names and their bindings. + */ + private final Map> outputBindings = new HashMap<>(); + + public synchronized void addOutputBinding(String name, Binding binding) { + this.outputBindings.put(name, binding); + } + + @Override + public synchronized Set getOutputs() { + return Collections.unmodifiableSet(this.outputBindings.keySet()); + } + + @Override + public synchronized void unbindOutputs(BindingService adapter) { + for (Map.Entry> entry : this.outputBindings.entrySet()) { + entry.getValue().unbind(); + } + this.outputBindings.clear(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/InputBindingLifecycle.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/InputBindingLifecycle.java new file mode 100644 index 000000000..1055dd358 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/InputBindingLifecycle.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.util.CollectionUtils; + +/** + * Coordinates binding/unbinding of input binding targets in accordance to the lifecycle + * of the host context. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class InputBindingLifecycle extends AbstractBindingLifecycle { + + @SuppressWarnings("unused") + // It is actually used reflectively since at the moment we do not want to expose it + // via public method + private Collection> inputBindings = new ArrayList>(); + + public InputBindingLifecycle(BindingService bindingService, + Map bindables) { + super(bindingService, bindables); + } + + /** + * Return a high value so that this bean is started after receiving Lifecycle beans + * are started. Beans that need to start after bindings will set a higher phase value. + */ + @Override + public int getPhase() { + return Integer.MAX_VALUE - 1000; + } + + @Override + void doStartWithBindable(Bindable bindable) { + Collection> bindableBindings = bindable + .createAndBindInputs(this.bindingService); + if (!CollectionUtils.isEmpty(bindableBindings)) { + this.inputBindings.addAll(bindableBindings); + } + } + + @Override + void doStopWithBindable(Bindable bindable) { + bindable.unbindInputs(this.bindingService); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelAndSourceConfigurer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelAndSourceConfigurer.java new file mode 100644 index 000000000..d88bd03a1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelAndSourceConfigurer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.cloud.stream.binder.PollableMessageSource; + +/** + * Configurer for {@link PollableMessageSource}. + * + * @author Gary Russell + * @since 2.0 + * + */ +public interface MessageChannelAndSourceConfigurer extends MessageChannelConfigurer { + + /** + * Configure the provided message source binding. + * @param binding the binding. + * @param name the name. + */ + void configurePolledMessageSource(PollableMessageSource binding, String name); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelConfigurer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelConfigurer.java new file mode 100644 index 000000000..0fa3c2698 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelConfigurer.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.messaging.MessageChannel; + +/** + * Interface to be implemented by the classes that configure the {@link Bindable} message + * channels. + * + * @author Ilayaperumal Gopinathan + */ +public interface MessageChannelConfigurer { + + /** + * Configure the given input message channel. + * @param messageChannel the message channel + * @param channelName name of the message channel + */ + void configureInputChannel(MessageChannel messageChannel, String channelName); + + /** + * Configure the given output message channel. + * @param messageChannel the message channel + * @param channelName name of the message channel + */ + void configureOutputChannel(MessageChannel messageChannel, String channelName); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelStreamListenerResultAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelStreamListenerResultAdapter.java new file mode 100644 index 000000000..16ce41dff --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageChannelStreamListenerResultAdapter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.io.Closeable; +import java.io.IOException; + +import org.springframework.integration.handler.BridgeHandler; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; + +/** + * A {@link StreamListenerResultAdapter} used for bridging an + * {@link org.springframework.cloud.stream.annotation.Output} {@link MessageChannel} to a + * bound {@link MessageChannel}. + * + * @author Marius Bogoevici + * @author Soby Chacko + */ +public class MessageChannelStreamListenerResultAdapter + implements StreamListenerResultAdapter { + + @Override + public boolean supports(Class resultType, Class bindingTarget) { + return MessageChannel.class.isAssignableFrom(resultType) + && MessageChannel.class.isAssignableFrom(bindingTarget); + } + + @Override + public Closeable adapt(MessageChannel streamListenerResult, + MessageChannel bindingTarget) { + BridgeHandler handler = new BridgeHandler(); + handler.setOutputChannel(bindingTarget); + handler.afterPropertiesSet(); + ((SubscribableChannel) streamListenerResult).subscribe(handler); + + return new NoOpCloseeable(); + } + + private static final class NoOpCloseeable implements Closeable { + + @Override + public void close() throws IOException { + + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageConverterConfigurer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageConverterConfigurer.java new file mode 100644 index 000000000..38cf7d7ce --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageConverterConfigurer.java @@ -0,0 +1,474 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.stream.binder.BinderException; +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.DefaultPollableMessageSource; +import org.springframework.cloud.stream.binder.JavaClassMimeTypeUtils; +import org.springframework.cloud.stream.binder.PartitionHandler; +import org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy; +import org.springframework.cloud.stream.binder.PartitionSelectorStrategy; +import org.springframework.cloud.stream.binder.PollableMessageSource; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.converter.MessageConverterUtils; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.integration.expression.ExpressionUtils; +import org.springframework.integration.support.MessageBuilderFactory; +import org.springframework.integration.support.MutableMessageBuilderFactory; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * A {@link MessageChannelConfigurer} that sets data types and message converters based on + * {@link org.springframework.cloud.stream.config.BindingProperties#contentType}. Also + * adds a {@link org.springframework.messaging.support.ChannelInterceptor} to the message + * channel to set the `ContentType` header for the message (if not already set) based on + * the `ContentType` binding property of the channel. + * + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @author Maxim Kirilov + * @author Gary Russell + * @author Soby Chacko + * @author Oleg Zhurakousky + */ +public class MessageConverterConfigurer + implements MessageChannelAndSourceConfigurer, BeanFactoryAware { + + private final Log logger = LogFactory.getLog(getClass()); + + private final MessageBuilderFactory messageBuilderFactory = new MutableMessageBuilderFactory(); + + private final CompositeMessageConverterFactory compositeMessageConverterFactory; + + private final BindingServiceProperties bindingServiceProperties; + + private final Field headersField; + + private ConfigurableListableBeanFactory beanFactory; + + public MessageConverterConfigurer(BindingServiceProperties bindingServiceProperties, + CompositeMessageConverterFactory compositeMessageConverterFactory) { + Assert.notNull(compositeMessageConverterFactory, + "The message converter factory cannot be null"); + this.bindingServiceProperties = bindingServiceProperties; + this.compositeMessageConverterFactory = compositeMessageConverterFactory; + + this.headersField = ReflectionUtils.findField(MessageHeaders.class, "headers"); + this.headersField.setAccessible(true); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = (ConfigurableListableBeanFactory) beanFactory; + } + + @Override + public void configureInputChannel(MessageChannel messageChannel, String channelName) { + configureMessageChannel(messageChannel, channelName, true); + } + + @Override + public void configureOutputChannel(MessageChannel messageChannel, + String channelName) { + configureMessageChannel(messageChannel, channelName, false); + } + + @Override + public void configurePolledMessageSource(PollableMessageSource binding, String name) { + BindingProperties bindingProperties = this.bindingServiceProperties + .getBindingProperties(name); + String contentType = bindingProperties.getContentType(); + ConsumerProperties consumerProperties = bindingProperties.getConsumer(); + if ((consumerProperties == null || !consumerProperties.isUseNativeDecoding()) + && binding instanceof DefaultPollableMessageSource) { + ((DefaultPollableMessageSource) binding).addInterceptor( + new InboundContentTypeEnhancingInterceptor(contentType)); + } + } + + /** + * Setup data-type and message converters for the given message channel. + * @param channel message channel to set the data-type and message converters + * @param channelName the channel name + * @param inbound inbound (i.e., "input") or outbound channel + */ + private void configureMessageChannel(MessageChannel channel, String channelName, + boolean inbound) { + Assert.isAssignable(AbstractMessageChannel.class, channel.getClass()); + AbstractMessageChannel messageChannel = (AbstractMessageChannel) channel; + BindingProperties bindingProperties = this.bindingServiceProperties + .getBindingProperties(channelName); + String contentType = bindingProperties.getContentType(); + ProducerProperties producerProperties = bindingProperties.getProducer(); + if (!inbound && producerProperties != null + && producerProperties.isPartitioned()) { + messageChannel.addInterceptor(new PartitioningInterceptor(bindingProperties, + getPartitionKeyExtractorStrategy(producerProperties), + getPartitionSelectorStrategy(producerProperties))); + } + + ConsumerProperties consumerProperties = bindingProperties.getConsumer(); + if (this.isNativeEncodingNotSet(producerProperties, consumerProperties, + inbound)) { + if (inbound) { + messageChannel.addInterceptor( + new InboundContentTypeEnhancingInterceptor(contentType)); + } + else { + messageChannel.addInterceptor( + new OutboundContentTypeConvertingInterceptor(contentType, + this.compositeMessageConverterFactory + .getMessageConverterForAllRegistered())); + } + } + } + + private boolean isNativeEncodingNotSet(ProducerProperties producerProperties, + ConsumerProperties consumerProperties, boolean input) { + if (input) { + return consumerProperties == null + || !consumerProperties.isUseNativeDecoding(); + } + else { + return producerProperties == null + || !producerProperties.isUseNativeEncoding(); + } + } + + @SuppressWarnings("deprecation") + private PartitionKeyExtractorStrategy getPartitionKeyExtractorStrategy( + ProducerProperties producerProperties) { + PartitionKeyExtractorStrategy partitionKeyExtractor; + if (producerProperties.getPartitionKeyExtractorClass() != null) { + this.logger.warn( + "'partitionKeyExtractorClass' option is deprecated as of v2.0. Please configure partition " + + "key extractor as a @Bean that implements 'PartitionKeyExtractorStrategy'. Additionally you can " + + "specify 'spring.cloud.stream.bindings.output.producer.partitionKeyExtractorName' to specify which " + + "bean to use in the event there are more then one."); + partitionKeyExtractor = instantiate( + producerProperties.getPartitionKeyExtractorClass(), + PartitionKeyExtractorStrategy.class); + } + else if (StringUtils.hasText(producerProperties.getPartitionKeyExtractorName())) { + partitionKeyExtractor = this.beanFactory.getBean( + producerProperties.getPartitionKeyExtractorName(), + PartitionKeyExtractorStrategy.class); + Assert.notNull(partitionKeyExtractor, + "PartitionKeyExtractorStrategy bean with the name '" + + producerProperties.getPartitionKeyExtractorName() + + "' can not be found. Has it been configured (e.g., @Bean)?"); + } + else { + Map extractors = this.beanFactory + .getBeansOfType(PartitionKeyExtractorStrategy.class); + Assert.isTrue(extractors.size() <= 1, + "Multiple beans of type 'PartitionKeyExtractorStrategy' found. " + + extractors + ". Please " + + "use 'spring.cloud.stream.bindings.output.producer.partitionKeyExtractorName' property to specify " + + "the name of the bean to be used."); + partitionKeyExtractor = CollectionUtils.isEmpty(extractors) ? null + : extractors.values().iterator().next(); + } + return partitionKeyExtractor; + } + + @SuppressWarnings("deprecation") + private PartitionSelectorStrategy getPartitionSelectorStrategy( + ProducerProperties producerProperties) { + PartitionSelectorStrategy partitionSelector; + if (producerProperties.getPartitionSelectorClass() != null) { + this.logger.warn( + "'partitionSelectorClass' option is deprecated as of v2.0. Please configure partition " + + "selector as a @Bean that implements 'PartitionSelectorStrategy'. Additionally you can " + + "specify 'spring.cloud.stream.bindings.output.producer.partitionSelectorName' to specify which " + + "bean to use in the event there are more then one."); + partitionSelector = instantiate( + producerProperties.getPartitionSelectorClass(), + PartitionSelectorStrategy.class); + } + else if (StringUtils.hasText(producerProperties.getPartitionSelectorName())) { + partitionSelector = this.beanFactory.getBean( + producerProperties.getPartitionSelectorName(), + PartitionSelectorStrategy.class); + Assert.notNull(partitionSelector, + "PartitionSelectorStrategy bean with the name '" + + producerProperties.getPartitionSelectorName() + + "' can not be found. Has it been configured (e.g., @Bean)?"); + } + else { + Map selectors = this.beanFactory + .getBeansOfType(PartitionSelectorStrategy.class); + Assert.isTrue(selectors.size() <= 1, + "Multiple beans of type 'PartitionSelectorStrategy' found. " + + selectors + ". Please " + + "use 'spring.cloud.stream.bindings.output.producer.partitionSelectorName' property to specify " + + "the name of the bean to be used."); + partitionSelector = CollectionUtils.isEmpty(selectors) + ? new DefaultPartitionSelector() + : selectors.values().iterator().next(); + } + return partitionSelector; + } + + @SuppressWarnings("unchecked") + private T instantiate(Class implClass, Class type) { + try { + return (T) implClass.newInstance(); + } + catch (Exception e) { + throw new BinderException( + "Failed to instantiate class: " + implClass.getName(), e); + } + } + + /** + * Default partition strategy; only works on keys with "real" hash codes, such as + * String. Caller now always applies modulo so no need to do so here. + */ + private static class DefaultPartitionSelector implements PartitionSelectorStrategy { + + @Override + public int selectPartition(Object key, int partitionCount) { + int hashCode = key.hashCode(); + if (hashCode == Integer.MIN_VALUE) { + hashCode = 0; + } + return Math.abs(hashCode); + } + + } + + /** + * Primary purpose of this interceptor is to enhance/enrich Message that sent to the + * *inbound* channel with 'contentType' header for cases where 'contentType' is not + * present in the Message itself but set on such channel via + * {@link BindingProperties#setContentType(String)}.
    + * Secondary purpose of this interceptor is to provide backward compatibility with + * previous versions of SCSt. + */ + private final class InboundContentTypeEnhancingInterceptor + extends AbstractContentTypeInterceptor { + + private InboundContentTypeEnhancingInterceptor(String contentType) { + super(contentType); + } + + @Override + public Message doPreSend(Message message, MessageChannel channel) { + @SuppressWarnings("unchecked") + Map headersMap = (Map) ReflectionUtils + .getField(MessageConverterConfigurer.this.headersField, + message.getHeaders()); + MimeType contentType = this.mimeType; + /* + * NOTE: The below code for BINDER_ORIGINAL_CONTENT_TYPE is to support legacy + * message format established in 1.x version of the framework and should/will + * no longer be supported in 3.x + */ + if (message.getHeaders() + .containsKey(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE)) { + Object ct = message.getHeaders() + .get(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE); + contentType = ct instanceof String ? MimeType.valueOf((String) ct) + : (ct == null ? this.mimeType : (MimeType) ct); + headersMap.put(MessageHeaders.CONTENT_TYPE, contentType); + headersMap.remove(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE); + } + // == end legacy note + + if (!message.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) { + headersMap.put(MessageHeaders.CONTENT_TYPE, contentType); + } + else if (message.getHeaders() + .get(MessageHeaders.CONTENT_TYPE) instanceof String) { + headersMap.put(MessageHeaders.CONTENT_TYPE, MimeType.valueOf( + (String) message.getHeaders().get(MessageHeaders.CONTENT_TYPE))); + } + + return message; + } + + } + + /** + * Unlike INBOUND where the target type is known and conversion is typically done by + * argument resolvers of {@link InvocableHandlerMethod} for the OUTBOUND case it is + * not known so we simply rely on provided MessageConverters that will use the + * provided 'contentType' and convert messages to a type dictated by the Binders + * (i.e., byte[]). + */ + private final class OutboundContentTypeConvertingInterceptor + extends AbstractContentTypeInterceptor { + + private final MessageConverter messageConverter; + + private OutboundContentTypeConvertingInterceptor(String contentType, + CompositeMessageConverter messageConverter) { + super(contentType); + this.messageConverter = messageConverter; + } + + @Override + public Message doPreSend(Message message, MessageChannel channel) { + // If handler is a function, FunctionInvoker will already perform message + // conversion. + // In fact in the future we should consider propagating knowledge of the + // default content type + // to MessageConverters instead of interceptors + if (message.getPayload() instanceof byte[] + && message.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) { + return message; + } + + // ===== 1.3 backward compatibility code part-1 === + String oct = message.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE) + ? message.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString() + : null; + String ct = message.getPayload() instanceof String + ? JavaClassMimeTypeUtils.mimeTypeFromObject(message.getPayload(), + ObjectUtils.nullSafeToString(oct)).toString() + : oct; + // ===== END 1.3 backward compatibility code part-1 === + + if (!message.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) { + @SuppressWarnings("unchecked") + Map headersMap = (Map) ReflectionUtils + .getField(MessageConverterConfigurer.this.headersField, + message.getHeaders()); + headersMap.put(MessageHeaders.CONTENT_TYPE, this.mimeType); + } + + @SuppressWarnings("unchecked") + Message outboundMessage = message.getPayload() instanceof byte[] + ? (Message) message : (Message) this.messageConverter + .toMessage(message.getPayload(), message.getHeaders()); + if (outboundMessage == null) { + throw new IllegalStateException("Failed to convert message: '" + message + + "' to outbound message."); + } + + /// ===== 1.3 backward compatibility code part-2 === + if (ct != null && !ct.equals(oct) && oct != null) { + @SuppressWarnings("unchecked") + Map headersMap = (Map) ReflectionUtils + .getField(MessageConverterConfigurer.this.headersField, + outboundMessage.getHeaders()); + headersMap.put(MessageHeaders.CONTENT_TYPE, MimeType.valueOf(ct)); + headersMap.put(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE, + MimeType.valueOf(oct)); + } + // ===== END 1.3 backward compatibility code part-2 === + return outboundMessage; + } + + } + + /** + * + */ + private abstract class AbstractContentTypeInterceptor implements ChannelInterceptor { + + final MimeType mimeType; + + private AbstractContentTypeInterceptor(String contentType) { + this.mimeType = MessageConverterUtils.getMimeType(contentType); + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + return message instanceof ErrorMessage ? message + : this.doPreSend(message, channel); + } + + protected abstract Message doPreSend(Message message, + MessageChannel channel); + + } + + /** + * Partitioning channel interceptor. + */ + public final class PartitioningInterceptor implements ChannelInterceptor { + + private final BindingProperties bindingProperties; + + private final PartitionHandler partitionHandler; + + PartitioningInterceptor(BindingProperties bindingProperties, + PartitionKeyExtractorStrategy partitionKeyExtractorStrategy, + PartitionSelectorStrategy partitionSelectorStrategy) { + this.bindingProperties = bindingProperties; + this.partitionHandler = new PartitionHandler( + ExpressionUtils.createStandardEvaluationContext( + MessageConverterConfigurer.this.beanFactory), + this.bindingProperties.getProducer(), partitionKeyExtractorStrategy, + partitionSelectorStrategy); + } + + public void setPartitionCount(int partitionCount) { + this.partitionHandler.setPartitionCount(partitionCount); + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + if (!message.getHeaders().containsKey(BinderHeaders.PARTITION_OVERRIDE)) { + int partition = this.partitionHandler.determinePartition(message); + return MessageConverterConfigurer.this.messageBuilderFactory + .fromMessage(message) + .setHeader(BinderHeaders.PARTITION_HEADER, partition).build(); + } + else { + return MessageConverterConfigurer.this.messageBuilderFactory + .fromMessage(message) + .setHeader(BinderHeaders.PARTITION_HEADER, + message.getHeaders() + .get(BinderHeaders.PARTITION_OVERRIDE)) + .removeHeader(BinderHeaders.PARTITION_OVERRIDE).build(); + } + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageSourceBindingTargetFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageSourceBindingTargetFactory.java new file mode 100644 index 000000000..2524d663f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/MessageSourceBindingTargetFactory.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.cloud.stream.binder.DefaultPollableMessageSource; +import org.springframework.cloud.stream.binder.PollableMessageSource; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.util.Assert; + +/** + * An implementation of {@link BindingTargetFactory} for creating + * {@link DefaultPollableMessageSource}s. + * + * @author Gary Russell + */ +public class MessageSourceBindingTargetFactory + extends AbstractBindingTargetFactory { + + private final MessageChannelAndSourceConfigurer messageSourceConfigurer; + + private final SmartMessageConverter messageConverter; + + public MessageSourceBindingTargetFactory(SmartMessageConverter messageConverter, + MessageChannelConfigurer messageSourceConfigurer) { + super(PollableMessageSource.class); + Assert.isInstanceOf(MessageChannelAndSourceConfigurer.class, + messageSourceConfigurer); + this.messageSourceConfigurer = (MessageChannelAndSourceConfigurer) messageSourceConfigurer; + this.messageConverter = messageConverter; + } + + @Override + public PollableMessageSource createInput(String name) { + DefaultPollableMessageSource binding = new DefaultPollableMessageSource( + this.messageConverter); + this.messageSourceConfigurer.configurePolledMessageSource(binding, name); + return binding; + } + + @Override + public PollableMessageSource createOutput(String name) { + throw new UnsupportedOperationException(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/OutputBindingLifecycle.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/OutputBindingLifecycle.java new file mode 100644 index 000000000..747ad660d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/OutputBindingLifecycle.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.util.CollectionUtils; + +/** + * Coordinates binding/unbinding of output binding targets in accordance to the lifecycle + * of the host context. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class OutputBindingLifecycle extends AbstractBindingLifecycle { + + @SuppressWarnings("unused") + // It is actually used reflectively since at the moment we do not want to expose it + // via public method + private Collection> outputBindings = new ArrayList>(); + + public OutputBindingLifecycle(BindingService bindingService, + Map bindables) { + super(bindingService, bindables); + } + + /** + * Return a low value so that this bean is started after receiving Lifecycle beans are + * started. Beans that need to start before bindings will set a lower phase value. + */ + @Override + public int getPhase() { + return Integer.MIN_VALUE + 1000; + } + + @Override + void doStartWithBindable(Bindable bindable) { + Collection> bindableBindings = bindable + .createAndBindOutputs(this.bindingService); + if (!CollectionUtils.isEmpty(bindableBindings)) { + this.outputBindings.addAll(bindableBindings); + } + } + + @Override + void doStopWithBindable(Bindable bindable) { + bindable.unbindOutputs(this.bindingService); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SingleBindingTargetBindable.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SingleBindingTargetBindable.java new file mode 100644 index 000000000..d66e59c5e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SingleBindingTargetBindable.java @@ -0,0 +1,72 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.cloud.stream.binder.Binding; + +/** + * A {@link Bindable} component that wraps a generic output binding target. Useful for + * binding targets outside the {@link org.springframework.cloud.stream.annotation.Input} + * and {@link org.springframework.cloud.stream.annotation.Output} annotated interfaces. + * + * @param type of binding target + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @deprecated This class is no longer used by the framework and maybe removed in a future + * release. + */ +@Deprecated +public class SingleBindingTargetBindable implements Bindable { + + private final String name; + + private final T bindingTarget; + + public SingleBindingTargetBindable(String name, T bindingTarget) { + this.name = name; + this.bindingTarget = bindingTarget; + } + + @Override + public void bindOutputs(BindingService bindingService) { + this.createAndBindOutputs(bindingService); + } + + @Override + public Collection> createAndBindOutputs( + BindingService bindingService) { + return Collections.singletonList( + bindingService.bindProducer(this.bindingTarget, this.name)); + } + + @Override + public void unbindOutputs(BindingService bindingService) { + bindingService.unbindProducers(this.name); + } + + @Override + public Set getOutputs() { + return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(this.name))); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationCommonMethodUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationCommonMethodUtils.java new file mode 100644 index 000000000..ae63e728f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationCommonMethodUtils.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Method; + +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Common methods that can be used across various Stream annotations. + * + * @author Soby Chacko + * @since 1.3.0 + */ +public abstract class StreamAnnotationCommonMethodUtils { + + public static String getOutboundBindingTargetName(Method method) { + SendTo sendTo = AnnotationUtils.findAnnotation(method, SendTo.class); + if (sendTo != null) { + Assert.isTrue(!ObjectUtils.isEmpty(sendTo.value()), + StreamAnnotationErrorMessages.ATLEAST_ONE_OUTPUT); + Assert.isTrue(sendTo.value().length == 1, + StreamAnnotationErrorMessages.SEND_TO_MULTIPLE_DESTINATIONS); + Assert.hasText(sendTo.value()[0], + StreamAnnotationErrorMessages.SEND_TO_EMPTY_DESTINATION); + return sendTo.value()[0]; + } + Output output = AnnotationUtils.findAnnotation(method, Output.class); + if (output != null) { + Assert.isTrue(StringUtils.hasText(output.value()), + StreamAnnotationErrorMessages.ATLEAST_ONE_OUTPUT); + return output.value(); + } + return null; + } + + public static int outputAnnotationCount(Method method) { + int outputAnnotationCount = 0; + for (int parameterIndex = 0; parameterIndex < method + .getParameterTypes().length; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Output.class)) { + outputAnnotationCount++; + } + } + return outputAnnotationCount; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationErrorMessages.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationErrorMessages.java new file mode 100644 index 000000000..d648293b5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamAnnotationErrorMessages.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +/** + * @author Soby Chacko + */ +public abstract class StreamAnnotationErrorMessages { + + /** + * Annotation error message when no output was passed. + */ + public static final String ATLEAST_ONE_OUTPUT = "At least one output must be specified"; + + /** + * Annotation error message when multiple destinations were set. + */ + public static final String SEND_TO_MULTIPLE_DESTINATIONS = "Multiple destinations cannot be specified"; + + /** + * Annotation error message when an empty destination was specified. + */ + public static final String SEND_TO_EMPTY_DESTINATION = "An empty destination cannot be specified"; + + /** + * Annotation error message when an invalid outbound name was set. + */ + public static final String INVALID_OUTBOUND_NAME = "The @Output annotation must have the name of an input as value"; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerAnnotationBeanPostProcessor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerAnnotationBeanPostProcessor.java new file mode 100644 index 000000000..66e819443 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerAnnotationBeanPostProcessor.java @@ -0,0 +1,572 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.config.SpringIntegrationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.core.DestinationResolver; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * {@link BeanPostProcessor} that handles {@link StreamListener} annotations found on bean + * methods. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Soby Chacko + * @author Oleg Zhurakousky + */ +public class StreamListenerAnnotationBeanPostProcessor implements BeanPostProcessor, + ApplicationContextAware, SmartInitializingSingleton { + + private static final SpelExpressionParser SPEL_EXPRESSION_PARSER = new SpelExpressionParser(); + + // @checkstyle:off + private final MultiValueMap mappedListenerMethods = new LinkedMultiValueMap<>(); + + // @checkstyle:on + + private final Set streamListenerCallbacks = new HashSet<>(); + + // == dependencies that are injected in 'afterSingletonsInstantiated' to avoid early + // initialization + private DestinationResolver binderAwareChannelResolver; + + private MessageHandlerMethodFactory messageHandlerMethodFactory; + + // == end dependencies + private SpringIntegrationProperties springIntegrationProperties; + + private ConfigurableApplicationContext applicationContext; + + private BeanExpressionResolver resolver; + + private BeanExpressionContext expressionContext; + + private Set streamListenerSetupMethodOrchestrators = new LinkedHashSet<>(); + + private boolean streamListenerPresent; + + @Override + public final void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + this.resolver = this.applicationContext.getBeanFactory() + .getBeanExpressionResolver(); + this.expressionContext = new BeanExpressionContext( + this.applicationContext.getBeanFactory(), null); + } + + @Override + public final void afterSingletonsInstantiated() { + if (!this.streamListenerPresent) { + return; + } + this.injectAndPostProcessDependencies(); + EvaluationContext evaluationContext = IntegrationContextUtils + .getEvaluationContext(this.applicationContext.getBeanFactory()); + for (Map.Entry> mappedBindingEntry : this.mappedListenerMethods + .entrySet()) { + ArrayList handlers; + handlers = new ArrayList<>(); + for (StreamListenerHandlerMethodMapping mapping : mappedBindingEntry + .getValue()) { + final InvocableHandlerMethod invocableHandlerMethod = this.messageHandlerMethodFactory + .createInvocableHandlerMethod(mapping.getTargetBean(), + checkProxy(mapping.getMethod(), mapping.getTargetBean())); + StreamListenerMessageHandler streamListenerMessageHandler = new StreamListenerMessageHandler( + invocableHandlerMethod, + resolveExpressionAsBoolean(mapping.getCopyHeaders(), + "copyHeaders"), + this.springIntegrationProperties + .getMessageHandlerNotPropagatedHeaders()); + streamListenerMessageHandler + .setApplicationContext(this.applicationContext); + streamListenerMessageHandler + .setBeanFactory(this.applicationContext.getBeanFactory()); + if (StringUtils.hasText(mapping.getDefaultOutputChannel())) { + streamListenerMessageHandler + .setOutputChannelName(mapping.getDefaultOutputChannel()); + } + streamListenerMessageHandler.afterPropertiesSet(); + if (StringUtils.hasText(mapping.getCondition())) { + String conditionAsString = resolveExpressionAsString( + mapping.getCondition(), "condition"); + Expression condition = SPEL_EXPRESSION_PARSER + .parseExpression(conditionAsString); + handlers.add( + new DispatchingStreamListenerMessageHandler.ConditionalStreamListenerMessageHandlerWrapper( + condition, streamListenerMessageHandler)); + } + else { + handlers.add( + new DispatchingStreamListenerMessageHandler.ConditionalStreamListenerMessageHandlerWrapper( + null, streamListenerMessageHandler)); + } + } + if (handlers.size() > 1) { + for (DispatchingStreamListenerMessageHandler.ConditionalStreamListenerMessageHandlerWrapper handler : handlers) { + Assert.isTrue(handler.isVoid(), + StreamListenerErrorMessages.MULTIPLE_VALUE_RETURNING_METHODS); + } + } + AbstractReplyProducingMessageHandler handler; + + if (handlers.size() > 1 || handlers.get(0).getCondition() != null) { + handler = new DispatchingStreamListenerMessageHandler(handlers, + evaluationContext); + } + else { + handler = handlers.get(0).getStreamListenerMessageHandler(); + } + handler.setApplicationContext(this.applicationContext); + handler.setChannelResolver(this.binderAwareChannelResolver); + handler.afterPropertiesSet(); + this.applicationContext.getBeanFactory().registerSingleton( + handler.getClass().getSimpleName() + handler.hashCode(), handler); + this.applicationContext + .getBean(mappedBindingEntry.getKey(), SubscribableChannel.class) + .subscribe(handler); + } + this.mappedListenerMethods.clear(); + } + + @Override + public final Object postProcessAfterInitialization(Object bean, final String beanName) + throws BeansException { + Class targetClass = AopUtils.isAopProxy(bean) ? AopUtils.getTargetClass(bean) + : bean.getClass(); + Method[] uniqueDeclaredMethods = ReflectionUtils + .getUniqueDeclaredMethods(targetClass); + for (Method method : uniqueDeclaredMethods) { + StreamListener streamListener = AnnotatedElementUtils + .findMergedAnnotation(method, StreamListener.class); + if (streamListener != null && !method.isBridge()) { + this.streamListenerPresent = true; + this.streamListenerCallbacks.add(() -> { + Assert.isTrue(method.getAnnotation(Input.class) == null, + StreamListenerErrorMessages.INPUT_AT_STREAM_LISTENER); + this.doPostProcess(streamListener, method, bean); + }); + } + } + return bean; + } + + /** + * Extension point, allowing subclasses to customize the {@link StreamListener} + * annotation detected by the postprocessor. + * @param originalAnnotation the original annotation + * @param annotatedMethod the method on which the annotation has been found + * @return the postprocessed {@link StreamListener} annotation + */ + protected StreamListener postProcessAnnotation(StreamListener originalAnnotation, + Method annotatedMethod) { + return originalAnnotation; + } + + private void doPostProcess(StreamListener streamListener, Method method, + Object bean) { + streamListener = postProcessAnnotation(streamListener, method); + Optional orchestratorOptional; + orchestratorOptional = this.streamListenerSetupMethodOrchestrators.stream() + .filter(t -> t.supports(method)).findFirst(); + Assert.isTrue(orchestratorOptional.isPresent(), + "A matching StreamListenerSetupMethodOrchestrator must be present"); + StreamListenerSetupMethodOrchestrator streamListenerSetupMethodOrchestrator = orchestratorOptional + .get(); + streamListenerSetupMethodOrchestrator + .orchestrateStreamListenerSetupMethod(streamListener, method, bean); + } + + private Method checkProxy(Method methodArg, Object bean) { + Method method = methodArg; + if (AopUtils.isJdkDynamicProxy(bean)) { + try { + // Found a @StreamListener method on the target class for this JDK proxy + // -> + // is it also present on the proxy itself? + method = bean.getClass().getMethod(method.getName(), + method.getParameterTypes()); + Class[] proxiedInterfaces = ((Advised) bean).getProxiedInterfaces(); + for (Class iface : proxiedInterfaces) { + try { + method = iface.getMethod(method.getName(), + method.getParameterTypes()); + break; + } + catch (NoSuchMethodException noMethod) { + } + } + } + catch (SecurityException ex) { + ReflectionUtils.handleReflectionException(ex); + } + catch (NoSuchMethodException ex) { + throw new IllegalStateException(String.format( + "@StreamListener method '%s' found on bean target class '%s', " + + "but not found in any interface(s) for bean JDK proxy. Either " + + "pull the method up to an interface or switch to subclass (CGLIB) " + + "proxies by setting proxy-target-class/proxyTargetClass attribute to 'true'", + method.getName(), method.getDeclaringClass().getSimpleName()), + ex); + } + } + return method; + } + + private String resolveExpressionAsString(String value, String property) { + Object resolved = resolveExpression(value); + if (resolved instanceof String) { + return (String) resolved; + } + else { + throw new IllegalStateException("Resolved " + property + " to [" + + resolved.getClass() + "] instead of String for [" + value + "]"); + } + } + + private boolean resolveExpressionAsBoolean(String value, String property) { + Object resolved = resolveExpression(value); + if (resolved == null) { + return false; + } + else if (resolved instanceof String) { + return Boolean.parseBoolean((String) resolved); + } + else if (resolved instanceof Boolean) { + return (Boolean) resolved; + } + else { + throw new IllegalStateException( + "Resolved " + property + " to [" + resolved.getClass() + + "] instead of String or Boolean for [" + value + "]"); + } + } + + private String resolveExpression(String value) { + String resolvedValue = this.applicationContext.getBeanFactory() + .resolveEmbeddedValue(value); + if (resolvedValue.startsWith("#{") && value.endsWith("}")) { + resolvedValue = (String) this.resolver.evaluate(resolvedValue, + this.expressionContext); + } + return resolvedValue; + } + + /** + * This operations ensures that required dependencies are not accidentally injected + * early given that this bean is BPP. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void injectAndPostProcessDependencies() { + Collection streamListenerParameterAdapters = this.applicationContext + .getBeansOfType(StreamListenerParameterAdapter.class).values(); + Collection streamListenerResultAdapters = this.applicationContext + .getBeansOfType(StreamListenerResultAdapter.class).values(); + this.binderAwareChannelResolver = this.applicationContext + .getBean(DestinationResolver.class); + this.messageHandlerMethodFactory = this.applicationContext + .getBean(MessageHandlerMethodFactory.class); + this.springIntegrationProperties = this.applicationContext + .getBean(SpringIntegrationProperties.class); + + this.streamListenerSetupMethodOrchestrators.addAll(this.applicationContext + .getBeansOfType(StreamListenerSetupMethodOrchestrator.class).values()); + + // Default orchestrator for StreamListener method invocation is added last into + // the LinkedHashSet. + this.streamListenerSetupMethodOrchestrators.add( + new DefaultStreamListenerSetupMethodOrchestrator(this.applicationContext, + streamListenerParameterAdapters, streamListenerResultAdapters)); + + this.streamListenerCallbacks.forEach(Runnable::run); + } + + private class StreamListenerHandlerMethodMapping { + + private final Object targetBean; + + private final Method method; + + private final String condition; + + private final String defaultOutputChannel; + + private final String copyHeaders; + + StreamListenerHandlerMethodMapping(Object targetBean, Method method, + String condition, String defaultOutputChannel, String copyHeaders) { + this.targetBean = targetBean; + this.method = method; + this.condition = condition; + this.defaultOutputChannel = defaultOutputChannel; + this.copyHeaders = copyHeaders; + } + + Object getTargetBean() { + return this.targetBean; + } + + Method getMethod() { + return this.method; + } + + String getCondition() { + return this.condition; + } + + String getDefaultOutputChannel() { + return this.defaultOutputChannel; + } + + public String getCopyHeaders() { + return this.copyHeaders; + } + + } + + @SuppressWarnings("rawtypes") + private final class DefaultStreamListenerSetupMethodOrchestrator + implements StreamListenerSetupMethodOrchestrator { + + private final ConfigurableApplicationContext applicationContext; + + private final Collection streamListenerParameterAdapters; + + private final Collection streamListenerResultAdapters; + + private DefaultStreamListenerSetupMethodOrchestrator( + ConfigurableApplicationContext applicationContext, + Collection streamListenerParameterAdapters, + Collection streamListenerResultAdapters) { + this.applicationContext = applicationContext; + this.streamListenerParameterAdapters = streamListenerParameterAdapters; + this.streamListenerResultAdapters = streamListenerResultAdapters; + } + + @Override + public void orchestrateStreamListenerSetupMethod(StreamListener streamListener, + Method method, Object bean) { + String methodAnnotatedInboundName = streamListener.value(); + + String methodAnnotatedOutboundName = StreamListenerMethodUtils + .getOutboundBindingTargetName(method); + int inputAnnotationCount = StreamListenerMethodUtils + .inputAnnotationCount(method); + int outputAnnotationCount = StreamListenerMethodUtils + .outputAnnotationCount(method); + boolean isDeclarative = checkDeclarativeMethod(method, + methodAnnotatedInboundName, methodAnnotatedOutboundName); + StreamListenerMethodUtils.validateStreamListenerMethod(method, + inputAnnotationCount, outputAnnotationCount, + methodAnnotatedInboundName, methodAnnotatedOutboundName, + isDeclarative, streamListener.condition()); + if (isDeclarative) { + StreamListenerParameterAdapter[] toSlpaArray; + toSlpaArray = new StreamListenerParameterAdapter[this.streamListenerParameterAdapters + .size()]; + Object[] adaptedInboundArguments = adaptAndRetrieveInboundArguments( + method, methodAnnotatedInboundName, this.applicationContext, + this.streamListenerParameterAdapters.toArray(toSlpaArray)); + invokeStreamListenerResultAdapter(method, bean, + methodAnnotatedOutboundName, adaptedInboundArguments); + } + else { + registerHandlerMethodOnListenedChannel(method, streamListener, bean); + } + } + + @Override + public boolean supports(Method method) { + // default catch all orchestrator + return true; + } + + @SuppressWarnings("unchecked") + private void invokeStreamListenerResultAdapter(Method method, Object bean, + String outboundName, Object... arguments) { + try { + if (Void.TYPE.equals(method.getReturnType())) { + method.invoke(bean, arguments); + } + else { + Object result = method.invoke(bean, arguments); + if (!StringUtils.hasText(outboundName)) { + for (int parameterIndex = 0; parameterIndex < method + .getParameterTypes().length; parameterIndex++) { + MethodParameter methodParameter = MethodParameter + .forExecutable(method, parameterIndex); + if (methodParameter.hasParameterAnnotation(Output.class)) { + outboundName = methodParameter + .getParameterAnnotation(Output.class).value(); + } + } + } + Object targetBean = this.applicationContext.getBean(outboundName); + for (StreamListenerResultAdapter streamListenerResultAdapter : this.streamListenerResultAdapters) { + if (streamListenerResultAdapter.supports(result.getClass(), + targetBean.getClass())) { + streamListenerResultAdapter.adapt(result, targetBean); + break; + } + } + } + } + catch (Exception e) { + throw new BeanInitializationException( + "Cannot setup StreamListener for " + method, e); + } + } + + private void registerHandlerMethodOnListenedChannel(Method method, + StreamListener streamListener, Object bean) { + Assert.hasText(streamListener.value(), "The binding name cannot be null"); + if (!StringUtils.hasText(streamListener.value())) { + throw new BeanInitializationException( + "A bound component name must be specified"); + } + final String defaultOutputChannel = StreamListenerMethodUtils + .getOutboundBindingTargetName(method); + if (Void.TYPE.equals(method.getReturnType())) { + Assert.isTrue(StringUtils.isEmpty(defaultOutputChannel), + "An output channel cannot be specified for a method that does not return a value"); + } + else { + Assert.isTrue(!StringUtils.isEmpty(defaultOutputChannel), + "An output channel must be specified for a method that can return a value"); + } + StreamListenerMethodUtils.validateStreamListenerMessageHandler(method); + StreamListenerAnnotationBeanPostProcessor.this.mappedListenerMethods.add( + streamListener.value(), + new StreamListenerHandlerMethodMapping(bean, method, + streamListener.condition(), defaultOutputChannel, + streamListener.copyHeaders())); + } + + private boolean checkDeclarativeMethod(Method method, + String methodAnnotatedInboundName, String methodAnnotatedOutboundName) { + int methodArgumentsLength = method.getParameterTypes().length; + for (int parameterIndex = 0; parameterIndex < methodArgumentsLength; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Input.class)) { + String inboundName = (String) AnnotationUtils.getValue( + methodParameter.getParameterAnnotation(Input.class)); + Assert.isTrue(StringUtils.hasText(inboundName), + StreamListenerErrorMessages.INVALID_INBOUND_NAME); + Assert.isTrue( + isDeclarativeMethodParameter(inboundName, methodParameter), + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + return true; + } + else if (methodParameter.hasParameterAnnotation(Output.class)) { + String outboundName = (String) AnnotationUtils.getValue( + methodParameter.getParameterAnnotation(Output.class)); + Assert.isTrue(StringUtils.hasText(outboundName), + StreamListenerErrorMessages.INVALID_OUTBOUND_NAME); + Assert.isTrue( + isDeclarativeMethodParameter(outboundName, methodParameter), + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + return true; + } + else if (StringUtils.hasText(methodAnnotatedOutboundName)) { + return isDeclarativeMethodParameter(methodAnnotatedOutboundName, + methodParameter); + } + else if (StringUtils.hasText(methodAnnotatedInboundName)) { + return isDeclarativeMethodParameter(methodAnnotatedInboundName, + methodParameter); + } + } + return false; + } + + /** + * Determines if method parameters signify an imperative or declarative listener + * definition.
    + * Imperative - where handler method is invoked on each message by the handler + * infrastructure provided by the framework
    + * Declarative - where handler is provided by the method itself.
    + * Declarative method parameter could either be {@link MessageChannel} or any + * other Object for which there is a {@link StreamListenerParameterAdapter} (i.e., + * {@link reactor.core.publisher.Flux}). Declarative method is invoked only once + * during initialization phase. + * @param targetBeanName name of the bean + * @param methodParameter method parameter + * @return {@code true} when the method parameter is declarative + */ + @SuppressWarnings("unchecked") + private boolean isDeclarativeMethodParameter(String targetBeanName, + MethodParameter methodParameter) { + boolean declarative = false; + if (!methodParameter.getParameterType().isAssignableFrom(Object.class) + && this.applicationContext.containsBean(targetBeanName)) { + declarative = MessageChannel.class + .isAssignableFrom(methodParameter.getParameterType()); + if (!declarative) { + Class targetBeanClass = this.applicationContext + .getType(targetBeanName); + declarative = this.streamListenerParameterAdapters.stream().anyMatch( + slpa -> slpa.supports(targetBeanClass, methodParameter)); + } + } + return declarative; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerErrorMessages.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerErrorMessages.java new file mode 100644 index 000000000..cf1a19311 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerErrorMessages.java @@ -0,0 +1,133 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +/** + * @author Ilayaperumal Gopinathan + */ +public abstract class StreamListenerErrorMessages { + + /** + * Error message when the inbound name was invalid. + */ + public static final String INVALID_INBOUND_NAME = "The @Input annotation must have the name of an input as value"; + + /** + * Error message when the outbound name was invalid. + */ + public static final String INVALID_OUTBOUND_NAME = "The @Output annotation must have the name of an input as value"; + + /** + * Error message when there were no outputs specified. + */ + public static final String ATLEAST_ONE_OUTPUT = "At least one output must be specified"; + + /** + * Error message when multiple destinations were specified. + */ + public static final String SEND_TO_MULTIPLE_DESTINATIONS = "Multiple destinations cannot be specified"; + + /** + * Error message when empty destination was provided. + */ + public static final String SEND_TO_EMPTY_DESTINATION = "An empty destination cannot be specified"; + + /** + * Error message when the input or output annotation got placed on a method parameter. + */ + public static final String INVALID_INPUT_OUTPUT_METHOD_PARAMETERS = "@Input or @Output annotations " + + "are not permitted on " + + "method parameters while using the @StreamListener value and a method-level output specification"; + + /** + * Error message when no input destination was provided. + */ + public static final String NO_INPUT_DESTINATION = "No input destination is configured. " + + "Use either the @StreamListener value or @Input"; + + /** + * Error message when an ambiguous message handler method argument was found. + */ + public static final String AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS = "Ambiguous method arguments " + + "for the StreamListener method"; + + /** + * Error message when invalid input values where set. + */ + public static final String INVALID_INPUT_VALUES = "Cannot set both @StreamListener " + + "value and @Input annotation as method parameter"; + + /** + * Error message when invalid input value with output method parameter was set. + */ + public static final String INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM = "Setting the @StreamListener " + + "value when using @Output annotation as method parameter is not permitted. " + + "Use @Input method parameter annotation to specify inbound value instead"; + + /** + * Error message when invalid output values were set. + */ + public static final String INVALID_OUTPUT_VALUES = "Cannot set both output (@Output/@SendTo) method annotation value" + + " and @Output annotation as a method parameter"; + + /** + * Error message when condition was set in declarative mode. + */ + public static final String CONDITION_ON_DECLARATIVE_METHOD = "Cannot set a condition when " + + "using @StreamListener in declarative mode"; + + /** + * Error message when condition was set for methods that return a value. + */ + public static final String CONDITION_ON_METHOD_RETURNING_VALUE = "Cannot set a condition " + + "for methods that return a value"; + + /** + * Error message when multiple value returning methods were provided. + */ + public static final String MULTIPLE_VALUE_RETURNING_METHODS = "If multiple @StreamListener " + + "methods are listening to the same binding target, none of them may return a value"; + + private static final String PREFIX = "A method annotated with @StreamListener "; + + /** + * Error message when @StreamListener was used with @Input. + */ + public static final String INPUT_AT_STREAM_LISTENER = PREFIX + + "may never be annotated with @Input. " + + "If it should listen to a specific input, use the value of @StreamListener instead"; + + /** + * Error message when invalid input value with output method parameter was set. + */ + public static final String RETURN_TYPE_NO_OUTBOUND_SPECIFIED = PREFIX + + "having a return type should also have an outbound target specified"; + + /** + * Error message when return type was specified for multiple outbound targets. + */ + public static final String RETURN_TYPE_MULTIPLE_OUTBOUND_SPECIFIED = PREFIX + + "having a return type should have only one outbound target specified"; + + /** + * Error message when invalid declarative method parameters were set. + */ + public static final String INVALID_DECLARATIVE_METHOD_PARAMETERS = PREFIX + + "may use @Input or @Output annotations only in declarative mode " + + "and for parameters that are binding targets or convertible from binding targets."; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMessageHandler.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMessageHandler.java new file mode 100644 index 000000000..4a311ce4a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMessageHandler.java @@ -0,0 +1,70 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +/** + * @author Marius Bogoevici + * @author Gary Russell + * @since 1.2 + */ +public class StreamListenerMessageHandler extends AbstractReplyProducingMessageHandler { + + private final InvocableHandlerMethod invocableHandlerMethod; + + private final boolean copyHeaders; + + StreamListenerMessageHandler(InvocableHandlerMethod invocableHandlerMethod, + boolean copyHeaders, String[] notPropagatedHeaders) { + super(); + this.invocableHandlerMethod = invocableHandlerMethod; + this.copyHeaders = copyHeaders; + this.setNotPropagatedHeaders(notPropagatedHeaders); + } + + @Override + protected boolean shouldCopyRequestHeaders() { + return this.copyHeaders; + } + + public boolean isVoid() { + return this.invocableHandlerMethod.isVoid(); + } + + @Override + protected Object handleRequestMessage(Message requestMessage) { + try { + return this.invocableHandlerMethod.invoke(requestMessage); + } + catch (Exception e) { + if (e instanceof MessagingException) { + throw (MessagingException) e; + } + else { + throw new MessagingException(requestMessage, + "Exception thrown while invoking " + + this.invocableHandlerMethod.getShortLogMessage(), + e); + } + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMethodUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMethodUtils.java new file mode 100644 index 000000000..d61905941 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerMethodUtils.java @@ -0,0 +1,185 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Method; + +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * This class contains utility methods for handling {@link StreamListener} annotated bean + * methods. + * + * @author Ilayaperumal Gopinathan + */ +public final class StreamListenerMethodUtils { + + private StreamListenerMethodUtils() { + throw new IllegalStateException("Can't instantiate a utility class"); + } + + protected static int inputAnnotationCount(Method method) { + int inputAnnotationCount = 0; + for (int parameterIndex = 0; parameterIndex < method + .getParameterTypes().length; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Input.class)) { + inputAnnotationCount++; + } + } + return inputAnnotationCount; + } + + protected static int outputAnnotationCount(Method method) { + int outputAnnotationCount = 0; + for (int parameterIndex = 0; parameterIndex < method + .getParameterTypes().length; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Output.class)) { + outputAnnotationCount++; + } + } + return outputAnnotationCount; + } + + protected static void validateStreamListenerMethod(Method method, + int inputAnnotationCount, int outputAnnotationCount, + String methodAnnotatedInboundName, String methodAnnotatedOutboundName, + boolean isDeclarative, String condition) { + int methodArgumentsLength = method.getParameterTypes().length; + if (!isDeclarative) { + Assert.isTrue(inputAnnotationCount == 0 && outputAnnotationCount == 0, + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + if (StringUtils.hasText(methodAnnotatedInboundName) + && StringUtils.hasText(methodAnnotatedOutboundName)) { + Assert.isTrue(inputAnnotationCount == 0 && outputAnnotationCount == 0, + StreamListenerErrorMessages.INVALID_INPUT_OUTPUT_METHOD_PARAMETERS); + } + if (StringUtils.hasText(methodAnnotatedInboundName)) { + Assert.isTrue(inputAnnotationCount == 0, + StreamListenerErrorMessages.INVALID_INPUT_VALUES); + Assert.isTrue(outputAnnotationCount == 0, + StreamListenerErrorMessages.INVALID_INPUT_VALUE_WITH_OUTPUT_METHOD_PARAM); + } + else { + Assert.isTrue(inputAnnotationCount >= 1, + StreamListenerErrorMessages.NO_INPUT_DESTINATION); + } + if (StringUtils.hasText(methodAnnotatedOutboundName)) { + Assert.isTrue(outputAnnotationCount == 0, + StreamListenerErrorMessages.INVALID_OUTPUT_VALUES); + } + if (!Void.TYPE.equals(method.getReturnType())) { + Assert.isTrue(!StringUtils.hasText(condition), + StreamListenerErrorMessages.CONDITION_ON_METHOD_RETURNING_VALUE); + } + if (isDeclarative) { + Assert.isTrue(!StringUtils.hasText(condition), + StreamListenerErrorMessages.CONDITION_ON_DECLARATIVE_METHOD); + for (int parameterIndex = 0; parameterIndex < methodArgumentsLength; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotation(Input.class)) { + String inboundName = (String) AnnotationUtils.getValue( + methodParameter.getParameterAnnotation(Input.class)); + Assert.isTrue(StringUtils.hasText(inboundName), + StreamListenerErrorMessages.INVALID_INBOUND_NAME); + } + if (methodParameter.hasParameterAnnotation(Output.class)) { + String outboundName = (String) AnnotationUtils.getValue( + methodParameter.getParameterAnnotation(Output.class)); + Assert.isTrue(StringUtils.hasText(outboundName), + StreamListenerErrorMessages.INVALID_OUTBOUND_NAME); + } + } + if (methodArgumentsLength > 1) { + Assert.isTrue( + inputAnnotationCount + + outputAnnotationCount == methodArgumentsLength, + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + + if (!method.getReturnType().equals(Void.TYPE)) { + if (!StringUtils.hasText(methodAnnotatedOutboundName)) { + if (outputAnnotationCount == 0) { + throw new IllegalArgumentException( + StreamListenerErrorMessages.RETURN_TYPE_NO_OUTBOUND_SPECIFIED); + } + Assert.isTrue((outputAnnotationCount == 1), + StreamListenerErrorMessages.RETURN_TYPE_MULTIPLE_OUTBOUND_SPECIFIED); + } + } + } + + protected static void validateStreamListenerMessageHandler(Method method) { + int methodArgumentsLength = method.getParameterTypes().length; + if (methodArgumentsLength > 1) { + int numAnnotatedMethodParameters = 0; + int numPayloadAnnotations = 0; + for (int parameterIndex = 0; parameterIndex < methodArgumentsLength; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + if (methodParameter.hasParameterAnnotations()) { + numAnnotatedMethodParameters++; + } + if (methodParameter.hasParameterAnnotation(Payload.class)) { + numPayloadAnnotations++; + } + } + if (numPayloadAnnotations > 0) { + Assert.isTrue( + methodArgumentsLength == numAnnotatedMethodParameters + && numPayloadAnnotations <= 1, + StreamListenerErrorMessages.AMBIGUOUS_MESSAGE_HANDLER_METHOD_ARGUMENTS); + } + } + } + + protected static String getOutboundBindingTargetName(Method method) { + SendTo sendTo = AnnotationUtils.findAnnotation(method, SendTo.class); + if (sendTo != null) { + Assert.isTrue(!ObjectUtils.isEmpty(sendTo.value()), + StreamListenerErrorMessages.ATLEAST_ONE_OUTPUT); + Assert.isTrue(sendTo.value().length == 1, + StreamListenerErrorMessages.SEND_TO_MULTIPLE_DESTINATIONS); + Assert.hasText(sendTo.value()[0], + StreamListenerErrorMessages.SEND_TO_EMPTY_DESTINATION); + return sendTo.value()[0]; + } + Output output = AnnotationUtils.findAnnotation(method, Output.class); + if (output != null) { + Assert.isTrue(StringUtils.hasText(output.value()), + StreamListenerErrorMessages.ATLEAST_ONE_OUTPUT); + return output.value(); + } + return null; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerParameterAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerParameterAdapter.java new file mode 100644 index 000000000..7d7ebf784 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerParameterAdapter.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.core.MethodParameter; + +/** + * Strategy for adapting a method argument type annotated with + * {@link org.springframework.cloud.stream.annotation.Input} or + * {@link org.springframework.cloud.stream.annotation.Output} from a binding type (e.g. + * {@link org.springframework.messaging.MessageChannel}) supported by an existing binder. + * + * This is a framework extension and is not primarily intended for use by end-users. + * + * @param adapter type + * @param binding result type + * @author Marius Bogoevici + */ +public interface StreamListenerParameterAdapter { + + /** + * Return true if the conversion from the binding target type to the argument type is + * supported. + * @param bindingTargetType the binding target type + * @param methodParameter the method parameter for which the conversion is performed + * @return true if the conversion is supported + */ + boolean supports(Class bindingTargetType, MethodParameter methodParameter); + + /** + * Adapts the binding target to the argument type. The result will be passed as + * argument to a method annotated with + * {@link org.springframework.cloud.stream.annotation.StreamListener} when used for + * setting up a pipeline. + * @param bindingTarget the binding target + * @param parameter the method parameter for which the conversion is performed + * @return an instance of the parameter type, which will be passed to the method + */ + A adapt(B bindingTarget, MethodParameter parameter); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerResultAdapter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerResultAdapter.java new file mode 100644 index 000000000..6724a5263 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerResultAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.io.Closeable; + +/** + * A strategy for adapting the result of a + * {@link org.springframework.cloud.stream.annotation.StreamListener} annotated method to + * a binding target annotated with + * {@link org.springframework.cloud.stream.annotation.Output}. + * + * Used when the {@link org.springframework.cloud.stream.annotation.StreamListener} + * annotated method is operating in declarative mode. + * + * @param stream listener result type + * @param binding target type + * @author Marius Bogoevici + */ +public interface StreamListenerResultAdapter { + + /** + * Return true if the result type can be converted to the binding target. + * @param resultType the result type. + * @param bindingTarget the binding target. + * @return true if the conversion can take place. + */ + boolean supports(Class resultType, Class bindingTarget); + + /** + * Adapts the result to the binding target. + * @param streamListenerResult the result of invoking the method. + * @param bindingTarget the binding target. + * @return an adapted result + */ + Closeable adapt(R streamListenerResult, B bindingTarget); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerSetupMethodOrchestrator.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerSetupMethodOrchestrator.java new file mode 100644 index 000000000..e1fe39af8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/StreamListenerSetupMethodOrchestrator.java @@ -0,0 +1,137 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Method; + +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.context.ApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Orchestrator used for invoking the {@link StreamListener} setup method. + * + * By default {@link StreamListenerAnnotationBeanPostProcessor} will use an internal + * implementation of this interface to invoke {@link StreamListenerParameterAdapter}s and + * {@link StreamListenerResultAdapter}s or handler mappings on the method annotated with + * {@link StreamListener}. + * + * By providing a different implementation of this interface and registering it as a + * Spring Bean in the context, one can override the default invocation strategies used by + * the {@link StreamListenerAnnotationBeanPostProcessor}. A typical usecase for such + * overriding can happen when a downstream + * {@link org.springframework.cloud.stream.binder.Binder} implementation wants to change + * the way in which any of the default StreamListener handling needs to be changed in a + * custom manner. + * + * When beans of this interface are present in the context, they get priority in the + * {@link StreamListenerAnnotationBeanPostProcessor} before falling back to the default + * implementation. + * + * @author Soby Chacko + * @see StreamListener + * @see StreamListenerAnnotationBeanPostProcessor + */ +public interface StreamListenerSetupMethodOrchestrator { + + /** + * Checks the method annotated with {@link StreamListener} to see if this + * implementation can successfully orchestrate this method. + * @param method annotated with {@link StreamListener} + * @return true if this implementation can orchestrate this method, false otherwise + */ + boolean supports(Method method); + + /** + * Method that allows custom orchestration on the {@link StreamListener} setup method. + * @param streamListener reference to the {@link StreamListener} annotation on the + * method + * @param method annotated with {@link StreamListener} + * @param bean that contains the StreamListener method + * + */ + void orchestrateStreamListenerSetupMethod(StreamListener streamListener, + Method method, Object bean); + + /** + * Default implementation for adapting each of the incoming method arguments using an + * available {@link StreamListenerParameterAdapter} and provide the adapted collection + * of arguments back to the caller. + * @param method annotated with {@link StreamListener} + * @param inboundName inbound binding + * @param applicationContext spring application context + * @param streamListenerParameterAdapters used for adapting the method arguments + * @return adapted incoming arguments + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + default Object[] adaptAndRetrieveInboundArguments(Method method, String inboundName, + ApplicationContext applicationContext, + StreamListenerParameterAdapter... streamListenerParameterAdapters) { + Object[] arguments = new Object[method.getParameterTypes().length]; + for (int parameterIndex = 0; parameterIndex < arguments.length; parameterIndex++) { + MethodParameter methodParameter = MethodParameter.forExecutable(method, + parameterIndex); + Class parameterType = methodParameter.getParameterType(); + Object targetReferenceValue = null; + if (methodParameter.hasParameterAnnotation(Input.class)) { + targetReferenceValue = AnnotationUtils + .getValue(methodParameter.getParameterAnnotation(Input.class)); + } + else if (methodParameter.hasParameterAnnotation(Output.class)) { + targetReferenceValue = AnnotationUtils + .getValue(methodParameter.getParameterAnnotation(Output.class)); + } + else if (arguments.length == 1 && StringUtils.hasText(inboundName)) { + targetReferenceValue = inboundName; + } + if (targetReferenceValue != null) { + Assert.isInstanceOf(String.class, targetReferenceValue, + "Annotation value must be a String"); + Object targetBean = applicationContext + .getBean((String) targetReferenceValue); + // Iterate existing parameter adapters first + for (StreamListenerParameterAdapter streamListenerParameterAdapter : streamListenerParameterAdapters) { + if (streamListenerParameterAdapter.supports(targetBean.getClass(), + methodParameter)) { + arguments[parameterIndex] = streamListenerParameterAdapter + .adapt(targetBean, methodParameter); + break; + } + } + if (arguments[parameterIndex] == null + && parameterType.isAssignableFrom(targetBean.getClass())) { + arguments[parameterIndex] = targetBean; + } + Assert.notNull(arguments[parameterIndex], + "Cannot convert argument " + parameterIndex + " of " + method + + "from " + targetBean.getClass() + " to " + + parameterType); + } + else { + throw new IllegalStateException( + StreamListenerErrorMessages.INVALID_DECLARATIVE_METHOD_PARAMETERS); + } + } + return arguments; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SubscribableChannelBindingTargetFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SubscribableChannelBindingTargetFactory.java new file mode 100644 index 000000000..a200aa715 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binding/SubscribableChannelBindingTargetFactory.java @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.springframework.cloud.stream.messaging.DirectWithAttributesChannel; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.messaging.SubscribableChannel; + +/** + * An implementation of {@link BindingTargetFactory} for creating + * {@link SubscribableChannel}s. + * + * @author Marius Bogoevici + * @author David Syer + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class SubscribableChannelBindingTargetFactory + extends AbstractBindingTargetFactory { + + private final MessageChannelConfigurer messageChannelConfigurer; + + public SubscribableChannelBindingTargetFactory( + MessageChannelConfigurer messageChannelConfigurer) { + super(SubscribableChannel.class); + this.messageChannelConfigurer = messageChannelConfigurer; + } + + @Override + public SubscribableChannel createInput(String name) { + DirectWithAttributesChannel subscribableChannel = new DirectWithAttributesChannel(); + subscribableChannel.setAttribute("type", Sink.INPUT); + this.messageChannelConfigurer.configureInputChannel(subscribableChannel, name); + return subscribableChannel; + } + + @Override + public SubscribableChannel createOutput(String name) { + DirectWithAttributesChannel subscribableChannel = new DirectWithAttributesChannel(); + subscribableChannel.setAttribute("type", Source.OUTPUT); + this.messageChannelConfigurer.configureOutputChannel(subscribableChannel, name); + return subscribableChannel; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderFactoryConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderFactoryConfiguration.java new file mode 100644 index 000000000..f470f910d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderFactoryConfiguration.java @@ -0,0 +1,296 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.FunctionRegistry; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.BinderType; +import org.springframework.cloud.stream.binder.BinderTypeRegistry; +import org.springframework.cloud.stream.binder.DefaultBinderTypeRegistry; +import org.springframework.cloud.stream.binding.BindableProxyFactory; +import org.springframework.cloud.stream.binding.CompositeMessageChannelConfigurer; +import org.springframework.cloud.stream.binding.MessageChannelConfigurer; +import org.springframework.cloud.stream.binding.MessageConverterConfigurer; +import org.springframework.cloud.stream.binding.MessageSourceBindingTargetFactory; +import org.springframework.cloud.stream.binding.SubscribableChannelBindingTargetFactory; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.core.io.support.PropertiesLoaderUtils; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.integration.handler.support.HandlerMethodArgumentResolversHolder; +import org.springframework.lang.Nullable; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.HeaderMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.HeadersMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.Validator; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + * @author Soby Chacko + * @author David Harrigan + * @deprecated since it really represents 'auto-configuration' it will be + * renamed/restructured in the next release. + */ +@Configuration +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +@EnableConfigurationProperties({ BindingServiceProperties.class }) +@Import(ContentTypeConfiguration.class) +@Deprecated +public class BinderFactoryConfiguration { + + private static final String SPRING_CLOUD_STREAM_INTERNAL_PREFIX = "spring.cloud.stream.internal"; + + private static final String SELF_CONTAINED_APP_PROPERTY_NAME = SPRING_CLOUD_STREAM_INTERNAL_PREFIX + + ".selfContained"; + + protected final Log logger = LogFactory.getLog(getClass()); + + @Value("${" + SELF_CONTAINED_APP_PROPERTY_NAME + ":}") + private String selfContained; + + static Collection parseBinderConfigurations(ClassLoader classLoader, + Resource resource) throws IOException, ClassNotFoundException { + Properties properties = PropertiesLoaderUtils.loadProperties(resource); + Collection parsedBinderConfigurations = new ArrayList<>(); + for (Map.Entry entry : properties.entrySet()) { + String binderType = (String) entry.getKey(); + String[] binderConfigurationClassNames = StringUtils + .commaDelimitedListToStringArray((String) entry.getValue()); + Class[] binderConfigurationClasses = new Class[binderConfigurationClassNames.length]; + int i = 0; + for (String binderConfigurationClassName : binderConfigurationClassNames) { + binderConfigurationClasses[i++] = ClassUtils + .forName(binderConfigurationClassName, classLoader); + } + parsedBinderConfigurations + .add(new BinderType(binderType, binderConfigurationClasses)); + } + return parsedBinderConfigurations; + } + + + + @Bean(IntegrationContextUtils.MESSAGE_HANDLER_FACTORY_BEAN_NAME) + public static MessageHandlerMethodFactory messageHandlerMethodFactory( + CompositeMessageConverterFactory compositeMessageConverterFactory, + @Qualifier(IntegrationContextUtils.ARGUMENT_RESOLVERS_BEAN_NAME) HandlerMethodArgumentResolversHolder ahmar, + @Nullable Validator validator, ConfigurableListableBeanFactory clbf) { + + DefaultMessageHandlerMethodFactory messageHandlerMethodFactory = new DefaultMessageHandlerMethodFactory(); + messageHandlerMethodFactory.setMessageConverter( + compositeMessageConverterFactory.getMessageConverterForAllRegistered()); + + /* + * We essentially do the same thing as the + * DefaultMessageHandlerMethodFactory.initArgumentResolvers(..). We can't do it as + * custom resolvers for two reasons. 1. We would have two duplicate (compatible) + * resolvers, so they would need to be ordered properly to ensure these new + * resolvers take precedence. 2. + * DefaultMessageHandlerMethodFactory.initArgumentResolvers(..) puts + * MessageMethodArgumentResolver before custom converters thus not allowing an + * override which kind of proves #1. + * + * In all, all this will be obsolete once https://jira.spring.io/browse/SPR-17503 + * is addressed and we can fall back on core resolvers + */ + List resolvers = new LinkedList<>(); + resolvers.add(new SmartPayloadArgumentResolver( + compositeMessageConverterFactory.getMessageConverterForAllRegistered(), + validator)); + resolvers.add(new SmartMessageMethodArgumentResolver( + compositeMessageConverterFactory.getMessageConverterForAllRegistered())); + resolvers.add(new HeaderMethodArgumentResolver(null, clbf)); + resolvers.add(new HeadersMethodArgumentResolver()); + resolvers.addAll(ahmar.getResolvers()); + + // modify HandlerMethodArgumentResolversHolder + Field field = ReflectionUtils + .findField(HandlerMethodArgumentResolversHolder.class, "resolvers"); + field.setAccessible(true); + ((List) ReflectionUtils.getField(field, ahmar)).clear(); + resolvers.forEach(ahmar::addResolver); + // -- + + messageHandlerMethodFactory.setArgumentResolvers(resolvers); + messageHandlerMethodFactory.setValidator(validator); + return messageHandlerMethodFactory; + } + + @Bean + public BinderTypeRegistry binderTypeRegistry( + ConfigurableApplicationContext configurableApplicationContext) { + Map binderTypes = new HashMap<>(); + ClassLoader classLoader = configurableApplicationContext.getClassLoader(); + // the above can never be null since it will default to + // ClassUtils.getDefaultClassLoader(..) + try { + Enumeration resources = classLoader + .getResources("META-INF/spring.binders"); + if (!Boolean.valueOf(this.selfContained) + && (resources == null || !resources.hasMoreElements())) { + this.logger.debug( + "Failed to locate 'META-INF/spring.binders' resources on the classpath." + + " Assuming standard boot 'META-INF/spring.factories' configuration is used"); + } + else { + while (resources.hasMoreElements()) { + URL url = resources.nextElement(); + UrlResource resource = new UrlResource(url); + for (BinderType binderType : parseBinderConfigurations(classLoader, + resource)) { + binderTypes.put(binderType.getDefaultName(), binderType); + } + } + } + + } + catch (IOException | ClassNotFoundException e) { + throw new BeanCreationException("Cannot create binder factory:", e); + } + return new DefaultBinderTypeRegistry(binderTypes); + } + + @Bean + public MessageConverterConfigurer messageConverterConfigurer( + BindingServiceProperties bindingServiceProperties, + CompositeMessageConverterFactory compositeMessageConverterFactory) { + return new MessageConverterConfigurer(bindingServiceProperties, + compositeMessageConverterFactory); + } + + @Bean + public SubscribableChannelBindingTargetFactory channelFactory( + CompositeMessageChannelConfigurer compositeMessageChannelConfigurer) { + return new SubscribableChannelBindingTargetFactory( + compositeMessageChannelConfigurer); + } + + @Bean + public MessageSourceBindingTargetFactory messageSourceFactory( + CompositeMessageConverterFactory compositeMessageConverterFactory, + CompositeMessageChannelConfigurer compositeMessageChannelConfigurer) { + return new MessageSourceBindingTargetFactory( + compositeMessageConverterFactory.getMessageConverterForAllRegistered(), + compositeMessageChannelConfigurer); + } + + @Bean + public CompositeMessageChannelConfigurer compositeMessageChannelConfigurer( + MessageConverterConfigurer messageConverterConfigurer) { + List configurerList = new ArrayList<>(); + configurerList.add(messageConverterConfigurer); + return new CompositeMessageChannelConfigurer(configurerList); + } + + @Bean + public BeanFactoryPostProcessor implicitFunctionBinder(Environment environment, + @Nullable FunctionRegistry functionCatalog, @Nullable FunctionInspector inspector) { + return new BeanFactoryPostProcessor() { + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + if (functionCatalog != null && ObjectUtils.isEmpty(beanFactory.getBeanNamesForAnnotation(EnableBinding.class))) { + BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory; + String name = determineFunctionName(functionCatalog, environment); + if (StringUtils.hasText(name)) { + Object definedFunction = functionCatalog.lookup(name); + Class inputType = inspector.getInputType(definedFunction); + Class outputType = inspector.getOutputType(definedFunction); + if (Void.class.isAssignableFrom(outputType)) { + bind(Sink.class, registry); + } + else if (Void.class.isAssignableFrom(inputType)) { + bind(Source.class, registry); + } + else { + bind(Processor.class, registry); + } + } + } + } + }; + } + + private String determineFunctionName(FunctionCatalog catalog, Environment environment) { + String name = environment.getProperty("spring.cloud.stream.function.definition"); + if (!StringUtils.hasText(name) && catalog.size() == 1) { + name = ((FunctionInspector) catalog).getName(catalog.lookup("")); + if (StringUtils.hasText(name)) { + ((StandardEnvironment) environment).getSystemProperties() + .putIfAbsent("spring.cloud.stream.function.definition", name); + } + } + + return name; + } + + private void bind(Class type, BeanDefinitionRegistry registry) { + if (!registry.containsBeanDefinition(type.getName())) { + RootBeanDefinition rootBeanDefinition = new RootBeanDefinition( + BindableProxyFactory.class); + rootBeanDefinition.getConstructorArgumentValues() + .addGenericArgumentValue(type); + registry.registerBeanDefinition(type.getName(), rootBeanDefinition); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderProperties.java new file mode 100644 index 000000000..944458894 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BinderProperties.java @@ -0,0 +1,100 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; + +/** + * Contains the properties of a binder. + * + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +public class BinderProperties { + + /** + * The binder type. It typically references one of the binders found on the classpath, + * in particular a key in a META-INF/spring.binders file. By default, it has the same + * value as the configuration name. + */ + private String type; + + /** + * Root for a set of properties that can be used to customize the environment of the + * binder. + */ + private Map environment = new HashMap<>(); + + /** + * Whether the configuration will inherit the environment of the application itself. + * Default: true + */ + private boolean inheritEnvironment = true; + + /** + * Whether the binder configuration is a candidate for being considered a default + * binder, or can be used only when explicitly referenced. Defaulys: true + */ + private boolean defaultCandidate = true; + + public String getType() { + return this.type; + } + + public void setType(String name) { + this.type = name; + } + + public Map getEnvironment() { + return this.environment; + } + + /** + * @deprecated in 2.0.0 in preference to {@link #setEnvironment(Map)} + * @param environment properties to which stream props will be added + */ + @Deprecated + public void setEnvironment(Properties environment) { + this.environment.clear(); + this.environment.putAll(environment.entrySet().stream().collect( + Collectors.toMap(e -> e.getKey().toString(), e -> e.getValue()))); + } + + public void setEnvironment(Map environment) { + this.environment = environment; + } + + public boolean isInheritEnvironment() { + return this.inheritEnvironment; + } + + public void setInheritEnvironment(boolean inheritEnvironment) { + this.inheritEnvironment = inheritEnvironment; + } + + public boolean isDefaultCandidate() { + return this.defaultCandidate; + } + + public void setDefaultCandidate(boolean defaultCandidate) { + this.defaultCandidate = defaultCandidate; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindersHealthIndicatorAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindersHealthIndicatorAutoConfiguration.java new file mode 100644 index 000000000..d1e3ff0fe --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindersHealthIndicatorAutoConfiguration.java @@ -0,0 +1,109 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.CompositeHealthIndicator; +import org.springframework.boot.actuate.health.DefaultHealthIndicatorRegistry; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.OrderedHealthAggregator; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.DefaultBinderFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Ilayaperumal Gopinathan + */ +@ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator") +@ConditionalOnEnabledHealthIndicator("binders") +@AutoConfigureBefore(EndpointAutoConfiguration.class) +@ConditionalOnBean(BinderFactory.class) +@AutoConfigureAfter(BindingServiceConfiguration.class) +@Configuration +public class BindersHealthIndicatorAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "bindersHealthIndicator") + public CompositeHealthIndicator bindersHealthIndicator() { + return new CompositeHealthIndicator(new OrderedHealthAggregator(), + new DefaultHealthIndicatorRegistry()); + } + + @Bean + public DefaultBinderFactory.Listener bindersHealthIndicatorListener( + @Qualifier("bindersHealthIndicator") CompositeHealthIndicator compositeHealthIndicator) { + return new BindersHealthIndicatorListener(compositeHealthIndicator); + } + + /** + * A {@link DefaultBinderFactory.Listener} that provides {@link HealthIndicator} + * support. + * + * @author Ilayaperumal Gopinathan + */ + private static class BindersHealthIndicatorListener + implements DefaultBinderFactory.Listener { + + private final CompositeHealthIndicator bindersHealthIndicator; + + BindersHealthIndicatorListener(CompositeHealthIndicator bindersHealthIndicator) { + this.bindersHealthIndicator = bindersHealthIndicator; + } + + @Override + public void afterBinderContextInitialized(String binderConfigurationName, + ConfigurableApplicationContext binderContext) { + if (this.bindersHealthIndicator != null) { + OrderedHealthAggregator healthAggregator = new OrderedHealthAggregator(); + Map indicators = binderContext + .getBeansOfType(HealthIndicator.class); + // if there are no health indicators in the child context, we just mark + // the binder's health as unknown + // this can happen due to the fact that configuration is inherited + HealthIndicator binderHealthIndicator = indicators.isEmpty() + ? new DefaultHealthIndicator() + : new CompositeHealthIndicator(healthAggregator, indicators); + this.bindersHealthIndicator.getRegistry() + .register(binderConfigurationName, binderHealthIndicator); + } + } + + private static class DefaultHealthIndicator extends AbstractHealthIndicator { + + @Override + protected void doHealthCheck(Health.Builder builder) throws Exception { + builder.unknown(); + } + + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingBeansRegistrar.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingBeansRegistrar.java new file mode 100644 index 000000000..9ca769139 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingBeansRegistrar.java @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; + +/** + * @author Marius Bogoevici + * @author Dave Syer + * @author Artem Bilan + * @author Oleg Zhurakousky + */ +public class BindingBeansRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata metadata, + BeanDefinitionRegistry registry) { + AnnotationAttributes attrs = AnnotatedElementUtils.getMergedAnnotationAttributes( + ClassUtils.resolveClassName(metadata.getClassName(), null), + EnableBinding.class); + for (Class type : collectClasses(attrs, metadata.getClassName())) { + if (!registry.containsBeanDefinition(type.getName())) { + BindingBeanDefinitionRegistryUtils.registerBindingTargetBeanDefinitions( + type, type.getName(), registry); + BindingBeanDefinitionRegistryUtils + .registerBindingTargetsQualifiedBeanDefinitions(ClassUtils + .resolveClassName(metadata.getClassName(), null), type, + registry); + } + } + } + + private Class[] collectClasses(AnnotationAttributes attrs, String className) { + EnableBinding enableBinding = AnnotationUtils.synthesizeAnnotation(attrs, + EnableBinding.class, ClassUtils.resolveClassName(className, null)); + return enableBinding.value(); + } +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingHandlerAdvise.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingHandlerAdvise.java new file mode 100644 index 000000000..2038c08a6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingHandlerAdvise.java @@ -0,0 +1,104 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationPropertiesBindHandlerAdvisor; +import org.springframework.boot.context.properties.bind.BindContext; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.validation.ValidationBindHandler; +import org.springframework.boot.context.properties.source.ConfigurationPropertyName; +import org.springframework.boot.context.properties.source.ConfigurationPropertyName.Form; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.validation.Validator; + +/** + * @author Oleg Zhurakousky + * @since 2.1 + * + */ +public class BindingHandlerAdvise implements ConfigurationPropertiesBindHandlerAdvisor { + + private final Map mappings; + + private final Validator[] validator; + + BindingHandlerAdvise( + Map additionalMappings, + @Nullable Validator validator) { + this.mappings = new LinkedHashMap<>(); + this.mappings.put(ConfigurationPropertyName.of("spring.cloud.stream.bindings"), + ConfigurationPropertyName.of("spring.cloud.stream.default")); + if (!CollectionUtils.isEmpty(additionalMappings)) { + this.mappings.putAll(additionalMappings); + } + this.validator = validator != null ? new Validator[] { validator } + : new Validator[] {}; + } + + @Override + public BindHandler apply(BindHandler bindHandler) { + BindHandler handler = new ValidationBindHandler(this.validator) { + @Override + public Bindable onStart(ConfigurationPropertyName name, + Bindable target, BindContext context) { + ConfigurationPropertyName defaultName = getDefaultName(name); + if (defaultName != null) { + BindResult result = context.getBinder().bind(defaultName, target); + if (result.isBound()) { + return target.withExistingValue(result.get()); + } + } + return bindHandler.onStart(name, target, context); + } + }; + return handler; + } + + private ConfigurationPropertyName getDefaultName(ConfigurationPropertyName name) { + for (Map.Entry mapping : this.mappings + .entrySet()) { + ConfigurationPropertyName from = mapping.getKey(); + ConfigurationPropertyName to = mapping.getValue(); + if ((from.isAncestorOf(name) + && name.getNumberOfElements() > from.getNumberOfElements())) { + ConfigurationPropertyName defaultName = to; + for (int i = from.getNumberOfElements() + 1; i < name + .getNumberOfElements(); i++) { + defaultName = defaultName.append(name.getElement(i, Form.UNIFORM)); + } + return defaultName; + } + } + return null; + } + + /** + * Provides mappings including the default mappings. + */ + public interface MappingsProvider { + + Map getDefaultMappings(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingProperties.java new file mode 100644 index 000000000..b87293715 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingProperties.java @@ -0,0 +1,161 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import javax.validation.constraints.AssertTrue; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.validation.annotation.Validated; + +/** + * Contains the properties of a binding. + * + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Soby Chacko + * @author Oleg Zhurakousky + */ +@JsonInclude(Include.NON_DEFAULT) +@Validated +public class BindingProperties { + + /** + * Default content type for bindings. + */ + public static final MimeType DEFAULT_CONTENT_TYPE = MimeTypeUtils.APPLICATION_JSON; + + private static final String COMMA = ","; + + /** + * The physical name at the broker that the binder binds to. + */ + private String destination; + + /** + * Unique name that the binding belongs to (applies to consumers only). Multiple + * consumers within the same group share the subscription. A null or empty String + * value indicates an anonymous group that is not shared. + * @see org.springframework.cloud.stream.binder.Binder#bindConsumer(java.lang.String, + * java.lang.String, java.lang.Object, + * org.springframework.cloud.stream.binder.ConsumerProperties) + */ + private String group; + + // Properties for both input and output bindings + + /** + * Specifies content-type that will be used by this binding in the event it is not + * specified in Message headers. Default: 'application/json'. + */ + private String contentType = DEFAULT_CONTENT_TYPE.toString(); + + /** + * The name of the binder to use for this binding in the event multiple binders + * available (e.g., 'rabbit'). + */ + private String binder; + + /** + * Additional consumer specific properties (see {@link ConsumerProperties}). + */ + private ConsumerProperties consumer; + + /** + * Additional producer specific properties (see {@link ProducerProperties}). + */ + private ProducerProperties producer; + + public String getDestination() { + return this.destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } + + public String getGroup() { + return this.group; + } + + public void setGroup(String group) { + this.group = group; + } + + public String getContentType() { + return this.contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getBinder() { + return this.binder; + } + + public void setBinder(String binder) { + this.binder = binder; + } + + public ConsumerProperties getConsumer() { + return this.consumer; + } + + public void setConsumer(ConsumerProperties consumer) { + this.consumer = consumer; + } + + public ProducerProperties getProducer() { + return this.producer; + } + + public void setProducer(ProducerProperties producer) { + this.producer = producer; + } + + @AssertTrue(message = "A binding must not set both producer and consumer properties.") + public boolean onlyOneOfProducerOrConsumerSet() { + return this.consumer == null || this.producer == null; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("destination=" + this.destination); + sb.append(COMMA); + sb.append("group=" + this.group); + sb.append(COMMA); + if (this.contentType != null) { + sb.append("contentType=" + this.contentType); + sb.append(COMMA); + } + if (this.binder != null) { + sb.append("binder=" + this.binder); + sb.append(COMMA); + } + sb.deleteCharAt(sb.lastIndexOf(COMMA)); + return "BindingProperties{" + sb.toString() + "}"; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java new file mode 100644 index 000000000..5196c6fe9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java @@ -0,0 +1,274 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.SearchStrategy; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.source.ConfigurationPropertyName; +import org.springframework.cloud.stream.binder.BinderConfiguration; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.BinderType; +import org.springframework.cloud.stream.binder.BinderTypeRegistry; +import org.springframework.cloud.stream.binder.DefaultBinderFactory; +import org.springframework.cloud.stream.binding.AbstractBindingTargetFactory; +import org.springframework.cloud.stream.binding.Bindable; +import org.springframework.cloud.stream.binding.BinderAwareChannelResolver; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.cloud.stream.binding.ContextStartAfterRefreshListener; +import org.springframework.cloud.stream.binding.DynamicDestinationsBindable; +import org.springframework.cloud.stream.binding.InputBindingLifecycle; +import org.springframework.cloud.stream.binding.MessageChannelStreamListenerResultAdapter; +import org.springframework.cloud.stream.binding.OutputBindingLifecycle; +import org.springframework.cloud.stream.binding.StreamListenerAnnotationBeanPostProcessor; +import org.springframework.cloud.stream.config.BindingHandlerAdvise.MappingsProvider; +import org.springframework.cloud.stream.function.StreamFunctionProperties; +import org.springframework.cloud.stream.micrometer.DestinationPublishingMetricsAutoConfiguration; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.integration.router.AbstractMappingMessageRouter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.core.DestinationResolver; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.Validator; + +/** + * Configuration class that provides necessary beans for {@link MessageChannel} binding. + * + * @author Dave Syer + * @author David Turanski + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Vinicius Carvalho + * @author Artem Bilan + * @author Oleg Zhurakousky + * @author Soby Chacko + */ +@Configuration +@EnableConfigurationProperties({ BindingServiceProperties.class, + SpringIntegrationProperties.class, StreamFunctionProperties.class }) +@Import({ DestinationPublishingMetricsAutoConfiguration.class, + SpelExpressionConverterConfiguration.class }) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +@ConditionalOnBean(value = BinderTypeRegistry.class, search = SearchStrategy.CURRENT) +public class BindingServiceConfiguration { + + // @checkstyle:off + /** + * Name of the Spring Cloud Stream stream listener annotation bean post processor. + */ + public static final String STREAM_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_NAME = "streamListenerAnnotationBeanPostProcessor"; + + // @checkstyle:on + + @Autowired(required = false) + private Collection binderFactoryListeners; + + private static Map getBinderConfigurations( + BinderTypeRegistry binderTypeRegistry, + BindingServiceProperties bindingServiceProperties) { + + Map binderConfigurations = new HashMap<>(); + Map declaredBinders = bindingServiceProperties + .getBinders(); + boolean defaultCandidatesExist = false; + Iterator> binderPropertiesIterator = declaredBinders + .entrySet().iterator(); + while (!defaultCandidatesExist && binderPropertiesIterator.hasNext()) { + defaultCandidatesExist = binderPropertiesIterator.next().getValue() + .isDefaultCandidate(); + } + List existingBinderConfigurations = new ArrayList<>(); + for (Map.Entry binderEntry : declaredBinders + .entrySet()) { + BinderProperties binderProperties = binderEntry.getValue(); + if (binderTypeRegistry.get(binderEntry.getKey()) != null) { + binderConfigurations.put(binderEntry.getKey(), + new BinderConfiguration(binderEntry.getKey(), + binderProperties.getEnvironment(), + binderProperties.isInheritEnvironment(), + binderProperties.isDefaultCandidate())); + existingBinderConfigurations.add(binderEntry.getKey()); + } + else { + Assert.hasText(binderProperties.getType(), + "No 'type' property present for custom binder " + + binderEntry.getKey()); + binderConfigurations.put(binderEntry.getKey(), + new BinderConfiguration(binderProperties.getType(), + binderProperties.getEnvironment(), + binderProperties.isInheritEnvironment(), + binderProperties.isDefaultCandidate())); + existingBinderConfigurations.add(binderEntry.getKey()); + } + } + for (Map.Entry configurationEntry : binderConfigurations + .entrySet()) { + if (configurationEntry.getValue().isDefaultCandidate()) { + defaultCandidatesExist = true; + } + } + if (!defaultCandidatesExist) { + for (Map.Entry binderEntry : binderTypeRegistry.getAll() + .entrySet()) { + if (!existingBinderConfigurations.contains(binderEntry.getKey())) { + binderConfigurations.put(binderEntry.getKey(), + new BinderConfiguration(binderEntry.getKey(), new HashMap<>(), + true, true)); + } + } + } + return binderConfigurations; + } + + @Bean(name = STREAM_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_NAME) + @ConditionalOnMissingBean(search = SearchStrategy.CURRENT) + public static StreamListenerAnnotationBeanPostProcessor streamListenerAnnotationBeanPostProcessor() { + return new StreamListenerAnnotationBeanPostProcessor(); + } + + @Bean + public BindingHandlerAdvise BindingHandlerAdvise( + @Nullable MappingsProvider[] providers, @Nullable Validator validator) { + Map additionalMappings = new HashMap<>(); + if (!ObjectUtils.isEmpty(providers)) { + for (int i = 0; i < providers.length; i++) { + MappingsProvider mappingsProvider = providers[i]; + additionalMappings.putAll(mappingsProvider.getDefaultMappings()); + } + } + return new BindingHandlerAdvise(additionalMappings, validator); + } + + @Bean + @ConditionalOnMissingBean(BinderFactory.class) + public BinderFactory binderFactory(BinderTypeRegistry binderTypeRegistry, + BindingServiceProperties bindingServiceProperties) { + + DefaultBinderFactory binderFactory = new DefaultBinderFactory( + getBinderConfigurations(binderTypeRegistry, bindingServiceProperties), + binderTypeRegistry); + binderFactory.setDefaultBinder(bindingServiceProperties.getDefaultBinder()); + binderFactory.setListeners(this.binderFactoryListeners); + return binderFactory; + } + + @Bean + public MessageChannelStreamListenerResultAdapter messageChannelStreamListenerResultAdapter() { + return new MessageChannelStreamListenerResultAdapter(); + } + + @Bean + // This conditional is intentionally not in an autoconfig (usually a bad idea) because + // it is used to detect a BindingService in the parent context (which we know + // already exists). + @ConditionalOnMissingBean(search = SearchStrategy.CURRENT) + public BindingService bindingService( + BindingServiceProperties bindingServiceProperties, + BinderFactory binderFactory, TaskScheduler taskScheduler) { + + return new BindingService(bindingServiceProperties, binderFactory, taskScheduler); + } + + @Bean + @DependsOn("bindingService") + public OutputBindingLifecycle outputBindingLifecycle(BindingService bindingService, + Map bindables) { + + return new OutputBindingLifecycle(bindingService, bindables); + } + + @Bean + @DependsOn("bindingService") + public InputBindingLifecycle inputBindingLifecycle(BindingService bindingService, + Map bindables) { + return new InputBindingLifecycle(bindingService, bindables); + } + + @Bean + @DependsOn("bindingService") + public ContextStartAfterRefreshListener contextStartAfterRefreshListener() { + return new ContextStartAfterRefreshListener(); + } + + @SuppressWarnings("rawtypes") + @Bean + public BinderAwareChannelResolver binderAwareChannelResolver( + BindingService bindingService, + AbstractBindingTargetFactory bindingTargetFactory, + DynamicDestinationsBindable dynamicDestinationsBindable, + @Nullable BinderAwareChannelResolver.NewDestinationBindingCallback callback) { + + return new BinderAwareChannelResolver(bindingService, bindingTargetFactory, + dynamicDestinationsBindable, callback); + } + + @Bean + public DynamicDestinationsBindable dynamicDestinationsBindable() { + return new DynamicDestinationsBindable(); + } + + @SuppressWarnings("deprecation") + @Bean + @ConditionalOnMissingBean + public org.springframework.cloud.stream.binding.BinderAwareRouterBeanPostProcessor binderAwareRouterBeanPostProcessor( + @Autowired(required = false) AbstractMappingMessageRouter[] routers, + @Autowired(required = false) DestinationResolver channelResolver) { + + return new org.springframework.cloud.stream.binding.BinderAwareRouterBeanPostProcessor( + routers, channelResolver); + } + + @Bean + public ApplicationListener appListener( + SpringIntegrationProperties springIntegrationProperties) { + return new ApplicationListener() { + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + event.getApplicationContext() + .getBeansOfType(AbstractReplyProducingMessageHandler.class) + .values() + .forEach(mh -> mh + .addNotPropagatedHeaders(springIntegrationProperties + .getMessageHandlerNotPropagatedHeaders())); + } + + }; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceProperties.java new file mode 100644 index 000000000..45977d0c1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceProperties.java @@ -0,0 +1,299 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.integration.support.utils.IntegrationUtils; +import org.springframework.util.Assert; + +/** + * @author Dave Syer + * @author Marius Bogoevici + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +@ConfigurationProperties("spring.cloud.stream") +@JsonInclude(Include.NON_DEFAULT) +public class BindingServiceProperties + implements ApplicationContextAware, InitializingBean { + + private static final int DEFAULT_BINDING_RETRY_INTERVAL = 30; + + /** + * The instance id of the application: a number from 0 to instanceCount-1. Used for + * partitioning and with Kafka. NOTE: Could also be managed per individual binding + * "spring.cloud.stream.bindings.foo.consumer.instance-index" where 'foo' is the name + * of the binding. + */ + @Value("${INSTANCE_INDEX:${CF_INSTANCE_INDEX:0}}") + private int instanceIndex; + + /** + * The number of deployed instances of an application. Default: 1. NOTE: Could also be + * managed per individual binding + * "spring.cloud.stream.bindings.foo.consumer.instance-count" where 'foo' is the name + * of the binding. + */ + private int instanceCount = 1; + + /** + * Additional binding properties (see {@link BinderProperties}) per binding name + * (e.g., 'input`). + * + * For example; This sets the content-type for the 'input' binding of a Sink + * application: 'spring.cloud.stream.bindings.input.contentType=text/plain' + */ + private Map bindings = new TreeMap<>( + String.CASE_INSENSITIVE_ORDER); + + /** + * Additional per-binder properties (see {@link BinderProperties}) if more then one + * binder of the same type is used (i.e., connect to multiple instances of RabbitMq). + * Here you can specify multiple binder configurations, each with different + * environment settings. For example; spring.cloud.stream.binders.rabbit1.environment. + * . . , spring.cloud.stream.binders.rabbit2.environment. . . + */ + private Map binders = new HashMap<>(); + + /** + * The name of the binder to use by all bindings in the event multiple binders + * available (e.g., 'rabbit'). + */ + private String defaultBinder; + + /** + * A list of destinations that can be bound dynamically. If set, only listed + * destinations can be bound. + */ + private String[] dynamicDestinations = new String[0]; + + /** + * Retry interval (in seconds) used to schedule binding attempts. Default: 30 sec. + */ + private int bindingRetryInterval = DEFAULT_BINDING_RETRY_INTERVAL; + + private ConfigurableApplicationContext applicationContext = new GenericApplicationContext(); + + private ConversionService conversionService; + + public Map getBindings() { + return this.bindings; + } + + public void setBindings(Map bindings) { + this.bindings.putAll(bindings); + } + + public Map getBinders() { + return this.binders; + } + + public void setBinders(Map binders) { + this.binders = binders; + } + + public String getDefaultBinder() { + return this.defaultBinder; + } + + public void setDefaultBinder(String defaultBinder) { + this.defaultBinder = defaultBinder; + } + + public int getInstanceIndex() { + return this.instanceIndex; + } + + public void setInstanceIndex(int instanceIndex) { + this.instanceIndex = instanceIndex; + } + + public int getInstanceCount() { + return this.instanceCount; + } + + public void setInstanceCount(int instanceCount) { + this.instanceCount = instanceCount; + } + + public String[] getDynamicDestinations() { + return this.dynamicDestinations; + } + + public void setDynamicDestinations(String[] dynamicDestinations) { + this.dynamicDestinations = dynamicDestinations; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + GenericConversionService cs = (GenericConversionService) IntegrationUtils + .getConversionService(this.applicationContext.getBeanFactory()); + if (this.applicationContext.containsBean("spelConverter")) { + Converter converter = (Converter) this.applicationContext + .getBean("spelConverter"); + cs.addConverter(converter); + } + } + + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (this.conversionService == null) { + this.conversionService = this.applicationContext.getBean( + IntegrationUtils.INTEGRATION_CONVERSION_SERVICE_BEAN_NAME, + ConversionService.class); + } + } + + public String getBinder(String bindingName) { + return getBindingProperties(bindingName).getBinder(); + } + + /** + * Return configuration properties as Map. + * @return map of binding configuration properties. + */ + public Map asMapProperties() { + Map properties = new HashMap<>(); + properties.put("instanceIndex", String.valueOf(getInstanceIndex())); + properties.put("instanceCount", String.valueOf(getInstanceCount())); + properties.put("defaultBinder", getDefaultBinder()); + properties.put("dynamicDestinations", getDynamicDestinations()); + for (Map.Entry entry : this.bindings.entrySet()) { + properties.put(entry.getKey(), entry.getValue().toString()); + } + for (Map.Entry entry : this.binders.entrySet()) { + properties.put(entry.getKey(), entry.getValue()); + } + return properties; + } + + public ConsumerProperties getConsumerProperties(String inputBindingName) { + Assert.notNull(inputBindingName, "The input binding name cannot be null"); + BindingProperties bindingProperties = getBindingProperties(inputBindingName); + ConsumerProperties consumerProperties = bindingProperties.getConsumer(); + if (consumerProperties == null) { + consumerProperties = new ConsumerProperties(); + bindingProperties.setConsumer(consumerProperties); + } + // propagate instance count and instance index if not already set + if (consumerProperties.getInstanceCount() < 0) { + consumerProperties.setInstanceCount(this.instanceCount); + } + if (consumerProperties.getInstanceIndex() < 0) { + consumerProperties.setInstanceIndex(this.instanceIndex); + } + return consumerProperties; + } + + public ProducerProperties getProducerProperties(String outputBindingName) { + Assert.notNull(outputBindingName, "The output binding name cannot be null"); + BindingProperties bindingProperties = getBindingProperties(outputBindingName); + ProducerProperties producerProperties = bindingProperties.getProducer(); + if (producerProperties == null) { + producerProperties = new ProducerProperties(); + bindingProperties.setProducer(producerProperties); + } + return producerProperties; + } + + public BindingProperties getBindingProperties(String bindingName) { + this.bindIfNecessary(bindingName); + BindingProperties bindingProperties = this.bindings.get(bindingName); + if (bindingProperties.getDestination() == null) { + bindingProperties.setDestination(bindingName); + } + return bindingProperties; + } + + public String getGroup(String bindingName) { + return getBindingProperties(bindingName).getGroup(); + } + + public String getBindingDestination(String bindingName) { + return getBindingProperties(bindingName).getDestination(); + } + + public int getBindingRetryInterval() { + return this.bindingRetryInterval; + } + + public void setBindingRetryInterval(int bindingRetryInterval) { + this.bindingRetryInterval = bindingRetryInterval; + } + + public void updateProducerProperties(String bindingName, + ProducerProperties producerProperties) { + if (this.bindings.containsKey(bindingName)) { + this.bindings.get(bindingName).setProducer(producerProperties); + } + } + + /* + * The "necessary" implies the scenario where only defaults are defined. + */ + private void bindIfNecessary(String bindingName) { + if (!this.bindings.containsKey(bindingName)) { + this.bindToDefault(bindingName); + } + } + + private void bindToDefault(String binding) { + BindingProperties bindingPropertiesTarget = new BindingProperties(); + Binder binder = new Binder( + ConfigurationPropertySources + .get(this.applicationContext.getEnvironment()), + new PropertySourcesPlaceholdersResolver( + this.applicationContext.getEnvironment()), + IntegrationUtils.getConversionService( + this.applicationContext.getBeanFactory()), + null); + binder.bind("spring.cloud.stream.default", + Bindable.ofInstance(bindingPropertiesTarget)); + this.bindings.put(binding, bindingPropertiesTarget); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingsEndpointAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingsEndpointAutoConfiguration.java new file mode 100644 index 000000000..7c831274c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingsEndpointAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.List; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.cloud.stream.binding.InputBindingLifecycle; +import org.springframework.cloud.stream.binding.OutputBindingLifecycle; +import org.springframework.cloud.stream.endpoint.BindingsEndpoint; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oleg Zhurakousky + * @since 2.0 + */ +@Configuration +@ConditionalOnClass(name = { + "org.springframework.boot.actuate.endpoint.annotation.Endpoint" }) +@ConditionalOnBean(BindingService.class) +@AutoConfigureAfter(EndpointAutoConfiguration.class) +public class BindingsEndpointAutoConfiguration { + + @Bean + public BindingsEndpoint bindingsEndpoint(List inputBindings, + List outputBindings) { + return new BindingsEndpoint(inputBindings, outputBindings); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelBindingAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelBindingAutoConfiguration.java new file mode 100644 index 000000000..8845e9ba8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelBindingAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.scheduling.PollerMetadata; +import org.springframework.messaging.MessageChannel; + +/** + * Configuration class with some useful beans for {@link MessageChannel} binding and + * general Spring Integration infrastructure. + * + * @author Dave Syer + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +@Configuration +@ConditionalOnBean(BindingService.class) +@EnableConfigurationProperties(DefaultPollerProperties.class) +public class ChannelBindingAutoConfiguration { + + @Autowired + private DefaultPollerProperties poller; + + @Bean(name = PollerMetadata.DEFAULT_POLLER) + @ConditionalOnMissingBean(PollerMetadata.class) + public PollerMetadata defaultPoller() { + return this.poller.getPollerMetadata(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelsEndpointAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelsEndpointAutoConfiguration.java new file mode 100644 index 000000000..b37a0dfd7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ChannelsEndpointAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.stream.binding.Bindable; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.cloud.stream.endpoint.ChannelsEndpoint; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Dave Syer + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +@Configuration +@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint") +@ConditionalOnBean(BindingService.class) +@AutoConfigureAfter(EndpointAutoConfiguration.class) +public class ChannelsEndpointAutoConfiguration { + + @Autowired(required = false) + private List adapters; + + @Bean + public ChannelsEndpoint channelsEndpoint(BindingServiceProperties properties) { + return new ChannelsEndpoint(this.adapters, properties); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ContentTypeConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ContentTypeConfiguration.java new file mode 100644 index 000000000..674577c14 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ContentTypeConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.integration.support.converter.ConfigurableCompositeMessageConverter; +import org.springframework.messaging.converter.MessageConverter; + +/** + * @author Vinicius Carvalho + * @author Artem Bilan + */ +@Configuration +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +class ContentTypeConfiguration { + + @Bean + public CompositeMessageConverterFactory compositeMessageConverterFactory( + ObjectProvider objectMapperObjectProvider, + @StreamMessageConverter List customMessageConverters) { + + return new CompositeMessageConverterFactory(customMessageConverters, + objectMapperObjectProvider.getIfAvailable(ObjectMapper::new)); + } + + @Bean(name = IntegrationContextUtils.ARGUMENT_RESOLVER_MESSAGE_CONVERTER_BEAN_NAME) + public ConfigurableCompositeMessageConverter configurableCompositeMessageConverter( + CompositeMessageConverterFactory factory) { + + return new ConfigurableCompositeMessageConverter( + factory.getMessageConverterForAllRegistered().getConverters()); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java new file mode 100644 index 000000000..43f1523eb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.integration.scheduling.PollerMetadata; +import org.springframework.scheduling.support.PeriodicTrigger; + +/** + * @author Dave Syer + * + */ +@ConfigurationProperties("spring.integration.poller") +public class DefaultPollerProperties { + + /** + * Fixed delay for default poller. + */ + private long fixedDelay = 1000L; + + /** + * Maximum messages per poll for the default poller. + */ + private long maxMessagesPerPoll = 1L; + + public PollerMetadata getPollerMetadata() { + PollerMetadata pollerMetadata = new PollerMetadata(); + pollerMetadata.setTrigger(new PeriodicTrigger(this.fixedDelay)); + pollerMetadata.setMaxMessagesPerPoll(this.maxMessagesPerPoll); + return pollerMetadata; + } + + public long getFixedDelay() { + return this.fixedDelay; + } + + public void setFixedDelay(long fixedDelay) { + this.fixedDelay = fixedDelay; + } + + public long getMaxMessagesPerPoll() { + return this.maxMessagesPerPoll; + } + + public void setMaxMessagesPerPoll(long maxMessagesPerPoll) { + this.maxMessagesPerPoll = maxMessagesPerPoll; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ListenerContainerCustomizer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ListenerContainerCustomizer.java new file mode 100644 index 000000000..a63b3c176 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/ListenerContainerCustomizer.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +/** + * If a single bean of this type is in the application context, listener containers + * created by the binder can be further customized after all the properties are set. For + * example, to configure less-common properties. + * + * @param container type + * @author Gary Russell + * @author Oleg Zhurakousky + * @since 2.1 + */ +@FunctionalInterface +public interface ListenerContainerCustomizer { + + /** + * Configure the container that is being created for the supplied queue name and + * consumer group. + * @param container the container. + * @param destinationName the destination name. + * @param group the consumer group. + */ + void configure(T container, String destinationName, String group); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/MessageSourceCustomizer.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/MessageSourceCustomizer.java new file mode 100644 index 000000000..97be0533c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/MessageSourceCustomizer.java @@ -0,0 +1,42 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.integration.core.MessageSource; + +/** + * If a single bean of this type is in the application context, pollable message sources + * created by the binder can be further customized after all the properties are set. For + * example, to configure less-common properties. + * + * @param {@link MessageSource} type + * @author Gary Russell + * @since 2.2 + */ +@FunctionalInterface +public interface MessageSourceCustomizer { + + /** + * Configure the container that is being created for the supplied queue name and + * consumer group. + * @param source the MessageSource. + * @param destinationName the destination name. + * @param group the consumer group. + */ + void configure(T source, String destinationName, String group); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartMessageMethodArgumentResolver.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartMessageMethodArgumentResolver.java new file mode 100644 index 000000000..a46e7906b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartMessageMethodArgumentResolver.java @@ -0,0 +1,138 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.reflect.Type; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.handler.annotation.support.MessageMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.MethodArgumentTypeMismatchException; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * @author Oleg Zhurakousky + * @deprecated will be removed once https://jira.spring.io/browse/SPR-17503 is addressed + */ +@Deprecated +class SmartMessageMethodArgumentResolver extends MessageMethodArgumentResolver { + + private final MessageConverter messageConverter; + + SmartMessageMethodArgumentResolver() { + this(null); + } + + /** + * Create a resolver instance with the given {@link MessageConverter}. + * @param converter the MessageConverter to use (may be {@code null}) + * @since 4.3 + */ + SmartMessageMethodArgumentResolver(@Nullable MessageConverter converter) { + this.messageConverter = converter; + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) + throws Exception { + Class targetMessageType = parameter.getParameterType(); + Class targetPayloadType = getPayloadType(parameter); + + if (!targetMessageType.isAssignableFrom(message.getClass())) { + throw new MethodArgumentTypeMismatchException(message, parameter, + "Actual message type '" + ClassUtils.getDescriptiveType(message) + + "' does not match expected type '" + + ClassUtils.getQualifiedName(targetMessageType) + "'"); + } + + Class payloadClass = message.getPayload().getClass(); + + if (message instanceof ErrorMessage + || conversionNotRequired(payloadClass, targetPayloadType)) { + return message; + } + Object payload = message.getPayload(); + if (isEmptyPayload(payload)) { + throw new MessageConversionException(message, + "Cannot convert from actual payload type '" + + ClassUtils.getDescriptiveType(payload) + + "' to expected payload type '" + + ClassUtils.getQualifiedName(targetPayloadType) + + "' when payload is empty"); + } + + payload = convertPayload(message, parameter, targetPayloadType); + return MessageBuilder.createMessage(payload, message.getHeaders()); + } + + private boolean conversionNotRequired(Class a, Class b) { + return b == Object.class + ? ClassUtils.isAssignable(a, b) : ClassUtils.isAssignable(b, a); + } + + private Class getPayloadType(MethodParameter parameter) { + Type genericParamType = parameter.getGenericParameterType(); + ResolvableType resolvableType = ResolvableType.forType(genericParamType) + .as(Message.class); + return resolvableType.getGeneric().toClass(); + } + + protected boolean isEmptyPayload(@Nullable Object payload) { + if (payload == null) { + return true; + } + else if (payload instanceof byte[]) { + return ((byte[]) payload).length == 0; + } + else if (payload instanceof String) { + return !StringUtils.hasText((String) payload); + } + else { + return false; + } + } + + private Object convertPayload(Message message, MethodParameter parameter, + Class targetPayloadType) { + Object result = null; + if (this.messageConverter instanceof SmartMessageConverter) { + SmartMessageConverter smartConverter = (SmartMessageConverter) this.messageConverter; + result = smartConverter.fromMessage(message, targetPayloadType, parameter); + } + else if (this.messageConverter != null) { + result = this.messageConverter.fromMessage(message, targetPayloadType); + } + + if (result == null) { + throw new MessageConversionException(message, + "No converter found from actual payload type '" + + ClassUtils.getDescriptiveType(message.getPayload()) + + "' to expected payload type '" + + ClassUtils.getQualifiedName(targetPayloadType) + "'"); + } + return result; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartPayloadArgumentResolver.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartPayloadArgumentResolver.java new file mode 100644 index 000000000..839caf116 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SmartPayloadArgumentResolver.java @@ -0,0 +1,143 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.annotation.support.PayloadArgumentResolver; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.ObjectError; +import org.springframework.validation.Validator; + +/** + * @author Oleg Zhurakousky + * @author Gary Russell + * @deprecated will be removed once https://jira.spring.io/browse/SPR-17503 is addressed + * (but see note about KafkaNull below). + */ +@Deprecated +class SmartPayloadArgumentResolver extends PayloadArgumentResolver { + + private final MessageConverter messageConverter; + + SmartPayloadArgumentResolver(MessageConverter messageConverter) { + super(messageConverter); + this.messageConverter = messageConverter; + } + + SmartPayloadArgumentResolver(MessageConverter messageConverter, Validator validator) { + super(messageConverter, validator, true); + this.messageConverter = messageConverter; + } + + SmartPayloadArgumentResolver(MessageConverter messageConverter, Validator validator, + boolean useDefaultResolution) { + super(messageConverter, validator, useDefaultResolution); + this.messageConverter = messageConverter; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return (!Message.class.isAssignableFrom(parameter.getParameterType()) + && !MessageHeaders.class.isAssignableFrom(parameter.getParameterType()) + && !parameter.hasParameterAnnotation(Header.class) + && !parameter.hasParameterAnnotation(Headers.class)); + } + + /** + * Needed to support mapping KafkaNull to null in method invocation. + */ + @Override + protected boolean isEmptyPayload(Object payload) { + return super.isEmptyPayload(payload) + || "org.springframework.kafka.support.KafkaNull" + .equals(payload.getClass().getName()); + } + + @Override + @Nullable + public Object resolveArgument(MethodParameter parameter, Message message) + throws Exception { + Payload ann = parameter.getParameterAnnotation(Payload.class); + if (ann != null && StringUtils.hasText(ann.expression())) { + throw new IllegalStateException( + "@Payload SpEL expressions not supported by this resolver"); + } + + Object payload = message.getPayload(); + if (isEmptyPayload(payload)) { + if (ann == null || ann.required()) { + String paramName = getParameterName(parameter); + BindingResult bindingResult = new BeanPropertyBindingResult(payload, + paramName); + bindingResult.addError( + new ObjectError(paramName, "Payload value must not be empty")); + throw new MethodArgumentNotValidException(message, parameter, + bindingResult); + } + else { + return null; + } + } + + Class targetClass = parameter.getParameterType(); + Class payloadClass = payload.getClass(); + if (conversionNotRequired(payloadClass, targetClass)) { + validate(message, parameter, payload); + return payload; + } + else { + if (this.messageConverter instanceof SmartMessageConverter) { + SmartMessageConverter smartConverter = (SmartMessageConverter) this.messageConverter; + payload = smartConverter.fromMessage(message, targetClass, parameter); + } + else { + payload = this.messageConverter.fromMessage(message, targetClass); + } + if (payload == null) { + throw new MessageConversionException(message, + "Cannot convert from [" + payloadClass.getName() + "] to [" + + targetClass.getName() + "] for " + message); + } + validate(message, parameter, payload); + return payload; + } + } + + private boolean conversionNotRequired(Class a, Class b) { + return b == Object.class + ? ClassUtils.isAssignable(a, b) : ClassUtils.isAssignable(b, a); + } + + private String getParameterName(MethodParameter param) { + String paramName = param.getParameterName(); + return (paramName != null ? paramName : "Arg " + param.getParameterIndex()); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfiguration.java new file mode 100644 index 000000000..66acbfa9d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfiguration.java @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.beans.Introspector; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Role; +import org.springframework.core.convert.converter.Converter; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ParseException; +import org.springframework.expression.spel.standard.SpelExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.config.IntegrationConverter; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.integration.expression.SpelPropertyAccessorRegistrar; +import org.springframework.integration.json.JsonPropertyAccessor; +import org.springframework.tuple.spel.TuplePropertyAccessor; + +/** + * Adds a Converter from String to SpEL Expression in the context. + * + * @author Eric Bottard + * @author Artem Bilan + */ +@Configuration +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SpelExpressionConverterConfiguration { + + /** + * Provide a {@link SpelPropertyAccessorRegistrar} supplied with the + * {@link JsonPropertyAccessor} and {@link TuplePropertyAccessor}. This bean is used + * to customize an + * {@link org.springframework.integration.config.IntegrationEvaluationContextFactoryBean}. + * for additional {@link org.springframework.expression.PropertyAccessor}s. + * @return the SpelPropertyAccessorRegistrar bean + * @see org.springframework.integration.config.IntegrationEvaluationContextFactoryBean + */ + @Bean + public static SpelPropertyAccessorRegistrar spelPropertyAccessorRegistrar() { + return new SpelPropertyAccessorRegistrar() + .add(Introspector + .decapitalize(JsonPropertyAccessor.class.getSimpleName()), + new JsonPropertyAccessor()) + .add(Introspector + .decapitalize(TuplePropertyAccessor.class.getSimpleName()), + new TuplePropertyAccessor()); + } + + @Bean + @ConfigurationPropertiesBinding + @IntegrationConverter + public Converter spelConverter() { + return new SpelConverter(); + } + + /** + * A simple converter from String to Expression. + * + * @author Eric Bottard + */ + public static class SpelConverter implements Converter { + + private SpelExpressionParser parser = new SpelExpressionParser(); + + @Autowired + @Qualifier(IntegrationContextUtils.INTEGRATION_EVALUATION_CONTEXT_BEAN_NAME) + @Lazy + private EvaluationContext evaluationContext; + + @Override + public Expression convert(String source) { + try { + Expression expression = this.parser.parseExpression(source); + if (expression instanceof SpelExpression) { + ((SpelExpression) expression) + .setEvaluationContext(this.evaluationContext); + } + return expression; + } + catch (ParseException e) { + throw new IllegalArgumentException(String.format( + "Could not convert '%s' into a SpEL expression", source), e); + } + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpringIntegrationProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpringIntegrationProperties.java new file mode 100644 index 000000000..aa8754967 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/SpringIntegrationProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.messaging.MessageHeaders; + +/** + * Contains properties for Spring Integration settings. + * + * @author Marius Bogoevici + * @since 1.2.3 + */ +@ConfigurationProperties("spring.cloud.stream.integration") +public class SpringIntegrationProperties { + + /** + * Message header names that will NOT be copied from the inbound message. + */ + private String[] messageHandlerNotPropagatedHeaders = new String[] { + MessageHeaders.CONTENT_TYPE }; + + public String[] getMessageHandlerNotPropagatedHeaders() { + return this.messageHandlerNotPropagatedHeaders; + } + + public void setMessageHandlerNotPropagatedHeaders( + String[] messageHandlerNotPropagatedHeaders) { + this.messageHandlerNotPropagatedHeaders = messageHandlerNotPropagatedHeaders; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ApplicationJsonMessageMarshallingConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ApplicationJsonMessageMarshallingConverter.java new file mode 100644 index 000000000..848dd8500 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ApplicationJsonMessageMarshallingConverter.java @@ -0,0 +1,138 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConversionException; + +/** + * Variation of {@link MappingJackson2MessageConverter} to support marshalling and + * unmarshalling of Messages's payload from 'String' or 'byte[]' to an instance of a + * 'targetClass' and and back to 'byte[]'. + * + * @author Oleg Zhurakousky + * @author Gary Russell + * @since 2.0 + */ +class ApplicationJsonMessageMarshallingConverter extends MappingJackson2MessageConverter { + + private final Map, JavaType> typeCache = new ConcurrentHashMap<>(); + + ApplicationJsonMessageMarshallingConverter(@Nullable ObjectMapper objectMapper) { + if (objectMapper != null) { + this.setObjectMapper(objectMapper); + } + } + + @Override + protected Object convertToInternal(Object payload, @Nullable MessageHeaders headers, + @Nullable Object conversionHint) { + if (payload instanceof byte[]) { + return payload; + } + else if (payload instanceof String) { + return ((String) payload).getBytes(StandardCharsets.UTF_8); + } + else { + return super.convertToInternal(payload, headers, conversionHint); + } + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, + @Nullable Object conversionHint) { + Object result = null; + if (conversionHint instanceof MethodParameter) { + Class conversionHintType = ((MethodParameter) conversionHint) + .getParameterType(); + if (Message.class.isAssignableFrom(conversionHintType)) { + /* + * Ensures that super won't attempt to create Message as a result of + * conversion and stays at payload conversion only. The Message will + * eventually be created in + * MessageMethodArgumentResolver.resolveArgument(..) + */ + conversionHint = null; + } + else if (((MethodParameter) conversionHint) + .getGenericParameterType() instanceof ParameterizedType) { + ParameterizedTypeReference forType = ParameterizedTypeReference + .forType(((MethodParameter) conversionHint) + .getGenericParameterType()); + result = convertParameterizedType(message, targetClass, forType); + } + } + else if (conversionHint instanceof ParameterizedTypeReference) { + result = convertParameterizedType(message, targetClass, + (ParameterizedTypeReference) conversionHint); + } + + if (result == null) { + if (message.getPayload() instanceof byte[] + && targetClass.isAssignableFrom(String.class)) { + result = new String((byte[]) message.getPayload(), + StandardCharsets.UTF_8); + } + else { + result = super.convertFromInternal(message, targetClass, conversionHint); + } + } + + return result; + } + + private Object convertParameterizedType(Message message, Class targetClass, + ParameterizedTypeReference conversionHint) { + ObjectMapper objectMapper = this.getObjectMapper(); + Object payload = message.getPayload(); + try { + JavaType type = this.typeCache.get(conversionHint); + if (type == null) { + type = objectMapper.getTypeFactory() + .constructType((conversionHint).getType()); + this.typeCache.put(conversionHint, type); + } + if (payload instanceof byte[]) { + return objectMapper.readValue((byte[]) payload, type); + } + else if (payload instanceof String) { + return objectMapper.readValue((String) payload, type); + } + else { + return null; + } + } + catch (IOException e) { + throw new MessageConversionException("Cannot parse payload ", e); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CompositeMessageConverterFactory.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CompositeMessageConverterFactory.java new file mode 100644 index 000000000..147152ab5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CompositeMessageConverterFactory.java @@ -0,0 +1,146 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.ByteArrayMessageConverter; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +/** + * A factory for creating an instance of {@link CompositeMessageConverter} for a given + * target MIME type. + * + * @author David Turanski + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +public class CompositeMessageConverterFactory { + + private final Log log = LogFactory.getLog(CompositeMessageConverterFactory.class); + + private final ObjectMapper objectMapper; + + private final List converters; + + public CompositeMessageConverterFactory() { + this(Collections.emptyList(), new ObjectMapper()); + } + + /** + * @param customConverters a list of {@link AbstractMessageConverter} + * @param objectMapper object mapper for for serialization / deserialization + */ + public CompositeMessageConverterFactory( + List customConverters, + ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + if (!CollectionUtils.isEmpty(customConverters)) { + this.converters = new ArrayList<>(customConverters); + } + else { + this.converters = new ArrayList<>(); + } + initDefaultConverters(); + + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(BindingProperties.DEFAULT_CONTENT_TYPE); + this.converters.stream().filter(mc -> mc instanceof AbstractMessageConverter) + .forEach(mc -> ((AbstractMessageConverter) mc) + .setContentTypeResolver(resolver)); + } + + @SuppressWarnings("deprecation") + private void initDefaultConverters() { + ApplicationJsonMessageMarshallingConverter applicationJsonConverter = new ApplicationJsonMessageMarshallingConverter( + this.objectMapper); + applicationJsonConverter.setStrictContentTypeMatch(true); + this.converters.add(applicationJsonConverter); + this.converters.add(new TupleJsonMessageConverter(this.objectMapper)); + this.converters.add(new ByteArrayMessageConverter() { + @Override + protected boolean supports(Class clazz) { + if (!super.supports(clazz)) { + return (Object.class == clazz); + } + return true; + } + }); + this.converters.add(new ObjectStringMessageConverter()); + + // Deprecated converters + this.converters.add(new JavaSerializationMessageConverter()); + this.converters.add(new KryoMessageConverter(null, true)); + this.converters.add(new JsonUnmarshallingConverter(this.objectMapper)); + } + + /** + * Creation method. + * @param mimeType the target MIME type + * @return a converter for the target MIME type + */ + public MessageConverter getMessageConverterForType(MimeType mimeType) { + List converters = new ArrayList<>(); + for (MessageConverter converter : this.converters) { + if (converter instanceof AbstractMessageConverter) { + for (MimeType type : ((AbstractMessageConverter) converter) + .getSupportedMimeTypes()) { + if (type.includes(mimeType)) { + converters.add(converter); + } + } + } + else { + if (this.log.isDebugEnabled()) { + this.log.debug("Ommitted " + converter + " of type " + + converter.getClass().toString() + " for '" + + mimeType.toString() + + "' as it is not an AbstractMessageConverter"); + } + } + } + if (CollectionUtils.isEmpty(converters)) { + throw new ConversionException( + "No message converter is registered for " + mimeType.toString()); + } + if (converters.size() > 1) { + return new CompositeMessageConverter(converters); + } + else { + return converters.get(0); + } + } + + public CompositeMessageConverter getMessageConverterForAllRegistered() { + return new CompositeMessageConverter(new ArrayList<>(this.converters)); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ConversionException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ConversionException.java new file mode 100644 index 000000000..5746f4267 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ConversionException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +/** + * Exception thrown when an error is encountered during message conversion. + * + * @author David Turanski + */ +@SuppressWarnings("serial") +public class ConversionException extends RuntimeException { + + public ConversionException(String message) { + super(message); + } + + public ConversionException(String message, Throwable t) { + super(message, t); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CustomMimeTypeConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CustomMimeTypeConverter.java new file mode 100644 index 000000000..a57c36a9d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/CustomMimeTypeConverter.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.MimeType; + +/** + * A custom converter for {@link MimeType} that accepts a plain java class name as a + * shorthand for {@code application/x-java-object;type=the.qualified.ClassName}. + * + * @author Eric Bottard + * @author David Turanski + */ +public class CustomMimeTypeConverter implements Converter { + + @Override + public MimeType convert(String source) { + if (!source.contains("/")) { + return MimeType.valueOf("application/x-java-object;type=" + source); + } + return MimeType.valueOf(source); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JavaSerializationMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JavaSerializationMessageConverter.java new file mode 100644 index 000000000..4d0e8c529 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JavaSerializationMessageConverter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Arrays; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @deprecated as of 2.0. Will be removed in 2.1 + */ +@Deprecated +public class JavaSerializationMessageConverter extends AbstractMessageConverter { + + public JavaSerializationMessageConverter() { + super(Arrays.asList(MessageConverterUtils.X_JAVA_SERIALIZED_OBJECT)); + } + + @Override + protected boolean supports(Class clazz) { + if (clazz != null) { + return Serializable.class.isAssignableFrom(clazz); + } + return true; + } + + @Override + public Object convertFromInternal(Message message, Class targetClass, + Object conversionHint) { + if (!(message.getPayload() instanceof byte[])) { + return null; + } + try { + ByteArrayInputStream bis = new ByteArrayInputStream( + (byte[]) (message.getPayload())); + return new ObjectInputStream(bis).readObject(); + } + catch (Exception e) { + this.logger.error(e.getMessage(), e); + } + return null; + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + new ObjectOutputStream(bos).writeObject(payload); + } + catch (IOException e) { + this.logger.error(e.getMessage(), e); + return null; + } + return bos.toByteArray(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JsonUnmarshallingConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JsonUnmarshallingConverter.java new file mode 100644 index 000000000..35450c1b4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/JsonUnmarshallingConverter.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.MessageConversionException; + +/** + * Message converter providing backwards compatibility for applications using an Java type + * as input. + * + * @author Marius Bogoevici + * @deprecated as of 2.0. + */ +// NOTE we need to revisit as to why do we need it in the first place, given that our +// first converter already handles JSON +@Deprecated +public class JsonUnmarshallingConverter extends AbstractMessageConverter { + + private final ObjectMapper objectMapper; + + protected JsonUnmarshallingConverter(ObjectMapper objectMapper) { + super(MessageConverterUtils.X_JAVA_OBJECT); + setStrictContentTypeMatch(true); + this.objectMapper = objectMapper != null ? objectMapper : new ObjectMapper(); + } + + @Override + protected boolean supports(Class aClass) { + return String.class.isAssignableFrom(aClass) + || byte[].class.isAssignableFrom(aClass); + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, + Object conversionHint) { + Object payload = message.getPayload(); + try { + return payload instanceof byte[] + ? this.objectMapper.readValue((byte[]) payload, targetClass) + : this.objectMapper.readValue((String) payload, targetClass); + } + catch (IOException e) { + throw new MessageConversionException("Cannot parse payload ", e); + } + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + return super.convertToInternal(payload, headers, conversionHint); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/KryoMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/KryoMessageConverter.java new file mode 100644 index 000000000..b143f6fcb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/KryoMessageConverter.java @@ -0,0 +1,265 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.pool.KryoFactory; +import com.esotericsoftware.kryo.pool.KryoPool; +import org.objenesis.strategy.StdInstantiatorStrategy; + +import org.springframework.integration.codec.kryo.CompositeKryoRegistrar; +import org.springframework.integration.codec.kryo.KryoRegistrar; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.ContentTypeResolver; +import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +/** + * @author Vinicius Carvalho + * @deprecated as of 2.0 all language specific type converters (kryo, java etc) are + * deprecated and won't be supported in the future. + */ +@Deprecated +public class KryoMessageConverter implements SmartMessageConverter { + + /** + * Kryo mime type. + */ + public static final String KRYO_MIME_TYPE = "application/x-java-object"; + + protected final KryoPool pool; + + private final CompositeKryoRegistrar kryoRegistrar; + + private final boolean useReferences; + + private final List supportedMimeTypes; + + private ConcurrentMap mimeTypesCache = new ConcurrentHashMap<>(); + + private ContentTypeResolver contentTypeResolver = new DefaultContentTypeResolver(); + + public KryoMessageConverter(List kryoRegistrars, + boolean useReferences) { + this.useReferences = useReferences; + this.kryoRegistrar = CollectionUtils.isEmpty(kryoRegistrars) ? null + : new CompositeKryoRegistrar(kryoRegistrars); + KryoFactory factory = () -> { + Kryo kryo = new Kryo(); + configureKryoInstance(kryo); + return kryo; + }; + this.pool = new KryoPool.Builder(factory).softReferences().build(); + this.supportedMimeTypes = Collections + .singletonList(MimeType.valueOf(KRYO_MIME_TYPE)); + } + + @Nullable + @Override + public Object fromMessage(Message message, Class targetClass, + @Nullable Object conversionHint) { + if (!canConvertFrom(message, targetClass)) { + return null; + } + if (!message.getPayload().getClass().isAssignableFrom(byte[].class)) { + throw new MessageConversionException( + "This converter can only convert messages with byte[] payload"); + } + byte[] payload = (byte[]) message.getPayload(); + try { + return deserialize(payload, targetClass); + } + catch (IOException e) { + throw new MessageConversionException("Could not deserialize payload", e); + } + } + + @Nullable + @Override + public Message toMessage(Object payload, @Nullable MessageHeaders headers, + @Nullable Object conversionHint) { + if (!canConvertTo(payload, headers)) { + return null; + } + byte[] payloadToUse = serialize(payload); + MimeType mimeType = getDefaultContentType(payload); + if (headers != null) { + MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(headers, + MessageHeaderAccessor.class); + if (accessor != null && accessor.isMutable()) { + if (mimeType != null) { + accessor.setHeader(MessageHeaders.CONTENT_TYPE, mimeType); + } + return MessageBuilder.createMessage(payloadToUse, + accessor.getMessageHeaders()); + } + } + MessageBuilder builder = MessageBuilder.withPayload(payloadToUse); + if (headers != null) { + builder.copyHeaders(headers); + } + if (mimeType != null) { + builder.setHeader(MessageHeaders.CONTENT_TYPE, mimeType); + } + return builder.build(); + } + + private boolean canConvertTo(Object payload, MessageHeaders headers) { + return (supports(payload.getClass()) && supportsMimeType(headers)); + } + + @Nullable + protected MimeType getDefaultContentType(Object payload) { + return mimeTypeFromObject(payload); + } + + protected byte[] serialize(Object payload) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final Output output = new Output(baos); + this.pool.run(kryo -> { + kryo.writeObject(output, payload); + return Void.TYPE; + }); + output.close(); + return baos.toByteArray(); + } + + protected T deserialize(byte[] bytes, Class type) throws IOException { + Assert.notNull(bytes, "'bytes' cannot be null"); + final Input input = new Input(bytes); + try { + return deserialize(input, type); + } + finally { + input.close(); + } + } + + protected T deserialize(InputStream inputStream, final Class type) + throws IOException { + Assert.notNull(inputStream, "'inputStream' cannot be null"); + Assert.notNull(type, "'type' cannot be null"); + final Input input = (inputStream instanceof Input ? (Input) inputStream + : new Input(inputStream)); + T result = null; + try { + result = this.pool.run(kryo -> kryo.readObject(input, type)); + } + finally { + input.close(); + } + return result; + } + + protected void configureKryoInstance(Kryo kryo) { + kryo.setInstantiatorStrategy( + new Kryo.DefaultInstantiatorStrategy(new StdInstantiatorStrategy())); + if (this.kryoRegistrar != null) { + this.kryoRegistrar.registerTypes(kryo); + } + kryo.setReferences(this.useReferences); + } + + protected MimeType mimeTypeFromObject(Object payload) { + Assert.notNull(payload, "payload object cannot be null."); + String className = payload.getClass().getName(); + MimeType mimeType = this.mimeTypesCache.get(className); + if (mimeType == null) { + String modifiedClassName = className; + if (payload.getClass().isArray()) { + // Need to remove trailing ';' for an object array, e.g. + // "[Ljava.lang.String;" or multi-dimensional + // "[[[Ljava.lang.String;" + if (modifiedClassName.endsWith(";")) { + modifiedClassName = modifiedClassName.substring(0, + modifiedClassName.length() - 1); + } + // Wrap in quotes to handle the illegal '[' character + modifiedClassName = "\"" + modifiedClassName + "\""; + } + mimeType = MimeType.valueOf(KRYO_MIME_TYPE + ";type=" + modifiedClassName); + this.mimeTypesCache.put(className, mimeType); + } + return mimeType; + } + + protected boolean canConvertFrom(Message message, Class targetClass) { + return (supports(targetClass) && supportsMimeType(message.getHeaders())); + } + + protected boolean supportsMimeType(@Nullable MessageHeaders headers) { + if (getSupportedMimeTypes().isEmpty()) { + return true; + } + MimeType mimeType = getMimeType(headers); + if (mimeType == null) { + return false; + } + for (MimeType current : getSupportedMimeTypes()) { + if (current.getType().equals(mimeType.getType()) + && current.getSubtype().equals(mimeType.getSubtype())) { + return true; + } + } + return false; + } + + @Nullable + protected MimeType getMimeType(@Nullable MessageHeaders headers) { + return (headers != null && this.contentTypeResolver != null + ? this.contentTypeResolver.resolve(headers) : null); + } + + private boolean supports(Class targetClass) { + return true; + } + + @Nullable + @Override + public Object fromMessage(Message message, Class targetClass) { + return fromMessage(message, targetClass, null); + } + + @Nullable + @Override + public Message toMessage(Object payload, @Nullable MessageHeaders headers) { + return toMessage(payload, headers, null); + } + + public List getSupportedMimeTypes() { + return this.supportedMimeTypes; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/MessageConverterUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/MessageConverterUtils.java new file mode 100644 index 000000000..f39f2ccb0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/MessageConverterUtils.java @@ -0,0 +1,109 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import org.springframework.tuple.Tuple; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.MimeType; +import org.springframework.util.StringUtils; + +/** + * Message conversion utility methods. + * + * @author David Turanski + * @author Gary Russell + * @author Ilayaperumal Gopinathan + */ +public abstract class MessageConverterUtils { + + /** + * An MimeType specifying a {@link Tuple}. + */ + public static final MimeType X_SPRING_TUPLE = MimeType + .valueOf("application/x-spring-tuple"); + + /** + * A general MimeType for Java Types. + */ + public static final MimeType X_JAVA_OBJECT = MimeType + .valueOf("application/x-java-object"); + + /** + * A general MimeType for a Java serialized byte array. + */ + public static final MimeType X_JAVA_SERIALIZED_OBJECT = MimeType + .valueOf("application/x-java-serialized-object"); + + /** + * Get the java Object type for the MimeType X_JAVA_OBJECT. + * @param contentType content type + * @return the class for the content type. + */ + public static Class getJavaTypeForJavaObjectContentType(MimeType contentType) { + Assert.isTrue(X_JAVA_OBJECT.includes(contentType), "Content type must be " + + X_JAVA_OBJECT.toString() + ", or " + "included in it"); + if (contentType.getParameter("type") != null) { + try { + return ClassUtils.forName(contentType.getParameter("type"), null); + } + catch (Exception e) { + throw new ConversionException(e.getMessage(), e); + } + } + return Object.class; + } + + /** + * Build the conventional {@link MimeType} for a java object. + * @param clazz the java type + * @return the MIME type + */ + public static MimeType javaObjectMimeType(Class clazz) { + return MimeType.valueOf("application/x-java-object;type=" + clazz.getName()); + } + + public static MimeType getMimeType(String contentTypeString) { + MimeType mimeType = null; + if (StringUtils.hasText(contentTypeString)) { + try { + mimeType = resolveContentType(contentTypeString); + } + catch (ClassNotFoundException cfe) { + throw new IllegalArgumentException( + "Could not find the class required for " + contentTypeString, + cfe); + } + } + return mimeType; + } + + public static MimeType resolveContentType(String type) + throws ClassNotFoundException, LinkageError { + if (!type.contains("/")) { + Class javaType = resolveJavaType(type); + return javaObjectMimeType(javaType); + } + return MimeType.valueOf(type); + } + + public static Class resolveJavaType(String type) + throws ClassNotFoundException, LinkageError { + return ClassUtils.forName(type, null); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ObjectStringMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ObjectStringMessageConverter.java new file mode 100644 index 000000000..d47d9de35 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/ObjectStringMessageConverter.java @@ -0,0 +1,110 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.util.MimeType; + +/** + * A {@link org.springframework.messaging.converter.MessageConverter} to convert a + * non-String objects to a String, when expected content type is "text/plain". + * + * It only performs conversions to internal format and is a wrapper around + * {@link Object#toString()}. + * + * @author Marius Bogoevici + * @author Oleg Zhurakousky + * @since 1.2 + */ +public class ObjectStringMessageConverter extends AbstractMessageConverter { + + public ObjectStringMessageConverter() { + super(new MimeType("text", "*", Charset.forName("UTF-8"))); + setStrictContentTypeMatch(true); + } + + protected boolean supports(Class clazz) { + return true; + } + + @Override + protected boolean canConvertFrom(Message message, Class targetClass) { + // only supports the conversion to String + return supportsMimeType(message.getHeaders()); + } + + @Override + protected boolean supportsMimeType(@Nullable MessageHeaders headers) { + MimeType mimeType = getMimeType(headers); + if (mimeType != null) { + for (MimeType current : getSupportedMimeTypes()) { + if (current.getType().equals(mimeType.getType())) { + return true; + } + } + } + + return super.supportsMimeType(headers); + } + + protected Object convertFromInternal(Message message, Class targetClass, + Object conversionHint) { + if (message.getPayload() != null) { + if (message.getPayload() instanceof byte[]) { + if (byte[].class.isAssignableFrom(targetClass)) { + return message.getPayload(); + } + else { + return new String((byte[]) message.getPayload(), + StandardCharsets.UTF_8); + } + } + else { + if (byte[].class.isAssignableFrom(targetClass)) { + return message.getPayload().toString() + .getBytes(StandardCharsets.UTF_8); + } + else { + return message.getPayload(); + } + } + } + return null; + } + + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + if (payload != null) { + if ((payload instanceof byte[])) { + return payload; + } + else { + return payload.toString().getBytes(); + } + } + else { + return null; + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/TupleJsonMessageConverter.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/TupleJsonMessageConverter.java new file mode 100644 index 000000000..35a6417d2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/converter/TupleJsonMessageConverter.java @@ -0,0 +1,101 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.tuple.Tuple; +import org.springframework.tuple.TupleBuilder; +import org.springframework.util.MimeTypeUtils; + +/** + * A {@link org.springframework.messaging.converter.MessageConverter} to convert a + * {@link Tuple} to JSON bytes. + * + * @author David Turanski + * @author Ilayaperumal Gopinathan + * @author Marius Bogoevici + * @author Vinicius Carvalho + * @deprecated as of 2.0. please use 'application/json' content type + */ +@Deprecated +public class TupleJsonMessageConverter extends AbstractMessageConverter { + + private final ObjectMapper objectMapper; + + @Value("${typeconversion.json.prettyPrint:false}") + private volatile boolean prettyPrint; + + public TupleJsonMessageConverter(ObjectMapper objectMapper) { + super(Arrays.asList(MessageConverterUtils.X_SPRING_TUPLE, + MimeTypeUtils.APPLICATION_JSON)); + this.objectMapper = (objectMapper != null) ? objectMapper : new ObjectMapper(); + } + + public void setPrettyPrint(boolean prettyPrint) { + this.prettyPrint = prettyPrint; + } + + @Override + protected boolean supports(Class clazz) { + return Tuple.class.isAssignableFrom(clazz); + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + Tuple t = (Tuple) payload; + String json; + if (this.prettyPrint) { + try { + Object tmp = this.objectMapper.readValue(t.toString(), Object.class); + json = this.objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(tmp); + } + catch (IOException e) { + this.logger.error(e.getMessage(), e); + return null; + } + } + else { + json = t.toString(); + } + return json.getBytes(); + } + + @Override + public Object convertFromInternal(Message message, Class targetClass, + Object conversionHint) { + String source; + if (message.getPayload() instanceof byte[]) { + source = new String((byte[]) message.getPayload(), Charset.forName("UTF-8")); + } + else { + source = message.getPayload().toString(); + } + return TupleBuilder.fromString(source); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/BindingsEndpoint.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/BindingsEndpoint.java new file mode 100644 index 000000000..7c3044094 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/BindingsEndpoint.java @@ -0,0 +1,153 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.endpoint; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binding.InputBindingLifecycle; +import org.springframework.cloud.stream.binding.OutputBindingLifecycle; +import org.springframework.util.Assert; + +/** + * + * Actuator endpoint for binding control. + * + * @author Oleg Zhurakousky + * @since 2.0 + */ +@Endpoint(id = "bindings") +public class BindingsEndpoint { + + private final List inputBindingLifecycles; + + private final List outputBindingsLifecycles; + + private final ObjectMapper objectMapper; + + public BindingsEndpoint(List inputBindingLifecycles, + List outputBindingsLifecycles) { + Assert.notEmpty(inputBindingLifecycles, + "'inputBindingLifecycles' must not be null or empty"); + this.inputBindingLifecycles = inputBindingLifecycles; + this.outputBindingsLifecycles = outputBindingsLifecycles; + this.objectMapper = new ObjectMapper(); + } + + @WriteOperation + public void changeState(@Selector String name, State state) { + Binding binding = BindingsEndpoint.this.locateBinding(name); + if (binding != null) { + switch (state) { + case STARTED: + binding.start(); + break; + case STOPPED: + binding.stop(); + break; + case PAUSED: + binding.pause(); + break; + case RESUMED: + binding.resume(); + break; + default: + break; + } + } + } + + @ReadOperation + public List queryStates() { + List> bindings = new ArrayList<>(gatherInputBindings()); + bindings.addAll(gatherOutputBindings()); + return this.objectMapper.convertValue(bindings, List.class); + } + + @ReadOperation + public Binding queryState(@Selector String name) { + Assert.notNull(name, "'name' must not be null"); + return this.locateBinding(name); + } + + @SuppressWarnings("unchecked") + private List> gatherInputBindings() { + List> inputBindings = new ArrayList<>(); + for (InputBindingLifecycle inputBindingLifecycle : this.inputBindingLifecycles) { + Collection> lifecycleInputBindings = (Collection>) new DirectFieldAccessor( + inputBindingLifecycle).getPropertyValue("inputBindings"); + inputBindings.addAll(lifecycleInputBindings); + } + return inputBindings; + } + + @SuppressWarnings("unchecked") + private List> gatherOutputBindings() { + List> outputBindings = new ArrayList<>(); + for (OutputBindingLifecycle inputBindingLifecycle : this.outputBindingsLifecycles) { + Collection> lifecycleInputBindings = (Collection>) new DirectFieldAccessor( + inputBindingLifecycle).getPropertyValue("outputBindings"); + outputBindings.addAll(lifecycleInputBindings); + } + return outputBindings; + } + + private Binding locateBinding(String name) { + Stream> bindings = Stream.concat(this.gatherInputBindings().stream(), + this.gatherOutputBindings().stream()); + return bindings.filter(binding -> name.equals(binding.getName())).findFirst() + .orElse(null); + } + + /** + * Binding states. + */ + public enum State { + + /** + * Started state of a binding. + */ + STARTED, + + /** + * Stopped state of a binding. + */ + STOPPED, + + /** + * Paused state of a binding. + */ + PAUSED, + + /** + * Resumed state of a binding. + */ + RESUMED; + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/ChannelsEndpoint.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/ChannelsEndpoint.java new file mode 100644 index 000000000..9b360ba28 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/endpoint/ChannelsEndpoint.java @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.endpoint; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.cloud.stream.binding.Bindable; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; + +/** + * An {@link Endpoint} that has the binding information on all the {@link Bindable} + * message channels. + * + * @author Dave Syer + * @author Ilayaperumal Gopinathan + * @author Vinicius Carvalho + */ +@Endpoint(id = "channels") +public class ChannelsEndpoint { + + private List adapters; + + private BindingServiceProperties properties; + + public ChannelsEndpoint(List adapters, + BindingServiceProperties properties) { + this.adapters = adapters; + this.properties = properties; + } + + @ReadOperation + public Map channels() { + ChannelsMetaData map = new ChannelsMetaData(); + Map inputs = map.getInputs(); + Map outputs = map.getOutputs(); + for (Bindable factory : this.adapters) { + for (String name : factory.getInputs()) { + inputs.put(name, this.properties.getBindingProperties(name)); + } + for (String name : factory.getOutputs()) { + outputs.put(name, this.properties.getBindingProperties(name)); + } + } + return new ObjectMapper().convertValue(map, + new TypeReference>() { + }); + } + + /** + * Meta data for channels. Contains e.g. input and outputs. + */ + @JsonInclude(Include.NON_DEFAULT) + public static class ChannelsMetaData { + + private Map inputs = new LinkedHashMap<>(); + + private Map outputs = new LinkedHashMap<>(); + + public Map getInputs() { + return this.inputs; + } + + public Map getOutputs() { + return this.outputs; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionConfiguration.java new file mode 100644 index 000000000..966af9a64 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionConfiguration.java @@ -0,0 +1,122 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.stream.config.BinderFactoryConfiguration; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.integration.channel.NullChannel; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.lang.Nullable; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; + +/** + * @author Oleg Zhurakousky + * @author David Turanski + * @author Ilayaperumal Gopinathan + * @since 2.1 + */ +@Configuration +@EnableConfigurationProperties(StreamFunctionProperties.class) +@Import(BinderFactoryConfiguration.class) +@AutoConfigureBefore(BindingServiceConfiguration.class) +public class FunctionConfiguration { + + @Bean + public IntegrationFlowFunctionSupport functionSupport( + FunctionCatalog functionCatalog, FunctionInspector functionInspector, + CompositeMessageConverterFactory messageConverterFactory, + StreamFunctionProperties functionProperties, + BindingServiceProperties bindingServiceProperties) { + return new IntegrationFlowFunctionSupport(functionCatalog, functionInspector, + messageConverterFactory, functionProperties, bindingServiceProperties); + } + + /** + * This configuration creates an instance of the {@link IntegrationFlow} from standard + * Spring Cloud Stream bindings such as {@link Source}, {@link Processor} and + * {@link Sink} ONLY if there are no existing instances of the {@link IntegrationFlow} + * already available in the context. This means that it only plays a role in + * green-field Spring Cloud Stream apps. + * + * For logic to compose functions into the existing apps please see + * "FUNCTION-TO-EXISTING-APP" section of AbstractMessageChannelBinder. + * + * The @ConditionalOnMissingBean ensures it does not collide with the the instance of + * the IntegrationFlow that may have been already defined by the existing (extended) + * app. + * @param functionSupport support for registering beans + * @param source source binding + * @param processor processor binding + * @param sink sink binding + * @return integration flow for Stream + */ + @ConditionalOnMissingBean + @Bean + public IntegrationFlow integrationFlowCreator( + IntegrationFlowFunctionSupport functionSupport, + @Nullable Source source, @Nullable Processor processor, @Nullable Sink sink) { + if (functionSupport.containsFunction(Consumer.class) + && consumerBindingPresent(processor, sink)) { + return functionSupport + .integrationFlowForFunction(getInputChannel(processor, sink), getOutputChannel(processor, source)) + .get(); + } + else if (functionSupport.containsFunction(Function.class) + && consumerBindingPresent(processor, sink)) { + return functionSupport + .integrationFlowForFunction(getInputChannel(processor, sink), getOutputChannel(processor, source)) + .get(); + } + else if (functionSupport.containsFunction(Supplier.class)) { + return functionSupport.integrationFlowFromNamedSupplier() + .channel(getOutputChannel(processor, source)).get(); + } + return null; + } + + private boolean consumerBindingPresent(Processor processor, Sink sink) { + return processor != null || sink != null; + } + + private SubscribableChannel getInputChannel(Processor processor, Sink sink) { + return processor != null ? processor.input() : sink.input(); + } + + private MessageChannel getOutputChannel(Processor processor, Source source) { + return processor != null ? processor.output() + : (source != null ? source.output() : new NullChannel()); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionInvoker.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionInvoker.java new file mode 100644 index 000000000..a8e08c5d9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/FunctionInvoker.java @@ -0,0 +1,232 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.FunctionType; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.ReflectionUtils; + +/** + * @param the payload type of the input Message + * @param the payload type of the output Message + * @author Oleg Zhurakousky + * @author David Turanski + * @author Tolga Kavukcu + * @since 2.1 + */ +class FunctionInvoker implements Function>, Flux>> { + + private static final Log logger = LogFactory.getLog(FunctionInvoker.class); + + private static final Field MESSAGE_HEADERS_FIELD; + + static { + MESSAGE_HEADERS_FIELD = ReflectionUtils.findField(MessageHeaders.class, + "headers"); + MESSAGE_HEADERS_FIELD.setAccessible(true); + } + + private final Class inputClass; + + private final Class outputClass; + + private final Function, Flux> userFunction; + + private final CompositeMessageConverter messageConverter; + + private final MessageChannel errorChannel; + + private final boolean isInputArgumentMessage; + + private final ConsumerProperties consumerProperties; + + private final ProducerProperties producerProperties; + + private final BindingServiceProperties bindingServiceProperties; + + private final StreamFunctionProperties functionProperties; + + FunctionInvoker(StreamFunctionProperties functionProperties, + FunctionCatalog functionCatalog, FunctionInspector functionInspector, + CompositeMessageConverterFactory compositeMessageConverterFactory) { + this(functionProperties, functionCatalog, functionInspector, + compositeMessageConverterFactory, null); + } + + @SuppressWarnings("unchecked") + FunctionInvoker(StreamFunctionProperties functionProperties, + FunctionCatalog functionCatalog, FunctionInspector functionInspector, + CompositeMessageConverterFactory compositeMessageConverterFactory, + MessageChannel errorChannel) { + + this.functionProperties = functionProperties; + Object originalUserFunction = functionCatalog + .lookup(functionProperties.getDefinition()); + + this.userFunction = (Function, Flux>) originalUserFunction; + + Assert.isInstanceOf(Function.class, this.userFunction); + this.messageConverter = compositeMessageConverterFactory + .getMessageConverterForAllRegistered(); + FunctionType functionType = functionInspector + .getRegistration(originalUserFunction).getType(); + this.isInputArgumentMessage = functionType.isMessage(); + this.inputClass = functionType.getInputType(); + this.outputClass = functionType.getOutputType(); + this.errorChannel = errorChannel; + this.bindingServiceProperties = functionProperties.getBindingServiceProperties(); + this.consumerProperties = this.bindingServiceProperties + .getConsumerProperties(functionProperties.getInputDestinationName()); + this.producerProperties = this.bindingServiceProperties + .getProducerProperties(functionProperties.getOutputDestinationName()); + } + + @Override + public Flux> apply(Flux> input) { + AtomicReference> originalMessageRef = new AtomicReference<>(); + + return input.concatMap(message -> { + return Flux.just(message).doOnNext(originalMessageRef::set) + .map(this::resolveArgument).transform(this.userFunction::apply) + .retryBackoff(this.consumerProperties.getMaxAttempts(), + Duration.ofMillis( + this.consumerProperties.getBackOffInitialInterval()), + Duration.ofMillis( + this.consumerProperties.getBackOffMaxInterval())) + .onErrorResume(e -> { + onError(e, originalMessageRef.get()); + return Mono.empty(); + }); + }).log().map(resultMessage -> toMessage(resultMessage, originalMessageRef.get())); // create + // output + // message + } + + private void onError(Throwable t, Message originalMessage) { + if (this.errorChannel != null) { + ErrorMessage em = new ErrorMessage(t, (Message) originalMessage); + logger.error(em); + this.errorChannel.send(em); + } + else { + logger.error(t); + } + } + + @SuppressWarnings("unchecked") + private Message toMessage(T value, Message originalMessage) { + if (logger.isDebugEnabled()) { + logger.debug("Converting result back to message using the original message: " + + originalMessage); + } + + Message returnMessage; + if (this.producerProperties.isUseNativeEncoding()) { + if (logger.isDebugEnabled()) { + logger.debug( + "Native encoding enabled wrapping result to message using the original message: " + + originalMessage); + } + returnMessage = wrapOutputToMessage(value, originalMessage); + } + else { + returnMessage = (Message) (value instanceof Message ? value + : this.messageConverter.toMessage(value, + originalMessage.getHeaders())); + if (returnMessage == null + && value.getClass().isAssignableFrom(this.outputClass)) { + returnMessage = wrapOutputToMessage(value, originalMessage); + } + else if (this.bindingServiceProperties != null + && this.bindingServiceProperties.getBindingProperties( + this.functionProperties.getOutputDestinationName()) != null + && !returnMessage.getHeaders() + .containsKey(MessageHeaders.CONTENT_TYPE)) { + + ((Map) ReflectionUtils.getField(MESSAGE_HEADERS_FIELD, + returnMessage.getHeaders())).put( + MessageHeaders.CONTENT_TYPE, + MimeType.valueOf(this.bindingServiceProperties + .getBindingProperties("output") + .getContentType())); + + } + Assert.notNull(returnMessage, + "Failed to convert result value '" + value + "' to message."); + } + return returnMessage; + } + + @SuppressWarnings("unchecked") + private Message wrapOutputToMessage(T value, Message originalMessage) { + Message returnMessage = (Message) MessageBuilder.withPayload(value) + .copyHeaders(originalMessage.getHeaders()) + .removeHeader(MessageHeaders.CONTENT_TYPE).build(); + return returnMessage; + } + + @SuppressWarnings("unchecked") + private T resolveArgument(Message message) { + if (logger.isDebugEnabled()) { + logger.debug("Resolving input argument from message: " + message); + } + + T argument = (T) (shouldConvertFromMessage(message) + ? this.messageConverter.fromMessage(message, this.inputClass) : message); + Assert.notNull(argument, "Failed to resolve argument type '" + this.inputClass + + "' from message: " + message); + if (this.isInputArgumentMessage && !(argument instanceof Message)) { + argument = (T) MessageBuilder.withPayload(argument) + .copyHeaders(message.getHeaders()).build(); + } + else if (!this.isInputArgumentMessage && argument instanceof Message) { + argument = ((Message) argument).getPayload(); + } + return argument; + } + + private boolean shouldConvertFromMessage(Message message) { + return !this.inputClass.isAssignableFrom(Message.class) + && !this.inputClass.isAssignableFrom(message.getPayload().getClass()) + && !this.inputClass.isAssignableFrom(Object.class); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/IntegrationFlowFunctionSupport.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/IntegrationFlowFunctionSupport.java new file mode 100644 index 000000000..2ff7a8dca --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/IntegrationFlowFunctionSupport.java @@ -0,0 +1,255 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.FunctionType; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.function.core.FluxSupplier; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.integration.context.IntegrationObjectSupport; +import org.springframework.integration.dsl.IntegrationFlowBuilder; +import org.springframework.integration.dsl.IntegrationFlows; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * @author Oleg Zhurakousky + * @author David Turanski + * @author Ilayaperumal Gopinathan + * @since 2.1 + */ +public class IntegrationFlowFunctionSupport { + + private final FunctionCatalog functionCatalog; + + private final FunctionInspector functionInspector; + + private final CompositeMessageConverterFactory messageConverterFactory; + + private final StreamFunctionProperties functionProperties; + + @Autowired + private MessageChannel errorChannel; + + IntegrationFlowFunctionSupport(FunctionCatalog functionCatalog, + FunctionInspector functionInspector, + CompositeMessageConverterFactory messageConverterFactory, + StreamFunctionProperties functionProperties, + BindingServiceProperties bindingServiceProperties) { + Assert.notNull(functionCatalog, "'functionCatalog' must not be null"); + Assert.notNull(functionInspector, "'functionInspector' must not be null"); + Assert.notNull(messageConverterFactory, + "'messageConverterFactory' must not be null"); + Assert.notNull(functionProperties, "'functionProperties' must not be null"); + this.functionCatalog = functionCatalog; + this.functionInspector = functionInspector; + this.messageConverterFactory = messageConverterFactory; + this.functionProperties = functionProperties; + this.functionProperties.setBindingServiceProperties(bindingServiceProperties); + } + + /** + * Determines if function specified via 'spring.cloud.stream.function.definition' + * property can be located in {@link FunctionCatalog}s. + * @param type of function + * @param typeOfFunction must be Supplier, Function or Consumer + * @return {@code true} if function is already stored + */ + public boolean containsFunction(Class typeOfFunction) { + return StringUtils.hasText(this.functionProperties.getDefinition()) + && this.catalogContains(typeOfFunction, + this.functionProperties.getDefinition()); + } + + /** + * Determines if function specified via 'spring.cloud.stream.function.definition' + * property can be located in {@link FunctionCatalog}. + * @param type of function + * @param typeOfFunction must be Supplier, Function or Consumer + * @param functionName the function name to check + * @return {@code true} if function is already stored + */ + public boolean containsFunction(Class typeOfFunction, String functionName) { + return StringUtils.hasText(functionName) + && this.catalogContains(typeOfFunction, functionName); + } + + /** + * @return the function type basing on the function definition. + */ + public FunctionType getCurrentFunctionType() { + FunctionType functionType = this.functionInspector.getRegistration( + this.functionCatalog.lookup(this.functionProperties.getDefinition())) + .getType(); + return functionType; + } + + /** + * Create an instance of the {@link IntegrationFlowBuilder} from a {@link Supplier} + * bean available in the context. The name of the bean must be provided via + * `spring.cloud.stream.function.definition` property. + * @return instance of {@link IntegrationFlowBuilder} + * @throws IllegalStateException if the named Supplier can not be located. + */ + public IntegrationFlowBuilder integrationFlowFromNamedSupplier() { + if (StringUtils.hasText(this.functionProperties.getDefinition())) { + Supplier supplier = this.functionCatalog.lookup(Supplier.class, + this.functionProperties.getDefinition()); + if (supplier instanceof FluxSupplier) { + supplier = ((FluxSupplier) supplier).getTarget(); + } + return integrationFlowFromProvidedSupplier(supplier).split(); + } + + throw new IllegalStateException( + "A Supplier is not specified in the 'spring.cloud.stream.function.definition' property."); + } + + /** + * Create an instance of the {@link IntegrationFlowBuilder} from a provided + * {@link Supplier}. + * @param supplier supplier from which the flow builder will be built + * @return instance of {@link IntegrationFlowBuilder} + */ + public IntegrationFlowBuilder integrationFlowFromProvidedSupplier( + Supplier supplier) { + return IntegrationFlows.from(supplier); + } + + /** + * @param inputChannel channel for which flow we be built + * @return instance of {@link IntegrationFlowBuilder} + */ + public IntegrationFlowBuilder integrationFlowFromChannel( + SubscribableChannel inputChannel) { + IntegrationFlowBuilder flowBuilder = IntegrationFlows.from(inputChannel); + return flowBuilder; + } + + /** + * @param inputChannel channel for which flow we be built + * @param outputChannel channel for which flow we be built + * @return instance of {@link IntegrationFlowBuilder} + */ + public IntegrationFlowBuilder integrationFlowForFunction( + SubscribableChannel inputChannel, MessageChannel outputChannel) { + + if (inputChannel instanceof IntegrationObjectSupport) { + String inputBindingName = ((IntegrationObjectSupport) inputChannel) + .getComponentName(); + if (StringUtils.hasText(inputBindingName)) { + this.functionProperties.setInputDestinationName(inputBindingName); + } + } + + if (outputChannel instanceof IntegrationObjectSupport) { + String outputBindingName = ((IntegrationObjectSupport) outputChannel) + .getComponentName(); + if (StringUtils.hasText(outputBindingName)) { + this.functionProperties.setOutputDestinationName(outputBindingName); + } + } + + IntegrationFlowBuilder flowBuilder = IntegrationFlows.from(inputChannel); + + if (!this.andThenFunction(flowBuilder, outputChannel, this.functionProperties)) { + flowBuilder = flowBuilder.channel(outputChannel); + } + return flowBuilder; + } + + /** + * Add a {@link Function} bean to the end of an integration flow. The name of the bean + * must be provided via `spring.cloud.stream.function.definition` property. + *

    + * NOTE: If this method returns true, the integration flow is now represented as a + * Reactive Streams {@link Publisher} bean. + *

    + * @param flowBuilder instance of the {@link IntegrationFlowBuilder} representing the + * current state of the integration flow + * @param outputChannel channel where the output of a function will be sent + * @param functionProperties the function properties + * @return true if {@link Function} was located and added and false if it wasn't. + */ + public boolean andThenFunction(IntegrationFlowBuilder flowBuilder, + MessageChannel outputChannel, StreamFunctionProperties functionProperties) { + return andThenFunction(flowBuilder.toReactivePublisher(), outputChannel, + functionProperties); + } + + /** + * @param publisher publisher to subscribe to + * @param outputChannel output channel to which a message will be sent + * @param functionProperties function properties + * @param input of the function + * @param output of the function + * @return whether the function was properly invoked + */ + public boolean andThenFunction(Publisher publisher, + MessageChannel outputChannel, StreamFunctionProperties functionProperties) { + if (!StringUtils.hasText(functionProperties.getDefinition())) { + return false; + } + FunctionInvoker functionInvoker = new FunctionInvoker<>(functionProperties, + this.functionCatalog, this.functionInspector, + this.messageConverterFactory, this.errorChannel); + + if (outputChannel != null) { + subscribeToInput(functionInvoker, publisher, outputChannel::send); + } + else { + subscribeToInput(functionInvoker, publisher, null); + } + return true; + } + + private boolean catalogContains(Class functionType, String name) { + return this.functionCatalog.lookup(functionType, name) != null; + } + + private Mono subscribeToOutput(Consumer> outputProcessor, + Publisher> outputPublisher) { + + Flux> output = outputProcessor == null ? Flux.from(outputPublisher) + : Flux.from(outputPublisher).doOnNext(outputProcessor); + return output.then(); + } + + @SuppressWarnings("unchecked") + private void subscribeToInput(FunctionInvoker functionInvoker, + Publisher publisher, Consumer> outputProcessor) { + + Flux inputPublisher = Flux.from(publisher); + subscribeToOutput(outputProcessor, + functionInvoker.apply((Flux>) inputPublisher)).subscribe(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/StreamFunctionProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/StreamFunctionProperties.java new file mode 100644 index 000000000..dab54b2c1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/StreamFunctionProperties.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.messaging.Processor; + +/** + * @author Oleg Zhurakousky + * @author Tolga Kavukcu + * @since 2.1 + */ +@ConfigurationProperties("spring.cloud.stream.function") +public class StreamFunctionProperties { + + /** + * Definition of functions to bind. If several functions need to be composed into one, + * use pipes (e.g., 'fooFunc\|barFunc') + */ + private String definition; + + private BindingServiceProperties bindingServiceProperties; + + private String inputDestinationName = Processor.INPUT; + + private String outputDestinationName = Processor.OUTPUT; + + public String getDefinition() { + return this.definition; + } + + public void setDefinition(String definition) { + this.definition = definition; + } + + BindingServiceProperties getBindingServiceProperties() { + return this.bindingServiceProperties; + } + + void setBindingServiceProperties(BindingServiceProperties bindingServiceProperties) { + this.bindingServiceProperties = bindingServiceProperties; + } + + String getInputDestinationName() { + return this.inputDestinationName; + } + + void setInputDestinationName(String inputDestinationName) { + this.inputDestinationName = inputDestinationName; + } + + String getOutputDestinationName() { + return this.outputDestinationName; + } + + void setOutputDestinationName(String outputDestinationName) { + this.outputDestinationName = outputDestinationName; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/internal/InternalPropertyNames.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/internal/InternalPropertyNames.java new file mode 100644 index 000000000..e4cf19c60 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/internal/InternalPropertyNames.java @@ -0,0 +1,43 @@ +/* + * Copyright 2016-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.internal; + +/** + * Contains the names of properties for the internal use of Spring Cloud Stream. + * + * @author Marius Bogoevici + */ +public abstract class InternalPropertyNames { + + /** + * Prefix for internal Spring Cloud Stream properties. + */ + public static final String SPRING_CLOUD_STREAM_INTERNAL_PREFIX = "spring.cloud.stream.internal"; + + /** + * Namespace property for internal Spring Cloud Stream properties. + */ + public static final String NAMESPACE_PROPERTY_NAME = SPRING_CLOUD_STREAM_INTERNAL_PREFIX + + ".namespace"; + + /** + * Self contained property for internal Spring Cloud Stream properties. + */ + public static final String SELF_CONTAINED_APP_PROPERTY_NAME = SPRING_CLOUD_STREAM_INTERNAL_PREFIX + + ".selfContained"; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/DirectWithAttributesChannel.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/DirectWithAttributesChannel.java new file mode 100644 index 000000000..0725adcf1 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/DirectWithAttributesChannel.java @@ -0,0 +1,40 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.messaging; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.integration.channel.DirectChannel; + +/** + * @author Oleg Zhurakousky + * @since 2.1 + */ +public class DirectWithAttributesChannel extends DirectChannel { + + private final Map attributes = new HashMap<>(); + + public void setAttribute(String key, Object value) { + this.attributes.put(key, value); + } + + public Object getAttribute(String key) { + return this.attributes.get(key); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Processor.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Processor.java new file mode 100644 index 000000000..a13266f2a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Processor.java @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.messaging; + +/** + * Bindable interface with one input and one output channel. + * + * @author Dave Syer + * @author Marius Bogoevici + * @see org.springframework.cloud.stream.annotation.EnableBinding + */ +public interface Processor extends Source, Sink { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Sink.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Sink.java new file mode 100644 index 000000000..f871473d4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Sink.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.messaging; + +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.messaging.SubscribableChannel; + +/** + * Bindable interface with one input channel. + * + * @author Dave Syer + * @author Marius Bogoevici + * @see org.springframework.cloud.stream.annotation.EnableBinding + */ +public interface Sink { + + /** + * Input channel name. + */ + String INPUT = "input"; + + /** + * @return input channel. + */ + @Input(Sink.INPUT) + SubscribableChannel input(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Source.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Source.java new file mode 100644 index 000000000..8e60775e7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/messaging/Source.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.messaging; + +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.messaging.MessageChannel; + +/** + * Bindable interface with one output channel. + * + * @author Dave Syer + * @author Marius Bogoevici + * @see org.springframework.cloud.stream.annotation.EnableBinding + */ +public interface Source { + + /** + * Name of the output channel. + */ + String OUTPUT = "output"; + + /** + * @return output channel + */ + @Output(Source.OUTPUT) + MessageChannel output(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetrics.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetrics.java new file mode 100644 index 000000000..74baacece --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetrics.java @@ -0,0 +1,90 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +/** + * @author Vinicius Carvalho + * @author Oleg Zhurakousky + */ +@JsonPropertyOrder({ "name", "inteval", "createdTime", "properties", "metrics" }) +class ApplicationMetrics { + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC") + private final Date createdTime; + + private String name; + + private long interval; + + private Collection> metrics; + + private Map properties; + + @JsonCreator + ApplicationMetrics(@JsonProperty("name") String name, + @JsonProperty("metrics") Collection> metrics) { + this.name = name; + this.metrics = metrics; + this.createdTime = new Date(); + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Collection> getMetrics() { + return this.metrics; + } + + public void setMetrics(Collection> metrics) { + this.metrics = metrics; + } + + public Date getCreatedTime() { + return this.createdTime; + } + + public Map getProperties() { + return this.properties; + } + + public void setProperties(Map properties) { + this.properties = properties; + } + + public long getInterval() { + return this.interval; + } + + public void setInterval(long interval) { + this.interval = interval; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetricsProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetricsProperties.java new file mode 100644 index 000000000..eb954b93f --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/ApplicationMetricsProperties.java @@ -0,0 +1,201 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.util.ObjectUtils; +import org.springframework.util.PatternMatchUtils; + +/** + * @author Vinicius Carvalho + * @author Janne Valkealahti + * @author Oleg Zhurakousky + */ +@ConfigurationProperties(prefix = ApplicationMetricsProperties.PREFIX) +public class ApplicationMetricsProperties + implements EnvironmentAware, ApplicationContextAware { + + /** + * Prefix for Stream application metrics. + */ + public static final String PREFIX = "spring.cloud.stream.metrics"; + + /** + * Property for the metrics filter. + */ + public static final String EXPORT_FILTER = PREFIX + ".filter"; + + private static final Bindable> STRING_STRING_MAP = Bindable + .mapOf(String.class, String.class); + + /** + * Pattern to control the 'meters' one wants to capture. By default all 'meters' will + * be captured. For example, 'spring.integration.*' will only capture metric + * information for meters whose name starts with 'spring.integration'. + */ + private String meterFilter; + + /** + * The name of the metric being emitted. Should be an unique value per application. + * Defaults to: + * ${spring.application.name:${vcap.application.name:${spring.config.name:application}}}. + */ + @Value("${spring.application.name:${vcap.application.name:${spring.config.name:application}}}") + private String key; + + /** + * Application properties that should be added to the metrics payload For example: + * `spring.application**`. + */ + private String[] properties; + + /** + * Interval expressed as Duration for scheduling metrics snapshots publishing. + * Defaults to 60 seconds + */ + private Duration scheduleInterval = Duration.ofSeconds(60); + + /** + * List of properties that are going to be appended to each message. This gets + * populate by onApplicationEvent, once the context refreshes to avoid overhead of + * doing per message basis. + */ + private Map exportProperties; + + private Environment environment; + + private ApplicationContext applicationContext; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + public String getKey() { + return this.key; + } + + public void setKey(String key) { + this.key = key; + } + + public String[] getProperties() { + return this.properties; + } + + public void setProperties(String[] properties) { + this.properties = properties; + } + + public Map getExportProperties() { + if (this.exportProperties == null) { + this.exportProperties = buildExportProperties(); + } + return this.exportProperties; + } + + public Duration getScheduleInterval() { + return this.scheduleInterval; + } + + public void setScheduleInterval(Duration scheduleInterval) { + this.scheduleInterval = scheduleInterval; + } + + public String getMeterFilter() { + return this.meterFilter; + } + + public void setMeterFilter(String meterFilter) { + this.meterFilter = meterFilter; + } + + private boolean isMatch(String name, String[] includes, String[] excludes) { + if (ObjectUtils.isEmpty(includes) + || PatternMatchUtils.simpleMatch(includes, name)) { + return !PatternMatchUtils.simpleMatch(excludes, name); + } + return false; + } + + private Map buildExportProperties() { + Map props = new HashMap<>(); + if (!ObjectUtils.isEmpty(this.properties)) { + Map target = bindProperties(); + + BeanExpressionResolver beanExpressionResolver = ((ConfigurableApplicationContext) this.applicationContext) + .getBeanFactory().getBeanExpressionResolver(); + BeanExpressionContext expressionContext = new BeanExpressionContext( + ((ConfigurableApplicationContext) this.applicationContext) + .getBeanFactory(), + null); + for (Entry entry : target.entrySet()) { + if (isMatch(entry.getKey(), this.properties, null)) { + String stringValue = ObjectUtils.nullSafeToString(entry.getValue()); + Object exportedValue = null; + if (stringValue != null) { + exportedValue = stringValue.startsWith("#{") + ? beanExpressionResolver.evaluate( + this.environment.resolvePlaceholders(stringValue), + expressionContext) + : this.environment.resolvePlaceholders(stringValue); + } + + props.put(entry.getKey(), exportedValue); + } + } + } + return props; + } + + private Map bindProperties() { + Map target; + BindResult> bindResult = Binder.get(this.environment).bind("", + STRING_STRING_MAP); + if (bindResult.isBound()) { + target = bindResult.get(); + } + else { + target = new HashMap<>(); + } + return target; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DefaultDestinationPublishingMeterRegistry.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DefaultDestinationPublishingMeterRegistry.java new file mode 100644 index 000000000..91277c5f9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DefaultDestinationPublishingMeterRegistry.java @@ -0,0 +1,254 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.ToDoubleFunction; +import java.util.function.ToLongFunction; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.FunctionTimer; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.LongTaskTimer; +import io.micrometer.core.instrument.Measurement; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Meter.Id; +import io.micrometer.core.instrument.Meter.Type; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import io.micrometer.core.instrument.distribution.pause.PauseDetector; +import io.micrometer.core.instrument.internal.DefaultGauge; +import io.micrometer.core.instrument.internal.DefaultLongTaskTimer; +import io.micrometer.core.instrument.internal.DefaultMeter; +import io.micrometer.core.instrument.step.StepCounter; +import io.micrometer.core.instrument.step.StepDistributionSummary; +import io.micrometer.core.instrument.step.StepFunctionCounter; +import io.micrometer.core.instrument.step.StepFunctionTimer; +import io.micrometer.core.instrument.step.StepTimer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.SmartLifecycle; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Oleg Zhurakousky + * @since 2.0 + */ +class DefaultDestinationPublishingMeterRegistry extends MeterRegistry + implements SmartLifecycle { + + private static final Log logger = LogFactory + .getLog(DefaultDestinationPublishingMeterRegistry.class); + + private final MetricsPublisherConfig metricsPublisherConfig; + + private final Consumer metricsConsumer; + + private final ApplicationMetricsProperties applicationProperties; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ScheduledFuture publisher; + + DefaultDestinationPublishingMeterRegistry( + ApplicationMetricsProperties applicationProperties, + MetersPublisherBinding publisherBinding, + MetricsPublisherConfig metricsPublisherConfig, Clock clock) { + super(clock); + this.metricsPublisherConfig = metricsPublisherConfig; + this.metricsConsumer = new MessageChannelPublisher(publisherBinding); + this.applicationProperties = applicationProperties; + } + + @Override + public void start() { + start(Executors.defaultThreadFactory()); + } + + @Override + public void stop() { + if (this.publisher != null) { + this.publisher.cancel(false); + this.publisher = null; + } + } + + @Override + public boolean isRunning() { + return this.publisher != null; + } + + @Override + public int getPhase() { + return 0; + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void stop(Runnable callback) { + this.stop(); + callback.run(); + } + + @Override + protected Gauge newGauge(Meter.Id id, T obj, ToDoubleFunction f) { + return new DefaultGauge<>(id, obj, f); + } + + @Override + protected Counter newCounter(Meter.Id id) { + return new StepCounter(id, this.clock, + this.metricsPublisherConfig.step().toMillis()); + } + + @Override + protected LongTaskTimer newLongTaskTimer(Meter.Id id) { + return new DefaultLongTaskTimer(id, this.clock); + } + + @Override + protected TimeUnit getBaseTimeUnit() { + return TimeUnit.MILLISECONDS; + } + + protected void publish() { + List> aggregatedMeters = new ArrayList<>(); + for (Meter meter : this.getMeters()) { + if (meter instanceof Timer) { + aggregatedMeters.add(toTimerMetric((Timer) meter)); + } + else if (meter instanceof DistributionSummary) { + aggregatedMeters.add(toSummaryMetric((DistributionSummary) meter)); + } + } + if (!aggregatedMeters.isEmpty()) { + ApplicationMetrics metrics = new ApplicationMetrics( + this.applicationProperties.getKey(), aggregatedMeters); + metrics.setInterval(this.metricsPublisherConfig.step().toMillis()); + metrics.setProperties(this.applicationProperties.getExportProperties()); + try { + String jsonString = this.objectMapper.writeValueAsString(metrics); + this.metricsConsumer.accept(jsonString); + } + catch (JsonProcessingException e) { + logger.warn("Error producing JSON String representation metric data", e); + } + } + } + + @Override + protected Timer newTimer(Id id, + DistributionStatisticConfig distributionStatisticConfig, + PauseDetector pauseDetector) { + return new StepTimer(id, this.clock, distributionStatisticConfig, pauseDetector, + getBaseTimeUnit(), this.metricsPublisherConfig.step().toMillis(), false); + } + + @Override + protected FunctionTimer newFunctionTimer(Id id, T obj, + ToLongFunction countFunction, ToDoubleFunction totalTimeFunction, + TimeUnit totalTimeFunctionUnits) { + return new StepFunctionTimer(id, this.clock, + this.metricsPublisherConfig.step().toMillis(), obj, countFunction, + totalTimeFunction, totalTimeFunctionUnits, getBaseTimeUnit()); + } + + @Override + protected FunctionCounter newFunctionCounter(Id id, T obj, + ToDoubleFunction valueFunction) { + return new StepFunctionCounter(id, this.clock, + this.metricsPublisherConfig.step().toMillis(), obj, valueFunction); + } + + @Override + protected Meter newMeter(Id id, Type type, Iterable measurements) { + return new DefaultMeter(id, type, measurements); + } + + @Override + protected DistributionSummary newDistributionSummary(Id id, + DistributionStatisticConfig distributionStatisticConfig, double scale) { + return new StepDistributionSummary(id, this.clock, distributionStatisticConfig, + scale, this.metricsPublisherConfig.step().toMillis(), false); + } + + @Override + protected DistributionStatisticConfig defaultHistogramConfig() { + return DistributionStatisticConfig.builder() + .expiry(this.metricsPublisherConfig.step()).build() + .merge(DistributionStatisticConfig.DEFAULT); + } + + private void start(ThreadFactory threadFactory) { + if (this.publisher != null) { + stop(); + } + this.publisher = Executors.newSingleThreadScheduledExecutor(threadFactory) + .scheduleAtFixedRate(this::publish, + this.metricsPublisherConfig.step().toMillis(), + this.metricsPublisherConfig.step().toMillis(), + TimeUnit.MILLISECONDS); + } + + private Metric toSummaryMetric(DistributionSummary summary) { + return new Metric(summary.getId(), summary.takeSnapshot()); + } + + private Metric toTimerMetric(Timer timer) { + return new Metric(timer.getId(), timer.takeSnapshot()); + } + + /** + * + */ + private static final class MessageChannelPublisher implements Consumer { + + private final MetersPublisherBinding metersPublisherBinding; + + MessageChannelPublisher(MetersPublisherBinding metersPublisherBinding) { + this.metersPublisherBinding = metersPublisherBinding; + } + + @Override + public void accept(String metricData) { + logger.trace(metricData); + Message message = MessageBuilder.withPayload(metricData) + .setHeader("STREAM_CLOUD_STREAM_VERSION", "2.x").build(); + this.metersPublisherBinding.applicationMetrics().send(message); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DestinationPublishingMetricsAutoConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DestinationPublishingMetricsAutoConfiguration.java new file mode 100644 index 000000000..992cdab07 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/DestinationPublishingMetricsAutoConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.config.MeterFilter; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binding.BindableProxyFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.PatternMatchUtils; +import org.springframework.util.StringUtils; + +/** + * @author Oleg Zhurakousky + * @since 2.0 + */ +@Configuration +@AutoConfigureBefore(SimpleMetricsExportAutoConfiguration.class) +@AutoConfigureAfter(MetricsAutoConfiguration.class) +@ConditionalOnClass({ Binder.class, MetricsAutoConfiguration.class }) +@ConditionalOnProperty("spring.cloud.stream.bindings." + + MetersPublisherBinding.APPLICATION_METRICS + ".destination") +@EnableConfigurationProperties(ApplicationMetricsProperties.class) +public class DestinationPublishingMetricsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public MetricsPublisherConfig metricsPublisherConfig( + ApplicationMetricsProperties metersPublisherProperties) { + return new MetricsPublisherConfig(metersPublisherProperties); + } + + @Bean + @ConditionalOnMissingBean + public DefaultDestinationPublishingMeterRegistry defaultDestinationPublishingMeterRegistry( + ApplicationMetricsProperties applicationMetricsProperties, + MetersPublisherBinding publisherBinding, + MetricsPublisherConfig metricsPublisherConfig, Clock clock) { + DefaultDestinationPublishingMeterRegistry registry = new DefaultDestinationPublishingMeterRegistry( + applicationMetricsProperties, publisherBinding, metricsPublisherConfig, + clock); + + if (StringUtils.hasText(applicationMetricsProperties.getMeterFilter())) { + registry.config() + .meterFilter(MeterFilter.denyUnless(id -> PatternMatchUtils + .simpleMatch(applicationMetricsProperties.getMeterFilter(), + id.getName()))); + } + return registry; + } + + @Bean + public BeanFactoryPostProcessor metersPublisherBindingRegistrant() { + return new BeanFactoryPostProcessor() { + @Override + public void postProcessBeanFactory( + ConfigurableListableBeanFactory beanFactory) throws BeansException { + RootBeanDefinition emitterBindingDefinition = new RootBeanDefinition( + BindableProxyFactory.class); + emitterBindingDefinition.getConstructorArgumentValues() + .addGenericArgumentValue(MetersPublisherBinding.class); + ((DefaultListableBeanFactory) beanFactory).registerBeanDefinition( + MetersPublisherBinding.class.getName(), emitterBindingDefinition); + } + }; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetersPublisherBinding.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetersPublisherBinding.java new file mode 100644 index 000000000..45d968189 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetersPublisherBinding.java @@ -0,0 +1,39 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.messaging.MessageChannel; + +/** + * @author Oleg Zhurakousky + * @since 2.0 + */ +public interface MetersPublisherBinding { + + /** + * Application metrics channel name. + */ + String APPLICATION_METRICS = "applicationMetrics"; + + /** + * @return Channel for application metrics. + */ + @Output(APPLICATION_METRICS) + MessageChannel applicationMetrics(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/Metric.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/Metric.java new file mode 100644 index 000000000..d26e4928c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/Metric.java @@ -0,0 +1,101 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.distribution.HistogramSnapshot; + +/** + * Immutable class that wraps the micrometer's {@link HistogramSnapshot}. + * + * @param the value of type {@link Number} + * @author Oleg Zhurakousky + */ +@JsonPropertyOrder({ "id", "timestamp", "sum", "count", "mean", "upper", "total" }) +class Metric { + + private final Date timestamp; + + private final Meter.Id id; + + private final Number sum; + + private final Number count; + + private final Number mean; + + private final Number upper; + + private final Number total; + + /** + * Create a new {@link Metric} instance. + * @param id Meter id + * @param snapshot instance of HistogramSnapshot + */ + Metric(Meter.Id id, HistogramSnapshot snapshot) { + this.timestamp = new Date(); + this.id = id; + this.sum = snapshot.total(TimeUnit.MILLISECONDS); + this.count = snapshot.count(); + this.mean = snapshot.mean(TimeUnit.MILLISECONDS); + this.upper = snapshot.max(TimeUnit.MILLISECONDS); + this.total = snapshot.total(TimeUnit.MILLISECONDS); + } + + public Meter.Id getId() { + return this.id; + } + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC") + public Date getTimestamp() { + return this.timestamp; + } + + public Number getSum() { + return this.sum; + } + + public Number getCount() { + return this.count; + } + + public Number getMean() { + return this.mean; + } + + public Number getUpper() { + return this.upper; + } + + public Number getTotal() { + return this.total; + } + + @Override + public String toString() { + return "Metric [id=" + this.id + ", sum=" + this.sum + ", count=" + this.count + + ", mean=" + this.mean + ", upper=" + this.upper + ", total=" + + this.total + ", timestamp=" + this.timestamp + "]"; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetricsPublisherConfig.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetricsPublisherConfig.java new file mode 100644 index 000000000..6f2fa191b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/micrometer/MetricsPublisherConfig.java @@ -0,0 +1,48 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.micrometer; + +import io.micrometer.core.instrument.step.StepRegistryConfig; + +/** + * @author Oleg Zhurakousky + * @since 2.0 + * + */ +class MetricsPublisherConfig implements StepRegistryConfig { + + private final ApplicationMetricsProperties applicationMetricsProperties; + + MetricsPublisherConfig(ApplicationMetricsProperties applicationMetricsProperties) { + this.applicationMetricsProperties = applicationMetricsProperties; + } + + @Override + public String prefix() { + return ApplicationMetricsProperties.PREFIX; + } + + @Override + public String get(String key) { + String value = null; + if (key.equals(this.prefix() + ".step")) { + value = this.applicationMetricsProperties.getScheduleInterval().toString(); + } + return value; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ConsumerDestination.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ConsumerDestination.java new file mode 100644 index 000000000..3c01c139b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ConsumerDestination.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.provisioning; + +import org.springframework.cloud.stream.binder.ConsumerProperties; + +/** + * Represents a ConsumerDestination that provides the information about the destination + * that is physically provisioned through + * {@link ProvisioningProvider#provisionConsumerDestination(String, String, ConsumerProperties)}. + * + * @author Soby Chacko + * @since 1.2 + */ +public interface ConsumerDestination { + + /** + * Provides the destination name. + * @return destination name + */ + String getName(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProducerDestination.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProducerDestination.java new file mode 100644 index 000000000..bac97b9c7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProducerDestination.java @@ -0,0 +1,55 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.provisioning; + +import org.springframework.cloud.stream.binder.ProducerProperties; + +/** + * Represents a ProducerDestination that provides the information about the destination + * that is physically provisioned through + * {@link ProvisioningProvider#provisionProducerDestination(String, ProducerProperties)}. + * + * @author Soby Chacko + * @since 1.2 + */ +public interface ProducerDestination { + + /** + * Provides the destination name. + * @return destination name + */ + String getName(); + + /** + * Provides the destination name for a given partition. + * + * If the producer provision the destination with partitions, on certain middleware + * brokers there may exist multiple destinations distinguishable by the partition. For + * example, if the destination name is xyz and it is provisioned with 4 + * partitions, there may be 4 different destinations on the broker such as - xyz-0, + * xyz-1, xyz-2 and xyz-3. This behavior is dependent on the broker and the way + * the corresponding binder implements the logic. + * + * On certain brokers (for instance, Kafka), this behavior is completely skipped and + * there is a one-to-one correspondence between the destination name in the + * provisioner and the physical destination on the broker. + * @param partition the partition to find destination for + * @return destination name for the given partition + */ + String getNameForPartition(int partition); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningException.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningException.java new file mode 100644 index 000000000..a39174e97 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningException.java @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.provisioning; + +import org.springframework.core.NestedRuntimeException; + +/** + * Generic unchecked exception to wrap middleware or technology specific exceptions. + * Wrapped exceptions could be either checked or unchecked. + * + * See {@link NestedRuntimeException} for more usage details. + * + * @author Soby Chacko + */ +@SuppressWarnings("serial") +public class ProvisioningException extends NestedRuntimeException { + + /** + * Constructor that takes a message. + * @param msg the detail message + */ + public ProvisioningException(String msg) { + super(msg); + } + + /** + * Constructor that takes a message and a root cause. + * @param msg the detail message + * @param cause the cause of the exception. This argument is generally expected to be + * middleware specific. + */ + public ProvisioningException(String msg, Throwable cause) { + super(msg, cause); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningProvider.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningProvider.java new file mode 100644 index 000000000..a827e3d93 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/provisioning/ProvisioningProvider.java @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.provisioning; + +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; + +/** + * Provisioning SPI that allows the users to provision destinations such as queues and + * topics. This SPI will allow the binders to be separated from any provisioning concerns + * and only focus on setting up endpoints for sending/receiving messages. + * + * Implementations must implement the following methods: + * + *
      + *
    • {@link #provisionProducerDestination(String, ProducerProperties)}
    • + *
    • {@link #provisionConsumerDestination(String, String, ConsumerProperties)}
    • + *
    + * + * @param the consumer properties type + * @param

    the producer properties type + * @author Soby Chacko + * @since 1.2 + */ +public interface ProvisioningProvider { + + /** + * Creates middleware destination on the physical broker for the producer to send + * data. The implementation is middleware-specific. + * @param name the name of the producer destination + * @param properties producer properties + * @return reference to {@link ProducerDestination} that represents a producer + * @throws ProvisioningException on underlying provisioning errors from the middleware + */ + ProducerDestination provisionProducerDestination(String name, P properties) + throws ProvisioningException; + + /** + * Creates the middleware destination on the physical broker for the consumer to + * consume data. The implementation is middleware-specific. + * @param name the name of the destination + * @param group the consumer group + * @param properties consumer properties + * @return reference to {@link ConsumerDestination} that represents a consumer + * @throws ProvisioningException on underlying provisioning errors from the middleware + */ + ConsumerDestination provisionConsumerDestination(String name, String group, + C properties) throws ProvisioningException; + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/reflection/GenericsUtils.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/reflection/GenericsUtils.java new file mode 100644 index 000000000..dbd814a04 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/reflection/GenericsUtils.java @@ -0,0 +1,145 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.reflection; + +import java.lang.reflect.Type; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.PollableConsumerBinder; +import org.springframework.cloud.stream.binder.PollableSource; +import org.springframework.core.ResolvableType; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Internal utilities for handling generics. + * + * @author Marius Bogoevici + * @author Gary Russell + */ +public final class GenericsUtils { + + private GenericsUtils() { + super(); + } + + /** + * For a specific class that implements or extends a parameterized type, return the + * parameter of that interface at a given position. For example, for this class: + * + *

     {@code
    +	 * class MessageChannelBinder implements Binder
    +	 * } 
    + * + *
     {@code
    +	 * getParameterType(MessageChannelBinder.class, Binder.class, 0);
    +	 * } 
    + * + * will return {@code Binder} + * @param evaluatedClass the evaluated class + * @param interfaceClass the parametrized interface + * @param position the position + * @return the parameter type if any + * @throws IllegalStateException if the evaluated class does not implement the + * interface or + */ + public static Class getParameterType(Class evaluatedClass, + Class interfaceClass, int position) { + Class bindableType = null; + Assert.isTrue(interfaceClass.isInterface(), + "'interfaceClass' must be an interface"); + if (!interfaceClass.isAssignableFrom(evaluatedClass)) { + throw new IllegalStateException( + evaluatedClass + " does not implement " + interfaceClass); + } + ResolvableType currentType = ResolvableType.forType(evaluatedClass); + while (!Object.class.equals(currentType.getRawClass()) && bindableType == null) { + ResolvableType[] interfaces = currentType.getInterfaces(); + ResolvableType resolvableType = null; + for (ResolvableType interfaceType : interfaces) { + if (interfaceClass.equals(interfaceType.getRawClass())) { + resolvableType = interfaceType; + break; + } + } + if (resolvableType == null) { + currentType = currentType.getSuperType(); + } + else { + ResolvableType[] generics = resolvableType.getGenerics(); + ResolvableType generic = generics[position]; + Class resolvedParameter = generic.resolve(); + if (resolvedParameter != null) { + bindableType = resolvedParameter; + } + else { + bindableType = Object.class; + } + } + } + if (bindableType == null) { + throw new IllegalStateException( + "Cannot find parameter of " + evaluatedClass.getName() + " for " + + interfaceClass + " at position " + position); + } + return bindableType; + } + + /** + * Return the generic type of PollableSource to determine if it is appropriate for the + * binder. e.g., with PollableMessageSource extends + * PollableSource<MessageHandler> and AbstractMessageChannelBinder implements + * PollableConsumerBinder<MessageHandler, C> We're checking that the the generic + * type (MessageHandler) matches. + * @param binderInstance the binder. + * @param bindingTargetType the binding target type. + * @return true if found, false otherwise. + */ + @SuppressWarnings("rawtypes") + public static boolean checkCompatiblePollableBinder(Binder binderInstance, + Class bindingTargetType) { + Class[] binderInterfaces = ClassUtils.getAllInterfaces(binderInstance); + for (Class intf : binderInterfaces) { + if (PollableConsumerBinder.class.isAssignableFrom(intf)) { + Class[] targetInterfaces = ClassUtils + .getAllInterfacesForClass(bindingTargetType); + Class psType = findPollableSourceType(targetInterfaces); + if (psType != null) { + return getParameterType(binderInstance.getClass(), intf, 0) + .isAssignableFrom(psType); + } + } + } + return false; + } + + private static Class findPollableSourceType(Class[] targetInterfaces) { + for (Class targetIntf : targetInterfaces) { + if (PollableSource.class.isAssignableFrom(targetIntf)) { + Type[] supers = targetIntf.getGenericInterfaces(); + for (Type type : supers) { + ResolvableType resolvableType = ResolvableType.forType(type); + if (resolvableType.getRawClass().equals(PollableSource.class)) { + return resolvableType.getGeneric(0).getRawClass(); + } + } + } + } + return null; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..f2ee4de23 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,16 @@ +{ + "properties": [ + { + "defaultValue": "false", + "name": "spring.cloud.stream.override-cloud-connectors", + "description": "This property is only applicable when the cloud profile is active and Spring Cloud Connectors are provided with the application. If the property is false (the default), the binder detects a suitable bound service (for example, a RabbitMQ service bound in Cloud Foundry for the RabbitMQ binder) and uses it for creating connections (usually through Spring Cloud Connectors). When set to true, this property instructs binders to completely ignore the bound services and rely on Spring Boot properties (for example, relying on the spring.rabbitmq.* properties provided in the environment for the RabbitMQ binder). The typical usage of this property is to be nested in a customized environment when connecting to multiple systems.", + "type": "java.lang.Boolean" + }, + { + "defaultValue": "true", + "name": "management.health.binders.enabled", + "description": "Allows to enable/disable binder's' health indicators. If you want to disable health indicator completely, then set it to `false`.", + "type": "java.lang.Boolean" + } + ] +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/spring.factories b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..f7a9928b7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/main/resources/META-INF/spring.factories @@ -0,0 +1,10 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration:\ +org.springframework.cloud.stream.config.ChannelBindingAutoConfiguration,\ +org.springframework.cloud.stream.config.BindersHealthIndicatorAutoConfiguration,\ +org.springframework.cloud.stream.config.ChannelsEndpointAutoConfiguration,\ +org.springframework.cloud.stream.config.BindingsEndpointAutoConfiguration,\ +org.springframework.cloud.stream.config.BindingServiceConfiguration,\ +org.springframework.cloud.stream.function.FunctionConfiguration + + + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/aggregation/AggregationTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/aggregation/AggregationTest.java new file mode 100644 index 000000000..5afd37b1a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/aggregation/AggregationTest.java @@ -0,0 +1,458 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.aggregation; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.After; +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.aggregate.AggregateApplicationBuilder; +import org.springframework.cloud.stream.aggregate.AggregateApplicationBuilder.SourceConfigurer; +import org.springframework.cloud.stream.aggregate.SharedBindingTargetRegistry; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.binding.BindableProxyFactory; +import org.springframework.cloud.stream.binding.BindingTargetFactory; +import org.springframework.cloud.stream.binding.SubscribableChannelBindingTargetFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.MessageChannel; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Artem Bilan + * @author Janne Valkealahti + * @author Gary Russell + * @author Soby Chacko + */ +@Ignore +public class AggregationTest { + + private ConfigurableApplicationContext aggregatedApplicationContext; + + @After + public void closeContext() { + System.clearProperty("a.foo-value"); + System.clearProperty("c.fooValue"); + System.clearProperty("a.foo.value"); + System.clearProperty("c.foo.value"); + if (this.aggregatedApplicationContext != null) { + this.aggregatedApplicationContext.close(); + } + } + + @Test + public void aggregation() { + this.aggregatedApplicationContext = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock") + .web(false).from(TestSource.class).to(TestProcessor.class).run(); + SharedBindingTargetRegistry sharedBindingTargetRegistry = this.aggregatedApplicationContext + .getBean(SharedBindingTargetRegistry.class); + BindingTargetFactory channelFactory = this.aggregatedApplicationContext + .getBean(SubscribableChannelBindingTargetFactory.class); + assertThat(channelFactory).isNotNull(); + assertThat(sharedBindingTargetRegistry.getAll().keySet()).hasSize(2); + this.aggregatedApplicationContext.close(); + } + + @Test + public void testModuleAggregationUsingSharedChannelRegistry() { + // test backward compatibility + this.aggregatedApplicationContext = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock") + .web(false).from(TestSource.class).to(TestProcessor.class).run(); + SharedBindingTargetRegistry sharedChannelRegistry = this.aggregatedApplicationContext + .getBean(SharedBindingTargetRegistry.class); + BindingTargetFactory channelFactory = this.aggregatedApplicationContext + .getBean(SubscribableChannelBindingTargetFactory.class); + assertThat(channelFactory).isNotNull(); + assertThat(sharedChannelRegistry.getAll().keySet()).hasSize(2); + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testParentArgsAndSources() { + + List argsToVerify = new ArrayList<>(); + argsToVerify.add("--foo1=bar1"); + argsToVerify.add("--foo2=bar2"); + argsToVerify.add("--foo3=bar3"); + argsToVerify.add("--spring.cloud.stream.default-binder=mock"); + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--foo1=bar1"); + final ConfigurableApplicationContext context = aggregateApplicationBuilder + .parent(DummyConfig.class, "--foo2=bar2").web(false) + .from(TestSource.class).namespace("foo").to(TestProcessor.class) + .namespace("bar") + .run("--foo3=bar3", "--spring.cloud.stream.default-binder=mock"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + final List parentArgs = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("parentArgs"); + assertThat(parentArgs).containsExactlyInAnyOrder( + argsToVerify.toArray(new String[argsToVerify.size()])); + context.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testParentArgsAndSourcesWithWebDisabled() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--foo1=bar1"); + final ConfigurableApplicationContext context = aggregateApplicationBuilder + .parent(DummyConfig.class, "--foo2=bar2").web(false) + .from(TestSource.class).namespace("foo").to(TestProcessor.class) + .namespace("bar").run("--spring.cloud.stream.default-binder=mock"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + List sources = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("parentSources"); + assertThat(sources).containsExactlyInAnyOrder( + AggregateApplicationBuilder.ParentConfiguration.class, + AggregateApplicationBuilder.ParentActuatorConfiguration.class, + AggregationAppConfig.class, DummyConfig.class); + context.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesFromCmdLine() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").via(TestProcessor.class).namespace("b") + .via(TestProcessor.class).namespace("c") + .run("--a.foo1=bar1", "--b.foo1=bar2", "--c.foo1=bar3"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + assertThat(Arrays + .asList(((SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer")).getArgs()) + .contains("--foo1=bar1")).isTrue(); + final List processorConfigurers; + processorConfigurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : processorConfigurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--foo1=bar2" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays.asList(processorConfigurer.getArgs()) + .contains("--foo1=bar3")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesFromCmdLineVsArgs() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").args("--fooValue=bar").via(TestProcessor.class) + .namespace("b").args("--foo1=argbarb").via(TestProcessor.class) + .namespace("c").run("--a.fooValue=bara", "--c.foo1=barc"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + assertThat(Arrays + .asList(((SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer")).getArgs()) + .contains("--fooValue=bara")).isTrue(); + final List processorConfigurers; + processorConfigurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : processorConfigurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--foo1=argbarb" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays + .asList(((SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer")).getArgs()) + .contains("--fooValue=bara")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesFromCmdLineWithRelaxedNames() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").args("--foo-value=bar").via(TestProcessor.class) + .namespace("b").args("--fooValue=argbarb").via(TestProcessor.class) + .namespace("c") + .run("--a.fooValue=bara", "--b.foo-value=barb", "--c.foo1=barc"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + assertThat(Arrays + .asList(((SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer")).getArgs()) + .contains("--fooValue=bara")).isTrue(); + final List processorConfigurers; + processorConfigurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : processorConfigurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--foo-value=barb" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays.asList(processorConfigurer.getArgs()) + .contains("--foo1=barc")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesFromCmdLineWithRelaxedNamesAndMorePropertySources() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + System.setProperty("a.foo-value", "sysbara"); + System.setProperty("c.fooValue", "sysbarc"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").args("--foo-value=bar").via(TestProcessor.class) + .namespace("b").args("--fooValue=argbarb").via(TestProcessor.class) + .namespace("c").args("--foo-value=argbarc").run("--a.fooValue=bara"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + SourceConfigurer sourceConfigurer = (SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer"); + assertThat(Arrays.asList(sourceConfigurer.getArgs()).contains("--fooValue=bara")) + .isTrue(); + assertThat(Arrays.asList(sourceConfigurer.getArgs()).contains("--foo-value=bara")) + .isTrue(); + final List processorConfigurers; + processorConfigurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : processorConfigurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--fooValue=argbarb" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays.asList(processorConfigurer.getArgs()) + .contains("--fooValue=sysbarc")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesWithoutCmdLinePropertySource() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + System.setProperty("a.foo-value", "sysbara"); + System.setProperty("c.fooValue", "sysbarc"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").args("--foo-value=bar").via(TestProcessor.class) + .namespace("b").args("--fooValue=argbarb").via(TestProcessor.class) + .namespace("c").args("--foo-value=argbarc").run(); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + SourceConfigurer sourceConfigurer = (SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer"); + assertThat( + Arrays.asList(sourceConfigurer.getArgs()).contains("--foo-value=sysbara")) + .isTrue(); + List configurers; + configurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : configurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--fooValue=argbarb" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays.asList(processorConfigurer.getArgs()) + .contains("--fooValue=sysbarc")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testNamespacePrefixesWithCAPSProperties() { + AggregateApplicationBuilder aggregateApplicationBuilder = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock"); + System.setProperty("a.fooValue", "sysbara"); + System.setProperty("c.fooValue", "sysbarc"); + this.aggregatedApplicationContext = aggregateApplicationBuilder + .parent(DummyConfig.class).web(false).from(TestSource.class) + .namespace("a").args("--foo-value=bar").via(TestProcessor.class) + .namespace("b").args("--fooValue=argbarb").via(TestProcessor.class) + .namespace("c").args("--foo-value=argbarc").run("--a.fooValue=highest"); + DirectFieldAccessor aggregateApplicationBuilderAccessor = new DirectFieldAccessor( + aggregateApplicationBuilder); + String[] configurers = ((SourceConfigurer) aggregateApplicationBuilderAccessor + .getPropertyValue("sourceConfigurer")).getArgs(); + assertThat(configurers).contains(new String[] { "--fooValue=highest" }); + final List processorConfigurers; + processorConfigurers = (List) aggregateApplicationBuilderAccessor + .getPropertyValue("processorConfigurers"); + for (AggregateApplicationBuilder.ProcessorConfigurer processorConfigurer : processorConfigurers) { + if (processorConfigurer.getNamespace().equals("b")) { + assertThat(Arrays.equals(processorConfigurer.getArgs(), + new String[] { "--fooValue=argbarb" })).isTrue(); + } + if (processorConfigurer.getNamespace().equals("c")) { + assertThat(Arrays.asList(processorConfigurer.getArgs()) + .contains("--fooValue=sysbarc")).isTrue(); + } + } + this.aggregatedApplicationContext.close(); + } + + @Test + public void testNamespaces() { + this.aggregatedApplicationContext = new AggregateApplicationBuilder( + AggregationAppConfig.class, "--spring.cloud.stream.default-binder=mock") + .web(false).from(TestSource.class).namespace("foo") + .to(TestProcessor.class).namespace("bar").run(); + SharedBindingTargetRegistry sharedChannelRegistry = this.aggregatedApplicationContext + .getBean(SharedBindingTargetRegistry.class); + BindingTargetFactory channelFactory = this.aggregatedApplicationContext + .getBean(SubscribableChannelBindingTargetFactory.class); + MessageChannel fooOutput = sharedChannelRegistry.get("foo.output", + MessageChannel.class); + assertThat(fooOutput).isNotNull(); + Object barInput = sharedChannelRegistry.get("bar.input", MessageChannel.class); + assertThat(barInput).isNotNull(); + assertThat(channelFactory).isNotNull(); + assertThat(sharedChannelRegistry.getAll().keySet()).hasSize(2); + this.aggregatedApplicationContext.close(); + } + + @Test + public void testBindableProxyFactoryCaching() { + ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestSource2.class, TestProcessor.class).web(WebApplicationType.NONE) + .run("--spring.cloud.stream.default-binder=mock"); + + Map factories = context + .getBeansOfType(BindableProxyFactory.class); + assertThat(factories).hasSize(2); + + Map sources = context.getBeansOfType(Source.class); + assertThat(sources).hasSize(1); + for (Source source : sources.values()) { + source.output(); + } + + Map fooSources = context.getBeansOfType(FooSource.class); + assertThat(fooSources).hasSize(1); + for (FooSource source : fooSources.values()) { + source.output(); + } + + Map processors = context.getBeansOfType(Processor.class); + assertThat(processors).hasSize(1); + for (Processor processor : processors.values()) { + processor.input(); + processor.output(); + } + + for (BindableProxyFactory factory : factories.values()) { + Field field = ReflectionUtils.findField(BindableProxyFactory.class, + "targetCache"); + ReflectionUtils.makeAccessible(field); + Map targetCache = (Map) ReflectionUtils.getField(field, factory); + if (factory.getObjectType() == Source.class) { + assertThat(targetCache).hasSize(1); + } + if (factory.getObjectType() == FooSource.class) { + assertThat(targetCache).hasSize(1); + } + else if (factory.getObjectType() == Processor.class) { + assertThat(targetCache).hasSize(2); + } + else { + fail("Found unexpected type"); + } + } + context.close(); + } + + public interface FooSource { + + @Output("fooOutput") + MessageChannel output(); + + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class TestSource { + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestProcessor { + + } + + @EnableBinding(FooSource.class) + @EnableAutoConfiguration + public static class TestSource2 { + + } + + @Configuration + public static class DummyConfig { + + } + + @Configuration + @EnableAutoConfiguration + public static class AggregationAppConfig { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinderTests.java new file mode 100644 index 000000000..617b8f0eb --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/AbstractMessageChannelBinderTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Iterator; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.provisioning.ProvisioningProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.Lifecycle; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.core.MessageProducer; +import org.springframework.integration.handler.BridgeHandler; +import org.springframework.integration.test.util.TestUtils; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.SubscribableChannel; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Gary Russell + * @author Oleg Zhurakousky + * @since 1.2.2 + */ +public class AbstractMessageChannelBinderTests { + + private ApplicationContext context; + + @Before + public void prepare() { + this.context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration()) + .web(WebApplicationType.NONE).run(); + } + + @Test + @SuppressWarnings("unchecked") + public void testEndpointLifecycle() throws Exception { + // @checkstyle:off + AbstractMessageChannelBinder> binder = this.context + .getBean(AbstractMessageChannelBinder.class); + // @checkstyle:on + + ConsumerProperties consumerProperties = new ConsumerProperties(); + consumerProperties.setMaxAttempts(1); // to force error infrastructure creation + + Binding consumerBinding = binder.bindConsumer("foo", "fooGroup", + new DirectChannel(), consumerProperties); + DirectFieldAccessor consumerBindingAccessor = new DirectFieldAccessor( + consumerBinding); + MessageProducer messageProducer = (MessageProducer) consumerBindingAccessor + .getPropertyValue("lifecycle"); + assertThat(((Lifecycle) messageProducer).isRunning()).isTrue(); + assertThat(messageProducer.getOutputChannel()).isNotNull(); + + SubscribableChannel errorChannel = (SubscribableChannel) consumerBindingAccessor + .getPropertyValue("lifecycle.errorChannel"); + assertThat(errorChannel).isNotNull(); + Set handlers = TestUtils.getPropertyValue(errorChannel, + "dispatcher.handlers", Set.class); + assertThat(handlers.size()).isEqualTo(2); + Iterator iterator = handlers.iterator(); + assertThat(iterator.next()).isInstanceOf(BridgeHandler.class); + assertThat(iterator.next()).isInstanceOf(LastSubscriberMessageHandler.class); + assertThat(this.context.containsBean("foo.fooGroup.errors")).isTrue(); + assertThat(this.context.containsBean("foo.fooGroup.errors.recoverer")).isTrue(); + assertThat(this.context.containsBean("foo.fooGroup.errors.handler")).isTrue(); + assertThat(this.context.containsBean("foo.fooGroup.errors.bridge")).isTrue(); + consumerBinding.unbind(); + assertThat(this.context.containsBean("foo.fooGroup.errors")).isFalse(); + assertThat(this.context.containsBean("foo.fooGroup.errors.recoverer")).isFalse(); + assertThat(this.context.containsBean("foo.fooGroup.errors.handler")).isFalse(); + assertThat(this.context.containsBean("foo.fooGroup.errors.bridge")).isFalse(); + + assertThat(((Lifecycle) messageProducer).isRunning()).isFalse(); + + ProducerProperties producerProps = new ProducerProperties(); + producerProps.setErrorChannelEnabled(true); + Binding producerBinding = binder.bindProducer("bar", + new DirectChannel(), producerProps); + assertThat(this.context.containsBean("bar.errors")).isTrue(); + assertThat(this.context.containsBean("bar.errors.bridge")).isTrue(); + producerBinding.unbind(); + assertThat(this.context.containsBean("bar.errors")).isFalse(); + assertThat(this.context.containsBean("bar.errors.bridge")).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + public void testEndpointBinderHasRecoverer() throws Exception { + // @checkstyle:off + ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration()) + .web(WebApplicationType.NONE).run(); + + AbstractMessageChannelBinder> binder = context + .getBean(AbstractMessageChannelBinder.class); + // @checkstyle:on + + Binding consumerBinding = binder.bindConsumer("foo", "fooGroup", + new DirectChannel(), new ConsumerProperties()); + DirectFieldAccessor consumerBindingAccessor = new DirectFieldAccessor( + consumerBinding); + SubscribableChannel errorChannel = (SubscribableChannel) consumerBindingAccessor + .getPropertyValue("lifecycle.errorChannel"); + assertThat(errorChannel).isNull(); + errorChannel = (SubscribableChannel) consumerBindingAccessor + .getPropertyValue("lifecycle.recoveryCallback.channel"); + assertThat(errorChannel).isNotNull(); + Set handlers = TestUtils.getPropertyValue(errorChannel, + "dispatcher.handlers", Set.class); + assertThat(handlers.size()).isEqualTo(2); + Iterator iterator = handlers.iterator(); + assertThat(iterator.next()).isInstanceOf(BridgeHandler.class); + assertThat(iterator.next()).isInstanceOf(LastSubscriberMessageHandler.class); + assertThat(context.containsBean("foo.fooGroup.errors")).isTrue(); + assertThat(context.containsBean("foo.fooGroup.errors.recoverer")).isTrue(); + assertThat(context.containsBean("foo.fooGroup.errors.handler")).isTrue(); + assertThat(context.containsBean("foo.fooGroup.errors.bridge")).isTrue(); + consumerBinding.unbind(); + assertThat(context.containsBean("foo.fooGroup.errors")).isFalse(); + assertThat(context.containsBean("foo.fooGroup.errors.recoverer")).isFalse(); + assertThat(context.containsBean("foo.fooGroup.errors.handler")).isFalse(); + assertThat(context.containsBean("foo.fooGroup.errors.bridge")).isFalse(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithBindingTargetsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithBindingTargetsTests.java new file mode 100644 index 000000000..b4cee3666 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithBindingTargetsTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.context.annotation.PropertySource; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + */ +// @checkstyle:off +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = ArbitraryInterfaceWithBindingTargetsTests.TestFooChannels.class, properties = "spring.cloud.stream.default-binder=mock") +public class ArbitraryInterfaceWithBindingTargetsTests { + + // @checkstyle:on + + @Autowired + public FooChannels fooChannels; + + @Autowired + private BinderFactory binderFactory; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testArbitraryInterfaceChannelsBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindConsumer(eq("someQueue.0"), isNull(), + eq(this.fooChannels.foo()), Mockito.any()); + verify(binder).bindConsumer(eq("someQueue.1"), isNull(), + eq(this.fooChannels.bar()), Mockito.any()); + verify(binder).bindProducer(eq("someQueue.2"), eq(this.fooChannels.baz()), + Mockito.any()); + verify(binder).bindProducer(eq("someQueue.3"), eq(this.fooChannels.qux()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(FooChannels.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/arbitrary-binding-test.properties") + public static class TestFooChannels { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithDefaultsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithDefaultsTests.java new file mode 100644 index 000000000..d0c514b30 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ArbitraryInterfaceWithDefaultsTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + */ +// @checkstyle:off +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = ArbitraryInterfaceWithDefaultsTests.TestFooChannels.class, properties = "spring.cloud.stream.default-binder=mock") +public class ArbitraryInterfaceWithDefaultsTests { + + // @checkstyle:on + + @Autowired + public FooChannels fooChannels; + + @Autowired + private BinderFactory binderFactory; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testArbitraryInterfaceChannelsBound() { + final Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindConsumer(eq("foo"), isNull(), eq(this.fooChannels.foo()), + Mockito.any()); + verify(binder).bindConsumer(eq("bar"), isNull(), eq(this.fooChannels.bar()), + Mockito.any()); + verify(binder).bindProducer(eq("baz"), eq(this.fooChannels.baz()), Mockito.any()); + verify(binder).bindProducer(eq("qux"), eq(this.fooChannels.qux()), Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(FooChannels.class) + @EnableAutoConfiguration + public static class TestFooChannels { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderAwareChannelResolverTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderAwareChannelResolverTests.java new file mode 100644 index 000000000..4f9be83da --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderAwareChannelResolverTests.java @@ -0,0 +1,204 @@ +/* + * Copyright 2013-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.binding.Bindable; +import org.springframework.cloud.stream.binding.BinderAwareChannelResolver; +import org.springframework.cloud.stream.binding.BindingService; +import org.springframework.cloud.stream.binding.DynamicDestinationsBindable; +import org.springframework.cloud.stream.binding.SubscribableChannelBindingTargetFactory; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.messaging.DirectWithAttributesChannel; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.interceptor.GlobalChannelInterceptorWrapper; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.ImmutableMessageChannelInterceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Mark Fisher + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class BinderAwareChannelResolverTests { + + protected ConfigurableApplicationContext context; + + protected volatile BinderAwareChannelResolver resolver; + + protected volatile Binder binder; + + protected volatile SubscribableChannelBindingTargetFactory bindingTargetFactory; + + protected volatile BindingServiceProperties bindingServiceProperties; + + protected volatile DynamicDestinationsBindable dynamicDestinationsBindable; + + @SuppressWarnings("unchecked") + @Before + public void setupContext() throws Exception { + + this.context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + BinderAwareChannelResolverTests.InterceptorConfiguration.class)) + .web(WebApplicationType.NONE).run(); + + this.resolver = this.context.getBean(BinderAwareChannelResolver.class); + this.binder = this.context.getBean(Binder.class); + this.bindingServiceProperties = this.context + .getBean(BindingServiceProperties.class); + this.bindingTargetFactory = this.context + .getBean(SubscribableChannelBindingTargetFactory.class); + } + + @Test + public void resolveChannel() { + Map bindables = this.context.getBeansOfType(Bindable.class); + assertThat(bindables).hasSize(1); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); // producer + assertThat(bindable.getOutputs().size()).isEqualTo(0); // consumer + } + MessageChannel registered = this.resolver.resolveDestination("foo"); + assertThat(((AbstractMessageChannel) registered).getChannelInterceptors().size()) + .isEqualTo(2); + assertThat(((AbstractMessageChannel) registered).getChannelInterceptors() + .get(1) instanceof ImmutableMessageChannelInterceptor).isTrue(); + + bindables = this.context.getBeansOfType(Bindable.class); + assertThat(bindables).hasSize(1); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); // producer + assertThat(bindable.getOutputs().size()).isEqualTo(1); // consumer + } + DirectChannel testChannel = new DirectChannel(); + testChannel.setComponentName("INPUT"); + final CountDownLatch latch = new CountDownLatch(1); + final List> received = new ArrayList<>(); + testChannel.subscribe(new MessageHandler() { + @Override + public void handleMessage(Message message) throws MessagingException { + received.add(message); + latch.countDown(); + } + }); + this.binder.bindConsumer("foo", null, testChannel, new ConsumerProperties()); + assertThat(received).hasSize(0); + registered.send(MessageBuilder.withPayload("hello").build()); + try { + assertThat(latch.await(1, TimeUnit.SECONDS)).describedAs("Latch timed out"); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("interrupted while awaiting latch"); + } + assertThat(received).hasSize(1); + assertThat(new String((byte[]) received.get(0).getPayload())).isEqualTo("hello"); + this.context.close(); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); + assertThat(bindable.getOutputs().size()).isEqualTo(0); // Must not be bound" + } + } + + @Test + public void resolveNonRegisteredChannel() { + MessageChannel other = this.resolver.resolveDestination("other"); + assertThat(this.context.getBean("other")).isSameAs(other); + this.context.close(); + } + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void propertyPassthrough() { + Map bindings = new HashMap<>(); + BindingProperties genericProperties = new BindingProperties(); + genericProperties.setContentType("text/plain"); + bindings.put("foo", genericProperties); + this.bindingServiceProperties.setBindings(bindings); + Binder binder = mock(Binder.class); + Binder binder2 = mock(Binder.class); + BinderFactory mockBinderFactory = Mockito.mock(BinderFactory.class); + Binding fooBinding = Mockito.mock(Binding.class); + Binding barBinding = Mockito.mock(Binding.class); + when(binder.bindProducer(matches("foo"), any(DirectChannel.class), + any(ProducerProperties.class))).thenReturn(fooBinding); + when(binder2.bindProducer(matches("bar"), any(DirectChannel.class), + any(ProducerProperties.class))).thenReturn(barBinding); + when(mockBinderFactory.getBinder(null, DirectWithAttributesChannel.class)) + .thenReturn(binder); + when(mockBinderFactory.getBinder("someTransport", + DirectWithAttributesChannel.class)).thenReturn(binder2); + BindingService bindingService = new BindingService(this.bindingServiceProperties, + mockBinderFactory); + BinderAwareChannelResolver resolver = new BinderAwareChannelResolver( + bindingService, this.bindingTargetFactory, + new DynamicDestinationsBindable()); + resolver.setBeanFactory(this.context.getBeanFactory()); + SubscribableChannel resolved = (SubscribableChannel) resolver + .resolveDestination("foo"); + verify(binder).bindProducer(eq("foo"), any(MessageChannel.class), + any(ProducerProperties.class)); + assertThat(resolved).isSameAs(this.context.getBean("foo")); + this.context.close(); + } + + @Configuration + public static class InterceptorConfiguration { + + @Bean + public GlobalChannelInterceptorWrapper testInterceptor() { + return new GlobalChannelInterceptorWrapper( + new ImmutableMessageChannelInterceptor()); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderFactoryConfigurationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderFactoryConfigurationTests.java new file mode 100644 index 000000000..77257c50b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderFactoryConfigurationTests.java @@ -0,0 +1,306 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; + +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.stub1.StubBinder1; +import org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration; +import org.springframework.cloud.stream.binder.stub2.StubBinder2; +import org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationA; +import org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationB; +import org.springframework.cloud.stream.config.BinderFactoryConfiguration; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.messaging.MessageChannel; +import org.springframework.util.ObjectUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Soby Chacko + * @author Artem Bilan + */ +public class BinderFactoryConfigurationTests { + + private static ClassLoader createClassLoader(String[] additionalClasspathDirectories, + String... properties) throws IOException { + URL[] urls = ObjectUtils.isEmpty(additionalClasspathDirectories) ? new URL[0] + : new URL[additionalClasspathDirectories.length]; + if (!ObjectUtils.isEmpty(additionalClasspathDirectories)) { + for (int i = 0; i < additionalClasspathDirectories.length; i++) { + urls[i] = new URL(new ClassPathResource(additionalClasspathDirectories[i]) + .getURL().toString() + "/"); + } + } + return new URLClassLoader(urls, + BinderFactoryConfigurationTests.class.getClassLoader()); + } + + private static ConfigurableApplicationContext createBinderTestContext( + String[] additionalClasspathDirectories, String... properties) + throws IOException { + ClassLoader classLoader = createClassLoader(additionalClasspathDirectories, + properties); + return new SpringApplicationBuilder(SimpleApplication.class) + .resourceLoader(new DefaultResourceLoader(classLoader)) + .properties(properties).web(WebApplicationType.NONE).run(); + } + + private static ConfigurableApplicationContext createBinderTestContextWithSources( + Class[] sources, String[] additionalClasspathDirectories, + String... properties) throws IOException { + ClassLoader classLoader = createClassLoader(additionalClasspathDirectories, + properties); + return new SpringApplicationBuilder(sources) + .resourceLoader(new DefaultResourceLoader(classLoader)) + .properties(properties).web(WebApplicationType.NONE).run(); + } + + @Test + public void loadBinderTypeRegistryWithSelfContainedAggregatorApp() throws Exception { + createBinderTestContextWithSources(new Class[] { SimpleApplication.class }, + new String[] {}, "spring.cloud.stream.internal.selfContained=true"); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadBinderTypeRegistryWithOneBinder() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, "spring.cloud.stream.default-binder=binder1"); + + BinderTypeRegistry binderTypeRegistry = context.getBean(BinderTypeRegistry.class); + assertThat(binderTypeRegistry).isNotNull(); + assertThat(binderTypeRegistry.getAll()).hasSize(3); + assertThat(binderTypeRegistry.getAll()).containsKey("binder1"); + assertThat((Class[]) binderTypeRegistry.get("binder1").getConfigurationClasses()) + .containsExactlyInAnyOrder(StubBinder1Configuration.class); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + + Binder defaultBinder = binderFactory.getBinder(null, MessageChannel.class); + assertThat(defaultBinder).isSameAs(binder1); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadBinderTypeRegistryWithOneBinderAndSharedEnvironment() + throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, "binder1.name=foo"); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).hasFieldOrPropertyWithValue("name", "foo"); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadBinderTypeRegistryWithOneCustomBinderAndSharedEnvironment() + throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, "binder1.name=foo", + "spring.cloud.stream.binders.custom.environment.foo=bar", + "spring.cloud.stream.binders.custom.environment.spring.main.sources=" + + "org.springframework.cloud.stream.binder.BinderFactoryConfigurationTests.AdditionalBinderConfiguration", + "spring.cloud.stream.binders.custom.type=binder1"); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder binder1 = binderFactory.getBinder("custom", MessageChannel.class); + assertThat(binder1).hasFieldOrPropertyWithValue("name", "foo"); + + assertThat(binderFactory.getBinder(null, MessageChannel.class)).isSameAs(binder1); + + SimpleApplication simpleApplication = context.getBean(SimpleApplication.class); + + assertThat(simpleApplication.binderContext).isNotNull(); + + assertThat(simpleApplication.binderContext.containsBean("fooBean")).isTrue(); + } + + @SuppressWarnings("rawtypes") + @Test + public void testCustomEnvironmentHasAccessToOuterContext() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, "binder1.name=foo", + "spring.cloud.stream.binders.custom.environment.foo=bar", + "spring.cloud.stream.binders.custom.type=binder1"); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder binder1 = binderFactory.getBinder("custom", MessageChannel.class); + + assertThat(binder1).hasFieldOrPropertyWithValue("name", "foo"); + assertThat(binder1).hasFieldOrPropertyWithValue("outerContext", context); + + assertThat(binderFactory.getBinder(null, MessageChannel.class)).isSameAs(binder1); + } + + @SuppressWarnings("rawtypes") + @Test + public void testStandardBinderDoesNotHaveTheOuterContextBean() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, "binder1.name=foo"); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).hasFieldOrPropertyWithValue("name", "foo"); + + assertThat(((StubBinder1) binder1).getOuterContext()).isNull(); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadBinderTypeRegistryWithTwoBinders() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1", "binder2" }); + BinderTypeRegistry binderTypeRegistry = context.getBean(BinderTypeRegistry.class); + assertThat(binderTypeRegistry).isNotNull(); + assertThat(binderTypeRegistry.getAll()).hasSize(4); + assertThat(binderTypeRegistry.getAll()).containsOnlyKeys("binder1", "binder2", + "mock", "integration"); + assertThat((Class[]) binderTypeRegistry.get("binder1").getConfigurationClasses()) + .containsExactly(StubBinder1Configuration.class); + assertThat((Class[]) binderTypeRegistry.get("binder2").getConfigurationClasses()) + .containsExactlyInAnyOrder(StubBinder2ConfigurationA.class, + StubBinder2ConfigurationB.class); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + try { + binderFactory.getBinder(null, MessageChannel.class); + fail("Should throw an exception"); + } + catch (Exception e) { + assertThat(e).isInstanceOf(IllegalStateException.class); + assertThat(e.getMessage()).contains( + "A default binder has been requested, but there is more than one binder available"); + } + + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + Binder binder2 = binderFactory.getBinder("binder2", MessageChannel.class); + assertThat(binder2).isInstanceOf(StubBinder2.class); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadBinderTypeRegistryWithCustomNonDefaultCandidate() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1" }, + "spring.cloud.stream.binders.custom.type=binder1", + "spring.cloud.stream.binders.custom.defaultCandidate=false", + "spring.cloud.stream.binders.custom.inheritEnvironment=false", + "spring.cloud.stream.default-binder=binder1"); + BinderTypeRegistry binderTypeRegistry = context.getBean(BinderTypeRegistry.class); + assertThat(binderTypeRegistry).isNotNull(); + assertThat(binderTypeRegistry.getAll().size()).isEqualTo(3); + assertThat(binderTypeRegistry.getAll().keySet()).contains("binder1"); + assertThat((Class[]) binderTypeRegistry.get("binder1").getConfigurationClasses()) + .contains(StubBinder1Configuration.class); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder defaultBinder = binderFactory.getBinder(null, MessageChannel.class); + assertThat(defaultBinder).isInstanceOf(StubBinder1.class); + assertThat(((StubBinder1) defaultBinder).getName()).isNullOrEmpty(); + + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + assertThat(binder1).isSameAs(defaultBinder); + } + + @SuppressWarnings("rawtypes") + @Test + public void loadDefaultBinderWithTwoBinders() throws Exception { + + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1", "binder2" }, + "spring.cloud.stream.defaultBinder:binder2"); + BinderTypeRegistry binderTypeRegistry = context.getBean(BinderTypeRegistry.class); + assertThat(binderTypeRegistry).isNotNull(); + assertThat(binderTypeRegistry.getAll()).hasSize(4); + assertThat(binderTypeRegistry.getAll()).containsOnlyKeys("binder1", "binder2", + "mock", "integration"); + assertThat((Class[]) binderTypeRegistry.get("binder1").getConfigurationClasses()) + .containsExactlyInAnyOrder(StubBinder1Configuration.class); + assertThat((Class[]) binderTypeRegistry.get("binder2").getConfigurationClasses()) + .containsExactlyInAnyOrder(StubBinder2ConfigurationA.class, + StubBinder2ConfigurationB.class); + + BinderFactory binderFactory = context.getBean(BinderFactory.class); + + Binder binder1 = binderFactory.getBinder("binder1", MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + Binder binder2 = binderFactory.getBinder("binder2", MessageChannel.class); + assertThat(binder2).isInstanceOf(StubBinder2.class); + + Binder defaultBinder = binderFactory.getBinder(null, MessageChannel.class); + assertThat(defaultBinder).isSameAs(binder2); + } + + @Import({ BinderFactoryConfiguration.class, + PropertyPlaceholderAutoConfiguration.class, + BindingServiceConfiguration.class }) + @EnableBinding + public static class SimpleApplication { + + private volatile ApplicationContext binderContext; + + @Bean + public DefaultBinderFactory.Listener testBinderListener() { + return (configurationName, binderContext) -> { + this.binderContext = binderContext; + }; + + } + + } + + @Configuration + public static class AdditionalBinderConfiguration { + + @Bean + public String fooBean() { + return "foo"; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ErrorBindingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ErrorBindingTests.java new file mode 100644 index 000000000..6846ff1fc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ErrorBindingTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2016-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.mockito.Mockito; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +/** + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +public class ErrorBindingTests { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testErrorChannelNotBoundByDefault() { + ConfigurableApplicationContext applicationContext = SpringApplication.run( + TestProcessor.class, "--server.port=0", + "--spring.cloud.stream.default-binder=mock", + "--spring.jmx.enabled=false"); + BinderFactory binderFactory = applicationContext.getBean(BinderFactory.class); + + Binder binder = binderFactory.getBinder(null, MessageChannel.class); + + Mockito.verify(binder).bindConsumer(eq("input"), isNull(), + any(MessageChannel.class), any(ConsumerProperties.class)); + Mockito.verify(binder).bindProducer(eq("output"), any(MessageChannel.class), + any(ProducerProperties.class)); + Mockito.verifyNoMoreInteractions(binder); + applicationContext.close(); + } + + @Test + public void testConfigurationWithDefaultErrorHandler() { + ApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + ErrorBindingTests.ErrorConfigurationDefault.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.bindings.input.consumer.max-attempts=1", + "--spring.jmx.enabled=false"); + + InputDestination source = context.getBean(InputDestination.class); + source.send(new GenericMessage("Hello".getBytes())); + source.send(new GenericMessage("Hello".getBytes())); + source.send(new GenericMessage("Hello".getBytes())); + + ErrorConfigurationDefault errorConfiguration = context + .getBean(ErrorConfigurationDefault.class); + assertThat(errorConfiguration.counter == 3); + } + + @Test + public void testConfigurationWithCustomErrorHandler() { + ApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + ErrorBindingTests.ErrorConfigurationWithCustomErrorHandler.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.bindings.input.consumer.max-attempts=1", + "--spring.jmx.enabled=false"); + + InputDestination source = context.getBean(InputDestination.class); + source.send(new GenericMessage("Hello".getBytes())); + source.send(new GenericMessage("Hello".getBytes())); + source.send(new GenericMessage("Hello".getBytes())); + + ErrorConfigurationWithCustomErrorHandler errorConfiguration = context + .getBean(ErrorConfigurationWithCustomErrorHandler.class); + assertThat(errorConfiguration.counter == 6); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestProcessor { + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ErrorConfigurationDefault { + + private int counter; + + @StreamListener(Sink.INPUT) + public void handle(Object value) { + this.counter++; + throw new RuntimeException("BOOM!"); + } + + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class ErrorConfigurationWithCustomErrorHandler { + + private int counter; + + @StreamListener(Sink.INPUT) + public void handle(Object value) { + this.counter++; + throw new RuntimeException("BOOM!"); + } + + @ServiceActivator(inputChannel = "input.anonymous.errors") + public void error(Message message) { + this.counter++; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinderAwareChannelResolverTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinderAwareChannelResolverTests.java new file mode 100644 index 000000000..01eb0d23b --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesBinderAwareChannelResolverTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2013-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.springframework.cloud.stream.binding.Bindable; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Mark Fisher + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class ExtendedPropertiesBinderAwareChannelResolverTests + extends BinderAwareChannelResolverTests { + + @Test + @Override + public void resolveChannel() { + Map bindables = this.context.getBeansOfType(Bindable.class); + assertThat(bindables).hasSize(1); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); // producer + assertThat(bindable.getOutputs().size()).isEqualTo(0); // consumer + } + MessageChannel registered = this.resolver.resolveDestination("foo"); + bindables = this.context.getBeansOfType(Bindable.class); + assertThat(bindables).hasSize(1); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); // producer + assertThat(bindable.getOutputs().size()).isEqualTo(1); // consumer + } + DirectChannel testChannel = new DirectChannel(); + final CountDownLatch latch = new CountDownLatch(1); + final List> received = new ArrayList<>(); + testChannel.subscribe(new MessageHandler() { + + @Override + public void handleMessage(Message message) throws MessagingException { + received.add(message); + latch.countDown(); + } + }); + this.binder.bindConsumer("foo", null, testChannel, + new ExtendedConsumerProperties( + new ConsumerProperties())); + assertThat(received).hasSize(0); + registered.send(MessageBuilder.withPayload("hello").build()); + try { + assertThat(latch.await(1, TimeUnit.SECONDS)).describedAs("latch timed out"); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("interrupted while awaiting latch"); + } + assertThat(received).hasSize(1); + assertThat(new String((byte[]) received.get(0).getPayload())).isEqualTo("hello"); + this.context.close(); + for (Bindable bindable : bindables.values()) { + assertThat(bindable.getInputs().size()).isEqualTo(0); + assertThat(bindable.getOutputs().size()).isEqualTo(0); // Must not be bound" + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesDefaultTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesDefaultTests.java new file mode 100644 index 000000000..0cc87e7b0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ExtendedPropertiesDefaultTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.HashMap; + +import org.junit.Test; + +import org.springframework.cloud.stream.utils.FooConsumerProperties; +import org.springframework.cloud.stream.utils.FooProducerProperties; +import org.springframework.cloud.stream.utils.MockExtendedBinderConfiguration; +import org.springframework.messaging.MessageChannel; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + */ +public class ExtendedPropertiesDefaultTests { + + @Test + public void testExtendedDefaultProducerProperties() { + DefaultBinderFactory binderFactory = createMockExtendedBinderFactory(); + Binder binder = binderFactory.getBinder(null, + MessageChannel.class); + FooProducerProperties fooProducerProperties = (FooProducerProperties) ((ExtendedPropertiesBinder) binder) + .getExtendedProducerProperties("output"); + // Expectations are set in the mock configuration for the binder factory + assertThat(fooProducerProperties.getExtendedProperty()) + .isEqualTo("someFancyExtension"); + } + + @Test + public void testExtendedDefaultConsumerProperties() { + DefaultBinderFactory binderFactory = createMockExtendedBinderFactory(); + Binder binder = binderFactory.getBinder(null, + MessageChannel.class); + FooConsumerProperties fooConsumerProperties = (FooConsumerProperties) ((ExtendedPropertiesBinder) binder) + .getExtendedConsumerProperties("input"); + // Expectations are set in the mock configuration for the binder factory + assertThat(fooConsumerProperties.getExtendedProperty()) + .isEqualTo("someFancyExtension"); + } + + private DefaultBinderFactory createMockExtendedBinderFactory() { + BinderTypeRegistry binderTypeRegistry = createMockExtendedBinderTypeRegistry(); + return new DefaultBinderFactory( + Collections.singletonMap("mock", + new BinderConfiguration("mock", new HashMap<>(), true, true)), + binderTypeRegistry); + } + + private DefaultBinderTypeRegistry createMockExtendedBinderTypeRegistry() { + return new DefaultBinderTypeRegistry( + Collections.singletonMap("mock", new BinderType("mock", + new Class[] { MockExtendedBinderConfiguration.class }))); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/FooChannels.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/FooChannels.java new file mode 100644 index 000000000..1f5273520 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/FooChannels.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.messaging.MessageChannel; + +/** + * @author Marius Bogoevici + */ +public interface FooChannels { + + @Input + MessageChannel foo(); + + @Input + MessageChannel bar(); + + @Output + MessageChannel baz(); + + @Output + MessageChannel qux(); + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/HealthIndicatorsConfigurationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/HealthIndicatorsConfigurationTests.java new file mode 100644 index 000000000..acaed2129 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/HealthIndicatorsConfigurationTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2016-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.actuate.health.CompositeHealthIndicator; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.HealthIndicatorRegistry; +import org.springframework.boot.actuate.health.OrderedHealthAggregator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.stub1.StubBinder1; +import org.springframework.cloud.stream.binder.stub2.StubBinder2; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.messaging.MessageChannel; +import org.springframework.util.ObjectUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +public class HealthIndicatorsConfigurationTests { + + public static ConfigurableApplicationContext createBinderTestContext( + String[] additionalClasspathDirectories, String... properties) + throws IOException { + URL[] urls = ObjectUtils.isEmpty(additionalClasspathDirectories) ? new URL[0] + : new URL[additionalClasspathDirectories.length]; + if (!ObjectUtils.isEmpty(additionalClasspathDirectories)) { + for (int i = 0; i < additionalClasspathDirectories.length; i++) { + urls[i] = new URL(new ClassPathResource(additionalClasspathDirectories[i]) + .getURL().toString() + "/"); + } + } + ClassLoader classLoader = new URLClassLoader(urls, + BinderFactoryConfigurationTests.class.getClassLoader()); + + return new SpringApplicationBuilder(SimpleSource.class) + .resourceLoader(new DefaultResourceLoader(classLoader)) + .properties(properties).web(WebApplicationType.NONE).run(); + } + + @SuppressWarnings("rawtypes") + @Test + public void healthIndicatorsCheck() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1", "binder2" }, + "spring.cloud.stream.defaultBinder:binder2", + "--spring.jmx.enabled=false"); + Binder binder1 = context.getBean(BinderFactory.class).getBinder("binder1", + MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + Binder binder2 = context.getBean(BinderFactory.class).getBinder("binder2", + MessageChannel.class); + assertThat(binder2).isInstanceOf(StubBinder2.class); + CompositeHealthIndicator bindersHealthIndicator = context + .getBean("bindersHealthIndicator", CompositeHealthIndicator.class); + DirectFieldAccessor directFieldAccessor = new DirectFieldAccessor( + bindersHealthIndicator); + assertThat(bindersHealthIndicator).isNotNull(); + assertThat( + context.getBean("test1HealthIndicator1", CompositeHealthIndicator.class)) + .isNotNull(); + assertThat( + context.getBean("test2HealthIndicator2", CompositeHealthIndicator.class)) + .isNotNull(); + + HealthIndicatorRegistry registry = (HealthIndicatorRegistry) directFieldAccessor + .getPropertyValue("registry"); + + Map healthIndicators = registry.getAll(); + assertThat(healthIndicators).containsKey("binder1"); + assertThat(healthIndicators.get("binder1").health().getStatus()) + .isEqualTo(Status.UP); + assertThat(healthIndicators).containsKey("binder2"); + assertThat(healthIndicators.get("binder2").health().getStatus()) + .isEqualTo(Status.UNKNOWN); + context.close(); + } + + @SuppressWarnings("rawtypes") + @Test + public void healthIndicatorsCheckWhenDisabled() throws Exception { + ConfigurableApplicationContext context = createBinderTestContext( + new String[] { "binder1", "binder2" }, + "spring.cloud.stream.defaultBinder:binder2", + "management.health.binders.enabled:false", "--spring.jmx.enabled=false"); + + Binder binder1 = context.getBean(BinderFactory.class).getBinder("binder1", + MessageChannel.class); + assertThat(binder1).isInstanceOf(StubBinder1.class); + Binder binder2 = context.getBean(BinderFactory.class).getBinder("binder2", + MessageChannel.class); + assertThat(binder2).isInstanceOf(StubBinder2.class); + try { + context.getBean("bindersHealthIndicator", CompositeHealthIndicator.class); + fail("The 'bindersHealthIndicator' bean should have not been defined"); + } + catch (NoSuchBeanDefinitionException e) { + } + assertThat( + context.getBean("test1HealthIndicator1", CompositeHealthIndicator.class)) + .isNotNull(); + assertThat( + context.getBean("test2HealthIndicator2", CompositeHealthIndicator.class)) + .isNotNull(); + context.close(); + } + + @EnableAutoConfiguration + @EnableBinding + public static class SimpleSource { + + @Configuration + static class TestConfig { + + @Bean + public CompositeHealthIndicator test1HealthIndicator1() { + return new CompositeHealthIndicator(new OrderedHealthAggregator()); + } + + @Bean + public CompositeHealthIndicator test2HealthIndicator2() { + return new CompositeHealthIndicator(new OrderedHealthAggregator()); + } + + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/InputOutputBindingOrderTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/InputOutputBindingOrderTest.java new file mode 100644 index 000000000..e9740baf2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/InputOutputBindingOrderTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.messaging.MessageChannel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Janne Valkealahti + */ +public class InputOutputBindingOrderTest { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testInputOutputBindingOrder() { + ConfigurableApplicationContext applicationContext = new SpringApplicationBuilder( + TestSource.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.defaultBinder=mock", + "--spring.jmx.enabled=false"); + Binder binder = applicationContext.getBean(BinderFactory.class).getBinder(null, + MessageChannel.class); + Processor processor = applicationContext.getBean(Processor.class); + // input is bound after the context has been started + verify(binder).bindConsumer(eq("input"), isNull(), eq(processor.input()), + Mockito.any()); + SomeLifecycle someLifecycle = applicationContext.getBean(SomeLifecycle.class); + assertThat(someLifecycle.isRunning()); + applicationContext.close(); + assertThat(someLifecycle.isRunning()).isFalse(); + applicationContext.close(); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestSource { + + @Bean + public SomeLifecycle someLifecycle() { + return new SomeLifecycle(); + } + + } + + public static class SomeLifecycle implements SmartLifecycle { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Processor processor; + + private boolean running; + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public synchronized void start() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindProducer(eq("output"), eq(this.processor.output()), + Mockito.any()); + // input was not bound yet + verifyNoMoreInteractions(binder); + this.running = true; + } + + @Override + public synchronized void stop() { + this.running = false; + } + + @Override + public synchronized boolean isRunning() { + return this.running; + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public void stop(Runnable callback) { + stop(); + if (callback != null) { + callback.run(); + } + } + + @Override + public int getPhase() { + return 0; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/LifecycleBinderTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/LifecycleBinderTests.java new file mode 100644 index 000000000..2d3c73164 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/LifecycleBinderTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.Lifecycle; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +public class LifecycleBinderTests { + + @Test + public void testOnlySmartLifecyclesStarted() { + ConfigurableApplicationContext applicationContext = SpringApplication.run( + TestSource.class, "--server.port=-1", + "--spring.cloud.stream.defaultBinder=mock", "--spring.jmx.enabled=false"); + SimpleLifecycle simpleLifecycle = applicationContext + .getBean(SimpleLifecycle.class); + assertThat(simpleLifecycle.isRunning()).isFalse(); + applicationContext.close(); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class TestSource { + + @Bean + public SimpleLifecycle simpleLifecycle() { + return new SimpleLifecycle(); + } + + } + + public static class SimpleLifecycle implements Lifecycle { + + private boolean running; + + @Override + public synchronized void start() { + this.running = true; + } + + @Override + public synchronized void stop() { + this.running = false; + } + + @Override + public synchronized boolean isRunning() { + return this.running; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/MessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/MessageConverterTests.java new file mode 100644 index 000000000..0e49b6e4e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/MessageConverterTests.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.nio.BufferUnderflowException; + +import org.junit.Test; + +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Gary Russell + * @author Ilayaperumal Gopinathan + * @since 1.0 + */ +public class MessageConverterTests { + + @Test + public void testHeaderEmbedding() throws Exception { + Message message = MessageBuilder.withPayload("Hello".getBytes()) + .setHeader("foo", "bar").setHeader("baz", "quxx").build(); + byte[] embedded = EmbeddedHeaderUtils.embedHeaders(new MessageValues(message), + "foo", "baz"); + assertThat(embedded[0] & 0xff).isEqualTo(0xff); + assertThat(new String(embedded).substring(1)).isEqualTo( + "\u0002\u0003foo\u0000\u0000\u0000\u0005\"bar\"\u0003baz\u0000\u0000\u0000\u0006\"quxx\"Hello"); + + MessageValues extracted = EmbeddedHeaderUtils + .extractHeaders(MessageBuilder.withPayload(embedded).build(), false); + assertThat(new String((byte[]) extracted.getPayload())).isEqualTo("Hello"); + assertThat(extracted.get("foo")).isEqualTo("bar"); + assertThat(extracted.get("baz")).isEqualTo("quxx"); + } + + @Test + public void testConfigurableHeaders() throws Exception { + Message message = MessageBuilder.withPayload("Hello".getBytes()) + .setHeader("foo", "bar").setHeader("baz", "quxx") + .setHeader("contentType", "text/plain").build(); + String[] headers = new String[] { "foo" }; + byte[] embedded = EmbeddedHeaderUtils.embedHeaders(new MessageValues(message), + EmbeddedHeaderUtils.headersToEmbed(headers)); + assertThat(embedded[0] & 0xff).isEqualTo(0xff); + assertThat(new String(embedded).substring(1)).isEqualTo( + "\u0002\u000BcontentType\u0000\u0000\u0000\u000C\"text/plain\"\u0003foo\u0000\u0000\u0000\u0005\"bar\"Hello"); + MessageValues extracted = EmbeddedHeaderUtils + .extractHeaders(MessageBuilder.withPayload(embedded).build(), false); + assertThat(new String((byte[]) extracted.getPayload())).isEqualTo("Hello"); + assertThat(extracted.get("foo")).isEqualTo("bar"); + assertThat(extracted.get("baz")).isNull(); + assertThat(extracted.get("contentType")).isEqualTo("text/plain"); + assertThat(extracted.get("timestamp")).isNull(); + MessageValues extractedWithRequestHeaders = EmbeddedHeaderUtils + .extractHeaders(MessageBuilder.withPayload(embedded).build(), true); + assertThat(extractedWithRequestHeaders.get("foo")).isEqualTo("bar"); + assertThat(extractedWithRequestHeaders.get("baz")).isNull(); + assertThat(extractedWithRequestHeaders.get("contentType")) + .isEqualTo("text/plain"); + assertThat(extractedWithRequestHeaders.get("timestamp")).isNotNull(); + } + + @Test + public void testHeaderExtractionWithDirectPayload() throws Exception { + Message message = MessageBuilder.withPayload("Hello".getBytes()) + .setHeader("foo", "bar").setHeader("baz", "quxx").build(); + byte[] embedded = EmbeddedHeaderUtils.embedHeaders(new MessageValues(message), + "foo", "baz"); + assertThat(embedded[0] & 0xff).isEqualTo(0xff); + assertThat(new String(embedded).substring(1)).isEqualTo( + "\u0002\u0003foo\u0000\u0000\u0000\u0005\"bar\"\u0003baz\u0000\u0000\u0000\u0006\"quxx\"Hello"); + + MessageValues extracted = EmbeddedHeaderUtils.extractHeaders(embedded); + assertThat(new String((byte[]) extracted.getPayload())).isEqualTo("Hello"); + assertThat(extracted.get("foo")).isEqualTo("bar"); + assertThat(extracted.get("baz")).isEqualTo("quxx"); + } + + @Test + public void testUnicodeHeader() throws Exception { + Message message = MessageBuilder.withPayload("Hello".getBytes()) + .setHeader("foo", "bar").setHeader("baz", "ØØØØØØØØ").build(); + byte[] embedded = EmbeddedHeaderUtils.embedHeaders(new MessageValues(message), + "foo", "baz"); + assertThat(embedded[0] & 0xff).isEqualTo(0xff); + assertThat(new String(embedded, "UTF-8").substring(1)).isEqualTo( + "\u0002\u0003foo\u0000\u0000\u0000\u0005\"bar\"\u0003baz\u0000\u0000\u0000\u0012\"ØØØØØØØØ\"Hello"); + + MessageValues extracted = EmbeddedHeaderUtils + .extractHeaders(MessageBuilder.withPayload(embedded).build(), false); + assertThat(new String((byte[]) extracted.getPayload())).isEqualTo("Hello"); + assertThat(extracted.get("foo")).isEqualTo("bar"); + assertThat(extracted.get("baz")).isEqualTo("ØØØØØØØØ"); + } + + @Test + public void testHeaderEmbeddingMissingHeader() throws Exception { + Message message = MessageBuilder.withPayload("Hello".getBytes()) + .setHeader("foo", "bar").build(); + byte[] embedded = EmbeddedHeaderUtils.embedHeaders(new MessageValues(message), + "foo", "baz"); + assertThat(embedded[0] & 0xff).isEqualTo(0xff); + assertThat(new String(embedded).substring(1)) + .isEqualTo("\u0001\u0003foo\u0000\u0000\u0000\u0005\"bar\"Hello"); + } + + @Test + public void testBadDecode() throws Exception { + byte[] bytes = new byte[] { (byte) 0xff, 99 }; + Message message = new GenericMessage<>(bytes); + try { + EmbeddedHeaderUtils.extractHeaders(message, false); + fail("Exception expected"); + } + catch (Exception e) { + String s = EmbeddedHeaderUtils.decodeExceptionMessage(message); + assertThat(e).isInstanceOf(BufferUnderflowException.class); + assertThat(s).startsWith("Could not convert message: FF63"); + } + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/PollableConsumerTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/PollableConsumerTests.java new file mode 100644 index 000000000..b0f9fdd5d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/PollableConsumerTests.java @@ -0,0 +1,436 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.binder.test.TestChannelBinder; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.binding.MessageConverterConfigurer; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.integration.IntegrationMessageHeaderAccessor; +import org.springframework.integration.acks.AcknowledgmentCallback; +import org.springframework.integration.acks.AcknowledgmentCallback.Status; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.converter.SmartMessageConverter; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * @author Gary Russell + * @author Oleg Zhurakousky + * @since 2.0 + * + */ +public class PollableConsumerTests { + + private ApplicationContext context; + + private SmartMessageConverter messageConverter; + + @Before + public void before() { + this.messageConverter = new CompositeMessageConverterFactory() + .getMessageConverterForAllRegistered(); + } + + @Test + public void testSimple() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + pollableSource.addInterceptor(new ChannelInterceptor() { + + @Override + public Message preSend(Message message, MessageChannel channel) { + return MessageBuilder + .withPayload(((String) message.getPayload()).toUpperCase()) + .copyHeaders(message.getHeaders()).build(); + } + + }); + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(2); + properties.setBackOffInitialInterval(0); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final AtomicInteger count = new AtomicInteger(); + assertThat(pollableSource.poll(received -> { + assertThat(received.getPayload()).isEqualTo("POLLED DATA"); + assertThat(received.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeType.valueOf("text/plain")); + if (count.incrementAndGet() == 1) { + throw new RuntimeException("test retry"); + } + })).isTrue(); + assertThat(count.get()).isEqualTo(2); + } + + @Test + public void testConvertSimple() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + binder.setMessageSourceDelegate( + () -> new GenericMessage<>("{\"foo\":\"bar\"}".getBytes())); + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(1); + properties.setBackOffInitialInterval(0); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final AtomicReference payload = new AtomicReference<>(); + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference() { + })).isTrue(); + assertThat(payload.get()).isInstanceOf(Foo.class); + assertThat(((Foo) payload.get()).getFoo()).isEqualTo("bar"); + // test the cache for coverage + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference() { + })).isTrue(); + assertThat(payload.get()).isInstanceOf(Foo.class); + assertThat(((Foo) payload.get()).getFoo()).isEqualTo("bar"); + } + + @Test + public void testConvertSimpler() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + BindingServiceProperties bsps = this.context + .getBean(BindingServiceProperties.class); + BindingProperties props = new BindingProperties(); + props.setContentType("text/plain"); + bsps.setBindings(Collections.singletonMap("foo", props)); + + binder.setMessageSourceDelegate(() -> new GenericMessage<>("foo".getBytes())); + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(1); + properties.setBackOffInitialInterval(0); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final AtomicReference payload = new AtomicReference<>(); + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference() { + })).isTrue(); + assertThat(payload.get()).isInstanceOf(String.class); + assertThat(payload.get()).isEqualTo("foo"); + // test the cache for coverage + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference() { + })).isTrue(); + assertThat(payload.get()).isInstanceOf(String.class); + assertThat(payload.get()).isEqualTo("foo"); + } + + @Test + public void testConvertList() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + binder.setMessageSourceDelegate(() -> new GenericMessage<>( + "[{\"foo\":\"bar\"},{\"foo\":\"baz\"}]".getBytes())); + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(1); + properties.setBackOffInitialInterval(0); + + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + + final AtomicReference payload = new AtomicReference<>(); + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference>() { + })).isTrue(); + @SuppressWarnings("unchecked") + List list = (List) payload.get(); + assertThat(list.size()).isEqualTo(2); + assertThat(list.get(0).getFoo()).isEqualTo("bar"); + assertThat(list.get(1).getFoo()).isEqualTo("baz"); + } + + @Test + public void testConvertMap() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + binder.setMessageSourceDelegate( + () -> new GenericMessage<>("{\"qux\":{\"foo\":\"bar\"}}".getBytes())); + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(1); + properties.setBackOffInitialInterval(0); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final AtomicReference payload = new AtomicReference<>(); + assertThat(pollableSource.poll(received -> { + payload.set(received.getPayload()); + }, new ParameterizedTypeReference>() { + })).isTrue(); + @SuppressWarnings("unchecked") + Map map = (Map) payload.get(); + assertThat(map.size()).isEqualTo(1); + assertThat(map.get("qux").getFoo()).isEqualTo("bar"); + } + + @Test + public void testEmbedded() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + binder.setMessageSourceDelegate(() -> { + MessageValues original = new MessageValues("foo".getBytes(), + Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + "application/octet-stream")); + byte[] payload = new byte[0]; + try { + payload = EmbeddedHeaderUtils.embedHeaders(original, + MessageHeaders.CONTENT_TYPE); + } + catch (Exception e) { + fail(e.getMessage()); + } + return new GenericMessage<>(payload); + }); + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setHeaderMode(HeaderMode.embeddedHeaders); + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + pollableSource.addInterceptor(new ChannelInterceptor() { + + @Override + public Message preSend(Message message, MessageChannel channel) { + return MessageBuilder + .withPayload( + new String((byte[]) message.getPayload()).toUpperCase()) + .copyHeaders(message.getHeaders()).build(); + } + + }); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + assertThat(pollableSource.poll(received -> { + assertThat(received.getPayload()).isEqualTo("FOO"); + assertThat(received.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo("application/octet-stream"); + })).isTrue(); + } + + @Test + public void testErrors() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + pollableSource.addInterceptor(new ChannelInterceptor() { + + @Override + public Message preSend(Message message, MessageChannel channel) { + return MessageBuilder + .withPayload(((String) message.getPayload()).toUpperCase()) + .copyHeaders(message.getHeaders()).build(); + } + + }); + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(2); + properties.setBackOffInitialInterval(0); + properties.getRetryableExceptions().put(IllegalStateException.class, false); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final CountDownLatch latch = new CountDownLatch(1); + this.context.getBean(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME, + SubscribableChannel.class).subscribe(m -> { + latch.countDown(); + }); + final AtomicInteger count = new AtomicInteger(); + assertThat(pollableSource.poll(received -> { + count.incrementAndGet(); + throw new RuntimeException("test recoverer"); + })).isTrue(); + assertThat(count.get()).isEqualTo(2); + Message lastError = binder.getLastError(); + assertThat(lastError).isNotNull(); + assertThat(((Exception) lastError.getPayload()).getCause().getMessage()) + .isEqualTo("test recoverer"); + assertThat(pollableSource.poll(received -> { + count.incrementAndGet(); + throw new IllegalStateException("no retries"); + })).isTrue(); + assertThat(count.get()).isEqualTo(3); + lastError = binder.getLastError(); + assertThat(lastError).isNotNull(); + assertThat(((Exception) lastError.getPayload()).getCause().getMessage()) + .isEqualTo("no retries"); + } + + @Test + public void testErrorsNoRetry() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + pollableSource.addInterceptor(new ChannelInterceptor() { + + @Override + public Message preSend(Message message, MessageChannel channel) { + return MessageBuilder + .withPayload(((String) message.getPayload()).toUpperCase()) + .copyHeaders(message.getHeaders()).build(); + } + + }); + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(1); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final CountDownLatch latch = new CountDownLatch(1); + this.context.getBean(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME, + SubscribableChannel.class).subscribe(m -> { + latch.countDown(); + }); + final AtomicInteger count = new AtomicInteger(); + assertThat(pollableSource.poll(received -> { + count.incrementAndGet(); + throw new RuntimeException("test recoverer"); + })).isTrue(); + assertThat(count.get()).isEqualTo(1); + } + + @Test + public void testRequeue() { + TestChannelBinder binder = createBinder(); + MessageConverterConfigurer configurer = this.context + .getBean(MessageConverterConfigurer.class); + + DefaultPollableMessageSource pollableSource = new DefaultPollableMessageSource( + this.messageConverter); + configurer.configurePolledMessageSource(pollableSource, "foo"); + AcknowledgmentCallback callback = mock(AcknowledgmentCallback.class); + pollableSource.addInterceptor(new ChannelInterceptor() { + + @Override + public Message preSend(Message message, MessageChannel channel) { + return MessageBuilder.fromMessage(message) + .setHeader( + IntegrationMessageHeaderAccessor.ACKNOWLEDGMENT_CALLBACK, + callback) + .build(); + } + + }); + ExtendedConsumerProperties properties = new ExtendedConsumerProperties<>( + null); + properties.setMaxAttempts(2); + properties.setBackOffInitialInterval(0); + binder.bindPollableConsumer("foo", "bar", pollableSource, properties); + final AtomicInteger count = new AtomicInteger(); + try { + assertThat(pollableSource.poll(received -> { + count.incrementAndGet(); + throw new RequeueCurrentMessageException("test retry"); + })).isTrue(); + } + catch (Exception e) { + // no op + } + assertThat(count.get()).isEqualTo(2); + verify(callback).acknowledge(Status.REQUEUE); + } + + private TestChannelBinder createBinder(String... args) { + this.context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration()) + .web(WebApplicationType.NONE).run(args); + TestChannelBinder binder = this.context.getBean(TestChannelBinder.class); + return binder; + } + + public static class Foo { + + private String foo; + + protected String getFoo() { + return this.foo; + } + + protected void setFoo(String foo) { + this.foo = foo; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingWithBindingTargetsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingWithBindingTargetsTests.java new file mode 100644 index 000000000..b43760c38 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingWithBindingTargetsTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.annotation.PropertySource; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = ProcessorBindingWithBindingTargetsTests.TestProcessor.class, properties = "spring.cloud.stream.defaultBinder=mock") +// @checkstyle:on +public class ProcessorBindingWithBindingTargetsTests { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Processor testProcessor; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testSourceOutputChannelBound() { + final Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindConsumer(eq("testtock.0"), isNull(), + eq(this.testProcessor.input()), Mockito.any()); + verify(binder).bindProducer(eq("testtock.1"), eq(this.testProcessor.output()), + Mockito.any()); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/processor-binding-test.properties") + public static class TestProcessor { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingsWithDefaultsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingsWithDefaultsTests.java new file mode 100644 index 000000000..759e087a9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/ProcessorBindingsWithDefaultsTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = ProcessorBindingsWithDefaultsTests.TestProcessor.class, properties = "spring.cloud.stream.defaultBinder=mock") +public class ProcessorBindingsWithDefaultsTests { + + // @checkstyle:on + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Processor processor; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testSourceOutputChannelBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + Mockito.verify(binder).bindConsumer(eq("input"), isNull(), + eq(this.processor.input()), Mockito.any()); + Mockito.verify(binder).bindProducer(eq("output"), eq(this.processor.output()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Processor.class) + @EnableAutoConfiguration + public static class TestProcessor { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultTargetsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultTargetsTests.java new file mode 100644 index 000000000..89218c870 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultTargetsTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.PropertySource; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = SinkBindingWithDefaultTargetsTests.TestSink.class, properties = "spring.cloud.stream.defaultBinder=mock") +// @checkstyle:on +public class SinkBindingWithDefaultTargetsTests { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Sink testSink; + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void testSourceOutputChannelBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindConsumer(eq("testtock"), isNull(), eq(this.testSink.input()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/sink-binding-test.properties") + public static class TestSink { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultsTests.java new file mode 100644 index 000000000..7ba65e989 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SinkBindingWithDefaultsTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = SinkBindingWithDefaultsTests.TestSink.class, properties = "spring.cloud.stream.defaultBinder=mock") +// @checkstyle:on +public class SinkBindingWithDefaultsTests { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Sink testSink; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testSourceOutputChannelBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindConsumer(eq("input"), isNull(), eq(this.testSink.input()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + public static class TestSink { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithBindingTargetsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithBindingTargetsTests.java new file mode 100644 index 000000000..ea4567742 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithBindingTargetsTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.channel.PublishSubscribeChannel; +import org.springframework.integration.context.IntegrationContextUtils; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = SourceBindingWithBindingTargetsTests.TestSource.class, properties = "spring.cloud.stream.defaultBinder=mock") +// @checkstyle:on +public class SourceBindingWithBindingTargetsTests { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Source testSource; + + @Autowired + @Qualifier(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME) + private PublishSubscribeChannel errorChannel; + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void testSourceOutputChannelBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindProducer(eq("testtock"), eq(this.testSource.output()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/source-binding-test.properties") + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithDefaultsTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithDefaultsTests.java new file mode 100644 index 000000000..d6cedd37e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithDefaultsTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = SourceBindingWithDefaultsTests.TestSource.class, properties = "spring.cloud.stream.defaultBinder=mock") +// @checkstyle:on +public class SourceBindingWithDefaultsTests { + + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Source testSource; + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testSourceOutputChannelBound() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + verify(binder).bindProducer(eq("output"), eq(this.testSource.output()), + Mockito.any()); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesOnlyTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesOnlyTest.java new file mode 100644 index 000000000..6631ab0ce --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesOnlyTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.config.SpelExpressionConverterConfiguration; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = { TestChannelBinderConfiguration.class, + SourceBindingWithGlobalPropertiesOnlyTest.TestSource.class, + SpelExpressionConverterConfiguration.class }, properties = { + "spring.cloud.stream.default.contentType=application/json", + "spring.cloud.stream.default.producer.partitionKeyExpression=key" }) +public class SourceBindingWithGlobalPropertiesOnlyTest { + + @Autowired + private BindingServiceProperties bindingServiceProperties; + + @Test + public void testGlobalPropertiesSet() { + BindingProperties bindingProperties = this.bindingServiceProperties + .getBindingProperties(Source.OUTPUT); + Assertions.assertThat(bindingProperties.getContentType()) + .isEqualTo("application/json"); + Assertions.assertThat(bindingProperties.getProducer()).isNotNull(); + Assertions.assertThat(bindingProperties.getProducer().getPartitionKeyExpression() + .getExpressionString()).isEqualTo("key"); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesTest.java new file mode 100644 index 000000000..bd4fccd84 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/SourceBindingWithGlobalPropertiesTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Gary Russell + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = SourceBindingWithGlobalPropertiesTest.TestSource.class, properties = { + "spring.cloud.stream.default.contentType=application/json", + "spring.cloud.stream.bindings.output.destination=ticktock", + "spring.cloud.stream.default.producer.requiredGroups=someGroup", + "spring.cloud.stream.default.producer.partitionCount=1", + "spring.cloud.stream.bindings.output.producer.headerMode=none", + "spring.cloud.stream.bindings.output.producer.partitionCount=4", + "spring.cloud.stream.defaultBinder=mock" }) +public class SourceBindingWithGlobalPropertiesTest { + + @Autowired + private BindingServiceProperties serviceProperties; + + @Test + public void testGlobalPropertiesSet() { + BindingProperties bindingProperties = this.serviceProperties + .getBindingProperties(Source.OUTPUT); + Assertions.assertThat(bindingProperties.getContentType()) + .isEqualTo("application/json"); + Assertions.assertThat(bindingProperties.getDestination()).isEqualTo("ticktock"); + Assertions.assertThat(bindingProperties.getProducer().getRequiredGroups()) + .containsExactly("someGroup"); // default propagates to producer + Assertions.assertThat(bindingProperties.getProducer().getPartitionCount()) + .isEqualTo(4); // validates binding property takes precedence over default + Assertions.assertThat(bindingProperties.getProducer().getHeaderMode()) + .isEqualTo(HeaderMode.none); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + public static class TestSource { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1.java new file mode 100644 index 000000000..ed8dc9cae --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub1; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * @author Marius Bogoevici + * @author Mark Fisher + * @author Soby Chacko + */ +public class StubBinder1 + implements Binder { + + private String name; + + private ConfigurableApplicationContext outerContext; + + public ConfigurableApplicationContext getOuterContext() { + return this.outerContext; + } + + public void setOuterContext(ConfigurableApplicationContext outerContext) { + this.outerContext = outerContext; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public Binding bindConsumer(String name, String group, + Object inboundBindTarget, ConsumerProperties properties) { + return null; + } + + @Override + public Binding bindProducer(String name, Object outboundBindTarget, + ProducerProperties properties) { + return null; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1Configuration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1Configuration.java new file mode 100644 index 000000000..45946e5d7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub1/StubBinder1Configuration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub1; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.actuate.health.ApplicationHealthIndicator; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Marius Bogoevici + * @author Soby Chacko + */ +@Configuration +@EnableConfigurationProperties +public class StubBinder1Configuration { + + @Bean + @ConfigurationProperties("binder1") + public Binder binder(BeanFactory beanFactory) { + StubBinder1 stubBinder1 = new StubBinder1(); + ConfigurableApplicationContext outerContext = null; + try { + outerContext = (ConfigurableApplicationContext) beanFactory + .getBean("outerContext"); + } + catch (BeansException be) { + // Pass through + } + if (outerContext != null) { + stubBinder1.setOuterContext(outerContext); + } + return stubBinder1; + } + + @Bean + public HealthIndicator binderHealthIndicator() { + return new ApplicationHealthIndicator(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2.java new file mode 100644 index 000000000..a2433505a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2.java @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub2; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; + +/** + * @author Marius Bogoevici + * @author Mark Fisher + */ +public class StubBinder2 + implements Binder { + + @SuppressWarnings("unused") + private final StubBinder2Dependency stubBinder2Dependency; + + public StubBinder2(StubBinder2Dependency stubBinder2Dependency) { + this.stubBinder2Dependency = stubBinder2Dependency; + } + + @Override + public Binding bindConsumer(String name, String group, + Object inboundBindTarget, ConsumerProperties properties) { + return null; + } + + @Override + public Binding bindProducer(String name, Object outboundBindTarget, + ProducerProperties properties) { + return null; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationA.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationA.java new file mode 100644 index 000000000..97e0e9123 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationA.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015-2016 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub2; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Marius Bogoevici + */ +@Configuration +public class StubBinder2ConfigurationA { + + @Bean + public Binder binder(StubBinder2Dependency dependency) { + return new StubBinder2(dependency); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationB.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationB.java new file mode 100644 index 000000000..cf227c5e0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2ConfigurationB.java @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub2; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Marius Bogoevici + */ +@Configuration +public class StubBinder2ConfigurationB { + + @Bean + public StubBinder2Dependency dependency() { + return new StubBinder2Dependency(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2Dependency.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2Dependency.java new file mode 100644 index 000000000..3bb1d4dfe --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/stub2/StubBinder2Dependency.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.stub2; + +/** + * @author Marius Bogoevici + */ +public class StubBinder2Dependency { + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ContentTypeTckTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ContentTypeTckTests.java new file mode 100644 index 000000000..9efd50cba --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ContentTypeTckTests.java @@ -0,0 +1,1260 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.tck; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinder; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.converter.KryoMessageConverter; +import org.springframework.cloud.stream.converter.MessageConverterUtils; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Sort of a TCK test suite to validate payload conversion is done properly by interacting + * with binder's input/output destinations instead of its bridged channels. This means + * that all payloads (sent/received) must be expressed in the wire format (byte[]) + * + * @author Oleg Zhurakousky + * @author Gary Russell + * + */ +public class ContentTypeTckTests { + + @Test + public void stringToMapStreamListener() { + ApplicationContext context = new SpringApplicationBuilder( + StringToMapStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload())).isEqualTo("oleg"); + } + + @Test + public void stringToMapMessageStreamListener() { + ApplicationContext context = new SpringApplicationBuilder( + StringToMapMessageStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload())).isEqualTo("oleg"); + } + + @Test + // emulates 1.3 behavior + public void stringToMapMessageStreamListenerOriginalContentType() { + ApplicationContext context = new SpringApplicationBuilder( + StringToMapMessageStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + + Message message = MessageBuilder.withPayload(jsonPayload.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") + .setHeader("originalContentType", "application/json;charset=UTF-8") + .build(); + + source.send(message); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload())).isEqualTo("oleg"); + } + + @Test + public void withInternalPipeline() { + ApplicationContext context = new SpringApplicationBuilder(InternalPipeLine.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload())).isEqualTo("OLEG"); + } + + @Test + public void pojoToPojo() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void pojoToString() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToStringStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void pojoToStringOutboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToStringStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void pojoToByteArray() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToByteArrayStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void pojoToByteArrayOutboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToByteArrayStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void stringToPojoInboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + StringToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessToPojoInboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessToPojoInboundContentTypeBindingJson() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.input.contentType=application/json", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessMessageToPojoInboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessMessageToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessMessageToPojoInboundContentTypeBindingJson() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessMessageToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.cloud.stream.bindings.input.contentType=application/json", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessToPojoWithTextHeaderContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("text/plain")) + .build()); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void typelessToPojoOutboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessToMessageStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()) + .setHeader("contentType", new MimeType("text", "plain")).build()); + + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void outboundMessageWithTextContentTypeOnly() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessToMessageTextOnlyContentTypeStreamListener.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()) + .setHeader("contentType", new MimeType("text")).build()); + + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString()) + .isEqualTo("text/plain"); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void stringToPojoInboundContentTypeHeader() { + ApplicationContext context = new SpringApplicationBuilder( + StringToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes(), + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.TEXT_PLAIN)))); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void byteArrayToPojoInboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + ByteArrayToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void byteArrayToPojoInboundContentTypeHeader() { + ApplicationContext context = new SpringApplicationBuilder( + StringToPojoStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes(), + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.TEXT_PLAIN)))); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void byteArrayToByteArray() { + ApplicationContext context = new SpringApplicationBuilder( + ByteArrayToByteArrayStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void byteArrayToByteArrayInboundOutboundContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + ByteArrayToByteArrayStreamListener.class).web(WebApplicationType.NONE) + .run("--spring.cloud.stream.bindings.input.contentType=text/plain", + "--spring.cloud.stream.bindings.output.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void pojoMessageToStringMessage() { + ApplicationContext context = new SpringApplicationBuilder( + PojoMessageToStringMessageStreamListener.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void pojoMessageToStringMessageServiceActivator() { + ApplicationContext context = new SpringApplicationBuilder( + PojoMessageToStringMessageServiceActivator.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + public void byteArrayMessageToStringJsonMessageStreamListener() { + ApplicationContext context = new SpringApplicationBuilder( + ByteArrayMessageToStringJsonMessageStreamListener.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.APPLICATION_JSON); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("{\"name\":\"bob\"}"); + } + + @Test + public void byteArrayMessageToStringMessageStreamListener() { + ApplicationContext context = new SpringApplicationBuilder( + StringMessageToStringMessageStreamListener.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("oleg"); + } + + @Test + @SuppressWarnings("deprecation") + public void kryo_pojoToPojo() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.default.contentType=application/x-java-object", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + + KryoMessageConverter converter = new KryoMessageConverter(null, true); + @SuppressWarnings("unchecked") + Message message = (Message) converter.toMessage( + new Person("oleg"), + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + MessageConverterUtils.X_JAVA_OBJECT))); + + source.send(new GenericMessage<>(message.getPayload())); + Message outputMessage = target.receive(); + assertThat(outputMessage).isNotNull(); + MimeType contentType = (MimeType) outputMessage.getHeaders() + .get(MessageHeaders.CONTENT_TYPE); + assertThat(contentType.getSubtype()).isEqualTo("x-java-object"); + assertThat(contentType.getParameters().get("type")) + .isEqualTo(Person.class.getName()); + } + + @Test + @SuppressWarnings("deprecation") + public void kryo_pojoToPojoContentTypeHeader() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.output.contentType=application/x-java-object"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + + KryoMessageConverter converter = new KryoMessageConverter(null, true); + @SuppressWarnings("unchecked") + Message message = (Message) converter.toMessage( + new Person("oleg"), + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + MessageConverterUtils.X_JAVA_OBJECT))); + + source.send(message); + Message outputMessage = target.receive(); + assertThat(outputMessage).isNotNull(); + MimeType contentType = (MimeType) outputMessage.getHeaders() + .get(MessageHeaders.CONTENT_TYPE); + assertThat(contentType.getSubtype()).isEqualTo("x-java-object"); + } + + /** + * This test simply demonstrates how one can override an existing MessageConverter for + * a given contentType. In this case we are demonstrating how Kryo converter can be + * overriden ('application/x-java-object' maps to Kryo). + */ + @Test + public void overrideMessageConverter_defaultContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + StringToStringStreamListener.class, CustomConverters.class) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.default.contentType=application/x-java-object", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage).isNotNull(); + System.out + .println(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("AlwaysStringKryoMessageConverter"); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeType.valueOf("application/x-java-object")); + } + + @Test + public void customMessageConverter_defaultContentTypeBinding() { + ApplicationContext context = new SpringApplicationBuilder( + StringToStringStreamListener.class, CustomConverters.class) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.default.contentType=foo/bar", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage).isNotNull(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("FooBarMessageConverter"); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)) + .isEqualTo(MimeType.valueOf("foo/bar")); + } + + // Failure tests + + @Test + public void _jsonToPojoWrongDefaultContentTypeProperty() { + ApplicationContext context = new SpringApplicationBuilder( + PojoToPojoStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.default.contentType=text/plain", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + TestChannelBinder binder = context.getBean(TestChannelBinder.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + assertThat(binder.getLastError().getPayload() instanceof MessagingException) + .isTrue(); + } + + @Test + public void _toStringDefaultContentTypePropertyUnknownContentType() { + ApplicationContext context = new SpringApplicationBuilder( + StringToStringStreamListener.class).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.default.contentType=foo/bar", + "--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + TestChannelBinder binder = context.getBean(TestChannelBinder.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + assertThat( + binder.getLastError().getPayload() instanceof MessageConversionException) + .isTrue(); + } + + @Test + public void toCollectionWithParameterizedType() { + ApplicationContext context = new SpringApplicationBuilder( + CollectionWithParameterizedTypes.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "[{\"person\":{\"name\":\"jon\"},\"id\":123},{\"person\":{\"name\":\"jane\"},\"id\":456}]"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(outputMessage.getPayload()).isEqualTo(jsonPayload.getBytes()); + } + + // ====== + @Test + public void testWithMapInputParameter() { + ApplicationContext context = new SpringApplicationBuilder( + MapInputConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void testWithMapPayloadParameter() { + ApplicationContext context = new SpringApplicationBuilder( + MapInputConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void testWithListInputParameter() { + ApplicationContext context = new SpringApplicationBuilder( + ListInputConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "[\"foo\",\"bar\"]"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void testWithMessageHeadersInputParameter() { + ApplicationContext context = new SpringApplicationBuilder( + MessageHeadersInputConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "{\"name\":\"oleg\"}"; + source.send(new GenericMessage<>(jsonPayload.getBytes())); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isNotEqualTo(jsonPayload); + assertThat(outputMessage.getHeaders().containsKey(MessageHeaders.ID)).isTrue(); + assertThat(outputMessage.getHeaders().containsKey(MessageHeaders.CONTENT_TYPE)) + .isTrue(); + } + + @Test + public void testWithTypelessInputParameterAndOctetStream() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessPayloadConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "[\"foo\",\"bar\"]"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, + MimeTypeUtils.APPLICATION_OCTET_STREAM) + .build()); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void testWithTypelessInputParameterAndServiceActivator() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessPayloadConfigurationSA.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "[\"foo\",\"bar\"]"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()).build()); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @Test + public void testWithTypelessMessageInputParameterAndServiceActivator() { + ApplicationContext context = new SpringApplicationBuilder( + TypelessMessageConfigurationSA.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + String jsonPayload = "[\"foo\",\"bar\"]"; + source.send(MessageBuilder.withPayload(jsonPayload.getBytes()).build()); + Message outputMessage = target.receive(); + assertThat(new String(outputMessage.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo(jsonPayload); + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class CollectionWithParameterizedTypes { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public List> echo(List> value) { + assertThat(value.get(0) != null).isTrue(); + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TextInJsonOutListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message echo(String value) { + return MessageBuilder.withPayload(value).setHeader( + MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON).build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class PojoToPojoStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Person echo(Person value) { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class PojoToStringStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String echo(Person value) { + return value.toString(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class PojoToByteArrayStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public byte[] echo(Person value) { + return value.toString().getBytes(StandardCharsets.UTF_8); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class ByteArrayToPojoStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Person echo(byte[] value) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(value, Person.class); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class StringToPojoStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Person echo(String value) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(value, Person.class); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessToPojoStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Person echo(Object value) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + // assume it is string because CT is text/plain + return mapper.readValue((String) value, Person.class); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessMessageToPojoStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Person echo(Message message) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + // assume it is string because CT is text/plain + return mapper.readValue((String) message.getPayload(), Person.class); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessToMessageStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message echo(Object value) throws Exception { + return MessageBuilder.withPayload(value.toString()) + .setHeader("contentType", new MimeType("text", "plain")).build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessToMessageTextOnlyContentTypeStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message echo(Object value) throws Exception { + return MessageBuilder.withPayload(value.toString()) + .setHeader("contentType", new MimeType("text")).build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class ByteArrayToByteArrayStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public byte[] echo(byte[] value) { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class StringToStringStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String echo(String value) { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + /* + * Uncomment to test MBean name quoting for ":" in bean name component of ObjectName. + * Commented to avoid "InstanceAlreadyExistsException" in other tests. + */ + // @EnableIntegrationMBeanExport + public static class StringToMapStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String echo(@Payload Map value) { + return (String) value.get("name"); + } + + @ServiceActivator(inputChannel = "input:foo.myGroup.errors") + public void error(Message message) { + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class StringToMapMessageStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String echo(Message> value) { + assertThat(value.getPayload() instanceof Map).isTrue(); + return (String) value.getPayload().get("name"); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class PojoMessageToStringMessageStreamListener { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Message echo(Message value) { + return MessageBuilder.withPayload(value.getPayload().toString()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class PojoMessageToStringMessageServiceActivator { + + @ServiceActivator(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Message echo(Message value) { + return MessageBuilder.withPayload(value.getPayload().toString()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class StringMessageToStringMessageStreamListener { + + @ServiceActivator(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Message echo(Message value) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + Person person = mapper.readValue(value.getPayload(), Person.class); + return MessageBuilder.withPayload(person.toString()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class ByteArrayMessageToStringJsonMessageStreamListener { + + @ServiceActivator(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Message echo(Message value) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + Person person = mapper.readValue(value.getPayload(), Person.class); + person.setName("bob"); + String json = mapper.writeValueAsString(person); + return MessageBuilder.withPayload(json).build(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class InternalPipeLine { + + @StreamListener(Processor.INPUT) + @SendTo("internalChannel") + public String handleA(Person value) { + return "{\"name\":\"" + value.getName().toUpperCase() + "\"}"; + } + + @Bean + public MessageChannel internalChannel() { + return new DirectChannel(); + } + + @StreamListener("internalChannel") + @SendTo(Processor.OUTPUT) + public String handleB(Person value) { + return value.toString(); + } + + } + + public static class Employee

    { + + private P person; + + private int id; + + public int getId() { + return this.id; + } + + public void setId(int id) { + this.id = id; + } + + public P getPerson() { + return this.person; + } + + public void setPerson(P person) { + this.person = person; + } + + } + + public static class Person { + + private String name; + + public Person() { + this(null); + } + + public Person(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + } + + @Configuration + public static class CustomConverters { + + @Bean + @StreamMessageConverter + public FooBarMessageConverter fooBarMessageConverter() { + return new FooBarMessageConverter(MimeType.valueOf("foo/bar")); + } + + @Bean + @StreamMessageConverter + public AlwaysStringKryoMessageConverter kryoOverrideMessageConverter() { + return new AlwaysStringKryoMessageConverter( + MimeType.valueOf("application/x-java-object")); + } + + /** + * Even though this MessageConverter has nothing to do with Kryo it still shows + * how Kryo conversion can be customized/overriden since it simply overriding a + * converter for contentType 'application/x-java-object'. + * + */ + public static class AlwaysStringKryoMessageConverter + extends AbstractMessageConverter { + + public AlwaysStringKryoMessageConverter(MimeType supportedMimeType) { + super(supportedMimeType); + } + + @Override + protected boolean supports(Class clazz) { + return clazz == null || String.class.isAssignableFrom(clazz); + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, + @Nullable Object conversionHint) { + return this.getClass().getSimpleName(); + } + + @Override + protected Object convertToInternal(Object payload, + @Nullable MessageHeaders headers, @Nullable Object conversionHint) { + return ((String) payload).getBytes(StandardCharsets.UTF_8); + } + + } + + public static class FooBarMessageConverter extends AbstractMessageConverter { + + protected FooBarMessageConverter(MimeType supportedMimeType) { + super(supportedMimeType); + } + + @Override + protected boolean supports(Class clazz) { + return clazz != null && String.class.isAssignableFrom(clazz); + } + + @Override + protected Object convertFromInternal(Message message, Class targetClass, + @Nullable Object conversionHint) { + return this.getClass().getSimpleName(); + } + + @Override + protected Object convertToInternal(Object payload, + @Nullable MessageHeaders headers, @Nullable Object conversionHint) { + return ((String) payload).getBytes(StandardCharsets.UTF_8); + } + + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class MapInputConfiguration { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Map echo(Map value) throws Exception { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class MapPayloadConfiguration { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Map echo(Message> value) throws Exception { + return value.getPayload(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class ListInputConfiguration { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public List echo(List value) throws Exception { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class MessageHeadersInputConfiguration { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Map echo(MessageHeaders value) throws Exception { + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessPayloadConfiguration { + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public Object echo(Object value) throws Exception { + System.out.println(value); + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessPayloadConfigurationSA { + + @ServiceActivator(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Object echo(Object value) throws Exception { + System.out.println(value); + return value; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class TypelessMessageConfigurationSA { + + @ServiceActivator(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT) + public Object echo(Message value) throws Exception { + System.out.println(value.getPayload()); + return value.getPayload(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ErrorHandlingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ErrorHandlingTests.java new file mode 100644 index 000000000..f7134c610 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/tck/ErrorHandlingTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2019-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.tck; + +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * + */ +public class ErrorHandlingTests { + + @Test + public void testGlobalErrorWithMessage() { + ApplicationContext context = new SpringApplicationBuilder( + GlobalErrorHandlerWithErrorMessageConfig.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + source.send(new GenericMessage<>("foo".getBytes())); + GlobalErrorHandlerWithErrorMessageConfig config = context + .getBean(GlobalErrorHandlerWithErrorMessageConfig.class); + assertThat(config.globalErroInvoked).isTrue(); + } + + @Test + public void testGlobalErrorWithThrowable() { + ApplicationContext context = new SpringApplicationBuilder( + GlobalErrorHandlerWithThrowableConfig.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + InputDestination source = context.getBean(InputDestination.class); + source.send(new GenericMessage<>("foo".getBytes())); + GlobalErrorHandlerWithThrowableConfig config = context + .getBean(GlobalErrorHandlerWithThrowableConfig.class); + assertThat(config.globalErroInvoked).isTrue(); + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class GlobalErrorHandlerWithErrorMessageConfig { + + private boolean globalErroInvoked; + + @StreamListener(target = Processor.INPUT) + public void input(final String value) { + throw new RuntimeException("test exception"); + } + + @StreamListener("errorChannel") + public void generalError(Message message) { + this.globalErroInvoked = true; + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class GlobalErrorHandlerWithThrowableConfig { + + private boolean globalErroInvoked; + + @StreamListener(target = Processor.INPUT) + public void input(final String value) { + throw new RuntimeException("test exception"); + } + + @StreamListener("errorChannel") + public void generalError(Throwable exception) { + this.globalErroInvoked = true; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/AbstractDestination.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/AbstractDestination.java new file mode 100644 index 000000000..d9688d0c9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/AbstractDestination.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import org.springframework.messaging.SubscribableChannel; + +/** + * @author Oleg Zhurakousky + * + */ +abstract class AbstractDestination { + + private SubscribableChannel channel; + + SubscribableChannel getChannel() { + return this.channel; + } + + void setChannel(SubscribableChannel channel) { + this.channel = channel; + this.afterChannelIsSet(); + } + + void afterChannelIsSet() { + // noop + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/InputDestination.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/InputDestination.java new file mode 100644 index 000000000..b5f326771 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/InputDestination.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import org.springframework.messaging.Message; + +/** + * Implementation of binder endpoint that represents the source destination (e.g., + * destination from which messages will be received by Processor.INPUT).
    + * You can interact with it by calling {@link #send(Message)} operation. + * + * @author Oleg Zhurakousky + * + */ +public class InputDestination extends AbstractDestination { + + /** + * Allows the {@link Message} to be sent to a Binder to be delegated to binder's input + * destination (e.g., Processor.INPUT). + * @param message message to send + */ + public void send(Message message) { + this.getChannel().send(message); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/OutputDestination.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/OutputDestination.java new file mode 100644 index 000000000..a51bbfa3e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/OutputDestination.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedTransferQueue; +import java.util.concurrent.TimeUnit; + +import org.springframework.messaging.Message; + +/** + * Implementation of binder endpoint that represents the target destination (e.g., + * destination which receives messages sent to Processor.OUTPUT)
    + * You can interact with it by calling {@link #receive()} operation. + * + * @author Oleg Zhurakousky + * + */ +public class OutputDestination extends AbstractDestination { + + private BlockingQueue> messages; + + /** + * Allows to access {@link Message}s received by this {@link OutputDestination}. + * @param timeout how long to wait before giving up + * @return received message + */ + @SuppressWarnings("unchecked") + public Message receive(long timeout) { + try { + return (Message) this.messages.poll(timeout, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; + } + + /** + * Allows to access {@link Message}s received by this {@link OutputDestination}. + * @return received message + */ + public Message receive() { + return this.receive(0); + } + + @Override + void afterChannelIsSet() { + this.messages = new LinkedTransferQueue<>(); + this.getChannel().subscribe(message -> this.messages.offer(message)); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/SampleStreamApp.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/SampleStreamApp.java new file mode 100644 index 000000000..f6b0dfae3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/SampleStreamApp.java @@ -0,0 +1,91 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import java.nio.charset.StandardCharsets; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.StreamListener; +import org.springframework.cloud.stream.binder.PollableMessageSource; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Sample spring cloud stream application that demonstrates the usage of + * {@link TestChannelBinder}. + * + * @author Oleg Zhurakousky + * @author Gary Russell + * + */ +@SpringBootApplication +@EnableBinding(SampleStreamApp.PolledConsumer.class) +@Import(TestChannelBinderConfiguration.class) +public class SampleStreamApp { + + public static void main(String[] args) { + ApplicationContext context = new SpringApplicationBuilder(SampleStreamApp.class) + .web(WebApplicationType.NONE).run("--server.port=0"); + InputDestination source = context.getBean(InputDestination.class); + OutputDestination target = context.getBean(OutputDestination.class); + source.send(new GenericMessage("Hello".getBytes())); + + Message message = target.receive(); + assertThat(new String((byte[]) message.getPayload(), StandardCharsets.UTF_8)) + .isEqualTo("Hello"); + } + + @Bean + public ApplicationRunner runner(PollableMessageSource pollableSource) { + return args -> pollableSource.poll(message -> { + System.out.println("Polled payload: " + message.getPayload()); + }); + } + + @StreamListener(Processor.INPUT) + @SendTo(Processor.OUTPUT) + public String receive(String value) { + System.out.println("Handling payload: " + value); + return value; + } + + @ServiceActivator(inputChannel = "input.anonymous.errors") + public void error(String value) { + System.out.println("Handling ERROR payload: " + value); + } + + public interface PolledConsumer extends Processor { + + @Input + PollableMessageSource pollableSource(); + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinder.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinder.java new file mode 100644 index 000000000..956a573cf --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinder.java @@ -0,0 +1,318 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import java.util.Collections; +import java.util.function.Consumer; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.stream.binder.AbstractMessageChannelBinder; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.binder.test.TestChannelBinderProvisioner.SpringIntegrationConsumerDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderProvisioner.SpringIntegrationProducerDestination; +import org.springframework.cloud.stream.provisioning.ConsumerDestination; +import org.springframework.cloud.stream.provisioning.ProducerDestination; +import org.springframework.core.AttributeAccessor; +import org.springframework.integration.core.MessageProducer; +import org.springframework.integration.core.MessageSource; +import org.springframework.integration.endpoint.MessageProducerSupport; +import org.springframework.integration.handler.BridgeHandler; +import org.springframework.integration.support.DefaultErrorMessageStrategy; +import org.springframework.integration.support.ErrorMessageStrategy; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.retry.RecoveryCallback; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link Binder} backed by Spring Integration framework. It is useful + * for localized demos and testing. + *

    + * This binder extends from the same base class ({@link AbstractMessageChannelBinder}) as + * other binders (i.e., Rabbit, Kafka etc). Interaction with this binder is done via + * source and target destination which emulate real binder's destinations (i.e., Kafka + * topic)
    + * The destination classes are + *

      + *
    • {@link InputDestination}
    • + *
    • {@link OutputDestination}
    • + *
    + * Simply autowire them in your your application and send/receive messages. + *

    + * You must also add {@link TestChannelBinderConfiguration} to your configuration. Below + * is the example using Spring Boot test.
    + *
    + * @RunWith(SpringJUnit4ClassRunner.class)
    + * @SpringBootTest(classes = {SpringIntegrationBinderConfiguration.class, TestWithSIBinder.MyProcessor.class})
    + * public class TestWithSIBinder {
    + *     @Autowired
    + *     private SourceDestination sourceDestination;
    + *
    + *     @Autowired
    + *     private TargetDestination targetDestination;
    + *
    + *     @Test
    + *     public void testWiring() {
    + *         sourceDestination.send(new GenericMessage<String>("Hello"));
    + *         assertEquals("Hello world",
    + *             new String((byte[])targetDestination.receive().getPayload(), StandardCharsets.UTF_8));
    + *     }
    + *
    + *     @SpringBootApplication
    + *     @EnableBinding(Processor.class)
    + *     public static class MyProcessor {
    + *         @StreamListener(Processor.INPUT)
    + *         @SendTo(Processor.OUTPUT)
    + *         public String transform(String in) {
    + *             return in + " world";
    + *         }
    + *     }
    + * }
    + * 
    + * + * @author Oleg Zhurakousky + * @author Gary Russell + * + */ +public class TestChannelBinder extends + AbstractMessageChannelBinder { + + @Autowired + private BeanFactory beanFactory; + + private Message lastError; + + private MessageSource messageSourceDelegate = () -> new GenericMessage<>( + "polled data", + Collections.singletonMap(MessageHeaders.CONTENT_TYPE, "text/plain")); + + public TestChannelBinder(TestChannelBinderProvisioner provisioningProvider) { + super(new String[] {}, provisioningProvider); + } + + /** + * Set a delegate {@link MessageSource} for pollable consumers. + * @param messageSourceDelegate the delegate. + */ + @Autowired(required = false) + public void setMessageSourceDelegate(MessageSource messageSourceDelegate) { + this.messageSourceDelegate = messageSourceDelegate; + } + + public Message getLastError() { + return this.lastError; + } + + @Override + protected MessageHandler createProducerMessageHandler(ProducerDestination destination, + ProducerProperties producerProperties, MessageChannel errorChannel) + throws Exception { + BridgeHandler handler = new BridgeHandler(); + handler.setBeanFactory(this.beanFactory); + handler.setOutputChannel( + ((SpringIntegrationProducerDestination) destination).getChannel()); + return handler; + } + + @Override + protected MessageProducer createConsumerEndpoint(ConsumerDestination destination, + String group, ConsumerProperties properties) throws Exception { + ErrorMessageStrategy errorMessageStrategy = new DefaultErrorMessageStrategy(); + SubscribableChannel siBinderInputChannel = ((SpringIntegrationConsumerDestination) destination) + .getChannel(); + + IntegrationMessageListeningContainer messageListenerContainer = new IntegrationMessageListeningContainer(); + IntegrationBinderInboundChannelAdapter adapter = new IntegrationBinderInboundChannelAdapter( + messageListenerContainer); + + String groupName = StringUtils.hasText(group) ? group : "anonymous"; + ErrorInfrastructure errorInfrastructure = registerErrorInfrastructure(destination, + groupName, properties); + if (properties.getMaxAttempts() > 1) { + adapter.setRetryTemplate(buildRetryTemplate(properties)); + adapter.setRecoveryCallback(errorInfrastructure.getRecoverer()); + } + else { + adapter.setErrorMessageStrategy(errorMessageStrategy); + adapter.setErrorChannel(errorInfrastructure.getErrorChannel()); + } + + siBinderInputChannel.subscribe(messageListenerContainer); + + return adapter; + } + + @Override + protected PolledConsumerResources createPolledConsumerResources(String name, + String group, ConsumerDestination destination, + ConsumerProperties consumerProperties) { + return new PolledConsumerResources(this.messageSourceDelegate, + registerErrorInfrastructure(destination, group, consumerProperties)); + } + + @Override + protected MessageHandler getErrorMessageHandler(ConsumerDestination destination, + String group, ConsumerProperties consumerProperties) { + return m -> { + this.logger.debug("Error handled: " + m); + this.lastError = m; + }; + } + + /** + * Implementation of simple message listener container modeled after AMQP + * SimpleMessageListenerContainer. + */ + private static class IntegrationMessageListeningContainer implements MessageHandler { + + private Consumer> listener; + + @Override + public void handleMessage(Message message) throws MessagingException { + this.listener.accept(message); + } + + public void setMessageListener(Consumer> listener) { + this.listener = listener; + } + + } + + /** + * Implementation of inbound channel adapter modeled after AmqpInboundChannelAdapter. + */ + private static class IntegrationBinderInboundChannelAdapter + extends MessageProducerSupport { + + private static final ThreadLocal attributesHolder = new ThreadLocal(); + + private final IntegrationMessageListeningContainer listenerContainer; + + private RetryTemplate retryTemplate; + + private RecoveryCallback recoveryCallback; + + IntegrationBinderInboundChannelAdapter( + IntegrationMessageListeningContainer listenerContainer) { + this.listenerContainer = listenerContainer; + } + + @SuppressWarnings("unused") + // Temporarily unused until DLQ strategy for this binder becomes a requirement + public void setRecoveryCallback( + RecoveryCallback recoveryCallback) { + this.recoveryCallback = recoveryCallback; + } + + public void setRetryTemplate(RetryTemplate retryTemplate) { + this.retryTemplate = retryTemplate; + } + + @Override + protected void onInit() { + if (this.retryTemplate != null) { + Assert.state(getErrorChannel() == null, + "Cannot have an 'errorChannel' property when a 'RetryTemplate' is " + + "provided; use an 'ErrorMessageSendingRecoverer' in the 'recoveryCallback' property to " + + "send an error message when retries are exhausted"); + } + Listener messageListener = new Listener(); + if (this.retryTemplate != null) { + this.retryTemplate.registerListener(messageListener); + } + this.listenerContainer.setMessageListener(messageListener); + } + + protected class Listener implements RetryListener, Consumer> { + + @Override + @SuppressWarnings("unchecked") + public void accept(Message message) { + try { + if (IntegrationBinderInboundChannelAdapter.this.retryTemplate == null) { + try { + processMessage(message); + } + finally { + attributesHolder.remove(); + } + } + else { + IntegrationBinderInboundChannelAdapter.this.retryTemplate + .execute(context -> { + processMessage(message); + return null; + }, (RecoveryCallback) IntegrationBinderInboundChannelAdapter.this.recoveryCallback); + } + } + catch (RuntimeException e) { + if (getErrorChannel() != null) { + getMessagingTemplate() + .send(getErrorChannel(), + buildErrorMessage(null, new IllegalStateException( + "Message conversion failed: " + message, + e))); + } + else { + throw e; + } + } + } + + private void processMessage(Message message) { + sendMessage(message); + } + + @Override + public boolean open(RetryContext context, + RetryCallback callback) { + if (IntegrationBinderInboundChannelAdapter.this.recoveryCallback != null) { + attributesHolder.set(context); + } + return true; + } + + @Override + public void close(RetryContext context, + RetryCallback callback, Throwable throwable) { + attributesHolder.remove(); + } + + @Override + public void onError(RetryContext context, + RetryCallback callback, Throwable throwable) { + // Empty + } + + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderConfiguration.java new file mode 100644 index 000000000..dfda6e58a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderConfiguration.java @@ -0,0 +1,102 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.integration.config.EnableIntegration; + +/** + * {@link Binder} configuration backed by Spring Integration. + * + * Please see {@link TestChannelBinder} for more details. + * + * @param binding type + * @author Oleg Zhurakousky + * @see TestChannelBinder + */ +@Configuration +@ConditionalOnMissingBean(Binder.class) +@EnableIntegration +public class TestChannelBinderConfiguration { + + public static final String NAME = "integration"; + + /** + * Utility operation to return an array of configuration classes defined in + * {@link EnableBinding} annotation. Typically used for tests that do not rely on + * creating an SCSt boot application annotated with {@link EnableBinding}, yet require + * full {@link Binder} configuration. + * @param additionalConfigurationClasses config classes to be added to the default + * config + * @return an array of configuration classes defined in {@link EnableBinding} + * annotation + */ + public static Class[] getCompleteConfiguration( + Class... additionalConfigurationClasses) { + List> configClasses = new ArrayList<>(); + configClasses.add(TestChannelBinderConfiguration.class); + Import annotation = AnnotationUtils.getAnnotation(EnableBinding.class, + Import.class); + Map annotationAttributes = AnnotationUtils + .getAnnotationAttributes(annotation); + configClasses + .addAll(Arrays.asList((Class[]) annotationAttributes.get("value"))); + configClasses.add(BindingServiceConfiguration.class); + if (additionalConfigurationClasses != null) { + configClasses.addAll(Arrays.asList(additionalConfigurationClasses)); + } + return configClasses.toArray(new Class[] {}); + } + + @Bean + public InputDestination sourceDestination() { + return new InputDestination(); + } + + @Bean + public OutputDestination targetDestination() { + return new OutputDestination(); + } + + @SuppressWarnings("unchecked") + @Bean + public Binder springIntegrationChannelBinder( + TestChannelBinderProvisioner provisioner) { + return (Binder) new TestChannelBinder( + provisioner); + } + + @Bean + public TestChannelBinderProvisioner springIntegrationProvisioner() { + return new TestChannelBinderProvisioner(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderProvisioner.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderProvisioner.java new file mode 100644 index 000000000..7c91a21ac --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/TestChannelBinderProvisioner.java @@ -0,0 +1,145 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binder.test; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.provisioning.ConsumerDestination; +import org.springframework.cloud.stream.provisioning.ProducerDestination; +import org.springframework.cloud.stream.provisioning.ProvisioningException; +import org.springframework.cloud.stream.provisioning.ProvisioningProvider; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.PublishSubscribeChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.SubscribableChannel; + +/** + * {@link ProvisioningProvider} to support {@link TestChannelBinder}. It exists primarily + * to support {@link AbstractMessageChannel} semantics for creating + * {@link ConsumerDestination} and {@link ProducerDestination}, to interact with this + * {@link Binder}. + * + * @author Oleg Zhurakousky + * + */ +public class TestChannelBinderProvisioner + implements ProvisioningProvider { + + private final Map provisionedDestinations = new HashMap<>(); + + @Autowired + private InputDestination source; + + @Autowired + private OutputDestination target; + + /** + * Will provision producer destination as an SI {@link PublishSubscribeChannel}.
    + * This provides convenience of registering additional subscriber (handler in the test + * method) along side of being able to call {@link OutputDestination#receive()} to get + * a {@link Message} for additional assertions. + */ + @Override + public ProducerDestination provisionProducerDestination(String name, + ProducerProperties properties) throws ProvisioningException { + SubscribableChannel destination = this.provisionDestination(name, true); + this.target.setChannel(destination); + return new SpringIntegrationProducerDestination(name, destination); + } + + /** + * Will provision consumer destination as SI {@link DirectChannel}. + */ + @Override + public ConsumerDestination provisionConsumerDestination(String name, String group, + ConsumerProperties properties) throws ProvisioningException { + SubscribableChannel destination = this.provisionDestination(name, false); + if (this.source != null) { + this.source.setChannel(destination); + } + return new SpringIntegrationConsumerDestination(name, destination); + } + + private SubscribableChannel provisionDestination(String name, boolean pubSub) { + String destinationName = name + ".destination"; + SubscribableChannel destination = this.provisionedDestinations + .get(destinationName); + if (destination == null) { + destination = pubSub ? new PublishSubscribeChannel() : new DirectChannel(); + ((AbstractMessageChannel) destination).setBeanName(destinationName); + ((AbstractMessageChannel) destination).setComponentName(destinationName); + this.provisionedDestinations.put(destinationName, destination); + } + return destination; + } + + class SpringIntegrationConsumerDestination implements ConsumerDestination { + + private final String name; + + private final SubscribableChannel channel; + + SpringIntegrationConsumerDestination(String name, SubscribableChannel channel) { + this.name = name; + this.channel = channel; + } + + public SubscribableChannel getChannel() { + return this.channel; + } + + @Override + public String getName() { + return this.name; + } + + } + + class SpringIntegrationProducerDestination implements ProducerDestination { + + private final String name; + + private final SubscribableChannel channel; + + SpringIntegrationProducerDestination(String name, SubscribableChannel channel) { + this.name = name; + this.channel = channel; + } + + @Override + public String getNameForPartition(int partition) { + return this.getName() + partition; + } + + public SubscribableChannel getChannel() { + return this.channel; + } + + @Override + public String getName() { + return this.name; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/package-info.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/package-info.java new file mode 100644 index 000000000..763c01707 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/test/package-info.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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. + */ + +/** + * Provides test channel binder and supporting classes + * + * THe test binder is backed by Spring Integration framework and is not intended for uses + * outside of local testing. + * + * The test binder implementation - + * {@link org.springframework.cloud.stream.binder.test.TestChannelBinder} The test binder + * configuration - + * {@link org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration} The + * example that shows how to use it - + * {@link org.springframework.cloud.stream.binder.test.SampleStreamApp} + * + */ +package org.springframework.cloud.stream.binder.test; diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingLifecycleTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingLifecycleTests.java new file mode 100644 index 000000000..5b40978a8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingLifecycleTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.cloud.stream.binder.Binding; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * @author Oleg Zhurakousky + * + */ +public class BindingLifecycleTests { + + @Test + public void testInputBindingLifecycle() { + Map bindables = new HashMap<>(); + + Bindable bindableWithTwo = new Bindable() { + public Collection> createAndBindInputs( + BindingService adapter) { + return Arrays.asList(mock(Binding.class), mock(Binding.class)); + } + }; + Bindable bindableWithThree = new Bindable() { + public Collection> createAndBindInputs( + BindingService adapter) { + return Arrays.asList(mock(Binding.class), mock(Binding.class), + mock(Binding.class)); + } + }; + Bindable bindableEmpty = new Bindable() { + }; + + bindables.put("two", bindableWithTwo); + bindables.put("empty", bindableEmpty); + bindables.put("three", bindableWithThree); + + InputBindingLifecycle lifecycle = new InputBindingLifecycle( + mock(BindingService.class), bindables); + lifecycle.start(); + + Collection> lifecycleInputBindings = (Collection>) new DirectFieldAccessor( + lifecycle).getPropertyValue("inputBindings"); + assertThat(lifecycleInputBindings.size() == 5).isTrue(); + lifecycle.stop(); + } + + @Test + public void testOutputBindingLifecycle() { + Map bindables = new HashMap<>(); + + Bindable bindableWithTwo = new Bindable() { + public Collection> createAndBindOutputs( + BindingService adapter) { + return Arrays.asList(mock(Binding.class), mock(Binding.class)); + } + }; + Bindable bindableWithThree = new Bindable() { + public Collection> createAndBindOutputs( + BindingService adapter) { + return Arrays.asList(mock(Binding.class), mock(Binding.class), + mock(Binding.class)); + } + }; + Bindable bindableEmpty = new Bindable() { + }; + + bindables.put("two", bindableWithTwo); + bindables.put("empty", bindableEmpty); + bindables.put("three", bindableWithThree); + + OutputBindingLifecycle lifecycle = new OutputBindingLifecycle( + mock(BindingService.class), bindables); + lifecycle.start(); + + Collection> lifecycleOutputBindings = (Collection>) new DirectFieldAccessor( + lifecycle).getPropertyValue("outputBindings"); + assertThat(lifecycleOutputBindings.size() == 5).isTrue(); + lifecycle.stop(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingServiceTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingServiceTests.java new file mode 100644 index 000000000..7714831a3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/BindingServiceTests.java @@ -0,0 +1,724 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Ignore; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.BinderConfiguration; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.BinderType; +import org.springframework.cloud.stream.binder.BinderTypeRegistry; +import org.springframework.cloud.stream.binder.Binding; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.DefaultBinderFactory; +import org.springframework.cloud.stream.binder.DefaultBinderTypeRegistry; +import org.springframework.cloud.stream.binder.ExtendedProducerProperties; +import org.springframework.cloud.stream.binder.ExtendedPropertiesBinder; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.reflection.GenericsUtils; +import org.springframework.cloud.stream.utils.MockBinderConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.test.util.TestUtils; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; +import org.springframework.messaging.core.DestinationResolutionException; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Gary Russell + * @author Mark Fisher + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Janne Valkealahti + * @author Soby Chacko + */ +public class BindingServiceTests { + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testDefaultGroup() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo"); + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + properties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + BindingService service = new BindingService(properties, binderFactory); + MessageChannel inputChannel = new DirectChannel(); + Binding mockBinding = Mockito.mock(Binding.class); + when(binder.bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class))).thenReturn(mockBinding); + Collection> bindings = service.bindConsumer(inputChannel, + inputChannelName); + assertThat(bindings).hasSize(1); + Binding binding = bindings.iterator().next(); + assertThat(binding).isSameAs(mockBinding); + service.unbindConsumers(inputChannelName); + verify(binder).bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + verify(binding).unbind(); + binderFactory.destroy(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testMultipleConsumerBindings() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo,bar"); + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + + properties.setBindings(bindingProperties); + + DefaultBinderFactory binderFactory = createMockBinderFactory(); + + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + BindingService service = new BindingService(properties, binderFactory); + MessageChannel inputChannel = new DirectChannel(); + + Binding mockBinding1 = Mockito.mock(Binding.class); + Binding mockBinding2 = Mockito.mock(Binding.class); + + when(binder.bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class))).thenReturn(mockBinding1); + when(binder.bindConsumer(eq("bar"), isNull(), same(inputChannel), + any(ConsumerProperties.class))).thenReturn(mockBinding2); + + Collection> bindings = service.bindConsumer(inputChannel, + "input"); + assertThat(bindings).hasSize(2); + + Iterator> iterator = bindings.iterator(); + Binding binding1 = iterator.next(); + Binding binding2 = iterator.next(); + + assertThat(binding1).isSameAs(mockBinding1); + assertThat(binding2).isSameAs(mockBinding2); + + service.unbindConsumers("input"); + + verify(binder).bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + verify(binder).bindConsumer(eq("bar"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + verify(binding1).unbind(); + verify(binding2).unbind(); + + binderFactory.destroy(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testConsumerBindingWhenMultiplexingIsEnabled() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo,bar"); + + ConsumerProperties consumer = properties.getConsumerProperties("input"); + consumer.setMultiplex(true); + props.setConsumer(consumer); + + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + + properties.setBindings(bindingProperties); + + DefaultBinderFactory binderFactory = createMockBinderFactory(); + + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + BindingService service = new BindingService(properties, binderFactory); + MessageChannel inputChannel = new DirectChannel(); + + Binding mockBinding1 = Mockito.mock(Binding.class); + + when(binder.bindConsumer(eq("foo,bar"), isNull(), same(inputChannel), + any(ConsumerProperties.class))).thenReturn(mockBinding1); + + Collection> bindings = service.bindConsumer(inputChannel, + "input"); + assertThat(bindings).hasSize(1); + + Iterator> iterator = bindings.iterator(); + Binding binding1 = iterator.next(); + + assertThat(binding1).isSameAs(mockBinding1); + + service.unbindConsumers("input"); + + verify(binder).bindConsumer(eq("foo,bar"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + verify(binding1).unbind(); + + binderFactory.destroy(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testExplicitGroup() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo"); + props.setGroup("fooGroup"); + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + properties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + BindingService service = new BindingService(properties, binderFactory); + MessageChannel inputChannel = new DirectChannel(); + Binding mockBinding = Mockito.mock(Binding.class); + when(binder.bindConsumer(eq("foo"), eq("fooGroup"), same(inputChannel), + any(ConsumerProperties.class))).thenReturn(mockBinding); + Collection> bindings = service.bindConsumer(inputChannel, + inputChannelName); + assertThat(bindings).hasSize(1); + Binding binding = bindings.iterator().next(); + assertThat(binding).isSameAs(mockBinding); + + service.unbindConsumers(inputChannelName); + verify(binder).bindConsumer(eq("foo"), eq(props.getGroup()), same(inputChannel), + any(ConsumerProperties.class)); + verify(binding).unbind(); + binderFactory.destroy(); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void checkDynamicBinding() { + BindingServiceProperties properties = new BindingServiceProperties(); + BindingProperties bindingProperties = new BindingProperties(); + bindingProperties.setProducer(new ProducerProperties()); + properties.setBindings(Collections.singletonMap("foo", bindingProperties)); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + final ExtendedPropertiesBinder binder = mock(ExtendedPropertiesBinder.class); + Properties extendedProps = new Properties(); + when(binder.getExtendedProducerProperties(anyString())).thenReturn(extendedProps); + Binding mockBinding = Mockito.mock(Binding.class); + final AtomicReference dynamic = new AtomicReference<>(); + when(binder.bindProducer(matches("foo"), any(DirectChannel.class), + any(ProducerProperties.class))).thenReturn(mockBinding); + BindingService bindingService = new BindingService(properties, binderFactory) { + + @Override + protected Binder getBinder(String channelName, + Class bindableType) { + return binder; + } + + }; + SubscribableChannelBindingTargetFactory bindableSubscribableChannelFactory; + bindableSubscribableChannelFactory = new SubscribableChannelBindingTargetFactory( + new MessageConverterConfigurer(properties, + new CompositeMessageConverterFactory())); + final AtomicBoolean callbackInvoked = new AtomicBoolean(); + BinderAwareChannelResolver resolver = new BinderAwareChannelResolver( + bindingService, bindableSubscribableChannelFactory, + new DynamicDestinationsBindable(), (name, channel, props, extended) -> { + callbackInvoked.set(true); + assertThat(name).isEqualTo("foo"); + assertThat(channel).isNotNull(); + assertThat(props).isNotNull(); + assertThat(extended).isSameAs(extendedProps); + props.setUseNativeEncoding(true); + extendedProps.setProperty("bar", "baz"); + }); + ConfigurableListableBeanFactory beanFactory = mock( + ConfigurableListableBeanFactory.class); + when(beanFactory.getBean("foo", MessageChannel.class)) + .thenThrow(new NoSuchBeanDefinitionException(MessageChannel.class)); + when(beanFactory.getBean("bar", MessageChannel.class)) + .thenThrow(new NoSuchBeanDefinitionException(MessageChannel.class)); + doAnswer(new Answer() { + + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + dynamic.set(invocation.getArgument(1)); + return null; + } + + }).when(beanFactory).registerSingleton(eq("foo"), any(MessageChannel.class)); + doAnswer(new Answer() { + + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return dynamic.get(); + } + + }).when(beanFactory).initializeBean(any(MessageChannel.class), eq("foo")); + resolver.setBeanFactory(beanFactory); + MessageChannel resolved = resolver.resolveDestination("foo"); + assertThat(resolved).isSameAs(dynamic.get()); + ArgumentCaptor captor = ArgumentCaptor + .forClass(ProducerProperties.class); + verify(binder).bindProducer(eq("foo"), eq(dynamic.get()), captor.capture()); + assertThat(captor.getValue().isUseNativeEncoding()).isTrue(); + assertThat(captor.getValue()).isInstanceOf(ExtendedProducerProperties.class); + assertThat(((ExtendedProducerProperties) captor.getValue()).getExtension()) + .isSameAs(extendedProps); + doReturn(dynamic.get()).when(beanFactory).getBean("foo", MessageChannel.class); + properties.setDynamicDestinations(new String[] { "foo" }); + resolved = resolver.resolveDestination("foo"); + assertThat(resolved).isSameAs(dynamic.get()); + properties.setDynamicDestinations(new String[] { "test" }); + try { + resolver.resolveDestination("bar"); + fail("Should throw an exception"); + } + catch (DestinationResolutionException e) { + assertThat(e).hasMessageContaining( + "Failed to find MessageChannel bean with name 'bar'"); + } + } + + @Test + public void testProducerPropertiesValidation() { + BindingServiceProperties serviceProperties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + ProducerProperties producerProperties = new ProducerProperties(); + producerProperties.setPartitionCount(0); + props.setDestination("foo"); + props.setProducer(producerProperties); + final String outputChannelName = "output"; + bindingProperties.put(outputChannelName, props); + serviceProperties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + BindingService service = new BindingService(serviceProperties, binderFactory); + MessageChannel outputChannel = new DirectChannel(); + try { + service.bindProducer(outputChannel, outputChannelName); + fail("Producer properties should be validated."); + } + catch (IllegalStateException e) { + assertThat(e) + .hasMessageContaining("Partition count should be greater than zero."); + } + } + + @Test + public void testDefaultPropertyBehavior() { + ConfigurableApplicationContext run = SpringApplication.run( + DefaultConsumerPropertiesTestSink.class, + "--spring.cloud.stream.default.contentType=text/plain", + "--spring.cloud.stream.bindings.input1.contentType=application/json", + "--spring.cloud.stream.default.group=foo", + "--spring.cloud.stream.bindings.input2.group=bar", + "--spring.cloud.stream.default.consumer.concurrency=5", + "--spring.cloud.stream.bindings.input2.consumer.concurrency=1", + "--spring.cloud.stream.bindings.input1.consumer.partitioned=true", + "--spring.cloud.stream.default.producer.partitionCount=10", + "--spring.cloud.stream.bindings.output2.producer.partitionCount=1", + "--spring.cloud.stream.bindings.inputXyz.contentType=application/json", + "--spring.cloud.stream.bindings.inputFooBar.contentType=application/avro", + "--spring.cloud.stream.bindings.input_snake_case.contentType=application/avro"); + + BindingServiceProperties bindingServiceProperties = run.getBeanFactory() + .getBean(BindingServiceProperties.class); + Map bindings = bindingServiceProperties.getBindings(); + + assertThat(bindings.get("input1").getContentType()).isEqualTo("application/json"); + assertThat(bindings.get("input2").getContentType()).isEqualTo("text/plain"); + assertThat(bindings.get("input1").getGroup()).isEqualTo("foo"); + assertThat(bindings.get("input2").getGroup()).isEqualTo("bar"); + assertThat(bindings.get("input1").getConsumer().getConcurrency()).isEqualTo(5); + assertThat(bindings.get("input2").getConsumer().getConcurrency()).isEqualTo(1); + assertThat(bindings.get("input1").getConsumer().isPartitioned()).isEqualTo(true); + assertThat(bindings.get("input2").getConsumer().isPartitioned()).isEqualTo(false); + assertThat(bindings.get("output1").getProducer().getPartitionCount()) + .isEqualTo(10); + assertThat(bindings.get("output2").getProducer().getPartitionCount()) + .isEqualTo(1); + + assertThat(bindings.get("inputXyz").getContentType()) + .isEqualTo("application/json"); + assertThat(bindings.get("inputFooBar").getContentType()) + .isEqualTo("application/avro"); + assertThat(bindings.get("inputFooBarBuzz").getContentType()) + .isEqualTo("text/plain"); + assertThat(bindings.get("input_snake_case").getContentType()) + .isEqualTo("application/avro"); + } + + @Test + public void testConsumerPropertiesValidation() { + BindingServiceProperties serviceProperties = new BindingServiceProperties(); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + ConsumerProperties consumerProperties = new ConsumerProperties(); + consumerProperties.setConcurrency(0); + props.setDestination("foo"); + props.setConsumer(consumerProperties); + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + serviceProperties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + BindingService service = new BindingService(serviceProperties, binderFactory); + MessageChannel inputChannel = new DirectChannel(); + try { + service.bindConsumer(inputChannel, inputChannelName); + fail("Consumer properties should be validated."); + } + catch (IllegalStateException e) { + assertThat(e) + .hasMessageContaining("Concurrency should be greater than zero."); + } + } + + @Test + public void testUnknownBinderOnBindingFailure() { + HashMap properties = new HashMap<>(); + properties.put("spring.cloud.stream.bindings.input.destination", "fooInput"); + properties.put("spring.cloud.stream.bindings.input.binder", "mock"); + properties.put("spring.cloud.stream.bindings.output.destination", "fooOutput"); + properties.put("spring.cloud.stream.bindings.output.binder", "mockError"); + BindingServiceProperties bindingServiceProperties = createBindingServiceProperties( + properties); + BindingService bindingService = new BindingService(bindingServiceProperties, + createMockBinderFactory()); + bindingService.bindConsumer(new DirectChannel(), "input"); + try { + bindingService.bindProducer(new DirectChannel(), "output"); + fail("Expected 'Unknown binder configuration'"); + } + catch (IllegalStateException e) { + assertThat(e).hasMessageContaining("Unknown binder configuration: mockError"); + } + } + + @Test + public void testUnrecognizedBinderAllowedIfNotUsed() { + HashMap properties = new HashMap<>(); + properties.put("spring.cloud.stream.bindings.input.destination", "fooInput"); + properties.put("spring.cloud.stream.bindings.output.destination", "fooOutput"); + properties.put("spring.cloud.stream.defaultBinder", "mock1"); + properties.put("spring.cloud.stream.binders.mock1.type", "mock"); + properties.put("spring.cloud.stream.binders.kafka1.type", "kafka"); + BindingServiceProperties bindingServiceProperties = createBindingServiceProperties( + properties); + BinderFactory binderFactory = new BindingServiceConfiguration() + .binderFactory(createMockBinderTypeRegistry(), bindingServiceProperties); + BindingService bindingService = new BindingService(bindingServiceProperties, + binderFactory); + bindingService.bindConsumer(new DirectChannel(), "input"); + bindingService.bindProducer(new DirectChannel(), "output"); + } + + @Test + public void testUnrecognizedBinderDisallowedIfUsed() { + HashMap properties = new HashMap<>(); + properties.put("spring.cloud.stream.bindings.input.destination", "fooInput"); + properties.put("spring.cloud.stream.bindings.input.binder", "mock1"); + properties.put("spring.cloud.stream.bindings.output.destination", "fooOutput"); + properties.put("spring.cloud.stream.bindings.output.type", "kafka1"); + properties.put("spring.cloud.stream.binders.mock1.type", "mock"); + properties.put("spring.cloud.stream.binders.kafka1.type", "kafka"); + BindingServiceProperties bindingServiceProperties = createBindingServiceProperties( + properties); + BinderFactory binderFactory = new BindingServiceConfiguration() + .binderFactory(createMockBinderTypeRegistry(), bindingServiceProperties); + BindingService bindingService = new BindingService(bindingServiceProperties, + binderFactory); + bindingService.bindConsumer(new DirectChannel(), "input"); + try { + bindingService.bindProducer(new DirectChannel(), "output"); + fail("Expected 'Unknown binder configuration'"); + } + catch (IllegalArgumentException e) { + assertThat(e).hasMessageContaining("Binder type kafka is not defined"); + } + } + + @Test + public void testResolveBindableType() { + Class bindableType = GenericsUtils.getParameterType(FooBinder.class, + Binder.class, 0); + assertThat(bindableType).isSameAs(SomeBindableType.class); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + @Ignore + public void testLateBindingConsumer() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + properties.setBindingRetryInterval(1); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo"); + final String inputChannelName = "input"; + bindingProperties.put(inputChannelName, props); + properties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + BindingService service = new BindingService(properties, binderFactory, scheduler); + MessageChannel inputChannel = new DirectChannel(); + final Binding mockBinding = Mockito.mock(Binding.class); + final CountDownLatch fail = new CountDownLatch(2); + doAnswer(i -> { + fail.countDown(); + if (fail.getCount() == 1) { + throw new RuntimeException("fail"); + } + return mockBinding; + }).when(binder).bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + Collection> bindings = service.bindConsumer(inputChannel, + inputChannelName); + assertThat(fail.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(bindings).hasSize(1); + Binding delegate = TestUtils + .getPropertyValue(bindings.iterator().next(), "delegate", Binding.class); + int n = 0; + while (n++ < 300 && delegate == null) { + Thread.sleep(400); + } + assertThat(delegate).isSameAs(mockBinding); + service.unbindConsumers(inputChannelName); + verify(binder, times(2)).bindConsumer(eq("foo"), isNull(), same(inputChannel), + any(ConsumerProperties.class)); + verify(delegate).unbind(); + binderFactory.destroy(); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testLateBindingProducer() throws Exception { + BindingServiceProperties properties = new BindingServiceProperties(); + properties.setBindingRetryInterval(1); + Map bindingProperties = new HashMap<>(); + BindingProperties props = new BindingProperties(); + props.setDestination("foo"); + final String outputChannelName = "output"; + bindingProperties.put(outputChannelName, props); + properties.setBindings(bindingProperties); + DefaultBinderFactory binderFactory = createMockBinderFactory(); + Binder binder = binderFactory.getBinder("mock", MessageChannel.class); + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + BindingService service = new BindingService(properties, binderFactory, scheduler); + MessageChannel outputChannel = new DirectChannel(); + final Binding mockBinding = Mockito.mock(Binding.class); + final CountDownLatch fail = new CountDownLatch(2); + doAnswer(i -> { + fail.countDown(); + if (fail.getCount() == 1) { + throw new RuntimeException("fail"); + } + return mockBinding; + }).when(binder).bindProducer(eq("foo"), same(outputChannel), + any(ProducerProperties.class)); + Binding binding = service.bindProducer(outputChannel, + outputChannelName); + assertThat(fail.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(binding).isNotNull(); + Binding delegate = TestUtils.getPropertyValue(binding, "delegate", Binding.class); + int n = 0; + while (n++ < 300 && delegate == null) { + Thread.sleep(100); + delegate = TestUtils.getPropertyValue(binding, "delegate", Binding.class); + } + assertThat(delegate).isSameAs(mockBinding); + service.unbindProducers(outputChannelName); + verify(binder, times(2)).bindProducer(eq("foo"), same(outputChannel), + any(ProducerProperties.class)); + verify(delegate).unbind(); + binderFactory.destroy(); + scheduler.destroy(); + } + + @SuppressWarnings("unchecked") + @Test + public void testBindingAutostartup() throws Exception { + ApplicationContext context = new SpringApplicationBuilder(FooConfiguration.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.consumer.auto-startup=false"); + BindingService bindingService = context.getBean(BindingService.class); + + Field cbField = ReflectionUtils.findField(BindingService.class, + "consumerBindings"); + cbField.setAccessible(true); + Map cbMap = (Map) cbField.get(bindingService); + Binding inputBinding = ((List>) cbMap.get("input")).get(0); + assertThat(inputBinding.isRunning()).isFalse(); + } + + private DefaultBinderFactory createMockBinderFactory() { + BinderTypeRegistry binderTypeRegistry = createMockBinderTypeRegistry(); + return new DefaultBinderFactory( + Collections.singletonMap("mock", + new BinderConfiguration("mock", new HashMap<>(), true, true)), + binderTypeRegistry); + } + + private DefaultBinderTypeRegistry createMockBinderTypeRegistry() { + return new DefaultBinderTypeRegistry(Collections.singletonMap("mock", + new BinderType("mock", new Class[] { MockBinderConfiguration.class }))); + } + + private BindingServiceProperties createBindingServiceProperties( + HashMap properties) { + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + org.springframework.boot.context.properties.bind.Binder propertiesBinder; + propertiesBinder = new org.springframework.boot.context.properties.bind.Binder( + new MapConfigurationPropertySource(properties)); + propertiesBinder.bind("spring.cloud.stream", + org.springframework.boot.context.properties.bind.Bindable + .ofInstance(bindingServiceProperties)); + return bindingServiceProperties; + } + + public interface FooBinding { + + @Input("input1") + SubscribableChannel in1(); + + @Input("input2") + SubscribableChannel in2(); + + @Output("output1") + MessageChannel out1(); + + @Output("output2") + MessageChannel out2(); + + @Input("inputXyz") + SubscribableChannel inXyz(); + + @Input("inputFooBar") + SubscribableChannel inFooBar(); + + @Input("inputFooBarBuzz") + SubscribableChannel inFooBarBuzz(); + + @Input("input_snake_case") + SubscribableChannel inWithSnakeCase(); + + } + + @EnableBinding(FooBinding.class) + @EnableAutoConfiguration + public static class DefaultConsumerPropertiesTestSink { + + @Bean + public Binder binder() { + return Mockito.mock(Binder.class, + Mockito.withSettings().defaultAnswer(Mockito.RETURNS_MOCKS)); + } + + } + + @EnableBinding(Sink.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class FooConfiguration { + + @ServiceActivator(inputChannel = Processor.INPUT) + public void echo(Message value) throws Exception { + } + + } + + public static class FooBinder + implements Binder { + + @Override + public Binding bindConsumer(String name, String group, + SomeBindableType inboundBindTarget, + ConsumerProperties consumerProperties) { + throw new UnsupportedOperationException(); + } + + @Override + public Binding bindProducer(String name, + SomeBindableType outboundBindTarget, + ProducerProperties producerProperties) { + throw new UnsupportedOperationException(); + } + + } + + public static class SomeBindableType { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/CustomPartitionedProducerTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/CustomPartitionedProducerTest.java new file mode 100644 index 000000000..e52282b66 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/CustomPartitionedProducerTest.java @@ -0,0 +1,267 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.lang.reflect.Field; + +import org.junit.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.PartitionHandler; +import org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy; +import org.springframework.cloud.stream.binder.PartitionSelectorStrategy; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.cloud.stream.partitioning.CustomPartitionKeyExtractorClass; +import org.springframework.cloud.stream.partitioning.CustomPartitionSelectorClass; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.PropertySource; +import org.springframework.integration.annotation.InboundChannelAdapter; +import org.springframework.integration.annotation.Poller; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.core.MessageSource; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ilayaperumal Gopinathan + * @author Oleg Zhurakousky + */ +public class CustomPartitionedProducerTest { + + @Test + public void testCustomPartitionedProducer() { + ApplicationContext context = SpringApplication.run( + CustomPartitionedProducerTest.TestSource.class, + "--spring.jmx.enabled=false", "--spring.main.web-application-type=none", + "--spring.cloud.stream.bindings.output.producer.partitionKeyExtractorClass=" + + "org.springframework.cloud.stream.partitioning.CustomPartitionKeyExtractorClass", + "--spring.cloud.stream.bindings.output.producer.partitionSelectorClass=" + + "org.springframework.cloud.stream.partitioning.CustomPartitionSelectorClass", + "--spring.cloud.stream.default-binder=mock"); + Source testSource = context.getBean(Source.class); + DirectChannel messageChannel = (DirectChannel) testSource.output(); + for (ChannelInterceptor channelInterceptor : messageChannel + .getChannelInterceptors()) { + if (channelInterceptor instanceof MessageConverterConfigurer.PartitioningInterceptor) { + Field partitionHandlerField = ReflectionUtils.findField( + MessageConverterConfigurer.PartitioningInterceptor.class, + "partitionHandler"); + ReflectionUtils.makeAccessible(partitionHandlerField); + PartitionHandler partitionHandler = (PartitionHandler) ReflectionUtils + .getField(partitionHandlerField, channelInterceptor); + Field partitonKeyExtractorField = ReflectionUtils.findField( + PartitionHandler.class, "partitionKeyExtractorStrategy"); + ReflectionUtils.makeAccessible(partitonKeyExtractorField); + Field partitonSelectorField = ReflectionUtils + .findField(PartitionHandler.class, "partitionSelectorStrategy"); + ReflectionUtils.makeAccessible(partitonSelectorField); + assertThat(((PartitionKeyExtractorStrategy) ReflectionUtils + .getField(partitonKeyExtractorField, partitionHandler)).getClass() + .equals(CustomPartitionKeyExtractorClass.class)).isTrue(); + assertThat(((PartitionSelectorStrategy) ReflectionUtils + .getField(partitonSelectorField, partitionHandler)).getClass() + .equals(CustomPartitionSelectorClass.class)).isTrue(); + } + } + } + + @Test + public void testCustomPartitionedProducerByName() { + ApplicationContext context = SpringApplication.run( + CustomPartitionedProducerTest.TestSource.class, + "--spring.jmx.enabled=false", "--spring.main.web-application-type=none", + "--spring.cloud.stream.bindings.output.producer.partitionKeyExtractorName=customPartitionKeyExtractor", + "--spring.cloud.stream.bindings.output.producer.partitionSelectorName=customPartitionSelector", + "--spring.cloud.stream.default-binder=mock"); + Source testSource = context.getBean(Source.class); + DirectChannel messageChannel = (DirectChannel) testSource.output(); + for (ChannelInterceptor channelInterceptor : messageChannel + .getChannelInterceptors()) { + if (channelInterceptor instanceof MessageConverterConfigurer.PartitioningInterceptor) { + Field partitionHandlerField = ReflectionUtils.findField( + MessageConverterConfigurer.PartitioningInterceptor.class, + "partitionHandler"); + ReflectionUtils.makeAccessible(partitionHandlerField); + PartitionHandler partitionHandler = (PartitionHandler) ReflectionUtils + .getField(partitionHandlerField, channelInterceptor); + Field partitonKeyExtractorField = ReflectionUtils.findField( + PartitionHandler.class, "partitionKeyExtractorStrategy"); + ReflectionUtils.makeAccessible(partitonKeyExtractorField); + Field partitonSelectorField = ReflectionUtils + .findField(PartitionHandler.class, "partitionSelectorStrategy"); + ReflectionUtils.makeAccessible(partitonSelectorField); + assertThat(((PartitionKeyExtractorStrategy) ReflectionUtils + .getField(partitonKeyExtractorField, partitionHandler)).getClass() + .equals(CustomPartitionKeyExtractorClass.class)).isTrue(); + assertThat(((PartitionSelectorStrategy) ReflectionUtils + .getField(partitonSelectorField, partitionHandler)).getClass() + .equals(CustomPartitionSelectorClass.class)).isTrue(); + } + } + } + + @Test + public void testCustomPartitionedProducerAsSingletons() { + ApplicationContext context = SpringApplication.run( + CustomPartitionedProducerTest.TestSource.class, + "--spring.jmx.enabled=false", "--spring.main.web-application-type=none", + "--spring.cloud.stream.default-binder=mock"); + Source testSource = context.getBean(Source.class); + DirectChannel messageChannel = (DirectChannel) testSource.output(); + for (ChannelInterceptor channelInterceptor : messageChannel + .getChannelInterceptors()) { + if (channelInterceptor instanceof MessageConverterConfigurer.PartitioningInterceptor) { + Field partitionHandlerField = ReflectionUtils.findField( + MessageConverterConfigurer.PartitioningInterceptor.class, + "partitionHandler"); + ReflectionUtils.makeAccessible(partitionHandlerField); + PartitionHandler partitionHandler = (PartitionHandler) ReflectionUtils + .getField(partitionHandlerField, channelInterceptor); + Field partitonKeyExtractorField = ReflectionUtils.findField( + PartitionHandler.class, "partitionKeyExtractorStrategy"); + ReflectionUtils.makeAccessible(partitonKeyExtractorField); + Field partitonSelectorField = ReflectionUtils + .findField(PartitionHandler.class, "partitionSelectorStrategy"); + ReflectionUtils.makeAccessible(partitonSelectorField); + assertThat(((PartitionKeyExtractorStrategy) ReflectionUtils + .getField(partitonKeyExtractorField, partitionHandler)).getClass() + .equals(CustomPartitionKeyExtractorClass.class)).isTrue(); + assertThat(((PartitionSelectorStrategy) ReflectionUtils + .getField(partitonSelectorField, partitionHandler)).getClass() + .equals(CustomPartitionSelectorClass.class)).isTrue(); + } + } + } + + public void testCustomPartitionedProducerMultipleInstances() { + ApplicationContext context = SpringApplication.run( + CustomPartitionedProducerTest.TestSourceMultipleStrategies.class, + "--spring.jmx.enabled=false", "--spring.main.web-application-type=none", + "--spring.cloud.stream.bindings.output.producer.partitionKeyExtractorName=customPartitionKeyExtractorOne", + "--spring.cloud.stream.bindings.output.producer.partitionSelectorName=customPartitionSelectorTwo", + "--spring.cloud.stream.default-binder=mock"); + Source testSource = context.getBean(Source.class); + DirectChannel messageChannel = (DirectChannel) testSource.output(); + for (ChannelInterceptor channelInterceptor : messageChannel + .getChannelInterceptors()) { + if (channelInterceptor instanceof MessageConverterConfigurer.PartitioningInterceptor) { + Field partitionHandlerField = ReflectionUtils.findField( + MessageConverterConfigurer.PartitioningInterceptor.class, + "partitionHandler"); + ReflectionUtils.makeAccessible(partitionHandlerField); + PartitionHandler partitionHandler = (PartitionHandler) ReflectionUtils + .getField(partitionHandlerField, channelInterceptor); + Field partitonKeyExtractorField = ReflectionUtils.findField( + PartitionHandler.class, "partitionKeyExtractorStrategy"); + ReflectionUtils.makeAccessible(partitonKeyExtractorField); + Field partitonSelectorField = ReflectionUtils + .findField(PartitionHandler.class, "partitionSelectorStrategy"); + ReflectionUtils.makeAccessible(partitonSelectorField); + assertThat(((PartitionKeyExtractorStrategy) ReflectionUtils + .getField(partitonKeyExtractorField, partitionHandler)).getClass() + .equals(CustomPartitionKeyExtractorClass.class)).isTrue(); + assertThat(((PartitionSelectorStrategy) ReflectionUtils + .getField(partitonSelectorField, partitionHandler)).getClass() + .equals(CustomPartitionSelectorClass.class)).isTrue(); + } + } + } + + @Test(expected = Exception.class) + // It actually throws UnsatisfiedDependencyException, but it is confusing when it + // comes to test + // But for the purposes of the test all we care about is that it fails + public void testCustomPartitionedProducerMultipleInstancesFailNoFilter() { + SpringApplication.run( + CustomPartitionedProducerTest.TestSourceMultipleStrategies.class, + "--spring.jmx.enabled=false", "--spring.main.web-application-type=none"); + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/custom-partitioned-producer-test.properties") + public static class TestSource { + + @Bean + public CustomPartitionSelectorClass customPartitionSelector() { + return new CustomPartitionSelectorClass(); + } + + @Bean + public CustomPartitionKeyExtractorClass customPartitionKeyExtractor() { + return new CustomPartitionKeyExtractorClass(); + } + + @Bean + @InboundChannelAdapter(value = Source.OUTPUT, poller = @Poller(fixedDelay = "5000", maxMessagesPerPoll = "1")) + public MessageSource timerMessageSource() { + return new MessageSource() { + @Override + public Message receive() { + throw new MessagingException("test"); + } + }; + } + + } + + @EnableBinding(Source.class) + @EnableAutoConfiguration + @PropertySource("classpath:/org/springframework/cloud/stream/binder/custom-partitioned-producer-test.properties") + public static class TestSourceMultipleStrategies { + + @Bean + public CustomPartitionSelectorClass customPartitionSelectorOne() { + return new CustomPartitionSelectorClass(); + } + + @Bean + public CustomPartitionSelectorClass customPartitionSelectorTwo() { + return new CustomPartitionSelectorClass(); + } + + @Bean + public CustomPartitionKeyExtractorClass customPartitionKeyExtractorOne() { + return new CustomPartitionKeyExtractorClass(); + } + + @Bean + public CustomPartitionKeyExtractorClass customPartitionKeyExtractorTwo() { + return new CustomPartitionKeyExtractorClass(); + } + + @Bean + @InboundChannelAdapter(value = Source.OUTPUT, poller = @Poller(fixedDelay = "5000", maxMessagesPerPoll = "1")) + public MessageSource timerMessageSource() { + return new MessageSource() { + @Override + public Message receive() { + throw new MessagingException("test"); + } + }; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/InvalidBindingConfigurationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/InvalidBindingConfigurationTests.java new file mode 100644 index 000000000..7be330551 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/InvalidBindingConfigurationTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import org.junit.Test; + +import org.springframework.beans.factory.BeanDefinitionStoreException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.Input; +import org.springframework.cloud.stream.annotation.Output; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.SubscribableChannel; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Artem Bilan + * @since 1.3 + */ +public class InvalidBindingConfigurationTests { + + @Test + public void testDuplicateBeanByBindingConfig() { + assertThatThrownBy(() -> SpringApplication.run(TestBindingConfig.class)) + .isInstanceOf(BeanDefinitionStoreException.class) + .hasMessageContaining("bean definition with this name already exists") + .hasMessageContaining(TestInvalidBinding.NAME).hasNoCause(); + } + + public interface TestInvalidBinding { + + String NAME = "testName"; + + @Input(NAME) + SubscribableChannel in(); + + @Output(NAME) + MessageChannel out(); + + } + + @EnableBinding(TestInvalidBinding.class) + @EnableAutoConfiguration + public static class TestBindingConfig { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/MessageConverterConfigurerTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/MessageConverterConfigurerTests.java new file mode 100644 index 000000000..cef6c63df --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binding/MessageConverterConfigurerTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.binding; + +import java.util.Collections; + +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.cloud.stream.binder.BinderHeaders; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.AbstractMessageConverter; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Gary Russell + * @author Oleg Zhurakousky + * @since 1.3 + * + */ +public class MessageConverterConfigurerTests { + + // @Test + public void testConfigureOutputChannelWithBadContentType() { + BindingServiceProperties props = new BindingServiceProperties(); + BindingProperties bindingProps = new BindingProperties(); + bindingProps.setContentType("application/json"); + props.setBindings(Collections.singletonMap("foo", bindingProps)); + CompositeMessageConverterFactory converterFactory = new CompositeMessageConverterFactory( + Collections.emptyList(), null); + MessageConverterConfigurer configurer = new MessageConverterConfigurer(props, + converterFactory); + QueueChannel out = new QueueChannel(); + configurer.configureOutputChannel(out, "foo"); + out.send(new GenericMessage(new Foo(), Collections + .singletonMap(MessageHeaders.CONTENT_TYPE, "bad/ct"))); + Message received = out.receive(0); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isInstanceOf(Foo.class); + } + + @Test + @Ignore + public void testConfigureOutputChannelCannotConvert() { + BindingServiceProperties props = new BindingServiceProperties(); + BindingProperties bindingProps = new BindingProperties(); + bindingProps.setContentType("foo/bar"); + props.setBindings(Collections.singletonMap("foo", bindingProps)); + MessageConverter converter = new AbstractMessageConverter( + new MimeType("foo", "bar")) { + + @Override + protected boolean supports(Class clazz) { + return true; + } + + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, + Object conversionHint) { + return null; + } + + }; + CompositeMessageConverterFactory converterFactory = new CompositeMessageConverterFactory( + Collections.singletonList(converter), null); + MessageConverterConfigurer configurer = new MessageConverterConfigurer(props, + converterFactory); + QueueChannel out = new QueueChannel(); + configurer.configureOutputChannel(out, "foo"); + try { + out.send(new GenericMessage(new Foo(), + Collections.singletonMap(MessageHeaders.CONTENT_TYPE, + "bad/ct"))); + fail("Expected MessageConversionException: " + out.receive(0)); + } + catch (MessageConversionException e) { + assertThat(e.getMessage()) + .endsWith("to the configured output type: 'foo/bar'"); + } + } + + @Test + public void testConfigureInputChannelWithLegacyContentType() { + BindingServiceProperties props = new BindingServiceProperties(); + BindingProperties bindingProps = new BindingProperties(); + bindingProps.setContentType("foo/bar"); + props.setBindings(Collections.singletonMap("foo", bindingProps)); + CompositeMessageConverterFactory converterFactory = new CompositeMessageConverterFactory( + Collections.emptyList(), null); + MessageConverterConfigurer configurer = new MessageConverterConfigurer(props, + converterFactory); + QueueChannel in = new QueueChannel(); + configurer.configureInputChannel(in, "foo"); + Foo foo = new Foo(); + in.send(MessageBuilder.withPayload(foo) + .setHeader(BinderHeaders.BINDER_ORIGINAL_CONTENT_TYPE, "application/json") + .setHeader(BinderHeaders.SCST_VERSION, "1.x").build()); + Message received = in.receive(0); + assertThat(received).isNotNull(); + assertThat(received.getPayload()).isEqualTo(foo); + assertThat(received.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString()) + .isEqualTo("application/json"); + } + + public static class Foo { + + private String bar = "bar"; + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/ArgumentResolversTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/ArgumentResolversTests.java new file mode 100644 index 000000000..623b58bca --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/ArgumentResolversTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2019-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * + * @author Oleg Zhurakousky + * + */ +public class ArgumentResolversTests { + + @SuppressWarnings({ "deprecation", "rawtypes", "unchecked" }) + @Test + public void testSmartPayloadArgumentResolver() throws Exception { + + SmartPayloadArgumentResolver resolver = new SmartPayloadArgumentResolver(new TestMessageConverter()); + + Object payload = "hello".getBytes(); + GenericMessage message = new GenericMessage(payload); + MethodParameter parameter = new MethodParameter(getMethod("byteArray", byte[].class), 0); + Object resolvedArgument = resolver.resolveArgument(parameter, message); + assertThat(resolvedArgument).isSameAs(payload); + + parameter = new MethodParameter(getMethod("object", Object.class), 0); + resolvedArgument = resolver.resolveArgument(parameter, message); + assertThat(resolvedArgument).isInstanceOf(Message.class); + + payload = new LinkedHashMap<>(); + message = new GenericMessage(payload); + parameter = new MethodParameter(getMethod("map", Map.class), 0); + resolvedArgument = resolver.resolveArgument(parameter, message); + assertThat(resolvedArgument).isSameAs(payload); + + parameter = new MethodParameter(getMethod("object", Object.class), 0); + resolvedArgument = resolver.resolveArgument(parameter, message); + assertThat(resolvedArgument).isInstanceOf(Message.class); + } + + private Method getMethod(String name, Class parameter) { + return ReflectionUtils.findMethod(this.getClass(), name, parameter); + } + + public void byteArray(byte[] p) { + + } + + public void byteArrayMessage(Message p) { + + } + + public void object(Object p) { + + } + @SuppressWarnings("rawtypes") + public void map(Map p) { + + } + + /* + * The whole point of this converter is to return something other + * then what is being resolved to simply validate when it is invoked + * vs. when it is not. + */ + private static class TestMessageConverter implements MessageConverter { + + @Override + public Object fromMessage(Message message, Class targetClass) { + return message; + } + + @Override + public Message toMessage(Object payload, MessageHeaders headers) { + return new GenericMessage<>(payload); + } + + } +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderConfigurationParsingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderConfigurationParsingTests.java new file mode 100644 index 000000000..f0a8013f6 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderConfigurationParsingTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Test; + +import org.springframework.cloud.stream.binder.BinderType; +import org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration; +import org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationA; +import org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationB; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Marius Bogoevici + */ +public class BinderConfigurationParsingTests { + + private static ClassLoader classLoader = BinderConfigurationParsingTests.class + .getClassLoader(); + + @Test + public void testParseOneBinderConfiguration() throws Exception { + + // this is just checking that resources are passed and classes are loaded properly + // class values used here are not binder configurations + String oneBinderConfiguration = "binder1=org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration"; + Resource resource = new InputStreamResource( + new ByteArrayInputStream(oneBinderConfiguration.getBytes())); + + Collection binderConfigurations = BinderFactoryConfiguration + .parseBinderConfigurations(classLoader, resource); + + assertThat(binderConfigurations).isNotNull(); + assertThat(binderConfigurations.size()).isEqualTo(1); + BinderType type = binderConfigurations.iterator().next(); + assertThat(type.getDefaultName()).isEqualTo("binder1"); + assertThat(type.getConfigurationClasses()) + .contains(StubBinder1Configuration.class); + } + + @SuppressWarnings("unchecked") + @Test + public void testParseTwoBindersConfigurations() throws Exception { + // this is just checking that resources are passed and classes are loaded properly + // class values used here are not binder configurations + String binderConfiguration = "binder1=org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration\n" + + "binder2=org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationA"; + Resource twoBinderConfigurationResource = new InputStreamResource( + new ByteArrayInputStream(binderConfiguration.getBytes())); + + Collection twoBinderConfig = BinderFactoryConfiguration + .parseBinderConfigurations(classLoader, twoBinderConfigurationResource); + + assertThat(twoBinderConfig.size()).isEqualTo(2); + List stubBinder1 = stubBinders(twoBinderConfig, "binder1", + StubBinder1Configuration.class); + List stubBinder2 = stubBinders(twoBinderConfig, "binder2", + StubBinder2ConfigurationA.class); + assertThat(stubBinder1).isNotEmpty(); + assertThat(stubBinder2).isNotEmpty(); + + } + + private List stubBinders(Collection twoBinderConfigurations, + String binderName, Class... configurationNames) { + return twoBinderConfigurations.stream() + .filter(binderType -> binderName.equals(binderType.getDefaultName()) + && !Collections.disjoint( + Arrays.asList(binderType.getConfigurationClasses()), + Arrays.asList(configurationNames))) + .collect(Collectors.toList()); + } + + @Test + @SuppressWarnings("unchecked") + public void testParseTwoBindersWithMultipleClasses() throws Exception { + // this is just checking that resources are passed and classes are loaded properly + // class values used here are not binder configurations + String binderConfiguration = "binder1=org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration\n" + + "binder2=org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationA," + + "org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationB"; + Resource binderConfigurationResource = new InputStreamResource( + new ByteArrayInputStream(binderConfiguration.getBytes())); + + Collection binderConfigurations = BinderFactoryConfiguration + .parseBinderConfigurations(classLoader, binderConfigurationResource); + + assertThat(binderConfigurations.size()).isEqualTo(2); + assertThat(binderConfigurations.size()).isEqualTo(2); + List stubBinder1 = stubBinders(binderConfigurations, "binder1", + StubBinder1Configuration.class); + List stubBinder2 = stubBinders(binderConfigurations, "binder2", + StubBinder2ConfigurationA.class, StubBinder2ConfigurationB.class); + assertThat(stubBinder1).isNotEmpty(); + assertThat(stubBinder2).isNotEmpty(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderPropertiesTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderPropertiesTests.java new file mode 100644 index 000000000..03685e02a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BinderPropertiesTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2017-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.Collections; +import java.util.Map; +import java.util.Properties; + +import org.junit.Test; + +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint; +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ContextConfigurationProperties; +import org.springframework.context.support.StaticApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test primarily validates the correctness of BinderProperties where it as well as + * what it contains maintains the String-key/Object-value semantics. The use of + * {@link Properties} class does not exactly do that. + * + * @author Oleg Zhurakousky + * + */ +public class BinderPropertiesTests { + + @SuppressWarnings("unchecked") + @Test + public void testSerializationWithNonStringValues() { + StaticApplicationContext context = new StaticApplicationContext(); + DefaultListableBeanFactory bf = (DefaultListableBeanFactory) context + .getBeanFactory(); + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + bindingServiceProperties.setApplicationContext(context); + bf.registerSingleton("bindingServiceProperties", bindingServiceProperties); + + BindingServiceProperties bsp = context.getBean(BindingServiceProperties.class); + bsp.setApplicationContext(context); + BinderProperties bp = new BinderProperties(); + bsp.setBinders(Collections.singletonMap("testBinder", bp)); + bp.getEnvironment().put("spring.rabbitmq.connection-timeout", 2345); + bp.getEnvironment().put("foo", Collections.singletonMap("bar", "hello")); + + // using Spring Boot class to ensure that reliance on the same ObjectMapper + // configuration + ConfigurationPropertiesReportEndpoint endpoint = new ConfigurationPropertiesReportEndpoint(); + endpoint.setApplicationContext(context); + + ContextConfigurationProperties configurationProperties = endpoint + .configurationProperties().getContexts().values().iterator().next(); + + Map properties = configurationProperties.getBeans() + .get("bindingServiceProperties").getProperties(); + assertThat(properties.containsKey("error")).isFalse(); + assertThat(properties.containsKey("binders")).isTrue(); + Map testBinder = (Map) ((Map) properties + .get("binders")).get("testBinder"); + Map environment = (Map) testBinder + .get("environment"); + assertThat( + environment.get("spring.rabbitmq.connection-timeout") instanceof Integer) + .isTrue(); + assertThat(environment.get("foo") instanceof Map).isTrue(); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingHandlerAdviseTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingHandlerAdviseTests.java new file mode 100644 index 000000000..0557a1df7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingHandlerAdviseTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import javax.validation.constraints.Min; + +import org.junit.Test; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.Import; +import org.springframework.validation.annotation.Validated; + +import static org.assertj.core.api.Assertions.assertThat; + +// see https://github.com/spring-cloud/spring-cloud-stream/issues/1573 for more details +/** + * @author Oleg Zhurakousky + * + */ +public class BindingHandlerAdviseTests { + + @Test(expected = BeanCreationException.class) + public void testFailureWithWrongValue() { + new SpringApplicationBuilder(SampleConfiguration.class) + .web(WebApplicationType.NONE) + .run("--props.value=-1", "--spring.jmx.enabled=false"); + } + + @Test + public void testValidatedValueValue() { + ValidatedProps validatedProps = new SpringApplicationBuilder( + SampleConfiguration.class).web(WebApplicationType.NONE) + .run("--props.value=2", "--spring.jmx.enabled=false") + .getBean(ValidatedProps.class); + assertThat(validatedProps.getValue()).isEqualTo(2); + } + +} + +@EnableBinding(Sink.class) +@Import(TestChannelBinderConfiguration.class) +@EnableAutoConfiguration +@EnableConfigurationProperties(ValidatedProps.class) +class SampleConfiguration { + +} + +@ConfigurationProperties("props") +@Validated +class ValidatedProps { + + @Min(0) + private int value; + + public int getValue() { + return this.value; + } + + public void setValue(int value) { + this.value = value; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingServiceConfigurationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingServiceConfigurationTests.java new file mode 100644 index 000000000..fe287f6fe --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/BindingServiceConfigurationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.Map; + +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * + */ +public class BindingServiceConfigurationTests { + + @Test + public void valdateImportedConfiguartionHandlerPostProcessing() { + ApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(RootConfiguration.class)) + .web(WebApplicationType.NONE).run(); + Map beansOfType = context + .getBeansOfType(AbstractReplyProducingMessageHandler.class); + for (AbstractReplyProducingMessageHandler handler : beansOfType.values()) { + assertThat(handler.getNotPropagatedHeaders().contains("contentType")) + .isTrue(); + } + } + + @Configuration + @Import(ImportedConfiguration.class) + public static class RootConfiguration { + + @ServiceActivator(inputChannel = "input") + public void rootService(String val) { + } + + } + + @Configuration + public static class ImportedConfiguration { + + @ServiceActivator(inputChannel = "input") + public void importedService(String val) { + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/RetryTemplateTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/RetryTemplateTests.java new file mode 100644 index 000000000..0cba76f24 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/RetryTemplateTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamRetryTemplate; +import org.springframework.cloud.stream.binder.AbstractBinder; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.retry.support.RetryTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * + */ +public class RetryTemplateTests { + + @SuppressWarnings("rawtypes") + @Test + public void testSingleCustomRetryTemplate() throws Exception { + ApplicationContext context = new SpringApplicationBuilder( + SingleCustomRetryTemplateConfiguration.class).web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false"); + AbstractBinder binder = context.getBean(AbstractBinder.class); + Field f = AbstractBinder.class.getDeclaredField("consumerBindingRetryTemplates"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + Map consumerBindingRetryTemplates = (Map) f + .get(binder); + assertThat(consumerBindingRetryTemplates).hasSize(1); + } + + @SuppressWarnings("rawtypes") + @Test + public void testSpecificCustomRetryTemplate() throws Exception { + ApplicationContext context = new SpringApplicationBuilder( + SpecificCustomRetryTemplateConfiguration.class) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false", + "--spring.cloud.stream.bindings.input.consumer.retry-template-name=retryTemplateTwo"); + + RetryTemplate retryTemplateTwo = context.getBean("retryTemplateTwo", + RetryTemplate.class); + BindingServiceProperties bindingServiceProperties = context + .getBean(BindingServiceProperties.class); + ConsumerProperties consumerProperties = bindingServiceProperties + .getConsumerProperties("input"); + AbstractBinder binder = context.getBean(AbstractBinder.class); + + Method m = AbstractBinder.class.getDeclaredMethod("buildRetryTemplate", + ConsumerProperties.class); + m.setAccessible(true); + RetryTemplate retryTemplate = (RetryTemplate) m.invoke(binder, + consumerProperties); + assertThat(retryTemplate).isEqualTo(retryTemplateTwo); + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class SpecificCustomRetryTemplateConfiguration { + + @StreamRetryTemplate + public RetryTemplate retryTemplate() { + return new RetryTemplate(); + } + + @StreamRetryTemplate + public RetryTemplate retryTemplateTwo() { + return new RetryTemplate(); + } + + @Bean + public RetryTemplate otherRetryTemplate() { + return new RetryTemplate(); + } + + } + + @EnableBinding(Processor.class) + @Import(TestChannelBinderConfiguration.class) + @EnableAutoConfiguration + public static class SingleCustomRetryTemplateConfiguration { + + @StreamRetryTemplate + public RetryTemplate retryTemplate() { + return new RetryTemplate(); + } + + @Bean + public RetryTemplate otherRetryTemplate() { + return new RetryTemplate(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfigurationTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfigurationTests.java new file mode 100644 index 000000000..61e3c1413 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/config/SpelExpressionConverterConfigurationTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2015-2018 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.config; + +import java.util.List; + +import javax.annotation.PostConstruct; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.PropertyAccessor; +import org.springframework.integration.expression.ExpressionUtils; +import org.springframework.integration.json.JsonPropertyAccessor; +import org.springframework.integration.test.util.TestUtils; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for SpelExpressionConverterConfiguration. + * + * @author Eric Bottard + * @author Artem Bilan + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = SpelExpressionConverterConfigurationTests.Config.class, properties = { + "expression: a.b" }) +public class SpelExpressionConverterConfigurationTests { + + @Autowired + private Pojo pojo; + + @Autowired + private EvaluationContext evaluationContext; + + @Autowired + private Config config; + + @Test + @SuppressWarnings("unchecked") + public void converterCorrectlyInstalled() { + Expression expression = this.pojo.getExpression(); + assertThat(expression.getValue("{\"a\": {\"b\": 5}}").toString()).isEqualTo("5"); + + List propertyAccessors = TestUtils.getPropertyValue( + this.evaluationContext, "propertyAccessors", List.class); + + assertThat(propertyAccessors) + .hasAtLeastOneElementOfType(JsonPropertyAccessor.class); + + propertyAccessors = TestUtils.getPropertyValue(this.config.evaluationContext, + "propertyAccessors", List.class); + + assertThat(propertyAccessors) + .hasAtLeastOneElementOfType(JsonPropertyAccessor.class); + } + + @ConfigurationProperties + public static class Pojo { + + private Expression expression; + + public Expression getExpression() { + return this.expression; + } + + public void setExpression(Expression expression) { + this.expression = expression; + } + + } + + @Configuration + @EnableBinding + @EnableAutoConfiguration + @EnableConfigurationProperties(Pojo.class) + public static class Config implements BeanFactoryAware { + + private BeanFactory beanFactory; + + private EvaluationContext evaluationContext; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Bean + public EvaluationContext evaluationContext() { + return ExpressionUtils.createStandardEvaluationContext(this.beanFactory); + } + + @PostConstruct + public void setup() { + this.evaluationContext = ExpressionUtils + .createStandardEvaluationContext(this.beanFactory); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/converter/KryoMessageConverterTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/converter/KryoMessageConverterTests.java new file mode 100644 index 000000000..c12ffce98 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/converter/KryoMessageConverterTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.converter; + +import java.io.ByteArrayOutputStream; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Output; +import org.junit.Test; + +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConversionException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Vinicius Carvalho + */ +public class KryoMessageConverterTests { + + @Test + public void convertStringType() throws Exception { + KryoMessageConverter kryoMessageConverter = new KryoMessageConverter(null, true); + Message message = MessageBuilder.withPayload("foo") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/x-java-object") + .build(); + Message converted = kryoMessageConverter.toMessage(message.getPayload(), + message.getHeaders()); + assertThat(converted).isNotNull(); + assertThat(converted.getHeaders().get(MessageHeaders.CONTENT_TYPE).toString()) + .isEqualTo("application/x-java-object;type=java.lang.String"); + } + + @Test + public void readStringType() throws Exception { + KryoMessageConverter kryoMessageConverter = new KryoMessageConverter(null, true); + Kryo kryo = new Kryo(); + String foo = "foo"; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Output output = new Output(baos); + kryo.writeObject(output, foo); + output.close(); + Message message = MessageBuilder.withPayload(baos.toByteArray()) + .setHeader(MessageHeaders.CONTENT_TYPE, + KryoMessageConverter.KRYO_MIME_TYPE + ";type=java.lang.String") + .build(); + Object result = kryoMessageConverter.fromMessage(message, String.class); + assertThat(result).isEqualTo(foo); + } + + @Test + public void testMissingHeaders() throws Exception { + KryoMessageConverter kryoMessageConverter = new KryoMessageConverter(null, true); + Kryo kryo = new Kryo(); + String foo = "foo"; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Output output = new Output(baos); + kryo.writeObject(output, foo); + output.close(); + Message message = MessageBuilder.withPayload(baos.toByteArray()).build(); + Object result = kryoMessageConverter.fromMessage(message, String.class); + assertThat(result).isNull(); + } + + @Test(expected = MessageConversionException.class) + public void readWithWrongPayloadType() throws Exception { + KryoMessageConverter kryoMessageConverter = new KryoMessageConverter(null, true); + Message message = MessageBuilder.withPayload("foo") + .setHeader(MessageHeaders.CONTENT_TYPE, + KryoMessageConverter.KRYO_MIME_TYPE + ";type=java.lang.String") + .build(); + kryoMessageConverter.fromMessage(message, String.class); + } + + @Test(expected = MessageConversionException.class) + public void readWithWrongPayloadFormat() throws Exception { + KryoMessageConverter kryoMessageConverter = new KryoMessageConverter(null, true); + Message message = MessageBuilder.withPayload("foo") + .setHeader(MessageHeaders.CONTENT_TYPE, + KryoMessageConverter.KRYO_MIME_TYPE + ";type=java.lang.String") + .build(); + kryoMessageConverter.fromMessage(message, String.class); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/FunctionInvokerTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/FunctionInvokerTests.java new file mode 100644 index 000000000..f5b9a2fa3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/FunctionInvokerTests.java @@ -0,0 +1,614 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.lang.reflect.Field; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.catalog.FunctionInspector; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.annotation.StreamMessageConverter; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.function.pojo.Baz; +import org.springframework.cloud.stream.function.pojo.ErrorBaz; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * @author Tolga Kavukcu + * + */ +public class FunctionInvokerTests { + + private static String testWithFluxedConsumerValue; + + @Test + public void testSimpleEchoConfiguration() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + SimpleEchoConfiguration.class)).web(WebApplicationType.NONE).run( + "--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("{\"name\":\"bob\"}".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()) + .isEqualTo("{\"name\":\"bob\"}".getBytes()); + + } + } + + @Test + public void testFluxPojoFunction() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(SimpleFluxFunctionConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("{\"name\":\"bob\"}".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()).isEqualTo("Person: bob".getBytes()); + + } + } + + @Test + public void testFluxMessagePojoFunction() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + SimpleFluxMessageFunctionConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("{\"name\":\"bob\"}".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()).isEqualTo("Person: bob".getBytes()); + } + } + + @Test + public void testFunctionHonorsOutboundBindingContentType() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + ConverterDoesNotProduceCTConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func", + "--spring.cloud.stream.bindings.output.contentType=text/plain")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("{\"name\":\"bob\"}".getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, "foo/bar").build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE) + .toString()).isEqualTo("text/plain"); + + } + } + + @Test + public void testFunctionHonorsConverterSetContentType() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + ConverterInjectingCTConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func", + "--spring.cloud.stream.bindings.output.contentType=text/plain")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("{\"name\":\"bob\"}".getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, "foo/bar").build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE) + .toString()).isEqualTo("ping/pong"); + + } + } + + @Test + public void testSameMessageTypesAreNotConverted() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(MyFunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false")) { + + Message inputMessage = new GenericMessage<>(new Foo()); + + StreamFunctionProperties functionProperties = createStreamFunctionProperties(); + + functionProperties.setDefinition("messageToMessageSameType"); + FunctionInvoker messageToMessageSameType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + Message outputMessage = messageToMessageSameType + .apply(Flux.just(inputMessage)).blockFirst(); + assertThat(inputMessage).isSameAs(outputMessage); + + functionProperties.setDefinition("pojoToPojoSameType"); + FunctionInvoker pojoToPojoSameType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + outputMessage = pojoToPojoSameType.apply(Flux.just(inputMessage)) + .blockFirst(); + assertThat(inputMessage.getPayload()).isEqualTo(outputMessage.getPayload()); + + functionProperties.setDefinition("messageToMessageNoType"); + FunctionInvoker messageToMessageNoType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + outputMessage = messageToMessageNoType.apply(Flux.just(inputMessage)) + .blockFirst(); + assertThat(outputMessage).isInstanceOf(Message.class); + + functionProperties.setDefinition("withException"); + FunctionInvoker withException = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + + Flux> fluxOfMessages = Flux + .just(new GenericMessage<>(new ErrorFoo()), inputMessage); + Message resultMessage = withException.apply(fluxOfMessages).blockFirst(); + assertThat(resultMessage.getPayload()).isNotInstanceOf(ErrorFoo.class); + } + } + + @Test + public void testNativeEncodingEnabled() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(MyFunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false")) { + + Message inputMessage = new GenericMessage<>(new Baz()); + + StreamFunctionProperties functionProperties = createStreamFunctionPropertiesWithNativeEncoding(); + + functionProperties.setDefinition("pojoToPojoNonEmptyPojo"); + FunctionInvoker pojoToPojoSameType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + Message outputMessage = pojoToPojoSameType.apply(Flux.just(inputMessage)) + .blockFirst(); + assertThat(inputMessage.getPayload()).isEqualTo(outputMessage.getPayload()); + + Message inputMessageWithBaz = new GenericMessage<>(new Baz()); + + functionProperties.setDefinition("messageToMessageNoType"); + FunctionInvoker messageToMessageNoType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + outputMessage = messageToMessageNoType.apply(Flux.just(inputMessageWithBaz)) + .blockFirst(); + assertThat(outputMessage).isInstanceOf(Message.class); + + functionProperties.setDefinition("withExceptionNativeEncodingEnabled"); + FunctionInvoker withException = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + + Flux> fluxOfMessages = Flux + .just(new GenericMessage<>(new ErrorBaz()), inputMessage); + Message resultMessage = withException.apply(fluxOfMessages).blockFirst(); + assertThat(resultMessage.getPayload()).isNotInstanceOf(ErrorFoo.class); + } + } + + @Test + public void testWithOutNativeEncodingEnabled() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(MyFunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false")) { + + Message inputMessage = new GenericMessage<>(new Baz()); + + StreamFunctionProperties functionProperties = createStreamFunctionProperties(); + + functionProperties.setDefinition("pojoToPojoNonEmptyPojo"); + FunctionInvoker pojoToPojoSameType = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + Message outputMessage = pojoToPojoSameType.apply(Flux.just(inputMessage)) + .blockFirst(); + assertThat(inputMessage.getPayload()) + .isNotEqualTo(outputMessage.getPayload()); + + } + } + + @Test + public void testWithFluxedConsumer() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(MyFunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false")) { + + String value = "Hello"; + Message inputMessage = new GenericMessage<>(value); + + StreamFunctionProperties functionProperties = createStreamFunctionProperties(); + + functionProperties.setDefinition("fluxConsumer"); + FunctionInvoker fluxedConsumer = new FunctionInvoker<>( + functionProperties, + context.getBean(FunctionCatalog.class), + context.getBean(FunctionInspector.class), + context.getBean(CompositeMessageConverterFactory.class)); + + fluxedConsumer.apply(Flux.just(inputMessage)).blockFirst(); + + assertThat(testWithFluxedConsumerValue).isEqualTo(value); + } + } + + private StreamFunctionProperties createStreamFunctionProperties() { + StreamFunctionProperties functionProperties = new StreamFunctionProperties(); + functionProperties.setInputDestinationName("input"); + functionProperties.setOutputDestinationName("output"); + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + bindingServiceProperties.getConsumerProperties("input").setMaxAttempts(3); + try { + Field f = ReflectionUtils.findField(StreamFunctionProperties.class, + "bindingServiceProperties"); + f.setAccessible(true); + f.set(functionProperties, bindingServiceProperties); + return functionProperties; + } + catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private StreamFunctionProperties createStreamFunctionPropertiesWithNativeEncoding() { + StreamFunctionProperties functionProperties = new StreamFunctionProperties(); + functionProperties.setInputDestinationName("input"); + functionProperties.setOutputDestinationName("output"); + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + bindingServiceProperties.getConsumerProperties("input").setMaxAttempts(3); + bindingServiceProperties.getProducerProperties("output") + .setUseNativeEncoding(true); + try { + Field bspField = ReflectionUtils.findField(StreamFunctionProperties.class, + "bindingServiceProperties"); + bspField.setAccessible(true); + bspField.set(functionProperties, bindingServiceProperties); + return functionProperties; + } + catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class SimpleEchoConfiguration { + + @Bean + public Function func() { + return x -> x; + } + + public static class Person { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + } + + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class SimpleFluxFunctionConfiguration { + + @Bean + public Function, Flux> func() { + return x -> x.map(person -> person.toString()); + } + + public static class Person { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String toString() { + return "Person: " + name; + } + + } + + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class SimpleFluxMessageFunctionConfiguration { + + @Bean + public Function>, Flux>> func() { + return x -> x.map(personMessage -> { + Person person = personMessage.getPayload(); + Message message = MessageBuilder.withPayload(person.toString()) + .copyHeaders(personMessage.getHeaders()).build(); + return message; + }); + } + + public static class Person { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String toString() { + return "Person: " + name; + } + + } + + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class ConverterDoesNotProduceCTConfiguration { + + @Bean + public Function func() { + return x -> x; + } + + @StreamMessageConverter + public MessageConverter customConverter() { + return new MessageConverter() { + + @Override + public Message toMessage(Object payload, MessageHeaders headers) { + return new GenericMessage(((String) payload).getBytes()); + } + + @Override + public Object fromMessage(Message message, Class targetClass) { + String contentType = (String) message.getHeaders() + .get(MessageHeaders.CONTENT_TYPE).toString(); + if (contentType.equals("foo/bar")) { + return new String((byte[]) message.getPayload()); + } + return null; + } + }; + } + + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class ConverterInjectingCTConfiguration { + + @Bean + public Function func() { + return x -> x; + } + + @StreamMessageConverter + public MessageConverter customConverter() { + return new MessageConverter() { + + @Override + public Message toMessage(Object payload, MessageHeaders headers) { + return MessageBuilder.withPayload(((String) payload).getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, "ping/pong").build(); + } + + @Override + public Object fromMessage(Message message, Class targetClass) { + String contentType = (String) message.getHeaders() + .get(MessageHeaders.CONTENT_TYPE).toString(); + if (contentType.equals("foo/bar")) { + return new String((byte[]) message.getPayload()); + } + return null; + } + }; + } + + } + + @EnableAutoConfiguration + public static class MyFunctionsConfiguration { + + @Bean + public Consumer> fluxConsumer() { + return f -> f.subscribe(v -> { + System.out.println("Consuming flux: " + v); + testWithFluxedConsumerValue = v; + }); + } + + @Bean + public Function, Message> messageToMessageDifferentType() { + return x -> MessageBuilder.withPayload(new Bar()).copyHeaders(x.getHeaders()) + .build(); + } + + @Bean + public Function, Message> messageToMessageAnyType() { + return x -> MessageBuilder.withPayload(new Bar()).copyHeaders(x.getHeaders()) + .build(); + } + + @Bean + public Function, Message> messageToMessageNoType() { + return x -> MessageBuilder.withPayload(new Bar()).copyHeaders(x.getHeaders()) + .build(); + } + + @Bean + public Function, Message> messageToMessageSameType() { + return x -> x; + } + + @Bean + public Function pojoToPojoSameType() { + return x -> x; + } + + @Bean + public Function pojoToPojoNonEmptyPojo() { + return x -> x; + } + + @Bean + public Function withException() { + return x -> { + if (x instanceof ErrorFoo) { + System.out.println("Throwing exception "); + throw new RuntimeException("Boom!"); + } + else { + System.out.println("All is good "); + return x; + } + }; + } + + @Bean + public Function withExceptionNativeEncodingEnabled() { + return x -> { + if (x instanceof ErrorBaz) { + System.out.println("Throwing exception "); + throw new RuntimeException("Boom!"); + } + else { + System.out.println("All is good "); + return x; + } + }; + } + + } + + private static class Foo { + + } + + private static class ErrorFoo extends Foo { + + } + + private static class Bar { + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/GreenfieldFunctionEnableBindingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/GreenfieldFunctionEnableBindingTests.java new file mode 100644 index 000000000..81758b2da --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/GreenfieldFunctionEnableBindingTests.java @@ -0,0 +1,267 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.converter.CompositeMessageConverterFactory; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.integration.dsl.IntegrationFlows; +import org.springframework.integration.http.dsl.Http; +import org.springframework.integration.http.dsl.HttpRequestHandlerEndpointSpec; +import org.springframework.integration.http.inbound.HttpRequestHandlingEndpointSupport; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.PollableChannel; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test validates proper function binding for applications where EnableBinding is + * declared. + * + * @author Oleg Zhurakousky + * @author Artem Bilan + */ +public class GreenfieldFunctionEnableBindingTests { + + @Test + public void testSourceFromSupplier() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(SourceFromSupplier.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=date", + "--spring.jmx.enabled=false")) { + + OutputDestination target = context.getBean(OutputDestination.class); + Message sourceMessage = target.receive(10000); + Date date = (Date) new CompositeMessageConverterFactory() + .getMessageConverterForAllRegistered() + .fromMessage(sourceMessage, Date.class); + assertThat(date).isEqualTo(new Date(12345L)); + + sourceMessage = target.receive(10000); + date = (Date) new CompositeMessageConverterFactory() + .getMessageConverterForAllRegistered() + .fromMessage(sourceMessage, Date.class); + assertThat(date).isEqualTo(new Date(12345L)); + } + } + + @Test + public void testProcessorFromFunction() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + ProcessorFromFunction.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=toUpperCase", + "--spring.jmx.enabled=false")) { + + InputDestination source = context.getBean(InputDestination.class); + source.send(new GenericMessage("John Doe".getBytes())); + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("JOHN DOE".getBytes(StandardCharsets.UTF_8)); + } + } + + @Test + public void testSinkFromConsumer() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(SinkFromConsumer.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=sink", + "--spring.jmx.enabled=false")) { + + InputDestination source = context.getBean(InputDestination.class); + PollableChannel result = context.getBean("result", PollableChannel.class); + source.send(new GenericMessage("John Doe".getBytes())); + assertThat(result.receive(10000).getPayload()).isEqualTo("John Doe"); + } + } + + @Test + public void testHttpEndpoint() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + HttpInboundEndpoint.class)).web(WebApplicationType.SERVLET).run( + "--spring.cloud.stream.function.definition=upperCase", + "--spring.jmx.enabled=false", "--server.port=0")) { + TestRestTemplate restTemplate = new TestRestTemplate(); + restTemplate.postForLocation( + "http://localhost:" + + context.getEnvironment().getProperty("local.server.port"), + "hello"); + OutputDestination target = context.getBean(OutputDestination.class); + String result = new String(target.receive(10000).getPayload()); + System.out.println(result); + assertThat(result).isEqualTo("HELLO"); + } + } + + @Test + public void testPojoReturn() throws IOException { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + FooTransform.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=fooFunction", + "--spring.jmx" + ".enabled=false", + "--logging.level.org.springframework.integration=TRACE")) { + MessageChannel input = context.getBean("input", MessageChannel.class); + OutputDestination target = context.getBean(OutputDestination.class); + + ObjectMapper mapper = context.getBean(ObjectMapper.class); + + input.send(MessageBuilder.withPayload("bar").build()); + byte[] payload = target.receive(2000).getPayload(); + + Foo result = mapper.readValue(payload, Foo.class); + + assertThat(result.getBar()).isEqualTo("bar"); + } + } + + @EnableAutoConfiguration + @EnableBinding(Source.class) + public static class SourceFromSupplier { + + @Bean + public Supplier date() { + return () -> new Date(12345L); + } + + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class ProcessorFromFunction { + + @Bean + public Function toUpperCase() { + return String::toUpperCase; + } + + } + + @EnableAutoConfiguration + @EnableBinding(Sink.class) + public static class SinkFromConsumer { + + @Bean + public PollableChannel result() { + return new QueueChannel(); + } + + @Bean + public Consumer sink(PollableChannel result) { + return s -> { + result.send(new GenericMessage(s)); + System.out.println(s); + }; + } + + } + + @EnableAutoConfiguration + @EnableBinding(Source.class) + public static class HttpInboundEndpoint { + + @Bean + public Function upperCase() { + return String::toUpperCase; + } + + @Bean + public HttpRequestHandlingEndpointSupport doFoo(Source source) { + HttpRequestHandlerEndpointSpec httpRequestHandler = Http + .inboundChannelAdapter("/*") + .requestMapping(requestMapping -> requestMapping + .methods(HttpMethod.POST).consumes("*/*")) + .requestChannel(source.output()); + return httpRequestHandler.get(); + } + + } + + @EnableAutoConfiguration + @EnableBinding(Source.class) + public static class FooTransform { + + @Bean + public MessageChannel input() { + return new DirectChannel(); + } + + @Bean + public IntegrationFlow flow() { + + return IntegrationFlows.from(input()).bridge().channel(Source.OUTPUT).get(); + } + + @Bean + public Function, Message> fooFunction() { + return m -> { + Foo foo = new Foo(); + foo.setBar(m.getPayload().toString()); + return MessageBuilder.withPayload(foo).setHeader("foo", "foo").build(); + }; + } + + } + + static class Foo { + + String bar; + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ImplicitFunctionBindingTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ImplicitFunctionBindingTests.java new file mode 100644 index 000000000..38508bd54 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ImplicitFunctionBindingTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2019-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * + * @author Oleg Zhurakousky + * + */ +public class ImplicitFunctionBindingTests { + + @Test + public void testBindingWithNoEnableBindingConfiguration() { + + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + NoEnableBindingConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("Hello".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()).isEqualTo("Hello".getBytes()); + + } + } + + @Test + public void testBindingWithEnableBindingConfiguration() { + + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + EnableBindingConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false", + "--spring.cloud.stream.function.definition=func")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("Hello".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()).isEqualTo("Hello".getBytes()); + + } + } + + @Test + public void testBindingWithNoEnableBindingAndNoDefinitionPropertyConfiguration() { + + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + SingleFunctionConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.jmx.enabled=false")) { + + InputDestination inputDestination = context.getBean(InputDestination.class); + OutputDestination outputDestination = context + .getBean(OutputDestination.class); + + Message inputMessage = MessageBuilder + .withPayload("Hello".getBytes()).build(); + inputDestination.send(inputMessage); + + Message outputMessage = outputDestination.receive(); + assertThat(outputMessage.getPayload()).isEqualTo("Hello".getBytes()); + + } + } + + @EnableAutoConfiguration + public static class NoEnableBindingConfiguration { + + @Bean + public Function func() { + return x -> { + System.out.println("Function"); + return x; + }; + } + + @Bean + public Consumer cons() { + return x -> { + System.out.println("Consumer"); + }; + } + } + + @EnableAutoConfiguration + @EnableBinding(Processor.class) + public static class EnableBindingConfiguration { + + @Bean + public Function func() { + return x -> { + System.out.println("Function"); + return x; + }; + } + + @Bean + public Consumer cons() { + return x -> { + System.out.println("Consumer"); + }; + } + } + + @EnableAutoConfiguration + public static class SingleFunctionConfiguration { + + @Bean + public Function func() { + return x -> { + System.out.println("Function"); + return x; + }; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ProcessorToFunctionsSupportTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ProcessorToFunctionsSupportTests.java new file mode 100644 index 000000000..68e23f0b8 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/ProcessorToFunctionsSupportTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2019-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.After; +import org.junit.Test; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Processor; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.integration.dsl.IntegrationFlows; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * @author David Turanski + * @since 2.1 + */ +public class ProcessorToFunctionsSupportTests { + + private ConfigurableApplicationContext context; + + @After + public void cleanUp() { + this.context.close(); + } + + @Test + public void testPathThrough() { + this.context = new SpringApplicationBuilder(TestChannelBinderConfiguration + .getCompleteConfiguration(FunctionsConfiguration.class)) + .web(WebApplicationType.NONE).run("--spring.jmx.enabled=false"); + InputDestination source = this.context.getBean(InputDestination.class); + OutputDestination target = this.context.getBean(OutputDestination.class); + source.send(new GenericMessage("hello".getBytes(StandardCharsets.UTF_8))); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testSingleFunction() { + this.context = new SpringApplicationBuilder(TestChannelBinderConfiguration + .getCompleteConfiguration(FunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=toUpperCase", + "--spring.jmx.enabled=false"); + + InputDestination source = this.context.getBean(InputDestination.class); + OutputDestination target = this.context.getBean(OutputDestination.class); + source.send(new GenericMessage("hello".getBytes(StandardCharsets.UTF_8))); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("HELLO".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testComposedFunction() { + this.context = new SpringApplicationBuilder(TestChannelBinderConfiguration + .getCompleteConfiguration(FunctionsConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=toUpperCase|concatWithSelf", + "--spring.jmx" + ".enabled=false", + "--logging.level.org.springframework.integration=DEBUG"); + + InputDestination source = this.context.getBean(InputDestination.class); + OutputDestination target = this.context.getBean(OutputDestination.class); + source.send(new GenericMessage("hello".getBytes(StandardCharsets.UTF_8))); + String result = new String(target.receive(1000).getPayload()); + System.out.println(result); + assertThat(result).isEqualTo("HELLO:HELLO"); + } + + @Test + public void testConsumer() { + this.context = new SpringApplicationBuilder(TestChannelBinderConfiguration + .getCompleteConfiguration(ConsumerConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=log", + "--spring.jmx.enabled=false"); + + InputDestination source = this.context.getBean(InputDestination.class); + OutputDestination target = this.context.getBean(OutputDestination.class); + source.send(new GenericMessage("hello".getBytes(StandardCharsets.UTF_8))); + source.send( + new GenericMessage("hello1".getBytes(StandardCharsets.UTF_8))); + source.send( + new GenericMessage("hello2".getBytes(StandardCharsets.UTF_8))); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("hello".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("hello1".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("hello2".getBytes(StandardCharsets.UTF_8)); + } + + @EnableAutoConfiguration + @Import(BaseProcessorConfiguration.class) + public static class FunctionsConfiguration { + + @Bean + public Function toUpperCase() { + return String::toUpperCase; + } + + @Bean + public Function concatWithSelf() { + return x -> x + ":" + x; + } + + } + + @EnableAutoConfiguration + @Import(BaseProcessorConfiguration.class) + public static class ConsumerConfiguration { + + @Bean + public Consumer log(OutputDestination out) { + return x -> { + DirectFieldAccessor dfa = new DirectFieldAccessor(out); + MessageChannel channel = (MessageChannel) dfa.getPropertyValue("channel"); + channel.send(new GenericMessage(x.getBytes())); + }; + } + + } + + /** + * This configuration essentially emulates our existing app-starters for Processor and + * essentially demonstrates how a function(s) could be applied to an existing + * processor app via {@link IntegrationFlowFunctionSupport} class. + */ + @EnableBinding(Processor.class) + public static class BaseProcessorConfiguration { + + @Autowired + private Processor processor; + + @Bean + public IntegrationFlow fromChannel() { + + return IntegrationFlows.from(this.processor.input()) + .channel(this.processor.output()).get(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/SourceToFunctionsSupportTests.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/SourceToFunctionsSupportTests.java new file mode 100644 index 000000000..b968e4a30 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/SourceToFunctionsSupportTests.java @@ -0,0 +1,317 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.publisher.Flux; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.messaging.Source; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.PollableChannel; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Oleg Zhurakousky + * @author David Turanski + * @since 2.1 + */ +public class SourceToFunctionsSupportTests { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Test + public void testFunctionIsAppliedToExistingMessageSource() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + FunctionsConfiguration.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=toUpperCase", + "--spring.jmx.enabled=false")) { + + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(1000).getPayload()) + .isEqualTo("HELLO FUNCTION".getBytes(StandardCharsets.UTF_8)); + } + } + + @Test + public void testComposedFunctionIsAppliedToExistingMessageSource() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + FunctionsConfiguration.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=toUpperCase|concatWithSelf", + "--spring.jmx.enabled=false")) { + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(1000).getPayload()).isEqualTo( + "HELLO FUNCTION:HELLO FUNCTION".getBytes(StandardCharsets.UTF_8)); + } + } + + @Test + public void testFailedInputTypeConversion() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + FunctionsConfigurationNoConversionPossible.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=toUpperCase|concatWithSelf", + "--spring.jmx.enabled=false")) { + PollableChannel errorChannel = context.getBean("errorChannel", + PollableChannel.class); + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(1000)).isNull(); + assertThat(errorChannel.receive(10000)).isNotNull(); + } + } + + @Test + public void testComposedFunctionIsAppliedToExistingMessageSourceFailedTypeConversion() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + FunctionsConfigurationNoConversionPossible.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=toUpperCase|concatWithSelf", + "--spring.jmx.enabled=false")) { + PollableChannel errorChannel = context.getBean("errorChannel", + PollableChannel.class); + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(1000)).isNull(); + assertThat(errorChannel.receive(10000)).isNotNull(); + } + } + + @Test + public void testMessageSourceIsCreatedFromProvidedSupplier() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(SupplierConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=number", + "--spring.jmx.enabled=false")) { + + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("1".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("2".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("3".getBytes(StandardCharsets.UTF_8)); + // etc + } + } + + @Test + public void testMessageSourceIsCreatedFromProvidedSupplierComposedWithSingleFunction() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + SupplierConfiguration.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=number|concatWithSelf", + "--spring.jmx.enabled=false")) { + + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("11".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("22".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("33".getBytes(StandardCharsets.UTF_8)); + // etc + } + } + + @Test + public void testMessageSourceIsCreatedFromProvidedSupplierComposedWithMultipleFunctions() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration( + SupplierConfiguration.class)).web(WebApplicationType.NONE).run( + "--spring.cloud.stream.function.definition=number|concatWithSelf|multiplyByTwo", + "--spring.jmx.enabled=false")) { + + OutputDestination target = context.getBean(OutputDestination.class); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("22".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("44".getBytes(StandardCharsets.UTF_8)); + assertThat(target.receive(10000).getPayload()) + .isEqualTo("66".getBytes(StandardCharsets.UTF_8)); + // etc + } + } + + @Test + public void testFunctionDoesNotExist() { + + this.expectedException.expect(BeanCreationException.class); + + new SpringApplicationBuilder(TestChannelBinderConfiguration + .getCompleteConfiguration(SupplierConfiguration.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=doesNotExist", + "--spring.jmx.enabled=false"); + } + + @EnableAutoConfiguration + @Import(ProvidedMessageSourceConfiguration.class) + public static class SupplierConfiguration { + + AtomicInteger counter = new AtomicInteger(); + + @Bean + public Supplier number() { + return () -> String.valueOf(this.counter.incrementAndGet()); + } + + @Bean + public Function concatWithSelf() { + return x -> x + x; + } + + @Bean + public Function, Flux> multiplyByTwo() { + return x -> x.map(i -> String.valueOf(Integer.valueOf(i) * 2)); + } + + } + + @EnableAutoConfiguration + @Import(ExistingMessageSourceConfiguration.class) + public static class FunctionsConfiguration { + + @Bean + public Function toUpperCase() { + return String::toUpperCase; + } + + @Bean + public Function concatWithSelf() { + return x -> x + ":" + x; + } + + } + + @EnableAutoConfiguration + @Import(ExistingMessageSourceConfigurationNoContentTypeSet.class) + public static class FunctionsConfigurationNoConversionPossible { + + @Bean + public PollableChannel errorChannel() { + return new QueueChannel(10); + } + + @Bean + public Function toUpperCase() { + return x -> true; + } + + @Bean + public Function concatWithSelf() { + return x -> 1; + } + + } + + /** + * This configuration essentially emulates our existing app-starters for Sources and + * essentially demonstrates how a function(s) could be applied to an existing source + * via {@link IntegrationFlowFunctionSupport} class. + */ + @EnableBinding(Source.class) + public static class ExistingMessageSourceConfiguration { + + @Autowired + private Source source; + + @Bean + public IntegrationFlow messageSourceFlow( + IntegrationFlowFunctionSupport functionSupport) { + Supplier> messageSource = () -> MessageBuilder + .withPayload("hello function") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN) + .build(); + + return functionSupport.integrationFlowFromProvidedSupplier(messageSource) + .channel(this.source.output()).get(); + } + + } + + @EnableBinding(Source.class) + public static class ExistingMessageSourceConfigurationNoContentTypeSet { + + @Autowired + private Source source; + + @Bean + public IntegrationFlow messageSourceFlow( + IntegrationFlowFunctionSupport functionSupport) { + Supplier> messageSource = () -> MessageBuilder + .withPayload("hello function") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/octet-stream") + .build(); + + return functionSupport.integrationFlowFromProvidedSupplier(messageSource) + .channel(this.source.output()).get(); + } + + } + + @EnableBinding(Source.class) + public static class ProvidedMessageSourceConfiguration { + + @Autowired + private Source source; + + @Autowired + private StreamFunctionProperties functionProperties; + + @Bean + public IntegrationFlow messageSourceFlow( + IntegrationFlowFunctionSupport functionSupport) { + Assert.hasText(this.functionProperties.getDefinition(), + "Supplier name must be provided"); + + return functionSupport.integrationFlowFromNamedSupplier() + .channel(this.source.output()).get(); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/Baz.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/Baz.java new file mode 100644 index 000000000..772941f5c --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/Baz.java @@ -0,0 +1,36 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function.pojo; + +/** + * Serializable pojo with object mapper. + * + * @author Tolga Kavukcu + */ +public class Baz { + + private String baz = "baz"; + + public String getBaz() { + return this.baz; + } + + public void setBaz(String baz) { + this.baz = baz; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/ErrorBaz.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/ErrorBaz.java new file mode 100644 index 000000000..cb77e16fc --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/function/pojo/ErrorBaz.java @@ -0,0 +1,36 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.function.pojo; + +/** + * Serializable pojo with object mapper. + * + * @author Tolga Kavukcu + */ +public class ErrorBaz extends Baz { + + private String baz = "bazError"; + + public String getBaz() { + return this.baz; + } + + public void setBaz(String baz) { + this.baz = baz; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/interceptor/BoundChannelsInterceptedTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/interceptor/BoundChannelsInterceptedTest.java new file mode 100644 index 000000000..28cd96c20 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/interceptor/BoundChannelsInterceptedTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.interceptor; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.config.GlobalChannelInterceptor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.util.MimeTypeUtils; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Verifies that interceptors used by modules are applied correctly to generated channels. + * + * @author Marius Bogoevici + * @author Oleg Zhurakousky + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = BoundChannelsInterceptedTest.Foo.class, properties = "spring.cloud.stream.default-binder=mock") +public class BoundChannelsInterceptedTest { + + // @checkstyle:on + + public static final Message TEST_MESSAGE = MessageBuilder.withPayload("bar") + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON) + .build(); + + @Autowired + ChannelInterceptor channelInterceptor; + + @Autowired + private Sink sink; + + @Test + public void testBoundChannelsIntercepted() { + this.sink.input().send(TEST_MESSAGE); + verify(this.channelInterceptor).preSend(Mockito.any(), + Mockito.eq(this.sink.input())); + verifyNoMoreInteractions(this.channelInterceptor); + } + + @SpringBootApplication + @EnableBinding(Sink.class) + public static class Foo { + + @ServiceActivator(inputChannel = Sink.INPUT) + public void fooSink(Message message) { + } + + @Bean + @GlobalChannelInterceptor + public ChannelInterceptor globalChannelInterceptor() { + return mock(ChannelInterceptor.class); + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionKeyExtractorClass.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionKeyExtractorClass.java new file mode 100644 index 000000000..fcfcf26f9 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionKeyExtractorClass.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.partitioning; + +import org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy; +import org.springframework.messaging.Message; + +/** + * @author Ilayaperumal Gopinathan + */ +public class CustomPartitionKeyExtractorClass implements PartitionKeyExtractorStrategy { + + @Override + public String extractKey(Message message) { + return (String) message.getHeaders().get("key"); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionSelectorClass.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionSelectorClass.java new file mode 100644 index 000000000..db5447b8a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/CustomPartitionSelectorClass.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.partitioning; + +import org.springframework.cloud.stream.binder.PartitionSelectorStrategy; + +/** + * @author Ilayaperumal Gopinathan + */ +public class CustomPartitionSelectorClass implements PartitionSelectorStrategy { + + @Override + public int selectPartition(Object key, int partitionCount) { + return Integer.valueOf((String) key); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/PartitionedConsumerTest.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/PartitionedConsumerTest.java new file mode 100644 index 000000000..ad0032093 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/partitioning/PartitionedConsumerTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2015-2017 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.partitioning; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatcher; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.BinderFactory; +import org.springframework.cloud.stream.binder.ConsumerProperties; +import org.springframework.cloud.stream.config.BinderFactoryConfiguration; +import org.springframework.cloud.stream.messaging.Sink; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.PropertySource; +import org.springframework.messaging.MessageChannel; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * @author Marius Bogoevici + * @author Ilayaperumal Gopinathan + * @author Janne Valkealahti + */ +@RunWith(SpringJUnit4ClassRunner.class) +// @checkstyle:off +@SpringBootTest(classes = PartitionedConsumerTest.TestSink.class, properties = "spring.cloud.stream.default-binder=mock") +public class PartitionedConsumerTest { + + // @checkstyle:on + @Autowired + private BinderFactory binderFactory; + + @Autowired + private Sink testSink; + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void testBindingPartitionedConsumer() { + Binder binder = this.binderFactory.getBinder(null, MessageChannel.class); + ArgumentCaptor argumentCaptor = ArgumentCaptor + .forClass(ConsumerProperties.class); + verify(binder).bindConsumer(eq("partIn"), isNull(), eq(this.testSink.input()), + argumentCaptor.capture()); + assertThat(argumentCaptor.getValue().getInstanceIndex()).isEqualTo(0); + assertThat(argumentCaptor.getValue().getInstanceCount()).isEqualTo(2); + verifyNoMoreInteractions(binder); + } + + @EnableBinding(Sink.class) + @EnableAutoConfiguration + @Import({ BinderFactoryConfiguration.class }) + @PropertySource("classpath:/org/springframework/cloud/stream/binder/partitioned-consumer-test.properties") + public static class TestSink { + + } + + class PropertiesArgumentMatcher implements ArgumentMatcher { + + @Override + public boolean matches(ConsumerProperties argument) { + return argument instanceof ConsumerProperties; + } + + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooBindingProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooBindingProperties.java new file mode 100644 index 000000000..146a11cd0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooBindingProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +import org.springframework.cloud.stream.binder.BinderSpecificPropertiesProvider; + +/** + * @author Soby Chacko + */ +public class FooBindingProperties implements BinderSpecificPropertiesProvider { + + private FooProducerProperties producer = new FooProducerProperties(); + + private FooConsumerProperties consumer = new FooConsumerProperties(); + + public FooProducerProperties getProducer() { + return this.producer; + } + + public void setProducer(FooProducerProperties producer) { + this.producer = producer; + } + + public FooConsumerProperties getConsumer() { + return this.consumer; + } + + public void setConsumer(FooConsumerProperties consumer) { + this.consumer = consumer; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooConsumerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooConsumerProperties.java new file mode 100644 index 000000000..f1419d37e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooConsumerProperties.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +/** + * @author Soby Chacko + */ +public class FooConsumerProperties { + + String extendedProperty; + + public String getExtendedProperty() { + return this.extendedProperty; + } + + public void setExtendedProperty(String extendedProperty) { + this.extendedProperty = extendedProperty; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooExtendedBindingProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooExtendedBindingProperties.java new file mode 100644 index 000000000..0b8c81219 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooExtendedBindingProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.stream.binder.AbstractExtendedBindingProperties; +import org.springframework.cloud.stream.binder.BinderSpecificPropertiesProvider; + +/** + * @author Soby Chacko + */ +@ConfigurationProperties("spring.cloud.stream.foo") +public class FooExtendedBindingProperties extends + AbstractExtendedBindingProperties { + + private static final String DEFAULTS_PREFIX = "spring.cloud.stream.foo.default"; + + @Override + public String getDefaultsPrefix() { + return DEFAULTS_PREFIX; + } + + public Map getBindings() { + return this.doGetBindings(); + } + + @Override + public Class getExtendedPropertiesEntryClass() { + return FooBindingProperties.class; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooProducerProperties.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooProducerProperties.java new file mode 100644 index 000000000..b69351bc3 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/FooProducerProperties.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +/** + * @author Soby Chacko + */ +public class FooProducerProperties { + + String extendedProperty; + + public String getExtendedProperty() { + return this.extendedProperty; + } + + public void setExtendedProperty(String extendedProperty) { + this.extendedProperty = extendedProperty; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockBinderConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockBinderConfiguration.java new file mode 100644 index 000000000..b642052d4 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockBinderConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +import org.mockito.Mockito; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Marius Bogoevici + */ +@Configuration +public class MockBinderConfiguration { + + @Bean + public Binder binder() { + return Mockito.mock(Binder.class, + Mockito.withSettings().defaultAnswer(Mockito.RETURNS_MOCKS)); + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockExtendedBinderConfiguration.java b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockExtendedBinderConfiguration.java new file mode 100644 index 000000000..5d73322b7 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/utils/MockExtendedBinderConfiguration.java @@ -0,0 +1,77 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * 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 + * + * https://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 org.springframework.cloud.stream.utils; + +import java.util.HashMap; +import java.util.Map; + +import org.mockito.Mockito; + +import org.springframework.cloud.stream.binder.Binder; +import org.springframework.cloud.stream.binder.ExtendedPropertiesBinder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import static org.mockito.Mockito.when; + +/** + * @author Soby Chacko + */ +@Configuration +public class MockExtendedBinderConfiguration { + + @SuppressWarnings("rawtypes") + @Bean + public Binder extendedPropertiesBinder() { + Binder mock = Mockito.mock(Binder.class, + Mockito.withSettings().defaultAnswer(Mockito.RETURNS_MOCKS) + .extraInterfaces(ExtendedPropertiesBinder.class)); + ConfigurableEnvironment environment = new StandardEnvironment(); + Map propertiesToAdd = new HashMap<>(); + propertiesToAdd.put("spring.cloud.stream.foo.default.consumer.extendedProperty", + "someFancyExtension"); + propertiesToAdd.put("spring.cloud.stream.foo.default.producer.extendedProperty", + "someFancyExtension"); + environment.getPropertySources() + .addLast(new MapPropertySource("extPropertiesConfig", propertiesToAdd)); + + ConfigurableApplicationContext applicationContext = new GenericApplicationContext(); + applicationContext.setEnvironment(environment); + + FooExtendedBindingProperties fooExtendedBindingProperties = new FooExtendedBindingProperties(); + fooExtendedBindingProperties.setApplicationContext(applicationContext); + + final FooConsumerProperties fooConsumerProperties = fooExtendedBindingProperties + .getExtendedConsumerProperties("input"); + final FooProducerProperties fooProducerProperties = fooExtendedBindingProperties + .getExtendedProducerProperties("output"); + + when(((ExtendedPropertiesBinder) mock).getExtendedConsumerProperties("input")) + .thenReturn(fooConsumerProperties); + + when(((ExtendedPropertiesBinder) mock).getExtendedProducerProperties("output")) + .thenReturn(fooProducerProperties); + + return mock; + } + +} diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/META-INF/spring.binders b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/META-INF/spring.binders new file mode 100644 index 000000000..84dc342e2 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/META-INF/spring.binders @@ -0,0 +1,4 @@ +integration:\ +org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration +mock:\ +org.springframework.cloud.stream.utils.MockBinderConfiguration diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder1/META-INF/spring.binders b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder1/META-INF/spring.binders new file mode 100644 index 000000000..1d4a3007d --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder1/META-INF/spring.binders @@ -0,0 +1 @@ +binder1=org.springframework.cloud.stream.binder.stub1.StubBinder1Configuration diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder2/META-INF/spring.binders b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder2/META-INF/spring.binders new file mode 100644 index 000000000..26dc2bc5e --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/binder2/META-INF/spring.binders @@ -0,0 +1,3 @@ +binder2:\ +org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationA,\ +org.springframework.cloud.stream.binder.stub2.StubBinder2ConfigurationB diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/arbitrary-binding-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/arbitrary-binding-test.properties new file mode 100644 index 000000000..3f7db81c5 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/arbitrary-binding-test.properties @@ -0,0 +1,4 @@ +spring.cloud.stream.bindings.foo.destination=someQueue.0 +spring.cloud.stream.bindings.bar.destination=someQueue.1 +spring.cloud.stream.bindings.baz.destination=someQueue.2 +spring.cloud.stream.bindings.qux.destination=someQueue.3 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/cloud-function-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/cloud-function-test.properties new file mode 100644 index 000000000..9c3e4cf55 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/cloud-function-test.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.function.producerProperties.useNativeEncoding:true +spring.cloud.stream.function.consumerProperties.maxAttempts:5 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/custom-partitioned-producer-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/custom-partitioned-producer-test.properties new file mode 100644 index 000000000..a96bf5076 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/custom-partitioned-producer-test.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.output.destination=partOut +spring.cloud.stream.bindings.output.producer.partitionCount=3 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-consumer-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-consumer-test.properties new file mode 100644 index 000000000..3c200ee1a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-consumer-test.properties @@ -0,0 +1,5 @@ +spring.cloud.stream.bindings.input.destination=partIn +spring.cloud.stream.bindings.input.consumer.partitioned=true +spring.cloud.stream.instanceCount=2 +spring.cloud.stream.instanceIndex=0 + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-producer-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-producer-test.properties new file mode 100644 index 000000000..aee346650 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/partitioned-producer-test.properties @@ -0,0 +1,4 @@ +spring.cloud.stream.bindings.output.destination=partOut +spring.cloud.stream.bindings.output.producer.partitionKeyExpression=payload +spring.cloud.stream.bindings.output.producer.partitionCount=3 + diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/processor-binding-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/processor-binding-test.properties new file mode 100644 index 000000000..06b42431a --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/processor-binding-test.properties @@ -0,0 +1,2 @@ +spring.cloud.stream.bindings.input.destination=testtock.0 +spring.cloud.stream.bindings.output.destination=testtock.1 diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/sink-binding-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/sink-binding-test.properties new file mode 100644 index 000000000..9d96ac395 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/sink-binding-test.properties @@ -0,0 +1 @@ +spring.cloud.stream.bindings.input.destination=testtock diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/source-binding-test.properties b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/source-binding-test.properties new file mode 100644 index 000000000..a9a6733d0 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/spring-cloud-stream/src/test/resources/org/springframework/cloud/stream/binder/source-binding-test.properties @@ -0,0 +1 @@ +spring.cloud.stream.bindings.output.destination=testtock diff --git a/docs/src/test/bats/fixtures/spring-cloud-stream/update-version.sh b/docs/src/test/bats/fixtures/spring-cloud-stream/update-version.sh new file mode 100755 index 000000000..f004f0682 --- /dev/null +++ b/docs/src/test/bats/fixtures/spring-cloud-stream/update-version.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +#Execute this script from local checkout of spring cloud stream + +./mvnw versions:update-parent -DparentVersion=[0.0.1,$2] -Pspring -DgenerateBackupPoms=false -DallowSnapshots=true +./mvnw versions:set -DnewVersion=$1 -DgenerateBackupPoms=false +./mvnw versions:set -DnewVersion=$1 -DgenerateBackupPoms=false -pl :spring-cloud-stream-tools + + +lines=$(find . -name 'pom.xml' | xargs egrep "SNAPSHOT|M[0-9]|RC[0-9]" | grep -v regex | wc -l) +if [ $lines -eq 0 ]; then + echo "No snapshots found" +else + echo "Snapshots found." +fi + +lines=$(find . -name 'pom.xml' | xargs egrep "M[0-9]" | grep -v regex | wc -l) +if [ $lines -eq 0 ]; then + echo "No milestones found" +else + echo "Milestones found." +fi + +lines=$(find . -name 'pom.xml' | xargs egrep "RC[0-9]" | grep -v regex | wc -l) +if [ $lines -eq 0 ]; then + echo "No release candidates found" +else + echo "Release candidates found." +fi diff --git a/docs/src/test/bats/ghpages.bats b/docs/src/test/bats/ghpages.bats new file mode 100644 index 000000000..30dd26b7b --- /dev/null +++ b/docs/src/test/bats/ghpages.bats @@ -0,0 +1,606 @@ +#!/usr/bin/env bats + +load 'test_helper' +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +setup() { + export TEMP_DIR="$( mktemp -d )" + + cp -a "${SOURCE_DIR}" "${TEMP_DIR}/sc-build" + + cp -a "${FIXTURES_DIR}/spring-cloud-stream" "${TEMP_DIR}/" + mv "${TEMP_DIR}/spring-cloud-stream/git" "${TEMP_DIR}/spring-cloud-stream/.git" + cp -a "${FIXTURES_DIR}/spring-cloud-static" "${TEMP_DIR}/" + mv "${TEMP_DIR}/spring-cloud-static/git" "${TEMP_DIR}/spring-cloud-static/.git" + + export SOURCE_FUNCTIONS="true" +} + +teardown() { + rm -rf "${TEMP_DIR}" +} + +function fake_git { + if [[ "$*" == *"push"* ]]; then + echo "pushing the project" + elif [[ "$*" == *"pull"* ]]; then + echo "pulling the project" + fi + git $* +} + +function git_with_remotes { + if [[ "$*" == *"set-url"* ]]; then + echo "git $*" + elif [[ "$*" == *"config remote.origin.url"* ]]; then + echo "git://foo.bar/baz.git" + else + git $* + fi +} + +function printing_git { + echo "git $*" +} + +function printing_git_failing_with_diff_index { + if [[ "$*" == *"diff-index"* ]]; then + return 1 + else + echo "git $*" + fi +} + +function printing_git_with_remotes { + if [[ "$*" == *"config remote.origin.url"* ]]; then + echo "git://foo.bar/baz.git" + else + printing_git $* + fi +} + +function stubbed_git { + if [[ "$*" == *"config remote.origin.url"* ]]; then + echo "git://foo.bar/baz.git" + elif [[ "$*" == *"diff-index"* ]]; then + return 1 + elif [[ "$*" == *"symbolic-ref"* ]]; then + echo "master" + elif [[ "$*" == *"remote -v"* ]]; then + git $* + else + printing_git $* + fi +} + +export -f fake_git +export -f git_with_remotes +export -f printing_git +export -f printing_git_with_remotes +export -f stubbed_git + +@test "should upload the built docs to the root of gh-pages for snapshot versions" { + export GIT_BIN="stubbed_git" + export SOURCE_FUNCTIONS="" + export RELEASER_GIT_OAUTH_TOKEN="mytoken" + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + run "${SOURCE_DIR}"/ghpages.sh + + assert_success + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git fetch -q" + assert_output --partial "git checkout master" + assert_output --partial "git stash" + assert_output --partial "git checkout gh-pages" + assert_output --partial "git pull origin gh-pages" + # Current branch is master - will copy the current docs only to the root folder + assert_output --partial "git add -A ${TEMP_DIR}/spring-cloud-stream" + assert_output --partial "git commit -a -m Sync docs from master to gh-pages" + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git push origin gh-pages" + assert_output --partial "git checkout master" + assert_output --partial "git stash pop" +} + +@test "should upload the built docs to spring-cloud-static gh-pages branch for non-snapshot versions" { + export GIT_BIN="stubbed_git" + export SOURCE_FUNCTIONS="" + export RELEASER_GIT_OAUTH_TOKEN="mytoken" + export VERSION="1.0.0.RELEASE" + export DESTINATION="${TEMP_DIR}/spring-cloud-static" + + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + run "${SOURCE_DIR}"/ghpages.sh + + assert_success + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git remote set-branches --add origin gh-pages" + assert_output --partial "git fetch -q" + # Previous branch was [master] + assert_output --partial "git checkout master" + assert_output --partial "git checkout v1.0.0.RELEASE" + assert_output --partial "Extracted 'main.adoc' from Maven build [home]" + assert_output --partial "git stash" + assert_output --partial "git checkout gh-pages" + assert_output --partial "git pull origin gh-pages" + # Current branch is master - will copy the current docs only to the root folder + assert [ -f "${TEMP_DIR}/spring-cloud-static/spring-cloud-stream/${VERSION}/foo.html" ] + assert_output --partial "git add -A ${TEMP_DIR}/spring-cloud-static/spring-cloud-stream/${VERSION}" + assert_output --partial "git commit -a -m Sync docs from v1.0.0.RELEASE to gh-pages" + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git push origin gh-pages" + assert_output --partial "git checkout master" + assert_output --partial "git stash pop" +} + +@test "should upload the release train docs to spring-cloud-static under the release train folder" { + export GIT_BIN="stubbed_git" + export SOURCE_FUNCTIONS="" + export RELEASER_GIT_OAUTH_TOKEN="mytoken" + export VERSION="Greenwich.SR2" + export DESTINATION="${TEMP_DIR}/spring-cloud-static" + export RELEASE_TRAIN="yes" + + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + run "${SOURCE_DIR}"/ghpages.sh + + assert_success + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git remote set-branches --add origin gh-pages" + assert_output --partial "git fetch -q" + # Previous branch was [master] + assert_output --partial "git checkout master" + assert_output --partial "Extracted 'main.adoc' from Maven build [home]" + assert_output --partial "git stash" + assert_output --partial "git checkout gh-pages" + assert_output --partial "git pull origin gh-pages" + # Current branch is master - will copy the current docs only to the root folder + assert [ -f "${TEMP_DIR}/spring-cloud-static/${VERSION}/foo.html" ] + assert_output --partial "git add -A ${TEMP_DIR}/spring-cloud-static/${VERSION}" + assert_output --partial "git commit -a -m Sync docs from vGreenwich.SR2 to gh-pages" + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git push origin gh-pages" + assert_output --partial "git checkout master" + assert_output --partial "git stash pop" +} + +@test "should set all the env vars" { + export SPRING_CLOUD_STATIC_REPO="${TEMP_DIR}/spring-cloud-static" + + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + set_default_props + + assert_success + assert [ "${ROOT_FOLDER}" != "" ] + assert [ "${MAVEN_EXEC}" != "" ] + assert [ "${REPO_NAME}" != "" ] + assert [ "${SPRING_CLOUD_STATIC_REPO}" != "" ] +} + +@test "should not add auth token to URL if token not present" { + export GIT_BIN="git_with_remotes" + + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run add_oauth_token_to_remote_url + + assert_success + assert_output --partial "git remote set-url --push origin https://foo.bar/baz.git" +} + +@test "should add auth token to URL if token is present" { + export GIT_BIN="git_with_remotes" + export RELEASER_GIT_OAUTH_TOKEN="mytoken" + + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run add_oauth_token_to_remote_url + + assert_success + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" +} + +@test "should retrieve the name of the current branch" { + export GIT_BIN="printing_git" + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run retrieve_current_branch + + assert_success + assert_output --partial "git checkout git symbolic-ref -q HEAD" +} + +@test "should retrieve the name of the current branch when previous branch was set" { + export GIT_BIN="printing_git" + export BRANCH="gh-pages" + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run retrieve_current_branch + + assert_success + assert_output --partial "Current branch is [gh-pages]" + refute_output --partial "git checkout git symbolic-ref -q HEAD" + assert_output --partial "git checkout gh-pages" + assert_output --partial "Previous branch was [gh-pages]" +} + +@test "should not switch to tag for release train" { + export GIT_BIN="printing_git" + export RELEASE_TRAIN="yes" + + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run switch_to_tag + + assert_success + refute_output --partial "git checkout" +} + +@test "should switch to tag for release train" { + export GIT_BIN="printing_git" + export RELEASE_TRAIN="no" + export VERSION="1.0.0" + + cd "${TEMP_DIR}/spring-cloud-stream/" + source "${SOURCE_DIR}"/ghpages.sh + + run switch_to_tag + + assert_success + assert_output --partial "git checkout v1.0.0" +} + +@test "should not build docs when build option is disabled" { + export BUILD="no" + + cd "${TEMP_DIR}/spring-cloud-stream/" + echo -e '#!/bin/sh\necho $*' > mvnw + source "${SOURCE_DIR}"/ghpages.sh + + run build_docs_if_applicable + + assert_success + refute_output --partial "clean install" +} + +@test "should build docs when build option is enabled" { + export BUILD="yes" + + cd "${TEMP_DIR}/spring-cloud-stream/" + echo -e '#!/bin/sh\necho $*' > mvnw + + source "${SOURCE_DIR}"/ghpages.sh + + run build_docs_if_applicable + + assert_success + assert_output --partial "clean install" +} + +@test "should retrieve maven properties for docs" { + export WHITELIST_PROPERTY="spring-doc-resources.version" + export MAVEN_EXEC="./mvnw" + + cd "${TEMP_DIR}/spring-cloud-stream/" + + source "${SOURCE_DIR}"/ghpages.sh + + retrieve_doc_properties + + assert_success + assert [ "${MAIN_ADOC_VALUE}" == "home" ] + assert [ "${WHITELISTED_BRANCHES_VALUE}" == "0.1.1.RELEASE" ] +} + +@test "should stash changes if dirty" { + export GIT_BIN="printing_git_failing_with_diff_index" + cd "${TEMP_DIR}/spring-cloud-stream/" + + source "${SOURCE_DIR}"/ghpages.sh + + run stash_changes + + assert_success + assert_output --partial "git stash" +} + +@test "should not stash changes if repo is not dirty" { + export GIT_BIN="printing_git" + cd "${TEMP_DIR}/spring-cloud-static/" + + source "${SOURCE_DIR}"/ghpages.sh + + run stash_changes + + assert_success + refute_output --partial "git stash" +} + +@test "should add and commit all non ignored files for master branch" { + export GIT_BIN="printing_git" + export CURRENT_BRANCH="master" + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + source "${SOURCE_DIR}"/ghpages.sh + + copy_docs_for_current_version + + assert_success + assert [ "${COMMIT_CHANGES}" == "yes" ] +} + +@test "should add and commit all non ignored files for a custom branch and convert root file to index.html" { + export GIT_BIN="printing_git" + export CURRENT_BRANCH="present" + export WHITELISTED_BRANCHES_VALUE="present" + export MAIN_ADOC_VALUE="my_doc" + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + source "${SOURCE_DIR}"/ghpages.sh + + copy_docs_for_current_version + + assert_success + assert [ "${COMMIT_CHANGES}" == "yes" ] + + run copy_docs_for_current_version + + assert_success + assert_output --partial "add -A ${ROOT_FOLDER}/present/index.html" + assert_output --partial "add -A ${ROOT_FOLDER}/present/foo.html" +} + +@test "should do nothing if current branch is not whitelisted" { + export CURRENT_BRANCH="custom" + export WHITELISTED_BRANCHES_VALUE="non_present" + cd "${TEMP_DIR}/spring-cloud-stream/" + + source "${SOURCE_DIR}"/ghpages.sh + + copy_docs_for_current_version + + assert_success + assert [ "${COMMIT_CHANGES}" != "yes" ] +} + +@test "should reuse main adoc value as new index.html" { + export GIT_BIN="printing_git" + export DESTINATION_REPO_FOLDER="${TEMP_DIR}/spring-cloud-static" + export VERSION="1.0.0.RELEASE" + export MAIN_ADOC_VALUE="my_doc" + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${MAIN_ADOC_VALUE}.html + touch docs/target/generated-docs/foo.html + + source "${SOURCE_DIR}"/ghpages.sh + + copy_docs_for_provided_version + + assert_success + assert [ "${COMMIT_CHANGES}" == "yes" ] + assert [ "${CURRENT_BRANCH}" == "v${VERSION}" ] + + run copy_docs_for_provided_version + + assert_success + assert_output --partial "add -A ${DESTINATION_REPO_FOLDER}/${VERSION}" + assert [ -f "${DESTINATION_REPO_FOLDER}/${VERSION}/index.html" ] + assert [ -f "${DESTINATION_REPO_FOLDER}/${VERSION}/foo.html" ] +} + +@test "should reuse repo name as new index.html" { + export GIT_BIN="printing_git" + export DESTINATION_REPO_FOLDER="${TEMP_DIR}/spring-cloud-static" + export VERSION="1.0.0.RELEASE" + export REPO_NAME="spring-cloud-stream" + cd "${TEMP_DIR}/spring-cloud-stream/" + mkdir -p docs/target/generated-docs/ + touch docs/target/generated-docs/${REPO_NAME}.html + touch docs/target/generated-docs/foo.html + + source "${SOURCE_DIR}"/ghpages.sh + + copy_docs_for_provided_version + + assert_success + assert [ "${COMMIT_CHANGES}" == "yes" ] + assert [ "${CURRENT_BRANCH}" == "v${VERSION}" ] + + run copy_docs_for_provided_version + + assert_success + assert_output --partial "add -A ${DESTINATION_REPO_FOLDER}/${VERSION}" + assert [ -f "${DESTINATION_REPO_FOLDER}/${VERSION}/index.html" ] + assert [ -f "${DESTINATION_REPO_FOLDER}/${VERSION}/foo.html" ] +} + +@test "should not do anything if commit flag not set" { + export GIT_BIN="printing_git_with_remotes" + export COMMIT_CHANGES="no" + + source "${SOURCE_DIR}"/ghpages.sh + + run commit_changes_if_applicable + + assert_success + refute_output --partial "git" +} + +@test "should commit changes if commit flag set" { + export GIT_BIN="printing_git_with_remotes" + export COMMIT_CHANGES="yes" + export RELEASER_GIT_OAUTH_TOKEN="mytoken" + + source "${SOURCE_DIR}"/ghpages.sh + + run commit_changes_if_applicable + + assert_success + assert_output --partial "git commit -a -m Sync docs from to gh-pages" + assert_output --partial "git remote set-url --push origin https://mytoken@foo.bar/baz.git" + assert_output --partial "git push origin gh-pages" +} + +@test "should checkout previous branch and pop changes from stash when dirty" { + export GIT_BIN="printing_git" + export PREVIOUS_BRANCH="previous_branch" + export dirty="1" + + source "${SOURCE_DIR}"/ghpages.sh + + run checkout_previous_branch + + assert_success + assert_output --partial "git checkout previous_branch" + assert_output --partial "git stash pop" +} + +@test "should checkout previous branch and not pop changes from stash when not dirty" { + export GIT_BIN="printing_git" + export PREVIOUS_BRANCH="previous_branch" + export dirty="0" + + source "${SOURCE_DIR}"/ghpages.sh + + run checkout_previous_branch + + assert_success + assert_output --partial "git checkout previous_branch" + refute_output --partial "git stash pop" +} + +@test "should fail when version was set but destination / clone was not" { + export VERSION="1.0.0.RELEASE" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_failure +} + +@test "should pass when version was set and destination was too" { + export VERSION="1.0.0.RELEASE" + export DESTINATION="/tmp/foo" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_success +} + +@test "should pass when version was set and clone was too" { + export VERSION="1.0.0.RELEASE" + export CLONE="true" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_success +} + +@test "should fail when destination and clone were set but version was not" { + export DESTINATION="/tmp/foo" + export CLONE="true" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_failure +} + +@test "should pass when destination and clone and version were set" { + export DESTINATION="/tmp/foo" + export CLONE="true" + export VERSION="1.0.0.RELEASE" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_success +} + +@test "should fail when clone was set to true and destination is defined" { + export CLONE="true" + export DESTINATION="/tmp/foo" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_failure +} + +@test "should pass when clone was set to true and destination is not defined" { + export DESTINATION="" + export CLONE="true" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_success +} + +@test "should fail when release train was set but no version was passed" { + export VERSION="" + export RELEASE_TRAIN="true" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_failure +} + +@test "should pass when release train was set and version and clone was passed" { + export VERSION="Greenwich.SR1" + export CLONE="true" + export RELEASE_TRAIN="true" + + source "${SOURCE_DIR}"/ghpages.sh + + run assert_properties + + assert_success +} + +@test "should print the usage" { + source "${SOURCE_DIR}"/ghpages.sh + + run print_usage + + assert_success + assert_output --partial "The idea of this script" +} \ No newline at end of file diff --git a/docs/src/test/bats/test_helper.bash b/docs/src/test/bats/test_helper.bash new file mode 100644 index 000000000..e9761675d --- /dev/null +++ b/docs/src/test/bats/test_helper.bash @@ -0,0 +1,6 @@ +#!/bin/bash + +FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures" +SOURCE_DIR="${BATS_TEST_DIRNAME}/../../main/asciidoc" + +export FIXTURES_DIR SOURCE_DIR diff --git a/docs/src/test/bats/test_helper/bats-assert b/docs/src/test/bats/test_helper/bats-assert new file mode 160000 index 000000000..9f88b4207 --- /dev/null +++ b/docs/src/test/bats/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 9f88b4207da750093baabc4e3f41bf68f0dd3630 diff --git a/docs/src/test/bats/test_helper/bats-support b/docs/src/test/bats/test_helper/bats-support new file mode 160000 index 000000000..004e70763 --- /dev/null +++ b/docs/src/test/bats/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 004e707638eedd62e0481e8cdc9223ad471f12ee