diff --git a/.github/scripts/automerge.py b/.github/scripts/automerge.py index d8c957575..b3428aee8 100644 --- a/.github/scripts/automerge.py +++ b/.github/scripts/automerge.py @@ -18,14 +18,13 @@ from github import Github g = Github(os.getenv("GITHUB_TOKEN")) repo = g.get_repo(os.getenv("GITHUB_REPOSITORY")) -maintainers = [m.strip() for m in os.getenv("MAINTAINERS").split(',')] def fetch_pulls(mergeable_state): return [pr for pr in repo.get_pulls(state='open', sort='created') \ if pr.mergeable_state == mergeable_state and 'auto-merge' in [l.name for l in pr.labels]] def is_approved(pr): - approvers = [r.user.login for r in pr.get_reviews() if r.state == 'APPROVED' and r.user.login in maintainers] + approvers = [r.user.login for r in pr.get_reviews() if r.state == 'APPROVED'] return len([a for a in approvers if repo.get_collaborator_permission(a) in ['admin', 'write']]) > 0 # First, find a PR that can be merged diff --git a/.github/scripts/create-release.sh b/.github/scripts/create-release.sh index ead98fc40..724c81afd 100755 --- a/.github/scripts/create-release.sh +++ b/.github/scripts/create-release.sh @@ -38,17 +38,13 @@ if [ "$VARIANT" = "SNAPSHOT" ]; then echo "Invalid snapshot version: $REL_VERSION" exit 3 fi - branch_name="automation/update_to_next_${current_time}" - git checkout -b $branch_name + + # Change is done directly in the master branch. ${script_dir}/update_sdk_version.sh $REL_VERSION - git clean -xdf git commit -s -m "Update master version to ${REL_VERSION}" -a - git push origin $branch_name - gh pr create --repo ${GITHUB_REPOSITORY} \ - --base master \ - --title "Update master version to ${REL_VERSION}" \ - --body "Update master version to ${REL_VERSION}" - echo "Done." + git clean -f -d + git push origin master + echo "Updated master branch with version ${REL_VERSION}." exit 0 elif [ "$VARIANT" = "rc" ]; then echo "Release-candidate version detected: $REL_VERSION" @@ -107,15 +103,13 @@ fi if [ "$VARIANT" = "" ]; then git clean -xdf - echo "Creating pull request to update docs ..." - branch_name="automation/update_docs_${current_time}" + echo "Updating docs in master branch ..." + git checkout master + git fetch origin git reset --hard origin/master git cherry-pick --strategy=recursive -X theirs $RELEASE_TAG - git push origin $branch_name - gh pr create --repo ${GITHUB_REPOSITORY} \ - --base master \ - --title "Update master docs for ${REL_VERSION} release" \ - --body "Update master docs for ${REL_VERSION} release" + git push origin master + echo "Updated docs in master branch." fi echo "Done." \ No newline at end of file diff --git a/.github/scripts/update_sdk_version.sh b/.github/scripts/update_sdk_version.sh index fba6ff9af..f66d2adf2 100755 --- a/.github/scripts/update_sdk_version.sh +++ b/.github/scripts/update_sdk_version.sh @@ -27,4 +27,7 @@ mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f testcontainers-dap # dapr-spring mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f dapr-spring/pom.xml +# spring-boot-examples +mvn versions:set -DnewVersion=$DAPR_JAVA_SDK_ALPHA_VERSION -f spring-boot-examples/pom.xml + git clean -f diff --git a/.github/workflows/automerge-bot.yml b/.github/workflows/automerge-bot.yml index 2ff0c795c..b2a301156 100644 --- a/.github/workflows/automerge-bot.yml +++ b/.github/workflows/automerge-bot.yml @@ -27,6 +27,5 @@ jobs: run: pip install PyGithub - name: Automerge and update env: - MAINTAINERS: artursouza,mukundansundar GITHUB_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }} run: python ./.github/scripts/automerge.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d67d7ad1..03e7e1427 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,12 +24,12 @@ jobs: fail-fast: false matrix: java: [ 17 ] - spring-boot-version: [ 3.2.6 ] - spring-boot-display-version: [ 3.2.x ] + spring-boot-version: [ 3.4.3 ] + spring-boot-display-version: [ 3.4.x ] experimental: [ false ] include: - java: 17 - spring-boot-version: 3.3.0 + spring-boot-version: 3.3.9 spring-boot-display-version: 3.3.x experimental: false env: @@ -38,9 +38,9 @@ jobs: GOARCH: amd64 GOPROXY: https://proxy.golang.org JDK_VER: ${{ matrix.java }} - DAPR_CLI_VER: 1.14.0 - DAPR_RUNTIME_VER: 1.14.4 - DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.14.0/install/install.sh + DAPR_CLI_VER: 1.15.0 + DAPR_RUNTIME_VER: 1.15.3 + DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.15.0/install/install.sh DAPR_CLI_REF: DAPR_REF: TOXIPROXY_URL: https://github.com/Shopify/toxiproxy/releases/download/v2.5.0/toxiproxy-server-linux-amd64 @@ -146,7 +146,7 @@ jobs: publish: runs-on: ubuntu-latest needs: build - timeout-minutes: 10 + timeout-minutes: 30 env: JDK_VER: 17 OSSRH_USER_TOKEN: ${{ secrets.OSSRH_USER_TOKEN }} @@ -174,11 +174,11 @@ jobs: echo "DEPLOY_OSSRH=true" >> $GITHUB_ENV - name: Install jars if: env.DEPLOY_OSSRH == 'true' - run: ./mvnw install -B -q + run: ./mvnw install -DskipTests -B -q - name: Publish to ossrh if: env.DEPLOY_OSSRH == 'true' run: | echo ${{ secrets.GPG_PRIVATE_KEY }} | base64 -d > private-key.gpg export GPG_TTY=$(tty) gpg --batch --import private-key.gpg - ./mvnw -V -B -Dgpg.skip=false -s settings.xml deploy -pl \!examples + ./mvnw -V -B -Dgpg.skip=false -s settings.xml deploy diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index a83356db1..5e529508b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -32,6 +32,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.DAPR_BOT_TOKEN }} + persist-credentials: false - name: Set up OpenJDK ${{ env.JDK_VER }} uses: actions/setup-java@v4 with: @@ -48,24 +50,7 @@ jobs: git config user.email "daprweb@microsoft.com" git config user.name "Dapr Bot" # Update origin with token - git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + git remote set-url origin https://x-access-token:${{ secrets.DAPR_BOT_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git # Copy first to allow automation to use the latest version and not the release branch's version. cp -R ./.github/scripts ${RUNNER_TEMP}/ - ${RUNNER_TEMP}/scripts/create-release.sh ${{ inputs.rel_version }} - trigger: - name: Triggers the Dapr SDK build - runs-on: ubuntu-latest - needs: create-release - steps: - - name: Identify build ref to trigger build and release. - run: | - if [[ "${{ inputs.rel_version }}" == *"SNAPSHOT"* ]]; then - echo "BUILD_GIT_REF=master" >> $GITHUB_ENV - else - echo "BUILD_GIT_REF=v${{ inputs.rel_version }}" >> $GITHUB_ENV - fi - - name: Triggers the build and release. - env: - GITHUB_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }} - run: | - gh workflow run build.yml --repo ${GITHUB_REPOSITORY} --ref v$(echo '${{ env.BUILD_GIT_REF }}' | sed -r 's/^[vV]?([0-9].+)$/\1/') \ No newline at end of file + ${RUNNER_TEMP}/scripts/create-release.sh ${{ inputs.rel_version }} \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8d1e23187..6711b07c6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,9 +37,9 @@ jobs: GOARCH: amd64 GOPROXY: https://proxy.golang.org JDK_VER: ${{ matrix.java }} - DAPR_CLI_VER: 1.14.0 - DAPR_RUNTIME_VER: 1.14.4 - DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.14.0/install/install.sh + DAPR_CLI_VER: 1.15.0 + DAPR_RUNTIME_VER: 1.15.3 + DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/v1.15.0/install/install.sh DAPR_CLI_REF: DAPR_REF: steps: @@ -164,3 +164,7 @@ jobs: working-directory: ./examples run: | mm.py ./src/main/java/io/dapr/examples/pubsub/stream/README.md + - name: Validate Spring Boot examples + working-directory: ./spring-boot-examples + run: | + mm.py README.md diff --git a/README.md b/README.md index 780c66412..f962cbc08 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is the Dapr SDK for Java, including the following features: * Binding * State Store * Actors +* Workflows ## Getting Started @@ -112,6 +113,13 @@ Try the following examples to learn more about Dapr's Java SDK: * [Exception handling](./examples/src/main/java/io/dapr/examples/exception) * [Unit testing](./examples/src/main/java/io/dapr/examples/unittesting) +### Running Spring Boot examples + +The Spring Boot integration for Dapr use [Testcontainers](https://testcontainers.com) to set up a local environment development flow that doesn't +require the use of the `dapr` CLI and it integrates with the Spring Boot programming model. + +You can find a [step-by-step tutorial showing this integration here](./spring-boot-examples/README.md). + ### API Documentation Please, refer to our [Javadoc](https://dapr.github.io/java-sdk/) website. diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/pom.xml b/dapr-spring/dapr-spring-boot-autoconfigure/pom.xml index 16df49076..3eca52652 100644 --- a/dapr-spring/dapr-spring-boot-autoconfigure/pom.xml +++ b/dapr-spring/dapr-spring-boot-autoconfigure/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-boot-autoconfigure @@ -75,5 +75,12 @@ test - + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientAutoConfiguration.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientAutoConfiguration.java index 7e10c1f8f..ebd6a18d7 100644 --- a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientAutoConfiguration.java +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/client/DaprClientAutoConfiguration.java @@ -13,6 +13,8 @@ limitations under the License. package io.dapr.spring.boot.autoconfigure.client; +import io.dapr.actors.client.ActorClient; +import io.dapr.actors.runtime.ActorRuntime; import io.dapr.client.DaprClient; import io.dapr.client.DaprClientBuilder; import io.dapr.config.Properties; @@ -70,6 +72,20 @@ public class DaprClientAutoConfiguration { return new DaprWorkflowClient(properties); } + @Bean + @ConditionalOnMissingBean + ActorClient daprActorClient(DaprConnectionDetails daprConnectionDetails) { + Properties properties = createPropertiesFromConnectionDetails(daprConnectionDetails); + return new ActorClient(properties); + } + + @Bean + @ConditionalOnMissingBean + ActorRuntime daprActorRuntime(DaprConnectionDetails daprConnectionDetails) { + Properties properties = createPropertiesFromConnectionDetails(daprConnectionDetails); + return ActorRuntime.getInstance(properties); + } + @Bean @ConditionalOnMissingBean WorkflowRuntimeBuilder daprWorkflowRuntimeBuilder(DaprConnectionDetails daprConnectionDetails) { diff --git a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter-test/pom.xml b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter-test/pom.xml index 0f2ef4506..623e1c1cd 100644 --- a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter-test/pom.xml +++ b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter-test/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT ../../pom.xml @@ -40,5 +40,12 @@ true - + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml index 682ce450e..623040b37 100644 --- a/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml +++ b/dapr-spring/dapr-spring-boot-starters/dapr-spring-boot-starter/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT ../../pom.xml @@ -47,4 +47,12 @@ + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-boot-tests/pom.xml b/dapr-spring/dapr-spring-boot-tests/pom.xml index a46957392..86286c08f 100644 --- a/dapr-spring/dapr-spring-boot-tests/pom.xml +++ b/dapr-spring/dapr-spring-boot-tests/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-boot-tests @@ -41,5 +41,12 @@ ${dapr.sdk.alpha.version} - + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-data/pom.xml b/dapr-spring/dapr-spring-data/pom.xml index 75d74a5c3..1de4b563d 100644 --- a/dapr-spring/dapr-spring-data/pom.xml +++ b/dapr-spring/dapr-spring-data/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-data @@ -21,4 +21,12 @@ + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-messaging/pom.xml b/dapr-spring/dapr-spring-messaging/pom.xml index 7fe1e7fcc..7b74a85a8 100644 --- a/dapr-spring/dapr-spring-messaging/pom.xml +++ b/dapr-spring/dapr-spring-messaging/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-messaging @@ -14,4 +14,12 @@ Dapr Spring Messaging jar + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-workflows/pom.xml b/dapr-spring/dapr-spring-workflows/pom.xml index 1b9776943..55708f500 100644 --- a/dapr-spring/dapr-spring-workflows/pom.xml +++ b/dapr-spring/dapr-spring-workflows/pom.xml @@ -6,7 +6,7 @@ io.dapr.spring dapr-spring-parent - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-workflows @@ -21,4 +21,12 @@ ${project.version} + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + diff --git a/dapr-spring/dapr-spring-workflows/src/main/java/io/dapr/spring/workflows/config/DaprWorkflowsConfiguration.java b/dapr-spring/dapr-spring-workflows/src/main/java/io/dapr/spring/workflows/config/DaprWorkflowsConfiguration.java index 5547d72f4..8629982a9 100644 --- a/dapr-spring/dapr-spring-workflows/src/main/java/io/dapr/spring/workflows/config/DaprWorkflowsConfiguration.java +++ b/dapr-spring/dapr-spring-workflows/src/main/java/io/dapr/spring/workflows/config/DaprWorkflowsConfiguration.java @@ -17,7 +17,7 @@ import java.util.Map; public class DaprWorkflowsConfiguration implements ApplicationContextAware { private static final Logger LOGGER = LoggerFactory.getLogger(DaprWorkflowsConfiguration.class); - private WorkflowRuntimeBuilder workflowRuntimeBuilder; + private final WorkflowRuntimeBuilder workflowRuntimeBuilder; public DaprWorkflowsConfiguration(WorkflowRuntimeBuilder workflowRuntimeBuilder) { this.workflowRuntimeBuilder = workflowRuntimeBuilder; @@ -29,16 +29,21 @@ public class DaprWorkflowsConfiguration implements ApplicationContextAware { */ private void registerWorkflowsAndActivities(ApplicationContext applicationContext) { LOGGER.info("Registering Dapr Workflows and Activities"); + Map workflowBeans = applicationContext.getBeansOfType(Workflow.class); - for (Workflow w : workflowBeans.values()) { - LOGGER.info("Dapr Workflow: '{}' registered", w.getClass().getName()); - workflowRuntimeBuilder.registerWorkflow(w.getClass()); + + for (Workflow workflow : workflowBeans.values()) { + LOGGER.info("Dapr Workflow: '{}' registered", workflow.getClass().getName()); + + workflowRuntimeBuilder.registerWorkflow(workflow); } Map workflowActivitiesBeans = applicationContext.getBeansOfType(WorkflowActivity.class); - for (WorkflowActivity a : workflowActivitiesBeans.values()) { - LOGGER.info("Dapr Workflow Activity: '{}' registered", a.getClass().getName()); - workflowRuntimeBuilder.registerActivity(a.getClass()); + + for (WorkflowActivity activity : workflowActivitiesBeans.values()) { + LOGGER.info("Dapr Workflow Activity: '{}' registered", activity.getClass().getName()); + + workflowRuntimeBuilder.registerActivity(activity); } try (WorkflowRuntime runtime = workflowRuntimeBuilder.build()) { diff --git a/dapr-spring/pom.xml b/dapr-spring/pom.xml index ffbb736f3..04f59acaa 100644 --- a/dapr-spring/pom.xml +++ b/dapr-spring/pom.xml @@ -7,13 +7,13 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT io.dapr.spring dapr-spring-parent pom - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-spring-parent SDK extension for Spring and Spring Boot @@ -92,6 +92,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md index 204104a7c..74f988fb4 100644 --- a/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md +++ b/daprdocs/content/en/java-sdk-docs/spring-boot/_index.md @@ -122,7 +122,7 @@ Besides the previous configuration (`DaprTestContainersConfig`) your tests shoul The Java SDK allows you to interface with all of the [Dapr building blocks]({{< ref building-blocks >}}). But if you want to leverage the Spring and Spring Boot programming model you can use the `dapr-spring-boot-starter` integration. This includes implementations of Spring Data (`KeyValueTemplate` and `CrudRepository`) as well as a `DaprMessagingTemplate` for producing and consuming messages -(similar to [Spring Kafka](https://spring.io/projects/spring-kafka), [Spring Pulsar](https://spring.io/projects/spring-pulsar) and [Spring AMQP for RabbitMQ](https://spring.io/projects/spring-amqp)). +(similar to [Spring Kafka](https://spring.io/projects/spring-kafka), [Spring Pulsar](https://spring.io/projects/spring-pulsar) and [Spring AMQP for RabbitMQ](https://spring.io/projects/spring-amqp)) and Dapr workflows. ## Using Spring Data `CrudRepository` and `KeyValueTemplate` @@ -277,6 +277,53 @@ public static void setup(){ You can check and run the [full example source code here](https://github.com/salaboy/dapr-spring-boot-docs-examples). +## Using Dapr Workflows with Spring Boot + +Following the same approach that we used for Spring Data and Spring Messaging, the `dapr-spring-boot-starter` brings Dapr Workflow integration for Spring Boot users. + +To work with Dapr Workflows you need to define and implement your workflows using code. The Dapr Spring Boot Starter makes your life easier by managing `Workflow`s and `WorkflowActivity`s as Spring beans. + +In order to enable the automatic bean discovery you can annotate your `@SpringBootApplication` with the `@EnableDaprWorkflows` annotation: + +``` +@SpringBootApplication +@EnableDaprWorkflows +public class MySpringBootApplication {} +``` + +By adding this annotation, all the `WorkflowActivity`s will be automatically managed by Spring and registered to the workflow engine. + +By having all `WorkflowActivity`s as managed beans we can use Spring `@Autowired` mechanism to inject any bean that our workflow activity might need to implement its functionality, for example the `@RestTemplate`: + +``` +public class MyWorkflowActivity implements WorkflowActivity { + + @Autowired + private RestTemplate restTemplate; +``` + +You can also `@Autowired` the `DaprWorkflowClient` to create new instances of your workflows. + +``` +@Autowired +private DaprWorkflowClient daprWorkflowClient; +``` + +This enable applications to schedule new workflow instances and raise events. + +``` +String instanceId = daprWorkflowClient.scheduleNewWorkflow(MyWorkflow.class, payload); +``` + +and + +``` +daprWorkflowClient.raiseEvent(instanceId, "MyEvenet", event); +``` + +Check the [Dapr Workflow documentation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) for more information about how to work with Dapr Workflows. + + ## Next steps Learn more about the [Dapr Java SDK packages available to add to your Java applications](https://dapr.github.io/java-sdk/). diff --git a/examples/pom.xml b/examples/pom.xml index 4611c69b8..4218d0e8e 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -7,12 +7,12 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-examples jar - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-examples @@ -21,7 +21,6 @@ 17 ${java.version} ${java.version} - true false 0.14.0 @@ -30,7 +29,7 @@ commons-cli commons-cli - 1.8.0 + 1.9.0 io.grpc @@ -134,6 +133,11 @@ protobuf-java ${protobuf.version} + + com.squareup.okhttp3 + okhttp + 4.12.0 + @@ -179,14 +183,6 @@ ${java.version} - - org.apache.maven.plugins - maven-deploy-plugin - ${maven-deploy-plugin.version} - - true - - org.apache.maven.plugins maven-site-plugin diff --git a/examples/src/main/java/io/dapr/examples/OpenTelemetryInterceptor.java b/examples/src/main/java/io/dapr/examples/OpenTelemetryInterceptor.java index cc250113d..b2da80a40 100644 --- a/examples/src/main/java/io/dapr/examples/OpenTelemetryInterceptor.java +++ b/examples/src/main/java/io/dapr/examples/OpenTelemetryInterceptor.java @@ -19,12 +19,13 @@ import io.opentelemetry.context.propagation.TextMapPropagator; import jakarta.servlet.DispatcherType; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; +import javax.annotation.Nullable; + import java.util.Collections; @Component diff --git a/pom.xml b/pom.xml index 1448ac448..5ab3051c2 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.dapr dapr-sdk-parent pom - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-parent SDK for Dapr. https://dapr.io @@ -18,8 +18,8 @@ 3.25.5 protoc https://raw.githubusercontent.com/dapr/dapr/v1.14.4/dapr/proto - 1.14.0-SNAPSHOT - 0.14.0-SNAPSHOT + 1.15.0-SNAPSHOT + 0.15.0-SNAPSHOT 1.7.1 3.1.1 1.8 @@ -45,7 +45,8 @@ 5.8.2 2.0 1.20.0 - 3.2.6 + 3.4.3 + 1.7.0 @@ -162,10 +163,25 @@ maven-resources-plugin ${maven-resources-plugin.version} + + org.sonatype.plugins + nexus-staging-maven-plugin + ${nexus-staging-maven-plugin.version} + true + + ossrh + https://oss.sonatype.org/ + true + + - + + org.sonatype.plugins + nexus-staging-maven-plugin + false + org.jacoco jacoco-maven-plugin @@ -199,17 +215,6 @@ - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 - true - - ossrh - https://oss.sonatype.org/ - true - - org.apache.maven.plugins maven-checkstyle-plugin @@ -337,6 +342,7 @@ sdk-springboot dapr-spring examples + spring-boot-examples testcontainers-dapr diff --git a/sdk-actors/pom.xml b/sdk-actors/pom.xml index 65c3f6851..a478f3036 100644 --- a/sdk-actors/pom.xml +++ b/sdk-actors/pom.xml @@ -7,12 +7,12 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-actors jar - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-actors SDK for Actors on Dapr @@ -45,7 +45,7 @@ commons-cli commons-cli - 1.4 + 1.9.0 test @@ -70,6 +70,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/sdk-actors/src/main/java/io/dapr/actors/client/ActorClient.java b/sdk-actors/src/main/java/io/dapr/actors/client/ActorClient.java index 08c0a1c9e..1fe2d4f19 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/client/ActorClient.java +++ b/sdk-actors/src/main/java/io/dapr/actors/client/ActorClient.java @@ -15,6 +15,7 @@ package io.dapr.actors.client; import io.dapr.client.resiliency.ResiliencyOptions; import io.dapr.config.Properties; +import io.dapr.utils.NetworkUtils; import io.dapr.utils.Version; import io.dapr.v1.DaprGrpc; import io.grpc.Channel; @@ -83,7 +84,7 @@ public class ActorClient implements AutoCloseable { * @param resiliencyOptions Client resiliency options. */ public ActorClient(Properties overrideProperties, Map metadata, ResiliencyOptions resiliencyOptions) { - this(buildManagedChannel(overrideProperties), + this(NetworkUtils.buildGrpcManagedChannel(overrideProperties), metadata, resiliencyOptions, overrideProperties.getValue(Properties.API_TOKEN)); @@ -129,25 +130,6 @@ public class ActorClient implements AutoCloseable { } } - /** - * Creates a GRPC managed channel (or null, if not applicable). - * - * @param overrideProperties Overrides - * @return GRPC managed channel or null. - */ - private static ManagedChannel buildManagedChannel(Properties overrideProperties) { - int port = overrideProperties.getValue(Properties.GRPC_PORT); - if (port <= 0) { - throw new IllegalArgumentException("Invalid port."); - } - - var sidecarHost = overrideProperties.getValue(Properties.SIDECAR_IP); - - return ManagedChannelBuilder.forAddress(sidecarHost, port) - .usePlaintext() - .userAgent(Version.getSdkVersion()) - .build(); - } /** * Build an instance of the Client based on the provided setup. diff --git a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorRuntime.java b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorRuntime.java index 8eb09bc7d..d329946e5 100644 --- a/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorRuntime.java +++ b/sdk-actors/src/main/java/io/dapr/actors/runtime/ActorRuntime.java @@ -18,9 +18,8 @@ import io.dapr.actors.ActorTrace; import io.dapr.config.Properties; import io.dapr.serializer.DaprObjectSerializer; import io.dapr.serializer.DefaultObjectSerializer; -import io.dapr.utils.Version; +import io.dapr.utils.NetworkUtils; import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; import reactor.core.publisher.Mono; import java.io.Closeable; @@ -80,23 +79,32 @@ public class ActorRuntime implements Closeable { * @throws IllegalStateException If cannot instantiate Runtime. */ private ActorRuntime() throws IllegalStateException { - this(buildManagedChannel()); + this(new Properties()); + } + + /** + * The default constructor. This should not be called directly. + * + * @throws IllegalStateException If cannot instantiate Runtime. + */ + private ActorRuntime(Properties properties) throws IllegalStateException { + this(NetworkUtils.buildGrpcManagedChannel(properties)); } /** * Constructor once channel is available. This should not be called directly. * * @param channel GRPC managed channel to be closed (or null). - * @throws IllegalStateException If cannot instantiate Runtime. + * @throws IllegalStateException If you cannot instantiate Runtime. */ private ActorRuntime(ManagedChannel channel) throws IllegalStateException { - this(channel, buildDaprClient(channel)); + this(channel, new DaprClientImpl(channel)); } /** * Constructor with dependency injection, useful for testing. This should not be called directly. * - * @param channel GRPC managed channel to be closed (or null). + * @param channel GRPC managed channel to be closed (or null). * @param daprClient Client to communicate with Dapr. * @throws IllegalStateException If class has one instance already. */ @@ -128,6 +136,24 @@ public class ActorRuntime implements Closeable { return instance; } + /** + * Returns an ActorRuntime object. + * + * @param properties Properties to be used for the runtime. + * @return An ActorRuntime object. + */ + public static ActorRuntime getInstance(Properties properties) { + if (instance == null) { + synchronized (ActorRuntime.class) { + if (instance == null) { + instance = new ActorRuntime(properties); + } + } + } + + return instance; + } + /** * Gets the Actor configuration for this runtime. * @@ -149,11 +175,10 @@ public class ActorRuntime implements Closeable { /** * Registers an actor with the runtime, using {@link DefaultObjectSerializer} and {@link DefaultActorFactory}. - * * {@link DefaultObjectSerializer} is not recommended for production scenarios. * - * @param clazz The type of actor. - * @param Actor class type. + * @param clazz The type of actor. + * @param Actor class type. */ public void registerActor(Class clazz) { registerActor(clazz, new DefaultObjectSerializer(), new DefaultObjectSerializer()); @@ -161,12 +186,11 @@ public class ActorRuntime implements Closeable { /** * Registers an actor with the runtime, using {@link DefaultObjectSerializer}. - * * {@link DefaultObjectSerializer} is not recommended for production scenarios. * - * @param clazz The type of actor. - * @param actorFactory An optional factory to create actors. This can be used for dependency injection. - * @param Actor class type. + * @param clazz The type of actor. + * @param actorFactory An optional factory to create actors. This can be used for dependency injection. + * @param Actor class type. */ public void registerActor(Class clazz, ActorFactory actorFactory) { registerActor(clazz, actorFactory, new DefaultObjectSerializer(), new DefaultObjectSerializer()); @@ -181,8 +205,8 @@ public class ActorRuntime implements Closeable { * @param Actor class type. */ public void registerActor( - Class clazz, DaprObjectSerializer objectSerializer, DaprObjectSerializer stateSerializer) { - registerActor(clazz, new DefaultActorFactory(), objectSerializer, stateSerializer); + Class clazz, DaprObjectSerializer objectSerializer, DaprObjectSerializer stateSerializer) { + registerActor(clazz, new DefaultActorFactory(), objectSerializer, stateSerializer); } /** @@ -195,9 +219,9 @@ public class ActorRuntime implements Closeable { * @param Actor class type. */ public void registerActor( - Class clazz, ActorFactory actorFactory, - DaprObjectSerializer objectSerializer, - DaprObjectSerializer stateSerializer) { + Class clazz, ActorFactory actorFactory, + DaprObjectSerializer objectSerializer, + DaprObjectSerializer stateSerializer) { if (clazz == null) { throw new IllegalArgumentException("Class is required."); } @@ -216,12 +240,12 @@ public class ActorRuntime implements Closeable { // Create ActorManager, if not yet registered. this.actorManagers.computeIfAbsent(actorTypeInfo.getName(), (k) -> { ActorRuntimeContext context = new ActorRuntimeContext<>( - this, - objectSerializer, - actorFactory, - actorTypeInfo, - this.daprClient, - new DaprStateAsyncProvider(this.daprClient, stateSerializer)); + this, + objectSerializer, + actorFactory, + actorTypeInfo, + this.daprClient, + new DaprStateAsyncProvider(this.daprClient, stateSerializer)); this.config.addRegisteredActorType(actorTypeInfo.getName()); return new ActorManager(context); }); @@ -236,7 +260,7 @@ public class ActorRuntime implements Closeable { */ public Mono deactivate(String actorTypeName, String actorId) { return Mono.fromSupplier(() -> this.getActorManager(actorTypeName)) - .flatMap(m -> m.deactivateActor(new ActorId(actorId))); + .flatMap(m -> m.deactivateActor(new ActorId(actorId))); } /** @@ -252,8 +276,8 @@ public class ActorRuntime implements Closeable { public Mono invoke(String actorTypeName, String actorId, String actorMethodName, byte[] payload) { ActorId id = new ActorId(actorId); return Mono.fromSupplier(() -> this.getActorManager(actorTypeName)) - .flatMap(m -> m.activateActor(id).thenReturn(m)) - .flatMap(m -> ((ActorManager)m).invokeMethod(id, actorMethodName, payload)); + .flatMap(m -> m.activateActor(id).thenReturn(m)) + .flatMap(m -> ((ActorManager) m).invokeMethod(id, actorMethodName, payload)); } /** @@ -268,8 +292,8 @@ public class ActorRuntime implements Closeable { public Mono invokeReminder(String actorTypeName, String actorId, String reminderName, byte[] params) { ActorId id = new ActorId(actorId); return Mono.fromSupplier(() -> this.getActorManager(actorTypeName)) - .flatMap(m -> m.activateActor(id).thenReturn(m)) - .flatMap(m -> ((ActorManager)m).invokeReminder(new ActorId(actorId), reminderName, params)); + .flatMap(m -> m.activateActor(id).thenReturn(m)) + .flatMap(m -> ((ActorManager) m).invokeReminder(new ActorId(actorId), reminderName, params)); } /** @@ -284,8 +308,8 @@ public class ActorRuntime implements Closeable { public Mono invokeTimer(String actorTypeName, String actorId, String timerName, byte[] params) { ActorId id = new ActorId(actorId); return Mono.fromSupplier(() -> this.getActorManager(actorTypeName)) - .flatMap(m -> m.activateActor(id).thenReturn(m)) - .flatMap(m -> ((ActorManager)m).invokeTimer(new ActorId(actorId), timerName, params)); + .flatMap(m -> m.activateActor(id).thenReturn(m)) + .flatMap(m -> ((ActorManager) m).invokeTimer(new ActorId(actorId), timerName, params)); } /** @@ -318,23 +342,6 @@ public class ActorRuntime implements Closeable { return new DaprClientImpl(channel); } - /** - * Creates a GRPC managed channel (or null, if not applicable). - * - * @return GRPC managed channel or null. - */ - private static ManagedChannel buildManagedChannel() { - int port = Properties.GRPC_PORT.get(); - if (port <= 0) { - throw new IllegalStateException("Invalid port."); - } - - return ManagedChannelBuilder.forAddress(Properties.SIDECAR_IP.get(), port) - .usePlaintext() - .userAgent(Version.getSdkVersion()) - .build(); - } - /** * {@inheritDoc} */ diff --git a/sdk-autogen/pom.xml b/sdk-autogen/pom.xml index 16bcb560f..0f82c5693 100644 --- a/sdk-autogen/pom.xml +++ b/sdk-autogen/pom.xml @@ -7,12 +7,12 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-autogen jar - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-autogen Auto-generated SDK for Dapr @@ -64,6 +64,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + com.googlecode.maven-download-plugin download-maven-plugin diff --git a/sdk-springboot/pom.xml b/sdk-springboot/pom.xml index b5b60ee3e..c2f0da488 100644 --- a/sdk-springboot/pom.xml +++ b/sdk-springboot/pom.xml @@ -7,19 +7,15 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-springboot jar - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-springboot SDK extension for Springboot - - false - - @@ -85,6 +81,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/sdk-tests/pom.xml b/sdk-tests/pom.xml index f1c32897f..c448aa586 100644 --- a/sdk-tests/pom.xml +++ b/sdk-tests/pom.xml @@ -7,11 +7,11 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-tests - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-tests Tests for Dapr's Java SDK - not to be published as a jar. @@ -22,15 +22,15 @@ 17 17 true - 1.14.0-SNAPSHOT - 0.14.0-SNAPSHOT + 1.15.0-SNAPSHOT + 0.15.0-SNAPSHOT ${project.build.directory}/generated-sources ${project.basedir}/proto 1.69.0 3.25.5 - 1.39.0 - 3.3.1 - 1.4.12 + 1.41.0 + 3.4.3 + 1.5.16 3.9.1 1.20.0 @@ -52,7 +52,7 @@ commons-cli commons-cli - 1.4 + 1.9.0 io.grpc @@ -70,14 +70,9 @@ ${grpc.version} - com.google.protobuf - protobuf-java-util - ${protobuf.version} - - - com.github.os72 - protoc-jar-maven-plugin - 3.11.4 + commons-io + commons-io + 2.14.0 io.opentelemetry @@ -178,32 +173,20 @@ org.springframework.data spring-data-keyvalue - - org.springframework.boot - spring-boot-starter-test - test - org.wiremock wiremock-standalone ${wiremock.version} test - - ch.qos.logback - logback-classic - ${logback-classic.version} - test - org.springframework.boot spring-boot-starter-web test - io.projectreactor - reactor-core - 3.6.7 + org.springframework.boot + spring-boot-starter-test test @@ -212,6 +195,12 @@ 3.9 test + + ch.qos.logback + logback-core + ${logback-core.version} + test + org.testcontainers postgresql diff --git a/sdk-tests/src/test/java/io/dapr/it/actors/ActorStateIT.java b/sdk-tests/src/test/java/io/dapr/it/actors/ActorStateIT.java index 67d6b9659..90bab85e5 100644 --- a/sdk-tests/src/test/java/io/dapr/it/actors/ActorStateIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/actors/ActorStateIT.java @@ -141,7 +141,7 @@ public class ActorStateIT extends BaseIT { proxyBuilder = new ActorProxyBuilder(actorType, ActorProxy.class, deferClose(run2.newActorClient())); ActorProxy newProxy = proxyBuilder.build(actorId); - // wating for actor to be activated + // waiting for actor to be activated Thread.sleep(2000); callWithRetry(() -> { diff --git a/sdk-tests/src/test/java/io/dapr/it/actors/app/TestApplication.java b/sdk-tests/src/test/java/io/dapr/it/actors/app/TestApplication.java index 4cf69f401..53813dc4e 100644 --- a/sdk-tests/src/test/java/io/dapr/it/actors/app/TestApplication.java +++ b/sdk-tests/src/test/java/io/dapr/it/actors/app/TestApplication.java @@ -21,14 +21,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; */ @SpringBootApplication public class TestApplication { - /** * Starts Dapr's callback in a given port. * @param port Port to listen to. */ public static void start(long port) { - SpringApplication app = new SpringApplication(TestApplication.class); - app.run(String.format("--server.port=%d", port)); + SpringApplication.run(TestApplication.class, String.format("--server.port=%d", port)); } } diff --git a/sdk-tests/src/test/java/io/dapr/it/resiliency/WaitForSidecarIT.java b/sdk-tests/src/test/java/io/dapr/it/resiliency/WaitForSidecarIT.java index 13d095470..c7dd5ccc5 100644 --- a/sdk-tests/src/test/java/io/dapr/it/resiliency/WaitForSidecarIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/resiliency/WaitForSidecarIT.java @@ -21,8 +21,8 @@ import org.junit.jupiter.api.Test; import java.time.Duration; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; /** * Test SDK resiliency. @@ -43,7 +43,7 @@ public class WaitForSidecarIT extends BaseIT { @BeforeAll public static void init() throws Exception { daprRun = startDaprApp(WaitForSidecarIT.class.getSimpleName(), 5000); - daprNotRunning = startDaprApp(WaitForSidecarIT.class.getSimpleName()+"NotRunning", 5000); + daprNotRunning = startDaprApp(WaitForSidecarIT.class.getSimpleName() + "NotRunning", 5000); daprNotRunning.stop(); toxiProxyRun = new ToxiProxyRun(daprRun, LATENCY, JITTER); @@ -61,24 +61,30 @@ public class WaitForSidecarIT extends BaseIT { public void waitTimeout() { int timeoutInMillis = (int)LATENCY.minusMillis(100).toMillis(); long started = System.currentTimeMillis(); + assertThrows(RuntimeException.class, () -> { try(var client = toxiProxyRun.newDaprClientBuilder().build()) { client.waitForSidecar(timeoutInMillis).block(); } }); + long duration = System.currentTimeMillis() - started; - assertTrue(duration >= timeoutInMillis); + + assertThat(duration).isGreaterThanOrEqualTo(timeoutInMillis); } @Test public void waitSlow() throws Exception { int timeoutInMillis = (int)LATENCY.plusMillis(100).toMillis(); long started = System.currentTimeMillis(); + try(var client = toxiProxyRun.newDaprClientBuilder().build()) { client.waitForSidecar(timeoutInMillis).block(); } + long duration = System.currentTimeMillis() - started; - assertTrue(duration >= LATENCY.toMillis()); + + assertThat(duration).isGreaterThanOrEqualTo(LATENCY.toMillis()); } @Test @@ -87,12 +93,15 @@ public class WaitForSidecarIT extends BaseIT { // This has to do with a previous bug in the implementation. int timeoutMilliseconds = 5000; long started = System.currentTimeMillis(); + assertThrows(RuntimeException.class, () -> { try(var client = daprNotRunning.newDaprClientBuilder().build()) { client.waitForSidecar(timeoutMilliseconds).block(); } }); + long duration = System.currentTimeMillis() - started; - assertTrue(duration >= timeoutMilliseconds); + + assertThat(duration).isGreaterThanOrEqualTo(timeoutMilliseconds); } } diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/data/DaprKeyValueRepositoryIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/data/DaprKeyValueRepositoryIT.java index 60d4b8854..131e1e615 100644 --- a/sdk-tests/src/test/java/io/dapr/it/spring/data/DaprKeyValueRepositoryIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/spring/data/DaprKeyValueRepositoryIT.java @@ -29,13 +29,13 @@ import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import static io.dapr.it.spring.data.DaprSpringDataConstants.BINDING_NAME; import static io.dapr.it.spring.data.DaprSpringDataConstants.STATE_STORE_NAME; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.junit.jupiter.api.Assertions.*; /** @@ -65,7 +65,7 @@ public class DaprKeyValueRepositoryIT { @Container @ServiceConnection - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.13.2") + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) .withAppName("postgresql-repository-dapr-app") .withNetwork(DAPR_NETWORK) .withComponent(new Component(STATE_STORE_NAME, "state.postgresql", "v1", STATE_STORE_PROPERTIES)) diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/data/MySQLDaprKeyValueTemplateIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/data/MySQLDaprKeyValueTemplateIT.java index d365685d9..6f372c39b 100644 --- a/sdk-tests/src/test/java/io/dapr/it/spring/data/MySQLDaprKeyValueTemplateIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/spring/data/MySQLDaprKeyValueTemplateIT.java @@ -26,8 +26,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.data.keyvalue.core.query.KeyValueQuery; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.containers.Network; @@ -39,7 +37,6 @@ import org.testcontainers.junit.jupiter.Testcontainers; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -47,6 +44,7 @@ import java.util.Optional; import static io.dapr.it.spring.data.DaprSpringDataConstants.STATE_STORE_NAME; import static io.dapr.it.spring.data.DaprSpringDataConstants.BINDING_NAME; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -82,7 +80,7 @@ public class MySQLDaprKeyValueTemplateIT { @Container @ServiceConnection - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.13.2") + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) .withAppName("mysql-dapr-app") .withNetwork(DAPR_NETWORK) .withComponent(new Component(STATE_STORE_NAME, "state.mysql", "v1", STATE_STORE_PROPERTIES)) diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/data/PostgreSQLDaprKeyValueTemplateIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/data/PostgreSQLDaprKeyValueTemplateIT.java index b055d3aef..c6f81daa5 100644 --- a/sdk-tests/src/test/java/io/dapr/it/spring/data/PostgreSQLDaprKeyValueTemplateIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/spring/data/PostgreSQLDaprKeyValueTemplateIT.java @@ -26,8 +26,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.data.keyvalue.core.query.KeyValueQuery; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.testcontainers.containers.Network; import org.testcontainers.containers.PostgreSQLContainer; @@ -38,6 +36,7 @@ import java.util.*; import static io.dapr.it.spring.data.DaprSpringDataConstants.BINDING_NAME; import static io.dapr.it.spring.data.DaprSpringDataConstants.STATE_STORE_NAME; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -68,7 +67,7 @@ public class PostgreSQLDaprKeyValueTemplateIT { @Container @ServiceConnection - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.13.2") + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) .withAppName("postgresql-dapr-app") .withNetwork(DAPR_NETWORK) .withComponent(new Component(STATE_STORE_NAME, "state.postgresql", "v1", STATE_STORE_PROPERTIES)) diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/messaging/DaprSpringMessagingIT.java b/sdk-tests/src/test/java/io/dapr/it/spring/messaging/DaprSpringMessagingIT.java index fc03f4412..ea1377949 100644 --- a/sdk-tests/src/test/java/io/dapr/it/spring/messaging/DaprSpringMessagingIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/spring/messaging/DaprSpringMessagingIT.java @@ -23,22 +23,21 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Disabled; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.Collections; import java.util.List; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest( @@ -56,16 +55,18 @@ public class DaprSpringMessagingIT { private static final Logger logger = LoggerFactory.getLogger(DaprSpringMessagingIT.class); private static final String TOPIC = "mockTopic"; - private static final Network DAPR_NETWORK = Network.newNetwork(); + private static final int APP_PORT = 8080; + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*app is subscribed to the following topics.*"; @Container @ServiceConnection - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.13.2") + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) .withAppName("messaging-dapr-app") .withNetwork(DAPR_NETWORK) .withComponent(new Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap())) - .withAppPort(8080) + .withAppPort(APP_PORT) + .withAppHealthCheckPath("/ready") .withDaprLogLevel(DaprLogLevel.DEBUG) .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) .withAppChannelAddress("host.testcontainers.internal"); @@ -78,16 +79,16 @@ public class DaprSpringMessagingIT { @BeforeAll public static void beforeAll(){ - org.testcontainers.Testcontainers.exposeHostPorts(8080); + org.testcontainers.Testcontainers.exposeHostPorts(APP_PORT); } @BeforeEach - public void beforeEach() throws InterruptedException { - Thread.sleep(1000); + public void beforeEach() { + // Ensure the subscriptions are registered + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(DAPR_CONTAINER); } @Test - @Disabled("Test is flaky due to global state in the spring test application.") public void testDaprMessagingTemplate() throws InterruptedException { for (int i = 0; i < 10; i++) { var msg = "ProduceAndReadWithPrimitiveMessageType:" + i; diff --git a/sdk-tests/src/test/java/io/dapr/it/spring/messaging/TestRestController.java b/sdk-tests/src/test/java/io/dapr/it/spring/messaging/TestRestController.java index a5d12093c..963cf6b3f 100644 --- a/sdk-tests/src/test/java/io/dapr/it/spring/messaging/TestRestController.java +++ b/sdk-tests/src/test/java/io/dapr/it/spring/messaging/TestRestController.java @@ -33,7 +33,7 @@ public class TestRestController { private static final Logger LOG = LoggerFactory.getLogger(TestRestController.class); private final List> events = new ArrayList<>(); - @GetMapping("/") + @GetMapping("/ready") public String ok() { return "OK"; } diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprActorsIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprActorsIT.java new file mode 100644 index 000000000..69eefd962 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprActorsIT.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.it.testcontainers; + +import io.dapr.actors.ActorId; +import io.dapr.actors.client.ActorClient; +import io.dapr.actors.client.ActorProxyBuilder; +import io.dapr.actors.runtime.ActorRuntime; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + classes = { + TestActorsApplication.class, + TestDaprActorsConfiguration.class + } +) +@Testcontainers +@Tag("testcontainers") +public class DaprActorsIT { + private static final Network DAPR_NETWORK = Network.newNetwork(); + private static final Random RANDOM = new Random(); + private static final int PORT = RANDOM.nextInt(1000) + 8000; + + private static final String ACTORS_MESSAGE_PATTERN = ".*Actor API level in the cluster has been updated to 10.*"; + + @Container + private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.14.4") + .withAppName("actor-dapr-app") + .withNetwork(DAPR_NETWORK) + .withComponent(new Component("kvstore", "state.in-memory", "v1", + Map.of("actorStateStore", "true"))) + .withDaprLogLevel(DaprLogLevel.DEBUG) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppChannelAddress("host.testcontainers.internal") + .withAppPort(PORT); + + /** + * Expose the Dapr ports to the host. + * + * @param registry the dynamic property registry + */ + @DynamicPropertySource + static void daprProperties(DynamicPropertyRegistry registry) { + registry.add("dapr.http.endpoint", DAPR_CONTAINER::getHttpEndpoint); + registry.add("dapr.grpc.endpoint", DAPR_CONTAINER::getGrpcEndpoint); + registry.add("server.port", () -> PORT); + } + + @Autowired + private ActorClient daprActorClient; + + @Autowired + private ActorRuntime daprActorRuntime; + + @BeforeEach + public void setUp(){ + org.testcontainers.Testcontainers.exposeHostPorts(PORT); + daprActorRuntime.registerActor(TestActorImpl.class); + // Ensure the subscriptions are registered + Wait.forLogMessage(ACTORS_MESSAGE_PATTERN, 1).waitUntilReady(DAPR_CONTAINER); + } + + @Test + public void testActors() { + ActorProxyBuilder builder = new ActorProxyBuilder<>(TestActor.class, daprActorClient); + ActorId actorId = ActorId.createRandom(); + TestActor actor = builder.build(actorId); + + String message = UUID.randomUUID().toString(); + + String echoedMessage = actor.echo(message); + + assertEquals(echoedMessage, message); + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerConstants.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerConstants.java new file mode 100644 index 000000000..26bf340a1 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerConstants.java @@ -0,0 +1,5 @@ +package io.dapr.it.testcontainers; + +public interface DaprContainerConstants { + String IMAGE_TAG = "daprio/daprd:1.14.1"; +} diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java index d37680a2d..605606028 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprContainerIT.java @@ -27,11 +27,15 @@ import okhttp3.Response; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; +import org.testcontainers.shaded.org.awaitility.core.ConditionTimeoutException; import java.io.IOException; import java.util.Map; +import java.util.concurrent.TimeUnit; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.any; @@ -44,9 +48,12 @@ import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + @Testcontainers @WireMockTest(httpPort = 8081) @@ -59,12 +66,14 @@ public class DaprContainerIT { private static final String KEY = "my-key"; private static final String PUBSUB_NAME = "pubsub"; private static final String PUBSUB_TOPIC_NAME = "topic"; + private static final String APP_FOUND_MESSAGE_PATTERN = ".*application discovered on port 8081.*"; @Container - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd") - .withAppName("dapr-app") - .withAppPort(8081) - .withAppChannelAddress("host.testcontainers.internal"); + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) + .withAppName("dapr-app") + .withAppPort(8081) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal"); /** * Sets the Dapr properties for the test. @@ -76,18 +85,21 @@ public class DaprContainerIT { } private void configStub() { + stubFor(any(urlMatching("/actuator/health")) + .willReturn(aResponse().withBody("[]").withStatus(200))); + stubFor(any(urlMatching("/dapr/subscribe")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); stubFor(get(urlMatching("/dapr/config")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); stubFor(any(urlMatching("/([a-z1-9]*)")) - .willReturn(aResponse().withBody("[]").withStatus(200))); + .willReturn(aResponse().withBody("[]").withStatus(200))); // create a stub stubFor(post(urlEqualTo("/events")) - .willReturn(aResponse().withBody("event received!").withStatus(200))); + .willReturn(aResponse().withBody("event received!").withStatus(200))); configureFor("localhost", 8081); } @@ -95,13 +107,13 @@ public class DaprContainerIT { @Test public void testDaprContainerDefaults() { assertEquals(2, - DAPR_CONTAINER.getComponents().size(), - "The pubsub and kvstore component should be configured by default" + DAPR_CONTAINER.getComponents().size(), + "The pubsub and kvstore component should be configured by default" ); assertEquals( - 1, - DAPR_CONTAINER.getSubscriptions().size(), - "A subscription should be configured by default if none is provided" + 1, + DAPR_CONTAINER.getSubscriptions().size(), + "A subscription should be configured by default if none is provided" ); } @@ -124,18 +136,35 @@ public class DaprContainerIT { @Test public void testPlacement() throws Exception { - // Dapr and Placement need some time to connect - Thread.sleep(1000); + Wait.forLogMessage(APP_FOUND_MESSAGE_PATTERN, 1).waitUntilReady(DAPR_CONTAINER); + try { + await().atMost(10, TimeUnit.SECONDS) + .pollDelay(500, TimeUnit.MILLISECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> { + String metadata = checkSidecarMetadata(); + if (metadata.contains("placement: connected")) { + return true; + } else { + return false; + } + }); + } catch (ConditionTimeoutException timeoutException) { + fail("The placement server is not connected"); + } + } + + private String checkSidecarMetadata() throws IOException { OkHttpClient okHttpClient = new OkHttpClient.Builder() - .build(); + .build(); Request request = new Request.Builder() - .url(DAPR_CONTAINER.getHttpEndpoint() + "/v1.0/metadata") - .build(); + .url(DAPR_CONTAINER.getHttpEndpoint() + "/v1.0/metadata") + .build(); try (Response response = okHttpClient.newCall(request).execute()) { if (response.isSuccessful() && response.body() != null) { - assertTrue(response.body().string().contains("placement: connected")); + return response.body().string(); } else { throw new IOException("Unexpected response: " + response.code()); } @@ -157,7 +186,7 @@ public class DaprContainerIT { private DaprClientBuilder createDaprClientBuilder() { return new DaprClientBuilder() - .withPropertyOverride(Properties.HTTP_ENDPOINT, DAPR_CONTAINER.getHttpEndpoint()) - .withPropertyOverride(Properties.GRPC_ENDPOINT, DAPR_CONTAINER.getGrpcEndpoint()); + .withPropertyOverride(Properties.HTTP_ENDPOINT, DAPR_CONTAINER.getHttpEndpoint()) + .withPropertyOverride(Properties.GRPC_ENDPOINT, DAPR_CONTAINER.getGrpcEndpoint()); } } diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprWorkflowsIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprWorkflowsIT.java index f0c39ed80..364f7d32e 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprWorkflowsIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/DaprWorkflowsIT.java @@ -39,6 +39,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Map; +import static io.dapr.it.testcontainers.DaprContainerConstants.IMAGE_TAG; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -56,7 +57,7 @@ public class DaprWorkflowsIT { private static final Network DAPR_NETWORK = Network.newNetwork(); @Container - private static final DaprContainer DAPR_CONTAINER = new DaprContainer("daprio/daprd:1.13.2") + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(IMAGE_TAG) .withAppName("workflow-dapr-app") .withNetwork(DAPR_NETWORK) .withComponent(new Component("kvstore", "state.in-memory", "v1", diff --git a/sdk-actors/src/test/java/io/dapr/client/DaprHttpProxy.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActor.java similarity index 64% rename from sdk-actors/src/test/java/io/dapr/client/DaprHttpProxy.java rename to sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActor.java index a1c263dd9..c5457fa55 100644 --- a/sdk-actors/src/test/java/io/dapr/client/DaprHttpProxy.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActor.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Dapr Authors + * Copyright 2025 The Dapr Authors * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -11,14 +11,12 @@ limitations under the License. */ -package io.dapr.client; - -import okhttp3.OkHttpClient; - -public class DaprHttpProxy extends io.dapr.client.DaprHttp { - - public DaprHttpProxy(String hostname, int port, String daprApiToken, OkHttpClient httpClient) { - super(hostname, port, daprApiToken, httpClient); - } +package io.dapr.it.testcontainers; +import io.dapr.actors.ActorMethod; +import io.dapr.actors.ActorType; +@ActorType(name = "TestActor") +public interface TestActor { + @ActorMethod(name = "echo_message") + String echo(String message); } diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaContext.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorImpl.java similarity index 54% rename from sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaContext.java rename to sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorImpl.java index 03470ff92..c17164299 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaContext.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Dapr Authors + * Copyright 2025 The Dapr Authors * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -11,24 +11,19 @@ limitations under the License. */ -package io.dapr.workflows.saga; +package io.dapr.it.testcontainers; -/** - * Saga context. - */ -public interface SagaContext { - /** - * Register a compensation activity. - * - * @param activityClassName name of the activity class - * @param activityInput input of the activity to be compensated - */ - void registerCompensation(String activityClassName, Object activityInput); +import io.dapr.actors.ActorId; +import io.dapr.actors.runtime.AbstractActor; +import io.dapr.actors.runtime.ActorRuntimeContext; - /** - * Compensate all registered activities. - * - */ - void compensate(); +public class TestActorImpl extends AbstractActor implements TestActor { + public TestActorImpl(ActorRuntimeContext runtimeContext, ActorId id) { + super(runtimeContext, id); + } + @Override + public String echo(String message) { + return message; + } } diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorsApplication.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorsApplication.java new file mode 100644 index 000000000..e55646218 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestActorsApplication.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.it.testcontainers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +@RestController +public class TestActorsApplication { + + public static void main(String[] args) { + SpringApplication.run(TestActorsApplication.class, args); + } + + //Mocking the actuator health endpoint for the sidecar health check + @GetMapping("/actuator/health") + public String health(){ + return "OK"; + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprActorsConfiguration.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprActorsConfiguration.java new file mode 100644 index 000000000..23f2cf2e0 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprActorsConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.it.testcontainers; + +import java.util.Map; + +import io.dapr.actors.runtime.ActorRuntime; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.dapr.actors.client.ActorClient; +import io.dapr.config.Properties; + +@Configuration +public class TestDaprActorsConfiguration { + @Bean + public ActorClient daprActorClient( + @Value("${dapr.http.endpoint}") String daprHttpEndpoint, + @Value("${dapr.grpc.endpoint}") String daprGrpcEndpoint + ){ + Map overrides = Map.of( + "dapr.http.endpoint", daprHttpEndpoint, + "dapr.grpc.endpoint", daprGrpcEndpoint + ); + + return new ActorClient(new Properties(overrides)); + } + + @Bean + public ActorRuntime daprActorRuntime( + @Value("${dapr.http.endpoint}") String daprHttpEndpoint, + @Value("${dapr.grpc.endpoint}") String daprGrpcEndpoint + ){ + Map overrides = Map.of( + "dapr.http.endpoint", daprHttpEndpoint, + "dapr.grpc.endpoint", daprGrpcEndpoint + ); + + return ActorRuntime.getInstance(new Properties(overrides)); + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprWorkflowsConfiguration.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprWorkflowsConfiguration.java index 982d7e89f..0a2487b70 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprWorkflowsConfiguration.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/TestDaprWorkflowsConfiguration.java @@ -1,3 +1,16 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + package io.dapr.it.testcontainers; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/sdk-workflows/pom.xml b/sdk-workflows/pom.xml index ef3f176dd..a5c4ae949 100644 --- a/sdk-workflows/pom.xml +++ b/sdk-workflows/pom.xml @@ -7,19 +7,15 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk-workflows jar - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT dapr-sdk-workflows SDK for Workflows on Dapr - - false - - io.dapr @@ -82,6 +78,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/Workflow.java b/sdk-workflows/src/main/java/io/dapr/workflows/Workflow.java index 8cb4750ae..ff08dc015 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/Workflow.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/Workflow.java @@ -13,11 +13,6 @@ limitations under the License. package io.dapr.workflows; -import com.microsoft.durabletask.interruption.ContinueAsNewInterruption; -import com.microsoft.durabletask.interruption.OrchestratorBlockedException; -import io.dapr.workflows.saga.SagaCompensationException; -import io.dapr.workflows.saga.SagaOptions; - /** * Common interface for workflow implementations. */ @@ -39,43 +34,6 @@ public interface Workflow { default void run(WorkflowContext ctx) { WorkflowStub stub = this.create(); - if (!this.isSagaEnabled()) { - // saga disabled - stub.run(ctx); - } else { - // saga enabled - try { - stub.run(ctx); - } catch (OrchestratorBlockedException | ContinueAsNewInterruption e) { - throw e; - } catch (SagaCompensationException e) { - // Saga compensation is triggered gracefully but failed in exception - // don't need to trigger compensation again - throw e; - } catch (Exception e) { - try { - ctx.getSagaContext().compensate(); - } catch (Exception se) { - se.addSuppressed(e); - throw se; - } - - throw e; - } - } - } - - default boolean isSagaEnabled() { - return this.getSagaOption() != null; - } - - /** - * get saga configuration. - * - * @return saga configuration - */ - default SagaOptions getSagaOption() { - // by default, saga is disabled - return null; + stub.run(ctx); } } diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/WorkflowContext.java b/sdk-workflows/src/main/java/io/dapr/workflows/WorkflowContext.java index 4156c5a59..9ed34fdc1 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/WorkflowContext.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/WorkflowContext.java @@ -17,7 +17,6 @@ import com.microsoft.durabletask.CompositeTaskFailedException; import com.microsoft.durabletask.Task; import com.microsoft.durabletask.TaskCanceledException; import com.microsoft.durabletask.TaskFailedException; -import io.dapr.workflows.saga.SagaContext; import org.slf4j.Logger; import javax.annotation.Nullable; @@ -530,12 +529,4 @@ public interface WorkflowContext { default UUID newUuid() { throw new RuntimeException("No implementation found."); } - - /** - * get saga context. - * - * @return saga context - * @throws UnsupportedOperationException if saga is not enabled. - */ - SagaContext getSagaContext(); } diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java index b843819ad..20572995c 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java @@ -22,9 +22,6 @@ import com.microsoft.durabletask.TaskOrchestrationContext; import io.dapr.workflows.WorkflowContext; import io.dapr.workflows.WorkflowTaskOptions; import io.dapr.workflows.WorkflowTaskRetryPolicy; -import io.dapr.workflows.runtime.saga.DefaultSagaContext; -import io.dapr.workflows.saga.Saga; -import io.dapr.workflows.saga.SagaContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.helpers.NOPLogger; @@ -39,7 +36,6 @@ import java.util.UUID; public class DefaultWorkflowContext implements WorkflowContext { private final TaskOrchestrationContext innerContext; private final Logger logger; - private final Saga saga; /** * Constructor for DaprWorkflowContextImpl. @@ -58,23 +54,7 @@ public class DefaultWorkflowContext implements WorkflowContext { * @param logger Logger * @throws IllegalArgumentException if context or logger is null */ - public DefaultWorkflowContext(TaskOrchestrationContext context, Logger logger) throws IllegalArgumentException { - this(context, logger, null); - } - - public DefaultWorkflowContext(TaskOrchestrationContext context, Saga saga) throws IllegalArgumentException { - this(context, LoggerFactory.getLogger(WorkflowContext.class), saga); - } - - /** - * Constructor for DaprWorkflowContextImpl. - * - * @param context TaskOrchestrationContext - * @param logger Logger - * @param saga saga object, if null, saga is disabled - * @throws IllegalArgumentException if context or logger is null - */ - public DefaultWorkflowContext(TaskOrchestrationContext context, Logger logger, Saga saga) + public DefaultWorkflowContext(TaskOrchestrationContext context, Logger logger) throws IllegalArgumentException { if (context == null) { throw new IllegalArgumentException("Context cannot be null"); @@ -85,7 +65,6 @@ public class DefaultWorkflowContext implements WorkflowContext { this.innerContext = context; this.logger = logger; - this.saga = saga; } /** @@ -249,15 +228,6 @@ public class DefaultWorkflowContext implements WorkflowContext { return this.innerContext.newUUID(); } - @Override - public SagaContext getSagaContext() { - if (this.saga == null) { - throw new UnsupportedOperationException("Saga is not enabled"); - } - - return new DefaultSagaContext(this.saga, this); - } - private static TaskOptions toTaskOptions(WorkflowTaskOptions options) { if (options == null) { return null; diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityWrapper.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapper.java similarity index 92% rename from sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityWrapper.java rename to sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapper.java index 18f4eb55d..3dcb8ef6b 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityWrapper.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapper.java @@ -23,7 +23,7 @@ import java.lang.reflect.InvocationTargetException; /** * Wrapper for Durable Task Framework task activity factory. */ -public class WorkflowActivityWrapper implements TaskActivityFactory { +public class WorkflowActivityClassWrapper implements TaskActivityFactory { private final Constructor activityConstructor; private final String name; @@ -32,7 +32,7 @@ public class WorkflowActivityWrapper implements Task * * @param clazz Class of the activity to wrap. */ - public WorkflowActivityWrapper(Class clazz) { + public WorkflowActivityClassWrapper(Class clazz) { this.name = clazz.getCanonicalName(); try { this.activityConstructor = clazz.getDeclaredConstructor(); diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapper.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapper.java new file mode 100644 index 000000000..17d509924 --- /dev/null +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapper.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.runtime; + +import com.microsoft.durabletask.TaskActivity; +import com.microsoft.durabletask.TaskActivityFactory; +import io.dapr.workflows.WorkflowActivity; + +/** + * Wrapper for Durable Task Framework task activity factory. + */ +public class WorkflowActivityInstanceWrapper implements TaskActivityFactory { + private final T activity; + private final String name; + + /** + * Constructor for WorkflowActivityWrapper. + * + * @param instance Instance of the activity to wrap. + */ + public WorkflowActivityInstanceWrapper(T instance) { + this.name = instance.getClass().getCanonicalName(); + this.activity = instance; + } + + @Override + public String getName() { + return name; + } + + @Override + public TaskActivity create() { + return ctx -> activity.run(new DefaultWorkflowActivityContext(ctx)); + } +} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowWrapper.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowClassWrapper.java similarity index 81% rename from sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowWrapper.java rename to sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowClassWrapper.java index 91f1dd8bc..4fab3f9cd 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowWrapper.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowClassWrapper.java @@ -16,7 +16,6 @@ package io.dapr.workflows.runtime; import com.microsoft.durabletask.TaskOrchestration; import com.microsoft.durabletask.TaskOrchestrationFactory; import io.dapr.workflows.Workflow; -import io.dapr.workflows.saga.Saga; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -24,12 +23,13 @@ import java.lang.reflect.InvocationTargetException; /** * Wrapper for Durable Task Framework orchestration factory. */ -class WorkflowWrapper implements TaskOrchestrationFactory { +class WorkflowClassWrapper implements TaskOrchestrationFactory { private final Constructor workflowConstructor; private final String name; - public WorkflowWrapper(Class clazz) { + public WorkflowClassWrapper(Class clazz) { this.name = clazz.getCanonicalName(); + try { this.workflowConstructor = clazz.getDeclaredConstructor(); } catch (NoSuchMethodException e) { @@ -48,6 +48,7 @@ class WorkflowWrapper implements TaskOrchestrationFactory { public TaskOrchestration create() { return ctx -> { T workflow; + try { workflow = this.workflowConstructor.newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { @@ -56,13 +57,7 @@ class WorkflowWrapper implements TaskOrchestrationFactory { ); } - if (workflow.getSagaOption() != null) { - Saga saga = new Saga(workflow.getSagaOption()); - workflow.run(new DefaultWorkflowContext(ctx, saga)); - } else { - workflow.run(new DefaultWorkflowContext(ctx)); - } + workflow.run(new DefaultWorkflowContext(ctx)); }; - } } diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowInstanceWrapper.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowInstanceWrapper.java new file mode 100644 index 000000000..ad3159406 --- /dev/null +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowInstanceWrapper.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.runtime; + +import com.microsoft.durabletask.TaskOrchestration; +import com.microsoft.durabletask.TaskOrchestrationFactory; +import io.dapr.workflows.Workflow; + +/** + * Wrapper for Durable Task Framework orchestration factory. + */ +class WorkflowInstanceWrapper implements TaskOrchestrationFactory { + private final T workflow; + private final String name; + + public WorkflowInstanceWrapper(T instance) { + this.name = instance.getClass().getCanonicalName(); + this.workflow = instance; + } + + @Override + public String getName() { + return name; + } + + @Override + public TaskOrchestration create() { + return ctx -> workflow.run(new DefaultWorkflowContext(ctx)); + } +} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilder.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilder.java index 86d0cf1e0..397e58b30 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilder.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilder.java @@ -92,11 +92,30 @@ public class WorkflowRuntimeBuilder { * @return the WorkflowRuntimeBuilder */ public WorkflowRuntimeBuilder registerWorkflow(Class clazz) { - this.builder.addOrchestration(new WorkflowWrapper<>(clazz)); + this.builder.addOrchestration(new WorkflowClassWrapper<>(clazz)); this.workflowSet.add(clazz.getCanonicalName()); this.workflows.add(clazz.getSimpleName()); - this.logger.info("Registered Workflow: " + clazz.getSimpleName()); + this.logger.info("Registered Workflow: {}", clazz.getSimpleName()); + + return this; + } + + /** + * Registers a Workflow object. + * + * @param any Workflow type + * @param instance the workflow instance being registered + * @return the WorkflowRuntimeBuilder + */ + public WorkflowRuntimeBuilder registerWorkflow(T instance) { + Class clazz = (Class) instance.getClass(); + + this.builder.addOrchestration(new WorkflowInstanceWrapper<>(instance)); + this.workflowSet.add(clazz.getCanonicalName()); + this.workflows.add(clazz.getSimpleName()); + + this.logger.info("Registered Workflow: {}", clazz.getSimpleName()); return this; } @@ -109,11 +128,30 @@ public class WorkflowRuntimeBuilder { * @return the WorkflowRuntimeBuilder */ public WorkflowRuntimeBuilder registerActivity(Class clazz) { - this.builder.addActivity(new WorkflowActivityWrapper<>(clazz)); + this.builder.addActivity(new WorkflowActivityClassWrapper<>(clazz)); this.activitySet.add(clazz.getCanonicalName()); this.activities.add(clazz.getSimpleName()); - this.logger.info("Registered Activity: " + clazz.getSimpleName()); + this.logger.info("Registered Activity: {}", clazz.getSimpleName()); + + return this; + } + + /** + * Registers an Activity object. + * + * @param any WorkflowActivity type + * @param instance the class instance being registered + * @return the WorkflowRuntimeBuilder + */ + public WorkflowRuntimeBuilder registerActivity(T instance) { + Class clazz = (Class) instance.getClass(); + + this.builder.addActivity(new WorkflowActivityInstanceWrapper<>(instance)); + this.activitySet.add(clazz.getCanonicalName()); + this.activities.add(clazz.getSimpleName()); + + this.logger.info("Registered Activity: {}", clazz.getSimpleName()); return this; } diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/saga/DefaultSagaContext.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/saga/DefaultSagaContext.java deleted file mode 100644 index 78d72b73d..000000000 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/saga/DefaultSagaContext.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2023 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.workflows.runtime.saga; - -import io.dapr.workflows.WorkflowContext; -import io.dapr.workflows.saga.Saga; -import io.dapr.workflows.saga.SagaContext; - -/** - * Dapr Saga Context implementation. - */ -public class DefaultSagaContext implements SagaContext { - - private final Saga saga; - private final WorkflowContext workflowContext; - - /** - * Constructor to build up instance. - * - * @param saga Saga instance. - * @param workflowContext Workflow context. - * @throws IllegalArgumentException if saga or workflowContext is null. - */ - public DefaultSagaContext(Saga saga, WorkflowContext workflowContext) { - if (saga == null) { - throw new IllegalArgumentException("Saga should not be null"); - } - if (workflowContext == null) { - throw new IllegalArgumentException("workflowContext should not be null"); - } - - this.saga = saga; - this.workflowContext = workflowContext; - } - - @Override - public void registerCompensation(String activityClassName, Object activityInput) { - this.saga.registerCompensation(activityClassName, activityInput); - } - - @Override - public void compensate() { - this.saga.compensate(workflowContext); - } -} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/saga/CompensationInformation.java b/sdk-workflows/src/main/java/io/dapr/workflows/saga/CompensationInformation.java deleted file mode 100644 index 33a2f741d..000000000 --- a/sdk-workflows/src/main/java/io/dapr/workflows/saga/CompensationInformation.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2023 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.workflows.saga; - -import io.dapr.workflows.WorkflowTaskOptions; - -/** - * Information for a compensation activity. - */ -class CompensationInformation { - private final String compensationActivityClassName; - private final Object compensationActivityInput; - private final WorkflowTaskOptions options; - - /** - * Constructor for a compensation information. - * - * @param compensationActivityClassName Class name of the activity to do - * compensation. - * @param compensationActivityInput Input of the activity to do - * compensation. - * @param options Task options to set retry strategy - */ - public CompensationInformation(String compensationActivityClassName, - Object compensationActivityInput, WorkflowTaskOptions options) { - this.compensationActivityClassName = compensationActivityClassName; - this.compensationActivityInput = compensationActivityInput; - this.options = options; - } - - /** - * Gets the class name of the activity. - * - * @return the class name of the activity. - */ - public String getCompensationActivityClassName() { - return compensationActivityClassName; - } - - /** - * Gets the input of the activity. - * - * @return the input of the activity. - */ - public Object getCompensationActivityInput() { - return compensationActivityInput; - } - - /** - * get task options. - * - * @return task options, null if not set - */ - public WorkflowTaskOptions getExecutionOptions() { - return options; - } -} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/saga/Saga.java b/sdk-workflows/src/main/java/io/dapr/workflows/saga/Saga.java deleted file mode 100644 index f02da10b4..000000000 --- a/sdk-workflows/src/main/java/io/dapr/workflows/saga/Saga.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2023 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.workflows.saga; - -import com.microsoft.durabletask.Task; -import com.microsoft.durabletask.interruption.ContinueAsNewInterruption; -import com.microsoft.durabletask.interruption.OrchestratorBlockedException; -import io.dapr.workflows.WorkflowContext; -import io.dapr.workflows.WorkflowTaskOptions; - -import java.util.ArrayList; -import java.util.List; - -public final class Saga { - private final SagaOptions options; - private final List compensationActivities = new ArrayList<>(); - - /** - * Build up a Saga with its options. - * - * @param options Saga option. - */ - public Saga(SagaOptions options) { - if (options == null) { - throw new IllegalArgumentException("option is required and should not be null."); - } - this.options = options; - } - - /** - * Register a compensation activity. - * - * @param activityClassName name of the activity class - * @param activityInput input of the activity to be compensated - */ - public void registerCompensation(String activityClassName, Object activityInput) { - this.registerCompensation(activityClassName, activityInput, null); - } - - /** - * Register a compensation activity. - * - * @param activityClassName name of the activity class - * @param activityInput input of the activity to be compensated - * @param options task options to set retry strategy - */ - public void registerCompensation(String activityClassName, Object activityInput, WorkflowTaskOptions options) { - if (activityClassName == null || activityClassName.isEmpty()) { - throw new IllegalArgumentException("activityClassName is required and should not be null or empty."); - } - this.compensationActivities.add(new CompensationInformation(activityClassName, activityInput, options)); - } - - /** - * Compensate all registered activities. - * - * @param ctx Workflow context. - */ - public void compensate(WorkflowContext ctx) { - // Check if parallel compensation is enabled - // Special case: when parallel compensation is enabled and there is only one - // compensation, we still - // compensate sequentially. - if (options.isParallelCompensation() && compensationActivities.size() > 1) { - compensateInParallel(ctx); - } else { - compensateSequentially(ctx); - } - } - - private void compensateInParallel(WorkflowContext ctx) { - List> tasks = new ArrayList<>(compensationActivities.size()); - for (CompensationInformation compensationActivity : compensationActivities) { - Task task = executeCompensateActivity(ctx, compensationActivity); - tasks.add(task); - } - - try { - ctx.allOf(tasks).await(); - } catch (Exception e) { - throw new SagaCompensationException("Failed to compensate in parallel.", e); - } - } - - private void compensateSequentially(WorkflowContext ctx) { - SagaCompensationException sagaException = null; - for (int i = compensationActivities.size() - 1; i >= 0; i--) { - String activityClassName = compensationActivities.get(i).getCompensationActivityClassName(); - try { - executeCompensateActivity(ctx, compensationActivities.get(i)).await(); - } catch (OrchestratorBlockedException | ContinueAsNewInterruption e) { - throw e; - } catch (Exception e) { - if (sagaException == null) { - sagaException = new SagaCompensationException( - "Exception in saga compensation: activity=" + activityClassName, e); - } else { - sagaException.addSuppressed(e); - } - - if (!options.isContinueWithError()) { - throw sagaException; - } - } - } - - if (sagaException != null) { - throw sagaException; - } - } - - private Task executeCompensateActivity(WorkflowContext ctx, CompensationInformation info) - throws SagaCompensationException { - String activityClassName = info.getCompensationActivityClassName(); - return ctx.callActivity(activityClassName, info.getCompensationActivityInput(), - info.getExecutionOptions()); - } -} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaOptions.java b/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaOptions.java deleted file mode 100644 index 8a7184b6d..000000000 --- a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaOptions.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2023 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ - -package io.dapr.workflows.saga; - -/** - * Saga option. - */ -public final class SagaOptions { - private final boolean parallelCompensation; - private final int maxParallelThread; - private final boolean continueWithError; - - private SagaOptions(boolean parallelCompensation, int maxParallelThread, boolean continueWithError) { - this.parallelCompensation = parallelCompensation; - this.maxParallelThread = maxParallelThread; - this.continueWithError = continueWithError; - } - - public boolean isParallelCompensation() { - return parallelCompensation; - } - - public boolean isContinueWithError() { - return continueWithError; - } - - public int getMaxParallelThread() { - return maxParallelThread; - } - - public static Builder newBuilder() { - return new Builder(); - } - - public static final class Builder { - // by default compensation is sequential - private boolean parallelCompensation = false; - - // by default max parallel thread is 16, it's enough for most cases - private int maxParallelThread = 16; - - // by default set continueWithError to be true - // So if a compensation fails, we should continue with the next compensations - private boolean continueWithError = true; - - /** - * Set parallel compensation. - * @param parallelCompensation parallel compensation or not - * @return this builder itself - */ - public Builder setParallelCompensation(boolean parallelCompensation) { - this.parallelCompensation = parallelCompensation; - return this; - } - - /** - * set max parallel thread. - * - *

Only valid when parallelCompensation is true. - * @param maxParallelThread max parallel thread - * @return this builder itself - */ - public Builder setMaxParallelThread(int maxParallelThread) { - if (maxParallelThread <= 2) { - throw new IllegalArgumentException("maxParallelThread should be greater than 1."); - } - this.maxParallelThread = maxParallelThread; - return this; - } - - /** - * Set continue with error. - * - *

Only valid when parallelCompensation is false. - * @param continueWithError continue with error or not - * @return this builder itself - */ - public Builder setContinueWithError(boolean continueWithError) { - this.continueWithError = continueWithError; - return this; - } - - /** - * Build Saga option. - * @return Saga option - */ - public SagaOptions build() { - return new SagaOptions(this.parallelCompensation, this.maxParallelThread, this.continueWithError); - } - } -} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java index 32af9fc6f..61d153484 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java @@ -20,15 +20,14 @@ import com.microsoft.durabletask.TaskOptions; import com.microsoft.durabletask.TaskOrchestrationContext; import io.dapr.workflows.runtime.DefaultWorkflowContext; -import io.dapr.workflows.saga.Saga; -import io.dapr.workflows.saga.SagaContext; -import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.slf4j.Logger; +import javax.annotation.Nullable; + import java.time.Duration; import java.time.Instant; import java.time.ZonedDateTime; @@ -134,12 +133,6 @@ public class DefaultWorkflowContextTest { @Override public void continueAsNew(Object input, boolean preserveUnprocessedEvents) { - - } - - @Override - public SagaContext getSagaContext() { - return null; } }; } @@ -334,19 +327,4 @@ public class DefaultWorkflowContextTest { String expectedMessage = "No implementation found."; assertEquals(expectedMessage, runtimeException.getMessage()); } - - @Test - public void getSagaContextTest_sagaEnabled() { - Saga saga = mock(Saga.class); - WorkflowContext context = new DefaultWorkflowContext(mockInnerContext, saga); - - SagaContext sagaContext = context.getSagaContext(); - assertNotNull(sagaContext, "SagaContext should not be null"); - } - - @Test - public void getSagaContextTest_sagaDisabled() { - WorkflowContext context = new DefaultWorkflowContext(mockInnerContext); - assertThrows(UnsupportedOperationException.class, context::getSagaContext); - } } diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/WorkflowTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/WorkflowTest.java index f319709ec..13fe3a1b1 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/WorkflowTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/WorkflowTest.java @@ -1,15 +1,8 @@ package io.dapr.workflows; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -17,21 +10,12 @@ import static org.mockito.Mockito.verify; import org.junit.Test; -import com.microsoft.durabletask.interruption.ContinueAsNewInterruption; -import com.microsoft.durabletask.interruption.OrchestratorBlockedException; - -import io.dapr.workflows.saga.SagaCompensationException; -import io.dapr.workflows.saga.SagaContext; -import io.dapr.workflows.saga.SagaOptions; - public class WorkflowTest { @Test - public void testWorkflow_WithoutSaga() { + public void testWorkflow() { WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithoutSaga(stub); - assertNull(workflow.getSagaOption()); - assertFalse(workflow.isSagaEnabled()); + Workflow workflow = new TestWorkflow(stub); WorkflowContext ctx = mock(WorkflowContext.class); doNothing().when(stub).run(ctx); @@ -41,9 +25,9 @@ public class WorkflowTest { } @Test - public void testWorkflow_WithoutSaga_throwException() { + public void testWorkflow_throwException() { WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithoutSaga(stub); + Workflow workflow = new TestWorkflow(stub); WorkflowContext ctx = mock(WorkflowContext.class); Exception e = new RuntimeException(); doThrow(e).when(stub).run(ctx); @@ -55,117 +39,10 @@ public class WorkflowTest { verify(stub, times(1)).run(eq(ctx)); } - @Test - public void testWorkflow_WithSaga() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - assertNotNull(workflow.getSagaOption()); - assertTrue(workflow.isSagaEnabled()); - - WorkflowContext ctx = mock(WorkflowContext.class); - doNothing().when(stub).run(ctx); - workflow.run(ctx); - - verify(stub, times(1)).run(eq(ctx)); - } - - @Test - public void testWorkflow_WithSaga_shouldNotCatch_OrchestratorBlockedException() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - - WorkflowContext ctx = mock(WorkflowContext.class); - Exception e = new OrchestratorBlockedException("test"); - doThrow(e).when(stub).run(ctx); - - // should not catch OrchestratorBlockedException - assertThrows(OrchestratorBlockedException.class, () -> { - workflow.run(ctx); - }); - verify(stub, times(1)).run(eq(ctx)); - } - - @Test - public void testWorkflow_WithSaga_shouldNotCatch_ContinueAsNewInterruption() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - - WorkflowContext ctx = mock(WorkflowContext.class); - Exception e = new ContinueAsNewInterruption("test"); - doThrow(e).when(stub).run(ctx); - - // should not catch ContinueAsNewInterruption - assertThrows(ContinueAsNewInterruption.class, () -> { - workflow.run(ctx); - }); - verify(stub, times(1)).run(eq(ctx)); - } - - @Test - public void testWorkflow_WithSaga_shouldNotCatch_SagaCompensationException() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - - WorkflowContext ctx = mock(WorkflowContext.class); - Exception e = new SagaCompensationException("test", null); - doThrow(e).when(stub).run(ctx); - - // should not catch SagaCompensationException - assertThrows(SagaCompensationException.class, () -> { - workflow.run(ctx); - }); - verify(stub, times(1)).run(eq(ctx)); - } - - @Test - public void testWorkflow_WithSaga_triggerCompensate() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - - WorkflowContext ctx = mock(WorkflowContext.class); - Exception e = new RuntimeException("test", null); - doThrow(e).when(stub).run(ctx); - SagaContext sagaContext = mock(SagaContext.class); - doReturn(sagaContext).when(ctx).getSagaContext(); - doNothing().when(sagaContext).compensate(); - - assertThrows(RuntimeException.class, () -> { - workflow.run(ctx); - }); - verify(stub, times(1)).run(eq(ctx)); - verify(sagaContext, times(1)).compensate(); - } - - @Test - public void testWorkflow_WithSaga_compensateFaile() { - WorkflowStub stub = mock(WorkflowStub.class); - Workflow workflow = new WorkflowWithSaga(stub); - - WorkflowContext ctx = mock(WorkflowContext.class); - Exception e = new RuntimeException("workflow fail", null); - doThrow(e).when(stub).run(ctx); - SagaContext sagaContext = mock(SagaContext.class); - doReturn(sagaContext).when(ctx).getSagaContext(); - Exception e2 = new RuntimeException("compensate fail", null); - doThrow(e2).when(sagaContext).compensate(); - - try { - workflow.run(ctx); - fail("sholdd throw exception"); - } catch (Exception ex) { - assertEquals(e2.getMessage(), ex.getMessage()); - assertEquals(1, ex.getSuppressed().length); - assertEquals(e.getMessage(), ex.getSuppressed()[0].getMessage()); - } - - verify(stub, times(1)).run(eq(ctx)); - verify(sagaContext, times(1)).compensate(); - } - - public static class WorkflowWithoutSaga implements Workflow { + public static class TestWorkflow implements Workflow { private final WorkflowStub stub; - public WorkflowWithoutSaga(WorkflowStub stub) { + public TestWorkflow(WorkflowStub stub) { this.stub = stub; } @@ -174,24 +51,4 @@ public class WorkflowTest { return stub; } } - - public static class WorkflowWithSaga implements Workflow { - private final WorkflowStub stub; - - public WorkflowWithSaga(WorkflowStub stub) { - this.stub = stub; - } - - @Override - public WorkflowStub create() { - return stub; - } - - @Override - public SagaOptions getSagaOption() { - return SagaOptions.newBuilder() - .setParallelCompensation(false) - .build(); - } - } } diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityWrapperTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapperTest.java similarity index 61% rename from sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityWrapperTest.java rename to sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapperTest.java index 754c02bd8..078317605 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityWrapperTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityClassWrapperTest.java @@ -3,16 +3,15 @@ package io.dapr.workflows.runtime; import com.microsoft.durabletask.TaskActivityContext; import io.dapr.workflows.WorkflowActivity; import io.dapr.workflows.WorkflowActivityContext; -import org.junit.Assert; import org.junit.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; - -public class WorkflowActivityWrapperTest { +public class WorkflowActivityClassWrapperTest { public static class TestActivity implements WorkflowActivity { @Override public Object run(WorkflowActivityContext ctx) { @@ -22,24 +21,26 @@ public class WorkflowActivityWrapperTest { } @Test - public void getName() throws NoSuchMethodException { - WorkflowActivityWrapper wrapper = new WorkflowActivityWrapper<>( - WorkflowActivityWrapperTest.TestActivity.class); - Assert.assertEquals( - "io.dapr.workflows.runtime.WorkflowActivityWrapperTest.TestActivity", + public void getName() { + WorkflowActivityClassWrapper wrapper = new WorkflowActivityClassWrapper<>(TestActivity.class); + + assertEquals( + "io.dapr.workflows.runtime.WorkflowActivityClassWrapperTest.TestActivity", wrapper.getName() ); } @Test - public void createWithClass() throws NoSuchMethodException { + public void createWithClass() { TaskActivityContext mockContext = mock(TaskActivityContext.class); - WorkflowActivityWrapper wrapper = new WorkflowActivityWrapper<>( - WorkflowActivityWrapperTest.TestActivity.class); + WorkflowActivityClassWrapper wrapper = new WorkflowActivityClassWrapper<>(TestActivity.class); + when(mockContext.getInput(String.class)).thenReturn("Hello"); when(mockContext.getName()).thenReturn("TestActivityContext"); + Object result = wrapper.create().run(mockContext); + verify(mockContext, times(1)).getInput(String.class); - Assert.assertEquals("Hello world! from TestActivityContext", result); + assertEquals("Hello world! from TestActivityContext", result); } } diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapperTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapperTest.java new file mode 100644 index 000000000..bd8788bbd --- /dev/null +++ b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowActivityInstanceWrapperTest.java @@ -0,0 +1,46 @@ +package io.dapr.workflows.runtime; + +import com.microsoft.durabletask.TaskActivityContext; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.junit.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorkflowActivityInstanceWrapperTest { + public static class TestActivity implements WorkflowActivity { + @Override + public Object run(WorkflowActivityContext ctx) { + String activityContextName = ctx.getName(); + return ctx.getInput(String.class) + " world! from " + activityContextName; + } + } + + @Test + public void getName() { + WorkflowActivityInstanceWrapper wrapper = new WorkflowActivityInstanceWrapper<>(new TestActivity()); + + assertEquals( + "io.dapr.workflows.runtime.WorkflowActivityInstanceWrapperTest.TestActivity", + wrapper.getName() + ); + } + + @Test + public void createWithInstance() { + TaskActivityContext mockContext = mock(TaskActivityContext.class); + WorkflowActivityInstanceWrapper wrapper = new WorkflowActivityInstanceWrapper<>(new TestActivity()); + + when(mockContext.getInput(String.class)).thenReturn("Hello"); + when(mockContext.getName()).thenReturn("TestActivityContext"); + + Object result = wrapper.create().run(mockContext); + + verify(mockContext, times(1)).getInput(String.class); + assertEquals("Hello world! from TestActivityContext", result); + } +} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowWrapperTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowClassWrapperTest.java similarity index 79% rename from sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowWrapperTest.java rename to sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowClassWrapperTest.java index 6066a7f7c..a73b616bc 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowWrapperTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowClassWrapperTest.java @@ -13,20 +13,19 @@ limitations under the License. package io.dapr.workflows.runtime; - import com.microsoft.durabletask.TaskOrchestrationContext; import io.dapr.workflows.Workflow; import io.dapr.workflows.WorkflowContext; import io.dapr.workflows.WorkflowStub; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class WorkflowWrapperTest { +public class WorkflowClassWrapperTest { public static class TestWorkflow implements Workflow { @Override public WorkflowStub create() { @@ -36,9 +35,10 @@ public class WorkflowWrapperTest { @Test public void getName() { - WorkflowWrapper wrapper = new WorkflowWrapper<>(TestWorkflow.class); - Assertions.assertEquals( - "io.dapr.workflows.runtime.WorkflowWrapperTest.TestWorkflow", + WorkflowClassWrapper wrapper = new WorkflowClassWrapper<>(TestWorkflow.class); + + assertEquals( + "io.dapr.workflows.runtime.WorkflowClassWrapperTest.TestWorkflow", wrapper.getName() ); } @@ -46,10 +46,11 @@ public class WorkflowWrapperTest { @Test public void createWithClass() { TaskOrchestrationContext mockContext = mock(TaskOrchestrationContext.class); - WorkflowWrapper wrapper = new WorkflowWrapper<>(TestWorkflow.class); + WorkflowClassWrapper wrapper = new WorkflowClassWrapper<>(TestWorkflow.class); + when(mockContext.getInstanceId()).thenReturn("uuid"); wrapper.create().run(mockContext); verify(mockContext, times(1)).getInstanceId(); } -} \ No newline at end of file +} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowInstanceWrapperTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowInstanceWrapperTest.java new file mode 100644 index 000000000..22f315aa5 --- /dev/null +++ b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowInstanceWrapperTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.runtime; + +import com.microsoft.durabletask.TaskOrchestrationContext; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowContext; +import io.dapr.workflows.WorkflowStub; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorkflowInstanceWrapperTest { + public static class TestWorkflow implements Workflow { + @Override + public WorkflowStub create() { + return WorkflowContext::getInstanceId; + } + } + + @Test + public void getName() { + WorkflowInstanceWrapper wrapper = new WorkflowInstanceWrapper<>(new TestWorkflow()); + + assertEquals( + "io.dapr.workflows.runtime.WorkflowInstanceWrapperTest.TestWorkflow", + wrapper.getName() + ); + } + + @Test + public void createWithInstance() { + TaskOrchestrationContext mockContext = mock(TaskOrchestrationContext.class); + WorkflowInstanceWrapper wrapper = new WorkflowInstanceWrapper<>(new TestWorkflow()); + + when(mockContext.getInstanceId()).thenReturn("uuid"); + wrapper.create().run(mockContext); + verify(mockContext, times(1)).getInstanceId(); + } + +} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilderTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilderTest.java index 81e3c30f1..c159930b9 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilderTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilderTest.java @@ -12,16 +12,18 @@ limitations under the License. */ package io.dapr.workflows.runtime; - import io.dapr.workflows.Workflow; import io.dapr.workflows.WorkflowActivity; import io.dapr.workflows.WorkflowActivityContext; import io.dapr.workflows.WorkflowStub; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.slf4j.Logger; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.io.ByteArrayOutputStream; import java.io.PrintStream; @@ -47,14 +49,30 @@ public class WorkflowRuntimeBuilderTest { assertDoesNotThrow(() -> new WorkflowRuntimeBuilder().registerWorkflow(TestWorkflow.class)); } + @Test + public void registerValidWorkflowInstance() { + assertDoesNotThrow(() -> new WorkflowRuntimeBuilder().registerWorkflow(new TestWorkflow())); + } + @Test public void registerValidWorkflowActivityClass() { assertDoesNotThrow(() -> new WorkflowRuntimeBuilder().registerActivity(TestActivity.class)); } + @Test + public void registerValidWorkflowActivityInstance() { + assertDoesNotThrow(() -> new WorkflowRuntimeBuilder().registerActivity(new TestActivity())); + } + @Test public void buildTest() { - assertDoesNotThrow(() -> new WorkflowRuntimeBuilder().build()); + assertDoesNotThrow(() -> { + try (WorkflowRuntime runtime = new WorkflowRuntimeBuilder().build()) { + System.out.println("WorkflowRuntime created"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); } @Test @@ -63,19 +81,20 @@ public class WorkflowRuntimeBuilderTest { ByteArrayOutputStream outStreamCapture = new ByteArrayOutputStream(); System.setOut(new PrintStream(outStreamCapture)); - Logger testLogger = Mockito.mock(Logger.class); + Logger testLogger = mock(Logger.class); assertDoesNotThrow(() -> new WorkflowRuntimeBuilder(testLogger).registerWorkflow(TestWorkflow.class)); assertDoesNotThrow(() -> new WorkflowRuntimeBuilder(testLogger).registerActivity(TestActivity.class)); - WorkflowRuntimeBuilder wfRuntime = new WorkflowRuntimeBuilder(); + WorkflowRuntimeBuilder workflowRuntimeBuilder = new WorkflowRuntimeBuilder(); - wfRuntime.build(); + try (WorkflowRuntime runtime = workflowRuntimeBuilder.build()) { + verify(testLogger, times(1)) + .info(eq("Registered Workflow: {}"), eq("TestWorkflow")); - Mockito.verify(testLogger, Mockito.times(1)) - .info(Mockito.eq("Registered Workflow: TestWorkflow")); - Mockito.verify(testLogger, Mockito.times(1)) - .info(Mockito.eq("Registered Activity: TestActivity")); + verify(testLogger, times(1)) + .info(eq("Registered Activity: {}"), eq("TestActivity")); + } } } diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/saga/DefaultSagaContextTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/saga/DefaultSagaContextTest.java deleted file mode 100644 index 4f92c4f9e..000000000 --- a/sdk-workflows/src/test/java/io/dapr/workflows/saga/DefaultSagaContextTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package io.dapr.workflows.saga; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import io.dapr.workflows.runtime.saga.DefaultSagaContext; -import org.junit.Test; - -import io.dapr.workflows.WorkflowContext; - -public class DefaultSagaContextTest { - - @Test - public void testDaprSagaContextImpl_IllegalArgumentException() { - Saga saga = mock(Saga.class); - WorkflowContext workflowContext = mock(WorkflowContext.class); - - assertThrows(IllegalArgumentException.class, () -> { - new DefaultSagaContext(saga, null); - }); - - assertThrows(IllegalArgumentException.class, () -> { - new DefaultSagaContext(null, workflowContext); - }); - } - - @Test - public void test_registerCompensation() { - Saga saga = mock(Saga.class); - WorkflowContext workflowContext = mock(WorkflowContext.class); - DefaultSagaContext ctx = new DefaultSagaContext(saga, workflowContext); - - String activityClassName = "name1"; - Object activityInput = new Object(); - doNothing().when(saga).registerCompensation(activityClassName, activityInput); - - ctx.registerCompensation(activityClassName, activityInput); - verify(saga, times(1)).registerCompensation(activityClassName, activityInput); - } - - @Test - public void test_compensate() { - Saga saga = mock(Saga.class); - WorkflowContext workflowContext = mock(WorkflowContext.class); - DefaultSagaContext ctx = new DefaultSagaContext(saga, workflowContext); - - doNothing().when(saga).compensate(workflowContext); - - ctx.compensate(); - verify(saga, times(1)).compensate(workflowContext); - } -} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaIntegrationTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaIntegrationTest.java deleted file mode 100644 index 0838aa1a3..000000000 --- a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaIntegrationTest.java +++ /dev/null @@ -1,322 +0,0 @@ -package io.dapr.workflows.saga; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.Test; - -import io.dapr.workflows.WorkflowActivity; -import io.dapr.workflows.WorkflowActivityContext; - -public class SagaIntegrationTest { - - private static int count = 0; - private static Object countLock = new Object(); - - @Test - public void testSaga_CompensateSequentially() { - int runCount = 10; - int succeedCount = 0; - int compensateCount = 0; - - for (int i = 0; i < runCount; i++) { - boolean isSuccueed = doExecuteWorkflowWithSaga(false); - if (isSuccueed) { - succeedCount++; - } else { - compensateCount++; - } - } - - System.out.println("Run workflow with saga " + runCount + " times: succeed " + succeedCount - + " times, failed and compensated " + compensateCount + " times"); - } - - @Test - public void testSaga_compensateInParallel() { - int runCount = 100; - int succeedCount = 0; - int compensateCount = 0; - - for (int i = 0; i < runCount; i++) { - boolean isSuccueed = doExecuteWorkflowWithSaga(true); - if (isSuccueed) { - succeedCount++; - } else { - compensateCount++; - } - } - - System.out.println("Run workflow with saga " + runCount + " times: succeed " + succeedCount - + " times, failed and compensated " + compensateCount + " times"); - } - - private boolean doExecuteWorkflowWithSaga(boolean parallelCompensation) { - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(parallelCompensation) - .setContinueWithError(true).build(); - Saga saga = new Saga(config); - boolean workflowSuccess = false; - - // reset count to zero - synchronized (countLock) { - count = 0; - } - - Integer addInput = 100; - Integer subtractInput = 20; - Integer multiplyInput = 10; - Integer divideInput = 5; - - try { - // step1: add activity - String result = callActivity(AddActivity.class.getName(), addInput, String.class); - saga.registerCompensation(AddCompentationActivity.class.getName(), addInput); - // step2: subtract activity - result = callActivity(SubtractActivity.class.getName(), subtractInput, String.class); - saga.registerCompensation(SubtractCompentationActivity.class.getName(), subtractInput); - - if (parallelCompensation) { - // only add/subtract activities support parallel compensation - // so in step3 and step4 we repeat add/subtract activities - - // step3: add activity again - result = callActivity(AddActivity.class.getName(), addInput, String.class); - saga.registerCompensation(AddCompentationActivity.class.getName(), addInput); - - // step4: substract activity again - result = callActivity(SubtractActivity.class.getName(), subtractInput, String.class); - saga.registerCompensation(SubtractCompentationActivity.class.getName(), subtractInput); - } else { - // step3: multiply activity - result = callActivity(MultiplyActivity.class.getName(), multiplyInput, String.class); - saga.registerCompensation(MultiplyCompentationActivity.class.getName(), multiplyInput); - - // step4: divide activity - result = callActivity(DivideActivity.class.getName(), divideInput, String.class); - saga.registerCompensation(DivideCompentationActivity.class.getName(), divideInput); - } - - randomFail(); - - workflowSuccess = true; - } catch (Exception e) { - saga.compensate(SagaTest.createMockContext()); - } - - if (workflowSuccess) { - int expectResult = 0; - if (parallelCompensation) { - expectResult = 0 + addInput - subtractInput + addInput - subtractInput; - } else { - expectResult = (0 + addInput - subtractInput) * multiplyInput / divideInput; - } - assertEquals(expectResult, count); - } else { - assertEquals(0, count); - } - - return workflowSuccess; - } - - // mock to call activity in dapr workflow - private V callActivity(String activityClassName, Object input, Class returnType) { - try { - Class activityClass = Class.forName(activityClassName); - WorkflowActivity activity = (WorkflowActivity) activityClass.getDeclaredConstructor().newInstance(); - WorkflowActivityContext ctx = new WorkflowActivityContext() { - - @Override - public java.lang.String getName() { - return activityClassName; - } - - @Override - public T getInput(Class targetType) { - return (T) input; - } - }; - - randomFail(); - - return (V) activity.run(ctx); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static void randomFail() { - int randomInt = (int) (Math.random() * 100); - // if randomInt mod 10 is 0, then throw exception - if (randomInt % 10 == 0) { - throw new RuntimeException("random fail"); - } - } - - public static class AddActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount + input; - count = updatedCount; - } - - String resultString = "current count is updated from " + originalCount + " to " + updatedCount - + " after adding " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class AddCompentationActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount - input; - count = updatedCount; - } - - String resultString = "current count is compensated from " + originalCount + " to " - + updatedCount + " after compensate adding " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class SubtractActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount - input; - count = updatedCount; - } - - String resultString = "current count is updated from " + originalCount + " to " + updatedCount - + " after substracting " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class SubtractCompentationActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount + input; - count = updatedCount; - } - - String resultString = "current count is compensated from " + originalCount + " to " + updatedCount - + " after compensate substracting " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class MultiplyActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount * input; - count = updatedCount; - } - - String resultString = "current count is updated from " + originalCount + " to " + updatedCount - + " after multiplying " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class MultiplyCompentationActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount / input; - count = updatedCount; - } - - String resultString = "current count is compensated from " + originalCount + " to " + updatedCount - + " after compensate multiplying " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class DivideActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount / input; - count = updatedCount; - } - - String resultString = "current count is updated from " + originalCount + " to " + updatedCount - + " after dividing " + input; - // System.out.println(resultString); - return resultString; - } - } - - public static class DivideCompentationActivity implements WorkflowActivity { - - @Override - public String run(WorkflowActivityContext ctx) { - Integer input = ctx.getInput(Integer.class); - - int originalCount = 0; - int updatedCount = 0; - synchronized (countLock) { - originalCount = count; - updatedCount = originalCount * input; - count = updatedCount; - } - - String resultString = "current count is compensated from " + originalCount + " to " + updatedCount - + " after compensate dividing " + input; - // System.out.println(resultString); - return resultString; - } - } -} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaOptionsTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaOptionsTest.java deleted file mode 100644 index 76c538813..000000000 --- a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaOptionsTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.dapr.workflows.saga; - -import static org.junit.Assert.assertThrows; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.Test; - -public class SagaOptionsTest { - - @Test - public void testBuild() { - SagaOptions.Builder builder = SagaOptions.newBuilder(); - builder.setParallelCompensation(true); - builder.setMaxParallelThread(32); - builder.setContinueWithError(false); - SagaOptions option = builder.build(); - - assertEquals(true, option.isParallelCompensation()); - assertEquals(32, option.getMaxParallelThread()); - assertEquals(false, option.isContinueWithError()); - } - - @Test - public void testBuild_default() { - SagaOptions.Builder builder = SagaOptions.newBuilder(); - SagaOptions option = builder.build(); - - assertEquals(false, option.isParallelCompensation()); - assertEquals(16, option.getMaxParallelThread()); - assertEquals(true, option.isContinueWithError()); - } - - @Test - public void testsetMaxParallelThread() { - SagaOptions.Builder builder = SagaOptions.newBuilder(); - - assertThrows(IllegalArgumentException.class, () -> { - builder.setMaxParallelThread(0); - }); - - assertThrows(IllegalArgumentException.class, () -> { - builder.setMaxParallelThread(1); - }); - - assertThrows(IllegalArgumentException.class, () -> { - builder.setMaxParallelThread(-1); - }); - } - -} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaTest.java deleted file mode 100644 index 8afa2eb10..000000000 --- a/sdk-workflows/src/test/java/io/dapr/workflows/saga/SagaTest.java +++ /dev/null @@ -1,454 +0,0 @@ -/* - * Copyright 2023 The Dapr Authors - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and -limitations under the License. -*/ -package io.dapr.workflows.saga; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; - -import io.dapr.workflows.WorkflowActivityContext; -import io.dapr.workflows.WorkflowTaskOptions; -import org.junit.Test; -import org.mockito.Mockito; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; - -import com.microsoft.durabletask.Task; - -import io.dapr.workflows.WorkflowContext; -import io.dapr.workflows.WorkflowActivity; - -public class SagaTest { - - public static WorkflowContext createMockContext() { - WorkflowContext workflowContext = mock(WorkflowContext.class); - when(workflowContext.callActivity(anyString(), any(), eq((WorkflowTaskOptions) null))).thenAnswer(new ActivityAnswer()); - when(workflowContext.allOf(anyList())).thenAnswer(new AllActivityAnswer()); - - return workflowContext; - } - - @Test - public void testSaga_IllegalArgument() { - assertThrows(IllegalArgumentException.class, () -> { - new Saga(null); - }); - } - - @Test - public void testregisterCompensation() { - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false) - .setContinueWithError(true).build(); - Saga saga = new Saga(config); - - saga.registerCompensation(MockActivity.class.getName(), new MockActivityInput()); - } - - @Test - public void testregisterCompensation_IllegalArgument() { - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false) - .setContinueWithError(true).build(); - Saga saga = new Saga(config); - - assertThrows(IllegalArgumentException.class, () -> { - saga.registerCompensation(null, "input"); - }); - assertThrows(IllegalArgumentException.class, () -> { - saga.registerCompensation("", "input"); - }); - } - - @Test - public void testCompensateInParallel() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(true).build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - saga.compensate(createMockContext()); - - assertEquals(3, MockCompensationActivity.compensateOrder.size()); - } - - @Test - public void testCompensateInParallel_exception_1failed() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(true).build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - // 3 compentation activities, 2 succeed, 1 failed - assertEquals(0, exception.getSuppressed().length); - assertEquals(2, MockCompensationActivity.compensateOrder.size()); - } - - @Test - public void testCompensateInParallel_exception_2failed() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(true).build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - input3.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - // 3 compentation activities, 1 succeed, 2 failed - assertEquals(1, MockCompensationActivity.compensateOrder.size()); - } - - @Test - public void testCompensateInParallel_exception_3failed() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(true).build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - input1.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - input3.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - // 3 compentation activities, 0 succeed, 3 failed - assertEquals(0, MockCompensationActivity.compensateOrder.size()); - } - - @Test - public void testCompensateSequentially() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false).build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - saga.compensate(createMockContext()); - - assertEquals(3, MockCompensationActivity.compensateOrder.size()); - - // the order should be 3 / 2 / 1 - assertEquals(Integer.valueOf(3), MockCompensationActivity.compensateOrder.get(0)); - assertEquals(Integer.valueOf(2), MockCompensationActivity.compensateOrder.get(1)); - assertEquals(Integer.valueOf(1), MockCompensationActivity.compensateOrder.get(2)); - } - - @Test - public void testCompensateSequentially_continueWithError() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false) - .setContinueWithError(true) - .build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - assertEquals(0, exception.getSuppressed().length); - - // 3 compentation activities, 2 succeed, 1 failed - assertEquals(2, MockCompensationActivity.compensateOrder.size()); - // the order should be 3 / 1 - assertEquals(Integer.valueOf(3), MockCompensationActivity.compensateOrder.get(0)); - assertEquals(Integer.valueOf(1), MockCompensationActivity.compensateOrder.get(1)); - } - - @Test - public void testCompensateSequentially_continueWithError_suppressed() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false) - .setContinueWithError(true) - .build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - input3.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - assertEquals(1, exception.getSuppressed().length); - - // 3 compentation activities, 1 succeed, 2 failed - assertEquals(1, MockCompensationActivity.compensateOrder.size()); - // the order should be 3 / 1 - assertEquals(Integer.valueOf(1), MockCompensationActivity.compensateOrder.get(0)); - } - - @Test - public void testCompensateSequentially_notContinueWithError() { - MockCompensationActivity.compensateOrder.clear(); - - SagaOptions config = SagaOptions.newBuilder() - .setParallelCompensation(false) - .setContinueWithError(false) - .build(); - Saga saga = new Saga(config); - MockActivityInput input1 = new MockActivityInput(); - input1.setOrder(1); - saga.registerCompensation(MockCompensationActivity.class.getName(), input1); - MockActivityInput input2 = new MockActivityInput(); - input2.setOrder(2); - input2.setThrowException(true); - saga.registerCompensation(MockCompensationActivity.class.getName(), input2); - MockActivityInput input3 = new MockActivityInput(); - input3.setOrder(3); - saga.registerCompensation(MockCompensationActivity.class.getName(), input3); - - SagaCompensationException exception = assertThrows(SagaCompensationException.class, () -> { - saga.compensate(createMockContext()); - }); - assertNotNull(exception.getCause()); - assertEquals(0, exception.getSuppressed().length); - - // 3 compentation activities, 1 succeed, 1 failed and not continue - assertEquals(1, MockCompensationActivity.compensateOrder.size()); - // the order should be 3 / 1 - assertEquals(Integer.valueOf(3), MockCompensationActivity.compensateOrder.get(0)); - } - - public static class MockActivity implements WorkflowActivity { - - @Override - public Object run(WorkflowActivityContext ctx) { - MockActivityOutput output = new MockActivityOutput(); - output.setSucceed(true); - return output; - } - } - - public static class MockCompensationActivity implements WorkflowActivity { - - private static final List compensateOrder = Collections.synchronizedList(new ArrayList<>()); - - @Override - public Object run(WorkflowActivityContext ctx) { - MockActivityInput input = ctx.getInput(MockActivityInput.class); - - if (input.isThrowException()) { - throw new RuntimeException("compensate failed: order=" + input.getOrder()); - } - - compensateOrder.add(input.getOrder()); - return null; - } - } - - public static class MockActivityInput { - private int order = 0; - private boolean throwException; - - public int getOrder() { - return order; - } - - public void setOrder(int order) { - this.order = order; - } - - public boolean isThrowException() { - return throwException; - } - - public void setThrowException(boolean throwException) { - this.throwException = throwException; - } - } - - public static class MockActivityOutput { - private boolean succeed; - - public boolean isSucceed() { - return succeed; - } - - public void setSucceed(boolean succeed) { - this.succeed = succeed; - } - } - - public static class ActivityAnswer implements Answer> { - - @Override - public Task answer(InvocationOnMock invocation) throws Throwable { - Object[] args = invocation.getArguments(); - String name = (String) args[0]; - Object input = args[1]; - - WorkflowActivity activity; - WorkflowActivityContext activityContext = Mockito.mock(WorkflowActivityContext.class); - try { - activity = (WorkflowActivity) Class.forName(name).getDeclaredConstructor().newInstance(); - } catch (Exception e) { - fail(e); - return null; - } - - Task task = mock(Task.class); - when(task.await()).thenAnswer(invocation1 -> { - Mockito.doReturn(input).when(activityContext).getInput(Mockito.any()); - activity.run(activityContext); - return null; - }); - return task; - } - - } - - public static class AllActivityAnswer implements Answer> { - @Override - public Task answer(InvocationOnMock invocation) throws Throwable { - Object[] args = invocation.getArguments(); - List> tasks = (List>) args[0]; - - ExecutorService executor = Executors.newFixedThreadPool(5); - List> compensationTasks = new ArrayList<>(); - for (Task task : tasks) { - Callable compensationTask = new Callable() { - @Override - public Void call() { - return task.await(); - } - }; - compensationTasks.add(compensationTask); - } - - List> resultFutures; - try { - resultFutures = executor.invokeAll(compensationTasks, 2, TimeUnit.SECONDS); - } catch (InterruptedException e) { - fail(e); - return null; - } - - Task task = mock(Task.class); - when(task.await()).thenAnswer(new Answer() { - @Override - public Void answer(InvocationOnMock invocation) throws Throwable { - Exception exception = null; - for (Future resultFuture : resultFutures) { - try { - resultFuture.get(); - } catch (Exception e) { - exception = e; - } - } - if (exception != null) { - throw exception; - } - return null; - } - }); - return task; - } - } - -} diff --git a/sdk/pom.xml b/sdk/pom.xml index 1c9817b87..ea59d88ad 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -7,12 +7,12 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk jar - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT dapr-sdk SDK for Dapr @@ -44,17 +44,6 @@ reactor-core 3.5.0 - - com.squareup.okhttp3 - okhttp - 4.12.0 - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - - - org.mockito mockito-core @@ -69,7 +58,7 @@ commons-cli commons-cli - 1.4 + 1.9.0 test @@ -158,6 +147,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-resources-plugin diff --git a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java index 1f39d7aad..0c1264eb1 100644 --- a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java +++ b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java @@ -80,7 +80,6 @@ import io.grpc.Channel; import io.grpc.Metadata; import io.grpc.stub.AbstractStub; import io.grpc.stub.StreamObserver; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -90,6 +89,8 @@ import reactor.core.publisher.MonoSink; import reactor.util.context.ContextView; import reactor.util.retry.Retry; +import javax.annotation.Nonnull; + import java.io.IOException; import java.time.Duration; import java.util.ArrayList; @@ -441,7 +442,7 @@ public class DaprClientImpl extends AbstractDaprClient { return buildSubscription(listener, type, request); } - @NotNull + @Nonnull private Subscription buildSubscription( SubscriptionListener listener, TypeRef type, diff --git a/sdk/src/main/java/io/dapr/client/DaprHttp.java b/sdk/src/main/java/io/dapr/client/DaprHttp.java index d2478d87f..5b23d733e 100644 --- a/sdk/src/main/java/io/dapr/client/DaprHttp.java +++ b/sdk/src/main/java/io/dapr/client/DaprHttp.java @@ -15,32 +15,28 @@ package io.dapr.client; import com.fasterxml.jackson.databind.ObjectMapper; import io.dapr.client.domain.Metadata; -import io.dapr.config.Properties; import io.dapr.exceptions.DaprError; import io.dapr.exceptions.DaprException; import io.dapr.internal.exceptions.DaprHttpException; import io.dapr.utils.Version; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import org.jetbrains.annotations.NotNull; import reactor.core.publisher.Mono; import reactor.util.context.ContextView; import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -94,12 +90,12 @@ public class DaprHttp implements AutoCloseable { } public static class Response { - private byte[] body; - private Map headers; - private int statusCode; + private final byte[] body; + private final Map headers; + private final int statusCode; /** - * Represents an http response. + * Represents a HTTP response. * * @param body The body of the http response. * @param headers The headers of the http response. @@ -127,58 +123,65 @@ public class DaprHttp implements AutoCloseable { /** * Defines the standard application/json type for HTTP calls in Dapr. */ - private static final MediaType MEDIA_TYPE_APPLICATION_JSON = - MediaType.get("application/json; charset=utf-8"); - - /** - * Shared object representing an empty request body in JSON. - */ - private static final RequestBody REQUEST_BODY_EMPTY_JSON = - RequestBody.Companion.create("", MEDIA_TYPE_APPLICATION_JSON); + private static final String MEDIA_TYPE_APPLICATION_JSON = "application/json; charset=utf-8"; /** * Empty input or output. */ private static final byte[] EMPTY_BYTES = new byte[0]; + /** + * Empty Body Publisher. + */ + private static final HttpRequest.BodyPublisher EMPTY_BODY_PUBLISHER = HttpRequest.BodyPublishers.noBody(); + /** * Endpoint used to communicate to Dapr's HTTP endpoint. */ private final URI uri; - /** - * Http client used for all API calls. - */ - private final OkHttpClient httpClient; - /** * Dapr API Token required to interact with DAPR APIs. */ private final String daprApiToken; + /** + * Http client request read timeout. + */ + private final Duration readTimeout; + + /** + * Http client used for all API calls. + */ + private final HttpClient httpClient; + /** * Creates a new instance of {@link DaprHttp}. * * @param hostname Hostname for calling Dapr. (e.g. "127.0.0.1") * @param port Port for calling Dapr. (e.g. 3500) + * @param readTimeout HTTP request read timeout * @param httpClient RestClient used for all API calls in this new instance. */ - DaprHttp(String hostname, int port, String daprApiToken, OkHttpClient httpClient) { + DaprHttp(String hostname, int port, String daprApiToken, Duration readTimeout, HttpClient httpClient) { this.uri = URI.create(DEFAULT_HTTP_SCHEME + "://" + hostname + ":" + port); - this.httpClient = httpClient; this.daprApiToken = daprApiToken; + this.readTimeout = readTimeout; + this.httpClient = httpClient; } /** * Creates a new instance of {@link DaprHttp}. * - * @param uri Endpoint for calling Dapr. (e.g. "https://my-dapr-api.company.com") + * @param uri Endpoint for calling Dapr. + * @param readTimeout HTTP request read timeout * @param httpClient RestClient used for all API calls in this new instance. */ - DaprHttp(String uri, String daprApiToken, OkHttpClient httpClient) { + DaprHttp(String uri, String daprApiToken, Duration readTimeout, HttpClient httpClient) { this.uri = URI.create(uri); - this.httpClient = httpClient; this.daprApiToken = daprApiToken; + this.readTimeout = readTimeout; + this.httpClient = httpClient; } /** @@ -244,13 +247,13 @@ public class DaprHttp implements AutoCloseable { Map headers, ContextView context) { // fromCallable() is needed so the invocation does not happen early, causing a hot mono. - return Mono.fromCallable(() -> doInvokeApi(method, pathSegments, urlParameters, content, headers, context)) - .flatMap(f -> Mono.fromFuture(f)); + return Mono.fromCallable(() -> doInvokeApi(method, headers, pathSegments, urlParameters, content, context)) + .flatMap(Mono::fromFuture); } /** - * Shutdown call is not necessary for OkHttpClient. - * @see OkHttpClient + * Shutdown call is not necessary for HttpClient. + * @see HttpClient */ @Override public void close() { @@ -268,77 +271,155 @@ public class DaprHttp implements AutoCloseable { * @param context OpenTelemetry's Context. * @return CompletableFuture for Response. */ - private CompletableFuture doInvokeApi(String method, + private CompletableFuture doInvokeApi( + String method, + Map headers, String[] pathSegments, Map> urlParameters, - byte[] content, Map headers, + byte[] content, ContextView context) { - final String requestId = UUID.randomUUID().toString(); - RequestBody body; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(); - String contentType = headers != null ? headers.get(Metadata.CONTENT_TYPE) : null; - MediaType mediaType = contentType == null ? MEDIA_TYPE_APPLICATION_JSON : MediaType.get(contentType); - if (content == null) { - body = mediaType.equals(MEDIA_TYPE_APPLICATION_JSON) - ? REQUEST_BODY_EMPTY_JSON - : RequestBody.Companion.create(new byte[0], mediaType); - } else { - body = RequestBody.Companion.create(content, mediaType); - } - HttpUrl.Builder urlBuilder = new HttpUrl.Builder(); - urlBuilder.scheme(uri.getScheme()) - .host(uri.getHost()); - if (uri.getPort() > 0) { - urlBuilder.port(uri.getPort()); - } - if (uri.getPath() != null) { - urlBuilder.addPathSegments(uri.getPath()); - } - for (String pathSegment : pathSegments) { - urlBuilder.addPathSegment(pathSegment); - } - Optional.ofNullable(urlParameters).orElse(Collections.emptyMap()).entrySet().stream() - .forEach(urlParameter -> - Optional.ofNullable(urlParameter.getValue()).orElse(Collections.emptyList()).stream() - .forEach(urlParameterValue -> - urlBuilder.addQueryParameter(urlParameter.getKey(), urlParameterValue))); + requestBuilder.uri(createUri(uri, pathSegments, urlParameters)); + addHeader(requestBuilder, Headers.DAPR_USER_AGENT, Version.getSdkVersion()); + addHeader(requestBuilder, HEADER_DAPR_REQUEST_ID, UUID.randomUUID().toString()); + addHeader(requestBuilder, "Content-Type", getContentType(headers)); + addHeaders(requestBuilder, headers); + + if (daprApiToken != null) { + addHeader(requestBuilder, Headers.DAPR_API_TOKEN, daprApiToken); + } - Request.Builder requestBuilder = new Request.Builder() - .url(urlBuilder.build()) - .addHeader(HEADER_DAPR_REQUEST_ID, requestId); if (context != null) { context.stream() .filter(entry -> ALLOWED_CONTEXT_IN_HEADERS.contains(entry.getKey().toString().toLowerCase())) - .forEach(entry -> requestBuilder.addHeader(entry.getKey().toString(), entry.getValue().toString())); + .forEach(entry -> addHeader(requestBuilder, entry.getKey().toString(), entry.getValue().toString())); } + + HttpRequest.BodyPublisher body = getBodyPublisher(content); + if (HttpMethods.GET.name().equals(method)) { - requestBuilder.get(); + requestBuilder.GET(); } else if (HttpMethods.DELETE.name().equals(method)) { - requestBuilder.delete(); + requestBuilder.DELETE(); } else if (HttpMethods.HEAD.name().equals(method)) { - requestBuilder.head(); + // HTTP HEAD is not exposed as a normal method + requestBuilder.method(HttpMethods.HEAD.name(), EMPTY_BODY_PUBLISHER); } else { requestBuilder.method(method, body); } - if (daprApiToken != null) { - requestBuilder.addHeader(Headers.DAPR_API_TOKEN, daprApiToken); - } - requestBuilder.addHeader(Headers.DAPR_USER_AGENT, Version.getSdkVersion()); + HttpRequest request = requestBuilder.timeout(readTimeout).build(); - if (headers != null) { - Optional.ofNullable(headers.entrySet()).orElse(Collections.emptySet()).stream() - .forEach(header -> { - requestBuilder.addHeader(header.getKey(), header.getValue()); - }); + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) + .thenApply(this::createResponse); + } + + private static String getContentType(Map headers) { + String result = headers != null ? headers.get(Metadata.CONTENT_TYPE) : null; + + return result == null ? MEDIA_TYPE_APPLICATION_JSON : result; + } + + private static URI createUri(URI uri, String[] pathSegments, Map> urlParameters) { + String path = createPath(uri, pathSegments); + String query = createQuery(urlParameters); + + try { + return new URI(uri.getScheme(), uri.getAuthority(), path, query, null); + } catch (URISyntaxException exception) { + throw new DaprException(exception); + } + } + + private static String createPath(URI uri, String[] pathSegments) { + String basePath = uri.getPath(); + + if (pathSegments == null || pathSegments.length == 0) { + return basePath; } - Request request = requestBuilder.build(); + StringBuilder pathBuilder = new StringBuilder(basePath); + if (!basePath.endsWith("/")) { // Add a "/" if needed + pathBuilder.append("/"); + } - CompletableFuture future = new CompletableFuture<>(); - this.httpClient.newCall(request).enqueue(new ResponseFutureCallback(future)); - return future; + for (String segment : pathSegments) { + pathBuilder.append(encodePathSegment(segment)).append("/"); // Encode each segment + } + + pathBuilder.deleteCharAt(pathBuilder.length() - 1); // Remove the trailing "/" + + return pathBuilder.toString(); + } + + private static String createQuery(Map> urlParameters) { + if (urlParameters == null || urlParameters.isEmpty()) { + return null; + } + + StringBuilder queryBuilder = new StringBuilder(); + + for (Map.Entry> entry : urlParameters.entrySet()) { + String key = entry.getKey(); + List values = entry.getValue(); + + for (String value : values) { + if (queryBuilder.length() > 0) { + queryBuilder.append("&"); + } + + queryBuilder.append(encodeQueryParam(key, value)); // Encode key and value + } + } + + return queryBuilder.toString(); + } + + private static String encodePathSegment(String segment) { + return URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"); // Encode and handle spaces + } + + private static String encodeQueryParam(String key, String value) { + return URLEncoder.encode(key, StandardCharsets.UTF_8) + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static void addHeader(HttpRequest.Builder requestBuilder, String name, String value) { + requestBuilder.header(name, value); + } + + private static void addHeaders(HttpRequest.Builder requestBuilder, Map headers) { + if (headers == null || headers.isEmpty()) { + return; + } + + headers.forEach((k, v) -> addHeader(requestBuilder, k, v)); + } + + private static HttpRequest.BodyPublisher getBodyPublisher(byte[] content) { + return HttpRequest.BodyPublishers.ofByteArray(Objects.requireNonNullElse(content, EMPTY_BYTES)); + } + + private Response createResponse(HttpResponse httpResponse) { + Optional headerValue = httpResponse.headers().firstValue("Metadata.statuscode"); + int httpStatusCode = parseHttpStatusCode(headerValue, httpResponse.statusCode()); + byte[] body = getBodyBytesOrEmptyArray(httpResponse.body()); + + if (!DaprHttpException.isSuccessfulHttpStatusCode(httpStatusCode)) { + DaprError error = parseDaprError(body); + + if (error != null) { + throw new DaprException(error, body, httpStatusCode); + } else { + throw new DaprException("UNKNOWN", "", body, httpStatusCode); + } + } + + Map responseHeaders = new HashMap<>(); + httpResponse.headers().map().forEach((k, v) -> responseHeaders.put(k, v.isEmpty() ? null : v.get(0))); + + return new Response(body, responseHeaders, httpStatusCode); } /** @@ -360,70 +441,18 @@ public class DaprHttp implements AutoCloseable { } } - - private static byte[] getBodyBytesOrEmptyArray(okhttp3.Response response) throws IOException { - ResponseBody body = response.body(); - if (body != null) { - return body.bytes(); - } - - return EMPTY_BYTES; + private static byte[] getBodyBytesOrEmptyArray(byte[] body) { + return body == null ? EMPTY_BYTES : body; } - /** - * Converts the okhttp3 response into the response object expected internally by the SDK. - */ - private static class ResponseFutureCallback implements Callback { - private final CompletableFuture future; - - public ResponseFutureCallback(CompletableFuture future) { - this.future = future; - } - - @Override - public void onFailure(Call call, IOException e) { - future.completeExceptionally(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull okhttp3.Response response) throws IOException { - int httpStatusCode = parseHttpStatusCode(response.header("Metadata.statuscode"), response.code()); - if (!DaprHttpException.isSuccessfulHttpStatusCode(httpStatusCode)) { - try { - byte[] payload = getBodyBytesOrEmptyArray(response); - DaprError error = parseDaprError(payload); - - if (error != null) { - future.completeExceptionally(new DaprException(error, payload, httpStatusCode)); - return; - } - - future.completeExceptionally( - new DaprException("UNKNOWN", "", payload, httpStatusCode)); - return; - } catch (DaprException e) { - future.completeExceptionally(e); - return; - } - } - - Map mapHeaders = new HashMap<>(); - byte[] result = getBodyBytesOrEmptyArray(response); - response.headers().forEach(pair -> { - mapHeaders.put(pair.getFirst(), pair.getSecond()); - }); - future.complete(new Response(result, mapHeaders, httpStatusCode)); - } - } - - private static int parseHttpStatusCode(String headerValue, int defaultStatusCode) { - if ((headerValue == null) || headerValue.isEmpty()) { + private static int parseHttpStatusCode(Optional headerValue, int defaultStatusCode) { + if (headerValue.isEmpty()) { return defaultStatusCode; } // Metadata used to override status code with code received from HTTP binding. try { - int httpStatusCode = Integer.parseInt(headerValue); + int httpStatusCode = Integer.parseInt(headerValue.get()); if (DaprHttpException.isValidHttpStatusCode(httpStatusCode)) { return httpStatusCode; } diff --git a/sdk/src/main/java/io/dapr/client/DaprHttpBuilder.java b/sdk/src/main/java/io/dapr/client/DaprHttpBuilder.java index 8ef163dd9..2de7fe631 100644 --- a/sdk/src/main/java/io/dapr/client/DaprHttpBuilder.java +++ b/sdk/src/main/java/io/dapr/client/DaprHttpBuilder.java @@ -14,15 +14,13 @@ limitations under the License. package io.dapr.client; import io.dapr.config.Properties; -import okhttp3.ConnectionPool; -import okhttp3.Dispatcher; -import okhttp3.OkHttpClient; +import java.net.http.HttpClient; import java.time.Duration; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import static io.dapr.config.Properties.API_TOKEN; -import static io.dapr.config.Properties.HTTP_CLIENT_MAX_IDLE_CONNECTIONS; import static io.dapr.config.Properties.HTTP_CLIENT_MAX_REQUESTS; import static io.dapr.config.Properties.HTTP_CLIENT_READ_TIMEOUT_SECONDS; import static io.dapr.config.Properties.HTTP_ENDPOINT; @@ -34,24 +32,13 @@ import static io.dapr.config.Properties.SIDECAR_IP; */ public class DaprHttpBuilder { - /** - * Singleton OkHttpClient. - */ - private static volatile OkHttpClient OK_HTTP_CLIENT; + private static volatile HttpClient HTTP_CLIENT; /** * Static lock object. */ private static final Object LOCK = new Object(); - /** - * HTTP keep alive duration in seconds. - * - *

Just hard code to a reasonable value. - */ - private static final int KEEP_ALIVE_DURATION = 30; - - /** * Build an instance of the Http client based on the provided setup. * @param properties to configure the DaprHttp client @@ -68,38 +55,30 @@ public class DaprHttpBuilder { * @return Instance of {@link DaprHttp} */ private DaprHttp buildDaprHttp(Properties properties) { - if (OK_HTTP_CLIENT == null) { + if (HTTP_CLIENT == null) { synchronized (LOCK) { - if (OK_HTTP_CLIENT == null) { - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - Duration readTimeout = Duration.ofSeconds(properties.getValue(HTTP_CLIENT_READ_TIMEOUT_SECONDS)); - builder.readTimeout(readTimeout); - - Dispatcher dispatcher = new Dispatcher(); - dispatcher.setMaxRequests(properties.getValue(HTTP_CLIENT_MAX_REQUESTS)); - // The maximum number of requests for each host to execute concurrently. - // Default value is 5 in okhttp which is totally UNACCEPTABLE! - // For sidecar case, set it the same as maxRequests. - dispatcher.setMaxRequestsPerHost(HTTP_CLIENT_MAX_REQUESTS.get()); - builder.dispatcher(dispatcher); - - ConnectionPool pool = new ConnectionPool(properties.getValue(HTTP_CLIENT_MAX_IDLE_CONNECTIONS), - KEEP_ALIVE_DURATION, TimeUnit.SECONDS); - builder.connectionPool(pool); - - OK_HTTP_CLIENT = builder.build(); + if (HTTP_CLIENT == null) { + int maxRequests = properties.getValue(HTTP_CLIENT_MAX_REQUESTS); + Executor executor = Executors.newFixedThreadPool(maxRequests); + HTTP_CLIENT = HttpClient.newBuilder() + .executor(executor) + .version(HttpClient.Version.HTTP_1_1) + .build(); } } } String endpoint = properties.getValue(HTTP_ENDPOINT); + String apiToken = properties.getValue(API_TOKEN); + Duration readTimeout = Duration.ofSeconds(properties.getValue(HTTP_CLIENT_READ_TIMEOUT_SECONDS)); + if ((endpoint != null) && !endpoint.isEmpty()) { - return new DaprHttp(endpoint, properties.getValue(API_TOKEN), OK_HTTP_CLIENT); + return new DaprHttp(endpoint, apiToken, readTimeout, HTTP_CLIENT); } - return new DaprHttp(properties.getValue(SIDECAR_IP), properties.getValue(HTTP_PORT), properties.getValue(API_TOKEN), - OK_HTTP_CLIENT); - + String sidecarIp = properties.getValue(SIDECAR_IP); + int port = properties.getValue(HTTP_PORT); + return new DaprHttp(sidecarIp, port, apiToken, readTimeout, HTTP_CLIENT); } } diff --git a/sdk/src/main/java/io/dapr/client/Subscription.java b/sdk/src/main/java/io/dapr/client/Subscription.java index 9d1a2a503..53e89e845 100644 --- a/sdk/src/main/java/io/dapr/client/Subscription.java +++ b/sdk/src/main/java/io/dapr/client/Subscription.java @@ -19,9 +19,10 @@ import io.dapr.v1.DaprAppCallbackProtos; import io.dapr.v1.DaprGrpc; import io.dapr.v1.DaprProtos; import io.grpc.stub.StreamObserver; -import org.jetbrains.annotations.NotNull; import reactor.core.publisher.Mono; +import javax.annotation.Nonnull; + import java.io.Closeable; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -153,7 +154,7 @@ public class Subscription implements Closeable { }).onErrorReturn(SubscriptionListener.Status.RETRY); } - @NotNull + @Nonnull private static DaprProtos.SubscribeTopicEventsRequestAlpha1 buildAckRequest( String id, SubscriptionListener.Status status) { DaprProtos.SubscribeTopicEventsRequestProcessedAlpha1 eventProcessed = diff --git a/sdk/src/main/java/io/dapr/client/domain/HttpExtension.java b/sdk/src/main/java/io/dapr/client/domain/HttpExtension.java index 5b7d546e7..7f763c05a 100644 --- a/sdk/src/main/java/io/dapr/client/domain/HttpExtension.java +++ b/sdk/src/main/java/io/dapr/client/domain/HttpExtension.java @@ -14,12 +14,12 @@ limitations under the License. package io.dapr.client.domain; import io.dapr.client.DaprHttp; -import okhttp3.HttpUrl; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Optional; /** * HTTP Extension class. @@ -67,17 +67,17 @@ public final class HttpExtension { /** * HTTP verb. */ - private DaprHttp.HttpMethods method; + private final DaprHttp.HttpMethods method; /** * HTTP query params. */ - private Map> queryParams; + private final Map> queryParams; /** * HTTP headers. */ - private Map headers; + private final Map headers; /** * Construct a HttpExtension object. @@ -126,18 +126,29 @@ public final class HttpExtension { * @return Encoded HTTP query string. */ public String encodeQueryString() { - if ((this.queryParams == null) || (this.queryParams.isEmpty())) { + if (queryParams == null || queryParams.isEmpty()) { return ""; } - HttpUrl.Builder urlBuilder = new HttpUrl.Builder(); - // Setting required values but we only need query params in the end. - urlBuilder.scheme("http").host("localhost"); - Optional.ofNullable(this.queryParams).orElse(Collections.emptyMap()).entrySet().stream() - .forEach(urlParameter -> - Optional.ofNullable(urlParameter.getValue()).orElse(Collections.emptyList()).stream() - .forEach(urlParameterValue -> - urlBuilder.addQueryParameter(urlParameter.getKey(), urlParameterValue))); - return urlBuilder.build().encodedQuery(); + StringBuilder queryBuilder = new StringBuilder(); + + for (Map.Entry> entry : queryParams.entrySet()) { + String key = entry.getKey(); + List values = entry.getValue(); + + for (String value : values) { + if (queryBuilder.length() > 0) { + queryBuilder.append("&"); + } + + queryBuilder.append(encodeQueryParam(key, value)); // Encode key and value + } + } + + return queryBuilder.toString(); + } + + private static String encodeQueryParam(String key, String value) { + return URLEncoder.encode(key, StandardCharsets.UTF_8) + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8); } } diff --git a/sdk/src/test/java/io/dapr/client/DaprClientBuilderTest.java b/sdk/src/test/java/io/dapr/client/DaprClientBuilderTest.java index f5116e36a..150281d03 100644 --- a/sdk/src/test/java/io/dapr/client/DaprClientBuilderTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprClientBuilderTest.java @@ -18,6 +18,7 @@ import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; import io.dapr.serializer.DaprObjectSerializer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -27,6 +28,16 @@ import static org.mockito.Mockito.when; public class DaprClientBuilderTest { + private DaprClient client; + + @AfterEach + public void cleanup() throws Exception { + if (client != null) { + client.close(); + client = null; + } + } + @Test public void build() { DaprObjectSerializer objectSerializer = mock(DaprObjectSerializer.class); @@ -35,17 +46,17 @@ public class DaprClientBuilderTest { DaprClientBuilder daprClientBuilder = new DaprClientBuilder(); daprClientBuilder.withObjectSerializer(objectSerializer); daprClientBuilder.withStateSerializer(stateSerializer); - DaprClient daprClient = daprClientBuilder.build(); - assertNotNull(daprClient); + client = daprClientBuilder.build(); + assertNotNull(client); } @Test public void buildWithOverrideSidecarIP() { DaprClientBuilder daprClientBuilder = new DaprClientBuilder(); daprClientBuilder.withPropertyOverride(Properties.SIDECAR_IP, "unknownhost"); - DaprClient daprClient = daprClientBuilder.build(); - assertNotNull(daprClient); - DaprException thrown = assertThrows(DaprException.class, () -> { daprClient.getMetadata().block(); }); + client = daprClientBuilder.build(); + assertNotNull(client); + DaprException thrown = assertThrows(DaprException.class, () -> { client.getMetadata().block(); }); assertTrue(thrown.toString().contains("UNAVAILABLE"), thrown.toString()); } diff --git a/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java b/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java index 44e36d06d..6864a1ee0 100644 --- a/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java @@ -13,24 +13,16 @@ limitations under the License. package io.dapr.client; import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.dataformat.xml.XmlMapper; import io.dapr.client.domain.HttpExtension; import io.dapr.client.domain.InvokeMethodRequest; import io.dapr.config.Properties; import io.dapr.exceptions.DaprException; -import io.dapr.serializer.DaprObjectSerializer; import io.dapr.serializer.DefaultObjectSerializer; import io.dapr.utils.TypeRef; import io.dapr.v1.DaprGrpc; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.ResponseBody; -import okhttp3.mock.Behavior; -import okhttp3.mock.MediaTypes; -import okhttp3.mock.MockInterceptor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -40,13 +32,15 @@ import reactor.util.context.ContextView; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import static io.dapr.utils.TestUtils.assertThrowsDaprException; import static io.dapr.utils.TestUtils.findFreePort; @@ -57,12 +51,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class DaprClientHttpTest { private final String EXPECTED_RESULT = "{\"data\":\"ewoJCSJwcm9wZXJ0eUEiOiAidmFsdWVBIiwKCQkicHJvcGVydHlCIjogInZhbHVlQiIKCX0=\"}"; + + private static final int HTTP_NO_CONTENT = 204; + private static final int HTTP_NOT_FOUND = 404; + private static final int HTTP_SERVER_ERROR = 500; + private static final int HTTP_OK = 200; + private static final Duration READ_TIMEOUT = Duration.ofSeconds(60); private String sidecarIp; @@ -72,17 +73,14 @@ public class DaprClientHttpTest { private DaprHttp daprHttp; - private OkHttpClient okHttpClient; - - private MockInterceptor mockInterceptor; + private HttpClient httpClient; @BeforeEach public void setUp() { sidecarIp = formatIpAddress(Properties.SIDECAR_IP.get()); daprApiToken = Properties.API_TOKEN.get(); - mockInterceptor = new MockInterceptor(Behavior.UNORDERED); - okHttpClient = new OkHttpClient.Builder().addInterceptor(mockInterceptor).build(); - daprHttp = new DaprHttp(sidecarIp, 3000, daprApiToken, okHttpClient); + httpClient = mock(HttpClient.class); + daprHttp = new DaprHttp(sidecarIp, 3000, daprApiToken, READ_TIMEOUT, httpClient); daprClientHttp = buildDaprClient(daprHttp); } @@ -100,14 +98,16 @@ public class DaprClientHttpTest { @Test public void waitForSidecarTimeOutHealthCheck() throws Exception { - daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3000, daprApiToken, okHttpClient); + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_NO_CONTENT); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3000, daprApiToken, READ_TIMEOUT, httpClient); DaprClient daprClientHttp = buildDaprClient(daprHttp); - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .delay(200) - .respond(204, ResponseBody.create("No Content", MediaType.get("application/json"))); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + Thread.sleep(200); + + return mockResponse; + }); StepVerifier.create(daprClientHttp.waitForSidecar(100)) .expectSubscription() @@ -123,15 +123,20 @@ public class DaprClientHttpTest { @Test public void waitForSidecarBadHealthCheck() throws Exception { + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_NOT_FOUND); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); int port = findFreePort(); - daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), port, daprApiToken, okHttpClient); + daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), port, daprApiToken, READ_TIMEOUT, httpClient); DaprClient daprClientHttp = buildDaprClient(daprHttp); + AtomicInteger count = new AtomicInteger(0); - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .times(6) - .respond(404, ResponseBody.create("Not Found", MediaType.get("application/json"))); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + if (count.getAndIncrement() < 6) { + return mockResponse; + } + + return CompletableFuture.failedFuture(new TimeoutException()); + }); // it will timeout. StepVerifier.create(daprClientHttp.waitForSidecar(5000)) @@ -143,24 +148,25 @@ public class DaprClientHttpTest { @Test public void waitForSidecarSlowSuccessfulHealthCheck() throws Exception { int port = findFreePort(); - daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), port, daprApiToken, okHttpClient); + daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), port, daprApiToken, READ_TIMEOUT, httpClient); DaprClient daprClientHttp = buildDaprClient(daprHttp); + AtomicInteger count = new AtomicInteger(0); + + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + if (count.getAndIncrement() < 2) { + Thread.sleep(1000); + + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_SERVER_ERROR); + return CompletableFuture.>completedFuture(mockHttpResponse); + } + + Thread.sleep(1000); + + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_NO_CONTENT); + return CompletableFuture.>completedFuture(mockHttpResponse); + }); // Simulate a slow response - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .delay(1000) - .times(2) - .respond(500, ResponseBody.create("Internal Server Error", MediaType.get("application/json"))); - - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .delay(1000) - .times(1) - .respond(204, ResponseBody.create("No Content", MediaType.get("application/json"))); - StepVerifier.create(daprClientHttp.waitForSidecar(5000)) .expectSubscription() .expectNext() @@ -170,14 +176,13 @@ public class DaprClientHttpTest { @Test public void waitForSidecarOK() throws Exception { + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_NO_CONTENT); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); int port = findFreePort(); - daprHttp = new DaprHttp(sidecarIp, port, daprApiToken, okHttpClient); + daprHttp = new DaprHttp(sidecarIp, port, daprApiToken, READ_TIMEOUT, httpClient); DaprClient daprClientHttp = buildDaprClient(daprHttp); - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .respond(204); + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); StepVerifier.create(daprClientHttp.waitForSidecar(10000)) .expectSubscription() @@ -187,12 +192,14 @@ public class DaprClientHttpTest { @Test public void waitForSidecarTimeoutOK() throws Exception { - mockInterceptor.addRule() - .get() - .path("/v1.0/healthz/outbound") - .respond(204); + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_NO_CONTENT); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + try (ServerSocket serverSocket = new ServerSocket(0)) { - final int port = serverSocket.getLocalPort(); + int port = serverSocket.getLocalPort(); + Thread t = new Thread(() -> { try { try (Socket socket = serverSocket.accept()) { @@ -201,7 +208,8 @@ public class DaprClientHttpTest { } }); t.start(); - daprHttp = new DaprHttp(sidecarIp, port, daprApiToken, okHttpClient); + + daprHttp = new DaprHttp(sidecarIp, port, daprApiToken, READ_TIMEOUT, httpClient); DaprClient daprClientHttp = buildDaprClient(daprHttp); daprClientHttp.waitForSidecar(10000).block(); } @@ -209,80 +217,146 @@ public class DaprClientHttpTest { @Test public void invokeServiceVerbNull() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/publish/A") - .respond(EXPECTED_RESULT); - String event = "{ \"message\": \"This is a test\" }"; + MockHttpResponse mockHttpResponse = new MockHttpResponse(EXPECTED_RESULT.getBytes(), HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); assertThrows(IllegalArgumentException.class, () -> - daprClientHttp.invokeMethod(null, "", "", null, null, (Class)null).block()); + daprClientHttp.invokeMethod( + null, + "", + "", + null, + null, + (Class)null + ).block()); } @Test public void invokeServiceIllegalArgumentException() { - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/badorder") - .respond("INVALID JSON"); + byte[] content = "INVALID JSON".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); assertThrows(IllegalArgumentException.class, () -> { // null HttpMethod - daprClientHttp.invokeMethod("1", "2", "3", new HttpExtension(null), null, (Class)null).block(); + daprClientHttp.invokeMethod( + "1", + "2", + "3", + new HttpExtension(null), + null, + (Class)null + ).block(); }); assertThrows(IllegalArgumentException.class, () -> { // null HttpExtension - daprClientHttp.invokeMethod("1", "2", "3", null, null, (Class)null).block(); + daprClientHttp.invokeMethod( + "1", + "2", + "3", + null, + null, + (Class)null + ).block(); }); assertThrows(IllegalArgumentException.class, () -> { // empty appId - daprClientHttp.invokeMethod("", "1", null, HttpExtension.GET, null, (Class)null).block(); + daprClientHttp.invokeMethod( + "", + "1", + null, + HttpExtension.GET, + null, + (Class)null + ).block(); }); assertThrows(IllegalArgumentException.class, () -> { // null appId, empty method - daprClientHttp.invokeMethod(null, "", null, HttpExtension.POST, null, (Class)null).block(); + daprClientHttp.invokeMethod( + null, + "", + null, + HttpExtension.POST, + null, + (Class)null + ).block(); }); assertThrows(IllegalArgumentException.class, () -> { // empty method - daprClientHttp.invokeMethod("1", "", null, HttpExtension.PUT, null, (Class)null).block(); + daprClientHttp.invokeMethod( + "1", + "", + null, + HttpExtension.PUT, + null, + (Class)null + ).block(); }); assertThrows(IllegalArgumentException.class, () -> { // null method - daprClientHttp.invokeMethod("1", null, null, HttpExtension.DELETE, null, (Class)null).block(); + daprClientHttp.invokeMethod( + "1", + null, + null, + HttpExtension.DELETE, + null, + (Class)null + ).block(); }); assertThrowsDaprException(JsonParseException.class, () -> { // invalid JSON response - daprClientHttp.invokeMethod("41", "badorder", null, HttpExtension.GET, null, String.class).block(); + daprClientHttp.invokeMethod( + "41", + "badorder", + null, + HttpExtension.GET, + null, + String.class + ).block(); }); } @Test public void invokeServiceDaprError() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/invoke/myapp/method/mymethod") - .respond(500, - ResponseBody.create( - "{ \"errorCode\": \"MYCODE\", \"message\": \"My Message\"}", - MediaTypes.MEDIATYPE_JSON)); + byte[] content = "{ \"errorCode\": \"MYCODE\", \"message\": \"My Message\"}".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); DaprException exception = assertThrows(DaprException.class, () -> { - daprClientHttp.invokeMethod("myapp", "mymethod", "anything", HttpExtension.POST).block(); + daprClientHttp.invokeMethod( + "myapp", + "mymethod", + "anything", + HttpExtension.POST + ).block(); }); assertEquals("MYCODE", exception.getErrorCode()); assertEquals("MYCODE: My Message (HTTP status code: 500)", exception.getMessage()); - assertEquals(500, exception.getHttpStatusCode()); + assertEquals(HTTP_SERVER_ERROR, exception.getHttpStatusCode()); } @Test public void invokeServiceDaprErrorFromGRPC() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/invoke/myapp/method/mymethod") - .respond(500, - ResponseBody.create( - "{ \"code\": 7 }", - MediaTypes.MEDIATYPE_JSON)); + byte[] content = "{ \"code\": 7 }".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); DaprException exception = assertThrows(DaprException.class, () -> { - daprClientHttp.invokeMethod("myapp", "mymethod", "anything", HttpExtension.POST).block(); + daprClientHttp.invokeMethod( + "myapp", + "mymethod", + "anything", + HttpExtension.POST + ).block(); }); assertEquals("PERMISSION_DENIED", exception.getErrorCode()); @@ -291,12 +365,11 @@ public class DaprClientHttpTest { @Test public void invokeServiceDaprErrorUnknownJSON() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/invoke/myapp/method/mymethod") - .respond(500, - ResponseBody.create( - "{ \"anything\": 7 }", - MediaTypes.MEDIATYPE_JSON)); + byte[] content = "{ \"anything\": 7 }".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); DaprException exception = assertThrows(DaprException.class, () -> { daprClientHttp.invokeMethod("myapp", "mymethod", "anything", HttpExtension.POST).block(); @@ -309,119 +382,203 @@ public class DaprClientHttpTest { @Test public void invokeServiceDaprErrorEmptyString() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/invoke/myapp/method/mymethod") - .respond(500, - ResponseBody.create( - "", - MediaTypes.MEDIATYPE_JSON)); + byte[] content = "".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); DaprException exception = assertThrows(DaprException.class, () -> { - daprClientHttp.invokeMethod("myapp", "mymethod", "anything", HttpExtension.POST).block(); + daprClientHttp.invokeMethod( + "myapp", + "mymethod", + "anything", + HttpExtension.POST + ).block(); }); assertEquals("UNKNOWN", exception.getErrorCode()); assertEquals("UNKNOWN: HTTP status code: 500", exception.getMessage()); } - @Test public void invokeServiceMethodNull() { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/publish/A") - .respond(EXPECTED_RESULT); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); assertThrows(IllegalArgumentException.class, () -> - daprClientHttp.invokeMethod("1", "", null, HttpExtension.POST, null, (Class)null).block()); + daprClientHttp.invokeMethod( + "1", + "", + null, + HttpExtension.POST, + null, + (Class)null + ).block()); } @Test public void invokeService() { - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond("\"hello world\""); + byte[] content = "\"hello world\"".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + null, + HttpExtension.GET, + null, + String.class + ); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", null, HttpExtension.GET, null, String.class); assertEquals("hello world", mono.block()); } @Test public void invokeServiceNullResponse() { - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(new byte[0]); + byte[] content = new byte[0]; + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + null, + HttpExtension.GET, + null, + String.class + ); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", null, HttpExtension.GET, null, String.class); assertNull(mono.block()); } @Test public void simpleInvokeService() { - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(EXPECTED_RESULT); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + null, + HttpExtension.GET, + byte[].class + ); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", null, HttpExtension.GET, byte[].class); assertEquals(new String(mono.block()), EXPECTED_RESULT); } @Test public void invokeServiceWithMetadataMap() { - Map map = new HashMap<>(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(EXPECTED_RESULT); + Map map = Map.of(); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", (byte[]) null, HttpExtension.GET, map); + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + (byte[]) null, + HttpExtension.GET, + map + ); String monoString = new String(mono.block()); + assertEquals(monoString, EXPECTED_RESULT); } @Test public void invokeServiceWithOutRequest() { - Map map = new HashMap<>(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(EXPECTED_RESULT); + Map map = Map.of(); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + HttpExtension.GET, + map + ); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", HttpExtension.GET, map); assertNull(mono.block()); } @Test public void invokeServiceWithRequest() { - Map map = new HashMap<>(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(EXPECTED_RESULT); + Map map = Map.of(); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + "", + HttpExtension.GET, + map + ); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", "", HttpExtension.GET, map); assertNull(mono.block()); } @Test public void invokeServiceWithRequestAndQueryString() { - Map map = new HashMap<>(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder?param1=1¶m2=a¶m2=b%2Fc") - .respond(EXPECTED_RESULT); + Map map = Map.of(); + Map> queryString = Map.of( + "param1", List.of("1"), + "param2", List.of("a", "b/c") + ); + byte[] content = EXPECTED_RESULT.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); - Map> queryString = new HashMap<>(); - queryString.put("param1", Collections.singletonList("1")); - queryString.put("param2", Arrays.asList("a", "b/c")); HttpExtension httpExtension = new HttpExtension(DaprHttp.HttpMethods.GET, queryString, null); - Mono mono = daprClientHttp.invokeMethod("41", "neworder", "", httpExtension, map); + Mono mono = daprClientHttp.invokeMethod( + "41", + "neworder", + "", + httpExtension, + map + ); + assertNull(mono.block()); } @Test public void invokeServiceNoHotMono() { - Map map = new HashMap<>(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .respond(500); + Map map = Map.of(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); - daprClientHttp.invokeMethod("41", "neworder", "", HttpExtension.GET, map); + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + daprClientHttp.invokeMethod( + "41", + "neworder", + "", + HttpExtension.GET, + map + ); // No exception should be thrown because did not call block() on mono above. } @@ -433,18 +590,27 @@ public class DaprClientHttpTest { .put("traceparent", traceparent) .put("tracestate", tracestate) .put("not_added", "xyz"); - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3000/v1.0/invoke/41/method/neworder") - .header("traceparent", traceparent) - .header("tracestate", tracestate) - .respond(new byte[0]); + byte[] content = new byte[0]; + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); InvokeMethodRequest req = new InvokeMethodRequest("41", "neworder") .setBody("request") .setHttpExtension(HttpExtension.POST); Mono result = daprClientHttp.invokeMethod(req, TypeRef.get(Void.class)) .contextWrite(it -> it.putAll((ContextView) context)); + result.block(); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + + assertEquals(traceparent, request.headers().firstValue("traceparent").get()); + assertEquals(tracestate, request.headers().firstValue("tracestate").get()); } @Test @@ -467,23 +633,4 @@ public class DaprClientHttpTest { daprClientHttp.close(); } - private static class XmlSerializer implements DaprObjectSerializer { - - private static final XmlMapper XML_MAPPER = new XmlMapper(); - - @Override - public byte[] serialize(Object o) throws IOException { - return XML_MAPPER.writeValueAsBytes(o); - } - - @Override - public T deserialize(byte[] data, TypeRef type) throws IOException { - return XML_MAPPER.readValue(data, new TypeReference() {}); - } - - @Override - public String getContentType() { - return "application/xml"; - } - } -} \ No newline at end of file +} diff --git a/sdk/src/test/java/io/dapr/client/DaprHttpBuilderTest.java b/sdk/src/test/java/io/dapr/client/DaprHttpBuilderTest.java index 78470719c..85863c046 100644 --- a/sdk/src/test/java/io/dapr/client/DaprHttpBuilderTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprHttpBuilderTest.java @@ -14,10 +14,10 @@ limitations under the License. package io.dapr.client; import io.dapr.config.Properties; -import okhttp3.OkHttpClient; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.net.http.HttpClient; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -30,14 +30,13 @@ public class DaprHttpBuilderTest { DaprHttp daprHttp = new DaprHttpBuilder().build(properties); DaprHttp anotherDaprHttp = new DaprHttpBuilder().build(properties); - assertSame(getOkHttpClient(daprHttp), getOkHttpClient(anotherDaprHttp)); + assertSame(getHttpClient(daprHttp), getHttpClient(anotherDaprHttp)); } - - private static OkHttpClient getOkHttpClient(DaprHttp daprHttp) throws Exception { + private static HttpClient getHttpClient(DaprHttp daprHttp) throws Exception { Field httpClientField = DaprHttp.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - OkHttpClient okHttpClient = (OkHttpClient) httpClientField.get(daprHttp); + HttpClient okHttpClient = (HttpClient) httpClientField.get(daprHttp); assertNotNull(okHttpClient); return okHttpClient; } diff --git a/sdk/src/test/java/io/dapr/client/DaprHttpStub.java b/sdk/src/test/java/io/dapr/client/DaprHttpStub.java index 2b38d7809..17d9205c2 100644 --- a/sdk/src/test/java/io/dapr/client/DaprHttpStub.java +++ b/sdk/src/test/java/io/dapr/client/DaprHttpStub.java @@ -16,6 +16,7 @@ package io.dapr.client; import reactor.core.publisher.Mono; import reactor.util.context.ContextView; +import java.time.Duration; import java.util.List; import java.util.Map; @@ -25,6 +26,8 @@ import java.util.Map; */ public class DaprHttpStub extends DaprHttp { + private static final Duration READ_TIMEOUT = Duration.ofSeconds(60); + public static class ResponseStub extends DaprHttp.Response { public ResponseStub(byte[] body, Map headers, int statusCode) { super(body, headers, statusCode); @@ -34,7 +37,7 @@ public class DaprHttpStub extends DaprHttp { * Instantiates a stub for DaprHttp */ public DaprHttpStub() { - super(null, 3000, "stubToken", null); + super(null, 3000, "stubToken", READ_TIMEOUT, null); } /** diff --git a/sdk/src/test/java/io/dapr/client/DaprHttpTest.java b/sdk/src/test/java/io/dapr/client/DaprHttpTest.java index bf21e6419..ad6753479 100644 --- a/sdk/src/test/java/io/dapr/client/DaprHttpTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprHttpTest.java @@ -16,14 +16,10 @@ import io.dapr.config.Properties; import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; import io.dapr.utils.TypeRef; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.ResponseBody; -import okhttp3.mock.Behavior; -import okhttp3.mock.MockInterceptor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import reactor.util.context.Context; @@ -32,214 +28,407 @@ import uk.org.webcompere.systemstubs.jupiter.SystemStub; import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; import java.io.IOException; -import java.net.HttpURLConnection; -import java.util.Collections; -import java.util.HashMap; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import static io.dapr.utils.TestUtils.formatIpAddress; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(SystemStubsExtension.class) public class DaprHttpTest { - @SystemStub - public final EnvironmentVariables environmentVariables = new EnvironmentVariables(); - - private static final String STATE_PATH = DaprHttp.API_VERSION + "/state"; - + private static final int HTTP_OK = 200; + private static final int HTTP_SERVER_ERROR = 500; + private static final int HTTP_NO_CONTENT = 204; + private static final int HTTP_NOT_FOUND = 404; private static final String EXPECTED_RESULT = "{\"data\":\"ewoJCSJwcm9wZXJ0eUEiOiAidmFsdWVBIiwKCQkicHJvcGVydHlCIjogInZhbHVlQiIKCX0=\"}"; - + private static final Duration READ_TIMEOUT = Duration.ofSeconds(60); + + @SystemStub + private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); + private String sidecarIp; private String daprTokenApi; - private OkHttpClient okHttpClient; + private HttpClient httpClient; - private MockInterceptor mockInterceptor; - - private ObjectSerializer serializer = new ObjectSerializer(); + private final ObjectSerializer serializer = new ObjectSerializer(); @BeforeEach public void setUp() { sidecarIp = formatIpAddress(Properties.SIDECAR_IP.get()); daprTokenApi = Properties.API_TOKEN.get(); - mockInterceptor = new MockInterceptor(Behavior.UNORDERED); - okHttpClient = new OkHttpClient.Builder().addInterceptor(mockInterceptor).build(); + httpClient = mock(HttpClient.class); } @Test public void invokeApi_daprApiToken_present() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .hasHeader(Headers.DAPR_API_TOKEN) - .respond(serializer.serialize(EXPECTED_RESULT)); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + environmentVariables.set(Properties.API_TOKEN.getEnvName(), "xyz"); assertEquals("xyz", Properties.API_TOKEN.get()); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, Properties.API_TOKEN.get(), okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, (byte[]) null, null, Context.empty()); + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, Properties.API_TOKEN.get(), READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + (byte[]) null, + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("POST", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); + assertEquals("xyz", request.headers().firstValue(Headers.DAPR_API_TOKEN).get()); } @Test public void invokeApi_daprApiToken_absent() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .not() - .hasHeader(Headers.DAPR_API_TOKEN) - .respond(serializer.serialize(EXPECTED_RESULT)); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + assertNull(Properties.API_TOKEN.get()); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, (byte[]) null, null, Context.empty()); + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + (byte[]) null, + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("POST", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); + assertFalse(request.headers().map().containsKey(Headers.DAPR_API_TOKEN)); } @Test public void invokeMethod() throws IOException { - Map headers = new HashMap<>(); - headers.put("content-type", "text/html"); - headers.put("header1", "value1"); - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(serializer.serialize(EXPECTED_RESULT)); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, (byte[]) null, headers, Context.empty()); + Map headers = Map.of( + "content-type", "text/html", + "header1", "value1" + ); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + (byte[]) null, + headers, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("POST", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); + assertEquals("text/html", request.headers().firstValue("content-type").get()); + assertEquals("value1", request.headers().firstValue("header1").get()); } @Test public void invokeMethodIPv6() throws IOException { sidecarIp = formatIpAddress("2001:db8:3333:4444:5555:6666:7777:8888"); - Map headers = new HashMap<>(); - headers.put("content-type", "text/html"); - headers.put("header1", "value1"); - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(serializer.serialize(EXPECTED_RESULT)); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, (byte[]) null, headers, Context.empty()); + Map headers = Map.of( + "content-type", "text/html", + "header1", "value1" + ); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + (byte[]) null, + headers, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("POST", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); + assertEquals("text/html", request.headers().firstValue("content-type").get()); + assertEquals("value1", request.headers().firstValue("header1").get()); } @Test public void invokePostMethod() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(serializer.serialize(EXPECTED_RESULT)) - .addHeader("Header", "Value"); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, "", null, Context.empty()); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + "", + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("POST", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); } @Test public void invokeDeleteMethod() throws IOException { - mockInterceptor.addRule() - .delete("http://" + sidecarIp + ":3500/v1.0/state") - .respond(serializer.serialize(EXPECTED_RESULT)); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("DELETE", "v1.0/state".split("/"), null, (String) null, null, Context.empty()); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "DELETE", + "v1.0/state".split("/"), + null, + (String) null, + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("DELETE", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); } @Test - public void invokeHEADMethod() throws IOException { - mockInterceptor.addRule().head("http://127.0.0.1:3500/v1.0/state").respond(HttpURLConnection.HTTP_OK); - DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("HEAD", "v1.0/state".split("/"), null, (String) null, null, Context.empty()); + public void invokeHeadMethod() { + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "HEAD", + "v1.0/state".split("/"), + null, + (String) null, + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); - assertEquals(HttpURLConnection.HTTP_OK, response.getStatusCode()); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + + assertEquals("HEAD", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); + assertEquals(HTTP_OK, response.getStatusCode()); } - + @Test public void invokeGetMethod() throws IOException { - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3500/v1.0/get") - .respond(serializer.serialize(EXPECTED_RESULT)); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = daprHttp.invokeApi("GET", "v1.0/get".split("/"), null, null, Context.empty()); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "GET", + "v1.0/state".split("/"), + null, + null, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("GET", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state", request.uri().toString()); } @Test public void invokeMethodWithHeaders() throws IOException { - Map headers = new HashMap<>(); - headers.put("header", "value"); - headers.put("header1", "value1"); - Map> urlParameters = new HashMap<>(); - urlParameters.put("orderId", Collections.singletonList("41")); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3500/v1.0/state/order?orderId=41") - .respond(serializer.serialize(EXPECTED_RESULT)); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("GET", "v1.0/state/order".split("/"), urlParameters, headers, Context.empty()); + Map headers = Map.of( + "header", "value", + "header1", "value1" + ); + Map> urlParameters = Map.of( + "orderId", List.of("41") + ); + byte[] content = serializer.serialize(EXPECTED_RESULT); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_OK); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "GET", + "v1.0/state/order".split("/"), + urlParameters, + headers, + Context.empty() + ); DaprHttp.Response response = mono.block(); String body = serializer.deserialize(response.getBody(), String.class); + + verify(httpClient).sendAsync(requestCaptor.capture(), any()); + + HttpRequest request = requestCaptor.getValue(); + assertEquals(EXPECTED_RESULT, body); + assertEquals("GET", request.method()); + assertEquals("http://" + sidecarIp + ":3500/v1.0/state/order?orderId=41", request.uri().toString()); + assertEquals("value", request.headers().firstValue("header").get()); + assertEquals("value1", request.headers().firstValue("header1").get()); } @Test - public void invokePostMethodRuntime() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(500); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = - daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); + public void invokePostMethodRuntime() { + MockHttpResponse mockHttpResponse = new MockHttpResponse(HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + null, + Context.empty()); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); } @Test - public void invokePostDaprError() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(500, ResponseBody.create(MediaType.parse("text"), - "{\"errorCode\":null,\"message\":null}")); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); + public void invokePostDaprError() { + byte[] content = "{\"errorCode\":null,\"message\":null}".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + null, + Context.empty() + ); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); } @Test - public void invokePostMethodUnknownError() throws IOException { - mockInterceptor.addRule() - .post("http://" + sidecarIp + ":3500/v1.0/state") - .respond(500, ResponseBody.create(MediaType.parse("application/json"), - "{\"errorCode\":\"null\",\"message\":\"null\"}")); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono mono = daprHttp.invokeApi("POST", "v1.0/state".split("/"), null, null, Context.empty()); + public void invokePostMethodUnknownError() { + byte[] content = "{\"errorCode\":null,\"message\":null}".getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/state".split("/"), + null, + null, + Context.empty() + ); + StepVerifier.create(mono).expectError(RuntimeException.class).verify(); } @Test public void validateExceptionParsing() { - final String payload = "{" + + String payload = "{" + "\"errorCode\":\"ERR_PUBSUB_NOT_FOUND\"," + "\"message\":\"pubsub abc is not found\"," + "\"details\":[" + @@ -249,14 +438,24 @@ public class DaprHttpTest { "\"metadata\":{}," + "\"reason\":\"DAPR_PUBSUB_NOT_FOUND\"" + "}]}"; - mockInterceptor.addRule() - .post("http://127.0.0.1:3500/v1.0/pubsub/publish") - .respond(500, ResponseBody.create(MediaType.parse("application/json"), - payload)); - DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, daprTokenApi, okHttpClient); - Mono mono = daprHttp.invokeApi("POST", "v1.0/pubsub/publish".split("/"), null, null, Context.empty()); + byte[] content = payload.getBytes(); + MockHttpResponse mockHttpResponse = new MockHttpResponse(content, HTTP_SERVER_ERROR); + CompletableFuture> mockResponse = CompletableFuture.completedFuture(mockHttpResponse); + + when(httpClient.sendAsync(any(), any())).thenReturn(mockResponse); + + DaprHttp daprHttp = new DaprHttp(Properties.SIDECAR_IP.get(), 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono mono = daprHttp.invokeApi( + "POST", + "v1.0/pubsub/publish".split("/"), + null, + null, + Context.empty() + ); + StepVerifier.create(mono).expectErrorMatches(e -> { assertEquals(DaprException.class, e.getClass()); + DaprException daprException = (DaprException)e; assertEquals("ERR_PUBSUB_NOT_FOUND", daprException.getErrorCode()); assertEquals("DAPR_PUBSUB_NOT_FOUND", @@ -267,15 +466,15 @@ public class DaprHttpTest { } /** - * The purpose of this test is to show that it doesn't matter when the client is called, the actual coll to DAPR + * The purpose of this test is to show that it doesn't matter when the client is called, the actual call to DAPR * will be done when the output Mono response call the Mono.block method. - * Like for instanche if you call getState, withouth blocking for the response, and then call delete for the same state - * you just retrived but block for the delete response, when later you block for the response of the getState, you will - * not found the state. + * Like for instance if you call getState, without blocking for the response, and then call delete for the same state + * you just retrieved but block for the delete response, when later you block for the response of the getState, you will + * not find the state. *

This test will execute the following flow:

*
    - *
  1. Exeucte client getState for Key=key1
  2. - *
  3. Block for result to the the state
  4. + *
  5. Execute client getState for Key=key1
  6. + *
  7. Block for result to the state
  8. *
  9. Assert the Returned State is the expected to key1
  10. *
  11. Execute client getState for Key=key2
  12. *
  13. Execute client deleteState for Key=key2
  14. @@ -285,35 +484,64 @@ public class DaprHttpTest { * * @throws IOException - Test will fail if any unexpected exception is being thrown */ - @Test() + @Test public void testCallbackCalledAtTheExpectedTimeTest() throws IOException { - String deletedStateKey = "deletedKey"; String existingState = "existingState"; - String urlDeleteState = STATE_PATH + "/" + deletedStateKey; - String urlExistingState = STATE_PATH + "/" + existingState; - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3500/" + urlDeleteState) - .respond(200, ResponseBody.create(MediaType.parse("application/json"), - deletedStateKey)); - mockInterceptor.addRule() - .delete("http://" + sidecarIp + ":3500/" + urlDeleteState) - .respond(204); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3500/" + urlExistingState) - .respond(200, ResponseBody.create(MediaType.parse("application/json"), - serializer.serialize(existingState))); - DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, okHttpClient); - Mono response = daprHttp.invokeApi("GET", urlExistingState.split("/"), null, null, Context.empty()); + String urlExistingState = "v1.0/state/" + existingState; + String deletedStateKey = "deletedKey"; + String urlDeleteState = "v1.0/state/" + deletedStateKey; + + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.uri().toString(); + + if (request.method().equals("GET") && url.contains(urlExistingState)) { + MockHttpResponse mockHttpResponse = new MockHttpResponse(serializer.serialize(existingState), HTTP_OK); + + return CompletableFuture.completedFuture(mockHttpResponse); + } + + if (request.method().equals("DELETE")) { + return CompletableFuture.completedFuture(new MockHttpResponse(HTTP_NO_CONTENT)); + } + + if (request.method().equals("GET")) { + byte [] content = "{\"errorCode\":\"404\",\"message\":\"State Not Found\"}".getBytes(); + + return CompletableFuture.completedFuture(new MockHttpResponse(content, HTTP_NOT_FOUND)); + } + + return CompletableFuture.failedFuture(new RuntimeException("Unexpected call")); + }); + + DaprHttp daprHttp = new DaprHttp(sidecarIp, 3500, daprTokenApi, READ_TIMEOUT, httpClient); + Mono response = daprHttp.invokeApi( + "GET", + urlExistingState.split("/"), + null, + null, + Context.empty() + ); + assertEquals(existingState, serializer.deserialize(response.block().getBody(), String.class)); - Mono responseDeleted = daprHttp.invokeApi("GET", urlDeleteState.split("/"), null, null, Context.empty()); - Mono responseDeleteKey = - daprHttp.invokeApi("DELETE", urlDeleteState.split("/"), null, null, Context.empty()); + + Mono responseDeleted = daprHttp.invokeApi( + "GET", + urlDeleteState.split("/"), + null, + null, + Context.empty() + ); + Mono responseDeleteKey = daprHttp.invokeApi( + "DELETE", + urlDeleteState.split("/"), + null, + null, + Context.empty() + ); + assertNull(serializer.deserialize(responseDeleteKey.block().getBody(), String.class)); - mockInterceptor.reset(); - mockInterceptor.addRule() - .get("http://" + sidecarIp + ":3500/" + urlDeleteState) - .respond(404, ResponseBody.create(MediaType.parse("application/json"), - "{\"errorCode\":\"404\",\"message\":\"State Not Found\"}")); + try { responseDeleted.block(); fail("Expected DaprException"); @@ -321,5 +549,4 @@ public class DaprHttpTest { assertEquals(DaprException.class, ex.getClass()); } } - } diff --git a/sdk/src/test/java/io/dapr/client/MockHttpResponse.java b/sdk/src/test/java/io/dapr/client/MockHttpResponse.java new file mode 100644 index 000000000..b5f0510a0 --- /dev/null +++ b/sdk/src/test/java/io/dapr/client/MockHttpResponse.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import javax.net.ssl.SSLSession; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Collections; +import java.util.Optional; + +public class MockHttpResponse implements HttpResponse { + + private final byte[] body; + private final int statusCode; + + public MockHttpResponse(int statusCode) { + this.body = null; + this.statusCode = statusCode; + } + + public MockHttpResponse(byte[] body, int statusCode) { + this.body = body; + this.statusCode = statusCode; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Collections.emptyMap(), (a, b) -> true); + } + + @Override + public byte[] body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return null; + } + + @Override + public HttpClient.Version version() { + return null; + } +} diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md new file mode 100644 index 000000000..3cc88610d --- /dev/null +++ b/spring-boot-examples/README.md @@ -0,0 +1,176 @@ +# Dapr Spring Boot and Testcontainers integration Example + +This example consists of two applications: +- Producer App: + - Publish messages using a Spring Messaging approach + - Store and retrieve information using Spring Data CrudRepository + - Implements a Workflow with Dapr Workflows +- Consumer App: + - Subscribe to messages + +## Running these examples from source code + +To run these examples you will need: +- Java SDK +- Maven +- Docker or a container runtime such as Podman + +From the `spring-boot-examples/` directory you can start each service using the test configuration that uses +[Testcontainers](https://testcontainers.com) to boostrap [Dapr](https://dapr.io) by running the following command: + + + + +```sh +cd producer-app/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + + +This will start the `producer-app` with Dapr services and the infrastructure needed by the application to run, +in this case RabbitMQ and PostgreSQL. The `producer-app` starts on port `8080` by default. + +The `-Dspring-boot.run.arguments="--reuse=true"` flag helps the application to connect to an existing shared +infrastructure if it already exists. For development purposes, and to connect both applications we will set the flag +in both. For more details check the `DaprTestContainersConfig.java` classes in both, the `producer-app` and the `consumer-app`. + +Then run in a different terminal: + + + + +```sh +cd consumer-app/ +../../mvnw -Dspring-boot.run.arguments="--reuse=true" spring-boot:test-run +``` + + +The `consumer-app` starts in port `8081` by default. + +## Interacting with the applications + +Now that both applications are up you can place an order by sending a POST request to `:8080/orders/` +You can use `curl` to send a POST request to the `producer-app`: + + + + + +```sh +curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{ "item": "the mars volta EP", "amount": 1 }' +``` + + + + +If you check the `producer-app` logs you should see the following lines: + +```bash +... +Storing Order: Order{id='null', item='the mars volta EP', amount=1} +Publishing Order Event: Order{id='d4f8ea15-b774-441e-bcd2-7a4208a80bec', item='the mars volta EP', amount=1} + +``` + +If you check the `consumer-app` logs you should see the following lines, showing that the message +published by the `producer-app` was correctly consumed by the `consumer-app`: + +```bash +Order Event Received: Order{id='d4f8ea15-b774-441e-bcd2-7a4208a80bec', item='the mars volta EP', amount=1} +``` + +Next, you can create a new customer to trigger the customer's tracking workflow: + + + + +```sh +curl -X POST localhost:8080/customers -H 'Content-Type: application/json' -d '{ "customerName": "salaboy" }' +``` + + + + +A new Workflow Instance was created to track the customers interactions. Now, the workflow instance +is waiting for the customer to request a follow-up. + +You should see in the `producer-app` logs: + +```bash +Workflow instance started +Let's register the customer: salaboy +Customer: salaboy registered. +Let's wait for the customer: salaboy to request a follow up. +``` + +Send an event simulating the customer request for a follow-up: + + + + +```sh +curl -X POST localhost:8080/customers/followup -H 'Content-Type: application/json' -d '{ "customerName": "salaboy" }' +``` + + + +In the `producer-app` logs you should see that the workflow instance id moved forward to the Customer Follow Up activity: + +```bash +Customer follow-up requested: salaboy +Let's book a follow up for the customer: salaboy +Customer: salaboy follow-up done. +Congratulations the customer: salaboy is happy! +``` + +## Running on Kubernetes + +You can run the same example on a Kubernetes cluster. [Check the Kubernetes tutorial here](kubernetes/README.md). diff --git a/spring-boot-examples/consumer-app/pom.xml b/spring-boot-examples/consumer-app/pom.xml new file mode 100644 index 000000000..9255bb13a --- /dev/null +++ b/spring-boot-examples/consumer-app/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + io.dapr + spring-boot-examples + 0.15.0-SNAPSHOT + + + consumer-app + consumer-app + Spring Boot, Testcontainers and Dapr Integration Examples :: Consumer App + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr.sdk.alpha.version} + + + + io.dapr.spring + dapr-spring-boot-starter-test + ${dapr.sdk.alpha.version} + test + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + 1.20.0 + test + + + org.testcontainers + rabbitmq + 1.20.0 + test + + + org.testcontainers + kafka + 1.20.0 + test + + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${springboot.version} + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + true + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + true + + + + + + diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaCompensationException.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java similarity index 58% rename from sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaCompensationException.java rename to spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java index 07396d9b5..fbf4e005a 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/saga/SagaCompensationException.java +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/ConsumerApplication.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Dapr Authors + * Copyright 2025 The Dapr Authors * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -11,18 +11,16 @@ limitations under the License. */ -package io.dapr.workflows.saga; +package io.dapr.springboot.examples.consumer; -/** - * saga compensation exception. - */ -public class SagaCompensationException extends RuntimeException { - /** - * build up a SagaCompensationException. - * @param message exception message - * @param cause exception cause - */ - public SagaCompensationException(String message, Exception cause) { - super(message, cause); +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(ConsumerApplication.class, args); } -} \ No newline at end of file + +} diff --git a/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java new file mode 100644 index 000000000..80fc2d0f2 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/Order.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +public class Order { + private String id; + private String item; + private Integer amount; + + public Order() { + } + + /** + * Creates a new Order. + * + * @param id order id + * @param item item reference + * @param amount of items in the order + */ + public Order(String id, String item, Integer amount) { + this.id = id; + this.item = item; + this.amount = amount; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getItem() { + return item; + } + + public void setItem(String item) { + this.item = item; + } + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Order{" + "id='" + id + '\'' + ", item='" + item + '\'' + ", amount=" + amount + '}'; + } +} diff --git a/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java new file mode 100644 index 000000000..42617699f --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/java/io/dapr/springboot/examples/consumer/SubscriberRestController.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.Topic; +import io.dapr.client.domain.CloudEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@RestController +public class SubscriberRestController { + + private final Logger logger = LoggerFactory.getLogger(SubscriberRestController.class); + + private List events = new ArrayList<>(); + + /** + * Subscribe to cloud events. + * @param cloudEvent payload + */ + @PostMapping("subscribe") + @Topic(pubsubName = "pubsub", name = "topic") + public void subscribe(@RequestBody CloudEvent cloudEvent) { + logger.info("Order Event Received: " + cloudEvent.getData()); + events.add(cloudEvent); + } + + @GetMapping("events") + public List getAllEvents() { + return events; + } + +} + diff --git a/spring-boot-examples/consumer-app/src/main/resources/application.properties b/spring-boot-examples/consumer-app/src/main/resources/application.properties new file mode 100644 index 000000000..b01c2106d --- /dev/null +++ b/spring-boot-examples/consumer-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +dapr.pubsub.name=pubsub +spring.application.name=consumer-app +server.port=8081 + diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java new file mode 100644 index 000000000..6a5f8a84d --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTestConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.autoconfigure.pubsub.DaprPubSubProperties; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({DaprPubSubProperties.class}) +public class ConsumerAppTestConfiguration { + @Bean + public DaprMessagingTemplate messagingTemplate(DaprClient daprClient, + DaprPubSubProperties daprPubSubProperties) { + return new DaprMessagingTemplate<>(daprClient, daprPubSubProperties.getName(), false); + } +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java new file mode 100644 index 000000000..e7e6f0c03 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/ConsumerAppTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.client.DaprClient; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.testcontainers.DaprContainer; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.IOException; +import java.time.Duration; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; + + +@SpringBootTest(classes = {TestConsumerApplication.class, DaprTestContainersConfig.class, + ConsumerAppTestConfiguration.class, DaprAutoConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class ConsumerAppTests { + + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*app is subscribed to the following topics.*"; + + @Autowired + private DaprMessagingTemplate messagingTemplate; + + @Autowired + private SubscriberRestController subscriberRestController; + + @Autowired + private DaprClient daprClient; + + @Autowired + private DaprContainer daprContainer; + + @BeforeAll + public static void setup() { + org.testcontainers.Testcontainers.exposeHostPorts(8081); + } + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8081; + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(daprContainer); + } + + + @Test + void testMessageConsumer() throws InterruptedException, IOException { + + messagingTemplate.send("topic", new Order("abc-123", "the mars volta LP", 1)); + + given().contentType(ContentType.JSON) + .when() + .get("/events") + .then() + .statusCode(200); + + await().atMost(Duration.ofSeconds(10)) + .until(subscriberRestController.getAllEvents()::size, equalTo(1)); + + } + +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java new file mode 100644 index 000000000..34fa4d82f --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/DaprTestContainersConfig.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + @Bean + public Network getDaprNetwork() { + Network defaultDaprNetwork = new Network() { + @Override + public String getId() { + return "dapr-network"; + } + + @Override + public void close() { + + } + + @Override + public Statement apply(Statement base, Description description) { + return null; + } + }; + + List networks = DockerClientFactory.instance().client().listNetworksCmd() + .withNameFilter("dapr-network").exec(); + if (networks.isEmpty()) { + Network.builder().createNetworkCmdModifier(cmd -> cmd.withName("dapr-network")).build().getId(); + return defaultDaprNetwork; + } else { + return defaultDaprNetwork; + } + } + + @Bean + public RabbitMQContainer rabbitMQContainer(Network daprNetwork, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + return new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.7.25-management-alpine")) + .withExposedPorts(5672) + .withNetworkAliases("rabbitmq") + .withReuse(reuse) + .withNetwork(daprNetwork); + } + + @Bean + @ServiceConnection + public DaprContainer daprContainer(Network daprNetwork, RabbitMQContainer rabbitMQContainer, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + Map rabbitMqProperties = new HashMap<>(); + rabbitMqProperties.put("connectionString", "amqp://guest:guest@rabbitmq:5672"); + rabbitMqProperties.put("user", "guest"); + rabbitMqProperties.put("password", "guest"); + + return new DaprContainer("daprio/daprd:1.14.4") + .withAppName("consumer-app") + .withNetwork(daprNetwork).withComponent(new Component("pubsub", + "pubsub.rabbitmq", "v1", rabbitMqProperties)) + .withDaprLogLevel(DaprLogLevel.INFO) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppPort(8081).withAppChannelAddress("host.testcontainers.internal") + .withReusablePlacement(reuse) + .withAppHealthCheckPath("/actuator/health") + .dependsOn(rabbitMQContainer); + } + + +} diff --git a/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java new file mode 100644 index 000000000..d37150746 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/java/io/dapr/springboot/examples/consumer/TestConsumerApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestConsumerApplication { + + public static void main(String[] args) { + SpringApplication.from(ConsumerApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8081); + } + + +} diff --git a/spring-boot-examples/consumer-app/src/test/resources/application.properties b/spring-boot-examples/consumer-app/src/test/resources/application.properties new file mode 100644 index 000000000..d6cb8d293 --- /dev/null +++ b/spring-boot-examples/consumer-app/src/test/resources/application.properties @@ -0,0 +1,2 @@ +dapr.pubsub.name=pubsub +server.port=8081 diff --git a/spring-boot-examples/kubernetes/README.md b/spring-boot-examples/kubernetes/README.md new file mode 100644 index 000000000..4c3fc709d --- /dev/null +++ b/spring-boot-examples/kubernetes/README.md @@ -0,0 +1,100 @@ +# Running this example on Kubernetes + +To run this example on Kubernetes, you can use any Kubernetes distribution. +We install Dapr on a Kubernetes cluster and then we will deploy both the `producer-app` and `consumer-app`. + +## Creating a cluster and installing Dapr + +If you don't have any Kubernetes cluster you can use Kubernetes KIND to create a local cluster. We will create a cluster +with a local container registry, so we can push our container images to it. This is covered in the +[KIND documentation here](https://kind.sigs.k8s.io/docs/user/local-registry/). + +```bash +./kind-with-registry.sh +``` + +Once you have the cluster up and running you can install Dapr: + +```bash +helm repo add dapr https://dapr.github.io/helm-charts/ +helm repo update +helm upgrade --install dapr dapr/dapr \ +--version=1.14.4 \ +--namespace dapr-system \ +--create-namespace \ +--wait +``` + +## Creating containers using Spring Boot and pushing to local registry + +Now that we have our cluster set up with a local container registry, we need to build our `producer-app` and `consumer-app` containers. +For this we will use Spring Boot build it functions to create container images using [Buildpacks](https://buildpacks.io): + +From inside the `spring-boot-examples/producer-app` directory you can run the following command to create a container: +```bash +mvn spring-boot:build-image +``` + +Once we have the container image created, we need to tag and push to the local registry, so the image can be used from our local cluster. +Alternatively, you can push the images to a public registry and update the Kubernetes manifests accordingly. + +```bash +docker tag producer-app:0.14.0-SNAPSHOT localhost:5001/sb-producer-app +docker push localhost:5001/sb-producer-app +``` + +From inside the `spring-boot-examples/consumer-app` directory you can run the following command to create a container: +```bash +mvn spring-boot:build-image +``` + +Once we have the container image created, we need to tag and push to the local registry, so the image can be used from our local cluster. +Alternatively, you can push the images to a public registry and update the Kubernetes manifests accordingly. + +```bash +docker tag consumer-app:0.14.0-SNAPSHOT localhost:5001/sb-consumer-app +docker push localhost:5001/sb-consumer-app +``` + +Now we are ready to install our application into the cluster. + +## Installing and interacting with the application + +Now that we have a running Kubernetes cluster, we need to first install the components needed by the application. +In this case RabbitMQ and PostgreSQL. We will use Helm to do so: + +Let's start with RabbitMQ: +```bash +helm install rabbitmq oci://registry-1.docker.io/bitnamicharts/rabbitmq --set auth.username=guest --set auth.password=guest --set auth.erlangCookie=ABC +``` + +Then PostgreSQL: +```bash +helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql --set global.postgresql.auth.database=dapr --set global.postgresql.auth.postgresPassword=password +``` + +Once we have these components up and running we can install the application by running from inside +the `spring-boot-examples/kubernetes/` directory: + +```bash +kubectl apply -f . +``` + +Next you need to use `kubectl port-forward` to be able to send requests to the applications. + +```bash +kubectl port-forward svc/producer-app 8080:8080 +``` + +In a different terminals you can check the logs of the `producer-app` and `consumer-app`: + +```bash +kubectl logs -f producer-app- +``` +and + +```bash +kubectl logs -f consumer-app- +``` + + diff --git a/spring-boot-examples/kubernetes/consumer-app.yaml b/spring-boot-examples/kubernetes/consumer-app.yaml new file mode 100644 index 000000000..ab9041783 --- /dev/null +++ b/spring-boot-examples/kubernetes/consumer-app.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: consumer-app + name: consumer-app +spec: + type: NodePort + ports: + - name: "consumer-app" + port: 8081 + targetPort: 8081 + nodePort: 31001 + selector: + app: consumer-app + +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: consumer-app + name: consumer-app +spec: + replicas: 1 + selector: + matchLabels: + app: consumer-app + template: + metadata: + annotations: + dapr.io/app-id: consumer-app + dapr.io/app-port: "8081" + dapr.io/enabled: "true" + labels: + app: consumer-app + spec: + containers: + - image: localhost:5001/sb-consumer-app + name: consumer-app + imagePullPolicy: Always + ports: + - containerPort: 8081 + name: consumer-app diff --git a/spring-boot-examples/kubernetes/kind-with-registry.sh b/spring-boot-examples/kubernetes/kind-with-registry.sh new file mode 100755 index 000000000..9fe55a821 --- /dev/null +++ b/spring-boot-examples/kubernetes/kind-with-registry.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -o errexit + +# 1. Create registry container unless it already exists +reg_name='kind-registry' +reg_port='5001' +if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then + docker run \ + -d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \ + registry:2 +fi + +# 2. Create kind cluster with containerd registry config dir enabled +# TODO: kind will eventually enable this by default and this patch will +# be unnecessary. +# +# See: +# https://github.com/kubernetes-sigs/kind/issues/2875 +# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration +# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md +cat < + + 4.0.0 + + io.dapr + dapr-sdk-parent + 1.15.0-SNAPSHOT + + + spring-boot-examples + 0.15.0-SNAPSHOT + pom + + + true + + + + producer-app + consumer-app + + + + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + true + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + true + + + + + diff --git a/spring-boot-examples/producer-app/pom.xml b/spring-boot-examples/producer-app/pom.xml new file mode 100644 index 000000000..dd7a4fee5 --- /dev/null +++ b/spring-boot-examples/producer-app/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + + io.dapr + spring-boot-examples + 0.15.0-SNAPSHOT + + + producer-app + producer-app + Spring Boot, Testcontainers and Dapr Integration Examples :: Producer App + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-web + + + io.dapr.spring + dapr-spring-boot-starter + ${dapr.sdk.alpha.version} + + + io.dapr.spring + dapr-spring-boot-starter-test + ${dapr.sdk.alpha.version} + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + 1.20.0 + test + + + org.testcontainers + rabbitmq + 1.20.0 + test + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${springboot.version} + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + true + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + true + + + + + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java new file mode 100644 index 000000000..2211d1e90 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Customer.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +public class Customer { + private String customerName; + private String workflowId; + private boolean inCustomerDB = false; + private boolean followUp = false; + + public boolean isFollowUp() { + return followUp; + } + + public void setFollowUp(boolean followUp) { + this.followUp = followUp; + } + + public boolean isInCustomerDB() { + return inCustomerDB; + } + + public void setInCustomerDB(boolean inCustomerDB) { + this.inCustomerDB = inCustomerDB; + } + + public String getWorkflowId() { + return workflowId; + } + + public void setWorkflowId(String workflowId) { + this.workflowId = workflowId; + } + + public String getCustomerName() { + return customerName; + } + + public void setCustomerName(String customerName) { + this.customerName = customerName; + } + + @Override + public String toString() { + return "Customer [customerName=" + customerName + ", workflowId=" + workflowId + ", inCustomerDB=" + + inCustomerDB + ", followUp=" + followUp + "]"; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java new file mode 100644 index 000000000..35c884e06 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomerStore.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +@Component +public class CustomerStore { + private Map customers = new HashMap<>(); + + public void addCustomer(Customer customer) { + customers.put(customer.getCustomerName(), customer); + } + + public Customer getCustomer(String customerName) { + return customers.get(customerName); + } + + public Collection getCustomers() { + return customers.values(); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java new file mode 100644 index 000000000..57622d104 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/CustomersRestController.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.spring.workflows.config.EnableDaprWorkflows; +import io.dapr.springboot.examples.producer.workflow.CustomerWorkflow; +import io.dapr.workflows.client.DaprWorkflowClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +@RestController +@EnableDaprWorkflows +public class CustomersRestController { + + + private final Logger logger = LoggerFactory.getLogger(CustomersRestController.class); + + @Autowired + private DaprWorkflowClient daprWorkflowClient; + + @Autowired + private CustomerStore customerStore; + + @GetMapping("/") + public String root() { + return "OK"; + } + + private Map customersWorkflows = new HashMap<>(); + + /** + * Track customer endpoint. + * + * @param customer provided customer to track + * @return confirmation that the workflow instance was created for a given customer + */ + @PostMapping("/customers") + public String trackCustomer(@RequestBody Customer customer) { + String instanceId = daprWorkflowClient.scheduleNewWorkflow(CustomerWorkflow.class, customer); + logger.info("Workflow instance " + instanceId + " started"); + customersWorkflows.put(customer.getCustomerName(), instanceId); + return "New Workflow Instance created for Customer: " + customer.getCustomerName(); + } + + /** + * Request customer follow-up. + * @param customer associated with a workflow instance + * @return confirmation that the follow-up was requested + */ + @PostMapping("/customers/followup") + public String customerNotification(@RequestBody Customer customer) { + logger.info("Customer follow-up requested: " + customer.getCustomerName()); + String workflowIdForCustomer = customersWorkflows.get(customer.getCustomerName()); + if (workflowIdForCustomer == null || workflowIdForCustomer.isEmpty()) { + return "There is no workflow associated with customer: " + customer.getCustomerName(); + } else { + daprWorkflowClient.raiseEvent(workflowIdForCustomer, "CustomerReachOut", customer); + return "Customer Follow-up requested"; + } + } + + + public Collection getCustomers() { + return customerStore.getCustomers(); + } + + +} + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java new file mode 100644 index 000000000..820613037 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/Order.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.data.annotation.Id; + +public class Order { + + @Id + private String id; + private String item; + private Integer amount; + + public Order() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getItem() { + return item; + } + + public void setItem(String item) { + this.item = item; + } + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Order{" + "id='" + id + '\'' + ", item='" + item + '\'' + ", amount=" + amount + '}'; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java new file mode 100644 index 000000000..1111f28db --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrderRepository.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.data.repository.CrudRepository; + +import java.util.List; + +public interface OrderRepository extends CrudRepository { + + List findByItem(String item); + + List findByAmount(Integer amount); +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java new file mode 100644 index 000000000..90384b8c0 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/OrdersRestController.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.spring.data.repository.config.EnableDaprRepositories; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@EnableDaprRepositories +public class OrdersRestController { + + private final Logger logger = LoggerFactory.getLogger(OrdersRestController.class); + + @Autowired + private OrderRepository repository; + + @Autowired + private DaprMessagingTemplate messagingTemplate; + + /** + * Store orders from customers. + * @param order from the customer + * + * @return confirmation that the order was stored and the event published + */ + @PostMapping("/orders") + public String storeOrder(@RequestBody Order order) { + logger.info("Storing Order: " + order); + repository.save(order); + logger.info("Publishing Order Event: " + order); + messagingTemplate.send("topic", order); + return "Order Stored and Event Published"; + } + + @GetMapping("/orders") + public Iterable getAll() { + return repository.findAll(); + } + + @GetMapping("/orders/byItem/") + public Iterable getAllByItem(@RequestParam("item") String item) { + return repository.findByItem(item); + } + + @GetMapping("/orders/byAmount/") + public Iterable getAllByItem(@RequestParam("amount") Integer amount) { + return repository.findByAmount(amount); + } + + +} + diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java new file mode 100644 index 000000000..18a3d237c --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerAppConfiguration.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dapr.client.DaprClient; +import io.dapr.spring.boot.autoconfigure.pubsub.DaprPubSubProperties; +import io.dapr.spring.boot.autoconfigure.statestore.DaprStateStoreProperties; +import io.dapr.spring.data.DaprKeyValueAdapterResolver; +import io.dapr.spring.data.DaprKeyValueTemplate; +import io.dapr.spring.data.KeyValueAdapterResolver; +import io.dapr.spring.messaging.DaprMessagingTemplate; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({DaprPubSubProperties.class, DaprStateStoreProperties.class}) +public class ProducerAppConfiguration { + @Bean + public ObjectMapper mapper() { + return new ObjectMapper(); + } + + + /** + * Produce a KeyValueAdapterResolver for Dapr. + * @param daprClient dapr client + * @param mapper object mapper + * @param daprStatestoreProperties properties to configure state store + * @return KeyValueAdapterResolver + */ + @Bean + public KeyValueAdapterResolver keyValueAdapterResolver(DaprClient daprClient, ObjectMapper mapper, + DaprStateStoreProperties daprStatestoreProperties) { + String storeName = daprStatestoreProperties.getName(); + String bindingName = daprStatestoreProperties.getBinding(); + + return new DaprKeyValueAdapterResolver(daprClient, mapper, storeName, bindingName); + } + + @Bean + public DaprKeyValueTemplate daprKeyValueTemplate(KeyValueAdapterResolver keyValueAdapterResolver) { + return new DaprKeyValueTemplate(keyValueAdapterResolver); + } + + @Bean + public DaprMessagingTemplate messagingTemplate(DaprClient daprClient, + DaprPubSubProperties daprPubSubProperties) { + return new DaprMessagingTemplate<>(daprClient, daprPubSubProperties.getName(), false); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java new file mode 100644 index 000000000..9ff372714 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/ProducerApplication.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class ProducerApplication { + + public static void main(String[] args) { + SpringApplication.run(ProducerApplication.class, args); + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java new file mode 100644 index 000000000..fb4e20770 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerFollowupActivity.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.springboot.examples.producer.CustomerStore; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class CustomerFollowupActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(CustomerFollowupActivity.class); + + private final CustomerStore customerStore; + + public CustomerFollowupActivity(CustomerStore customerStore) { + this.customerStore = customerStore; + } + + @Override + public Object run(WorkflowActivityContext ctx) { + Customer customer = ctx.getInput(Customer.class); + //Let's get the hydrate the real customer from the CustomerStore + customer = customerStore.getCustomer(customer.getCustomerName()); + customer.setFollowUp(true); + customerStore.addCustomer(customer); + logger.info("Customer: " + customer.getCustomerName() + " follow-up done."); + return customer; + } + +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java new file mode 100644 index 000000000..1e8757f99 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/CustomerWorkflow.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CustomerWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + String instanceId = ctx.getInstanceId(); + Customer customer = ctx.getInput(Customer.class); + customer.setWorkflowId(instanceId); + ctx.getLogger().info("Let's register the customer: " + customer.getCustomerName()); + ctx.callActivity(RegisterCustomerActivity.class.getName(), customer, Customer.class).await(); + ctx.getLogger().info("Let's wait for the customer: " + customer.getCustomerName() + " to request a follow up."); + customer = ctx.waitForExternalEvent("CustomerReachOut", Duration.ofMinutes(5), Customer.class).await(); + ctx.getLogger().info("Let's book a follow up for the customer: " + customer.getCustomerName()); + customer = ctx.callActivity(CustomerFollowupActivity.class.getName(), customer, Customer.class).await(); + ctx.getLogger().info("Congratulations the customer: " + customer.getCustomerName() + " is happy!"); + ctx.complete(customer); + }; + } +} diff --git a/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java new file mode 100644 index 000000000..c326c002c --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/java/io/dapr/springboot/examples/producer/workflow/RegisterCustomerActivity.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer.workflow; + + +import io.dapr.springboot.examples.producer.Customer; +import io.dapr.springboot.examples.producer.CustomerStore; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class RegisterCustomerActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(RegisterCustomerActivity.class); + private final CustomerStore customerStore; + + public RegisterCustomerActivity(CustomerStore customerStore) { + this.customerStore = customerStore; + } + + @Override + public Object run(WorkflowActivityContext ctx) { + Customer customer = ctx.getInput(Customer.class); + customer.setInCustomerDB(true); + logger.info("Customer: " + customer.getCustomerName() + " registered."); + customerStore.addCustomer(customer); + return customer; + } + + +} diff --git a/spring-boot-examples/producer-app/src/main/resources/application.properties b/spring-boot-examples/producer-app/src/main/resources/application.properties new file mode 100644 index 000000000..1498965c7 --- /dev/null +++ b/spring-boot-examples/producer-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.application.name=producer-app +dapr.pubsub.name=pubsub +dapr.statestore.name=kvstore +dapr.statestore.binding=kvbinding diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java new file mode 100644 index 000000000..b3ecb2e43 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/DaprTestContainersConfig.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.Subscription; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + static final String CONNECTION_STRING = + "host=postgres user=postgres password=password port=5432 connect_timeout=10 database=dapr_db_repository"; + static final Map STATE_STORE_PROPERTIES = createStateStoreProperties(); + + static final Map BINDING_PROPERTIES = Collections.singletonMap("connectionString", CONNECTION_STRING); + + + @Bean + public Network getNetwork() { + Network defaultDaprNetwork = new Network() { + @Override + public String getId() { + return "dapr-network"; + } + + @Override + public void close() { + + } + + @Override + public Statement apply(Statement base, Description description) { + return null; + } + }; + + List networks = DockerClientFactory.instance().client().listNetworksCmd().withNameFilter("dapr-network").exec(); + if (networks.isEmpty()) { + Network.builder() + .createNetworkCmdModifier(cmd -> cmd.withName("dapr-network")) + .build().getId(); + return defaultDaprNetwork; + } else { + return defaultDaprNetwork; + } + } + + + @Bean + public RabbitMQContainer rabbitMQContainer(Network daprNetwork, Environment env) { + boolean reuse = env.getProperty("reuse", Boolean.class, false); + return new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.7.25-management-alpine")) + .withExposedPorts(5672) + .withNetworkAliases("rabbitmq") + .withReuse(reuse) + .withNetwork(daprNetwork); + + } + + @Bean + public PostgreSQLContainer postgreSQLContainer(Network daprNetwork) { + return new PostgreSQLContainer<>("postgres:16-alpine") + .withNetworkAliases("postgres") + .withDatabaseName("dapr_db_repository") + .withUsername("postgres") + .withPassword("password") + .withExposedPorts(5432) + .withNetwork(daprNetwork); + + } + + @Bean + @ServiceConnection + public DaprContainer daprContainer(Network daprNetwork, PostgreSQLContainer postgreSQLContainer, RabbitMQContainer rabbitMQContainer) { + + Map rabbitMqProperties = new HashMap<>(); + rabbitMqProperties.put("connectionString", "amqp://guest:guest@rabbitmq:5672"); + rabbitMqProperties.put("user", "guest"); + rabbitMqProperties.put("password", "guest"); + + return new DaprContainer("daprio/daprd:1.14.4") + .withAppName("producer-app") + .withNetwork(daprNetwork) + .withComponent(new Component("kvstore", "state.postgresql", "v1", STATE_STORE_PROPERTIES)) + .withComponent(new Component("kvbinding", "bindings.postgresql", "v1", BINDING_PROPERTIES)) + .withComponent(new Component("pubsub", "pubsub.rabbitmq", "v1", rabbitMqProperties)) + .withSubscription(new Subscription("app", "pubsub", "topic", "/subscribe")) +// .withDaprLogLevel(DaprLogLevel.DEBUG) +// .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withAppPort(8080) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal") + .dependsOn(rabbitMQContainer) + .dependsOn(postgreSQLContainer); + } + + + private static Map createStateStoreProperties() { + Map result = new HashMap<>(); + + result.put("keyPrefix", "name"); + result.put("actorStateStore", String.valueOf(true)); + result.put("connectionString", CONNECTION_STRING); + + return result; + } + + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java new file mode 100644 index 000000000..47e5eb486 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/ProducerAppTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.client.DaprClient; +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.springboot.examples.producer.workflow.CustomerFollowupActivity; +import io.dapr.springboot.examples.producer.workflow.CustomerWorkflow; +import io.dapr.springboot.examples.producer.workflow.RegisterCustomerActivity; +import io.dapr.testcontainers.DaprContainer; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.IOException; +import java.time.Duration; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = {TestProducerApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class, CustomerWorkflow.class, CustomerFollowupActivity.class, + RegisterCustomerActivity.class, CustomerStore.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class ProducerAppTests { + + private static final String SUBSCRIPTION_MESSAGE_PATTERN = ".*app is subscribed to the following topics.*"; + + @Autowired + private TestSubscriberRestController controller; + + @Autowired + private CustomerStore customerStore; + + @Autowired + private DaprClient daprClient; + + + @Autowired + private DaprContainer daprContainer; + + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8080; + org.testcontainers.Testcontainers.exposeHostPorts(8080); + // Ensure the subscriptions are registered + Wait.forLogMessage(SUBSCRIPTION_MESSAGE_PATTERN, 1).waitUntilReady(daprContainer); + + } + + + @Test + void testOrdersEndpointAndMessaging() throws InterruptedException, IOException { + + given().contentType(ContentType.JSON) + .body("{ \"id\": \"abc-123\",\"item\": \"the mars volta LP\",\"amount\": 1}") + .when() + .post("/orders") + .then() + .statusCode(200); + + await().atMost(Duration.ofSeconds(15)) + .until(controller.getAllEvents()::size, equalTo(1)); + + given().contentType(ContentType.JSON) + .when() + .get("/orders") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("item", "the mars volta LP") + .get("/orders/byItem/") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("item", "other") + .get("/orders/byItem/") + .then() + .statusCode(200).body("size()", is(0)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("amount", 1) + .get("/orders/byAmount/") + .then() + .statusCode(200).body("size()", is(1)); + + given().contentType(ContentType.JSON) + .when() + .queryParam("amount", 2) + .get("/orders/byAmount/") + .then() + .statusCode(200).body("size()", is(0)); + + } + + @Test + void testCustomersWorkflows() throws InterruptedException, IOException { + + given().contentType(ContentType.JSON) + .body("{\"customerName\": \"salaboy\"}") + .when() + .post("/customers") + .then() + .statusCode(200); + + + await().atMost(Duration.ofSeconds(15)) + .until(customerStore.getCustomers()::size, equalTo(1)); + Customer customer = customerStore.getCustomer("salaboy"); + assertEquals(true, customer.isInCustomerDB()); + String workflowId = customer.getWorkflowId(); + given().contentType(ContentType.JSON) + .body("{ \"workflowId\": \"" + workflowId + "\",\"customerName\": \"salaboy\" }") + .when() + .post("/customers/followup") + .then() + .statusCode(200); + + assertEquals(1, customerStore.getCustomers().size()); + + await().atMost(Duration.ofSeconds(10)) + .until(customerStore.getCustomer("salaboy")::isFollowUp, equalTo(true)); + + } + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java new file mode 100644 index 000000000..0abb4c929 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestProducerApplication.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class TestProducerApplication { + + public static void main(String[] args) { + + SpringApplication.from(ProducerApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + +} diff --git a/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java new file mode 100644 index 000000000..0f39dd9a9 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/java/io/dapr/springboot/examples/producer/TestSubscriberRestController.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.springboot.examples.producer; + +import io.dapr.Topic; +import io.dapr.client.domain.CloudEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@RestController +public class TestSubscriberRestController { + + private List events = new ArrayList<>(); + + private final Logger logger = LoggerFactory.getLogger(TestSubscriberRestController.class); + + @PostMapping("subscribe") + @Topic(pubsubName = "pubsub", name = "topic") + public void subscribe(@RequestBody CloudEvent cloudEvent){ + logger.info("Order Event Received: " + cloudEvent.getData()); + events.add(cloudEvent); + } + + public List getAllEvents() { + return events; + } +} + diff --git a/spring-boot-examples/producer-app/src/test/resources/application.properties b/spring-boot-examples/producer-app/src/test/resources/application.properties new file mode 100644 index 000000000..2429abb69 --- /dev/null +++ b/spring-boot-examples/producer-app/src/test/resources/application.properties @@ -0,0 +1,3 @@ +dapr.statestore.name=kvstore +dapr.statestore.binding=kvbinding +dapr.pubsub.name=pubsub diff --git a/spring-boot-examples/spotbugs-exclude.xml b/spring-boot-examples/spotbugs-exclude.xml new file mode 100644 index 000000000..264fc79b0 --- /dev/null +++ b/spring-boot-examples/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/testcontainers-dapr/pom.xml b/testcontainers-dapr/pom.xml index 8267ae2dd..13110b70b 100644 --- a/testcontainers-dapr/pom.xml +++ b/testcontainers-dapr/pom.xml @@ -5,13 +5,13 @@ io.dapr dapr-sdk-parent - 1.14.0-SNAPSHOT + 1.15.0-SNAPSHOT testcontainers-dapr testcontainers-dapr Testcontainers Dapr Module - 0.14.0-SNAPSHOT + 0.15.0-SNAPSHOT jar @@ -45,6 +45,10 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/testcontainers-dapr/src/main/java/io/dapr/testcontainers/DaprContainer.java b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/DaprContainer.java index 9fce30934..145f61dea 100644 --- a/testcontainers-dapr/src/main/java/io/dapr/testcontainers/DaprContainer.java +++ b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/DaprContainer.java @@ -15,6 +15,7 @@ package io.dapr.testcontainers; import io.dapr.testcontainers.converter.ComponentYamlConverter; import io.dapr.testcontainers.converter.ConfigurationYamlConverter; +import io.dapr.testcontainers.converter.HttpEndpointYamlConverter; import io.dapr.testcontainers.converter.SubscriptionYamlConverter; import io.dapr.testcontainers.converter.YamlConverter; import io.dapr.testcontainers.converter.YamlMapperFactory; @@ -48,6 +49,7 @@ public class DaprContainer extends GenericContainer { private static final Yaml YAML_MAPPER = YamlMapperFactory.create(); private static final YamlConverter COMPONENT_CONVERTER = new ComponentYamlConverter(YAML_MAPPER); private static final YamlConverter SUBSCRIPTION_CONVERTER = new SubscriptionYamlConverter(YAML_MAPPER); + private static final YamlConverter HTTPENDPOINT_CONVERTER = new HttpEndpointYamlConverter(YAML_MAPPER); private static final YamlConverter CONFIGURATION_CONVERTER = new ConfigurationYamlConverter( YAML_MAPPER); private static final WaitStrategy WAIT_STRATEGY = Wait.forHttp("/v1.0/healthz/outbound") @@ -56,6 +58,7 @@ public class DaprContainer extends GenericContainer { private final Set components = new HashSet<>(); private final Set subscriptions = new HashSet<>(); + private final Set httpEndpoints = new HashSet<>(); private DaprLogLevel daprLogLevel = DaprLogLevel.INFO; private String appChannelAddress = "localhost"; private String placementService = "placement"; @@ -65,6 +68,7 @@ public class DaprContainer extends GenericContainer { private DaprPlacementContainer placementContainer; private String appName; private Integer appPort; + private String appHealthCheckPath; private boolean shouldReusePlacement; /** @@ -99,6 +103,10 @@ public class DaprContainer extends GenericContainer { return subscriptions; } + public Set getHttpEndpoints() { + return httpEndpoints; + } + public DaprContainer withAppPort(Integer port) { this.appPort = port; return this; @@ -109,6 +117,11 @@ public class DaprContainer extends GenericContainer { return this; } + public DaprContainer withAppHealthCheckPath(String appHealthCheckPath) { + this.appHealthCheckPath = appHealthCheckPath; + return this; + } + public DaprContainer withConfiguration(Configuration configuration) { this.configuration = configuration; return this; @@ -134,6 +147,11 @@ public class DaprContainer extends GenericContainer { return this; } + public DaprContainer withHttpEndpoint(HttpEndpoint httpEndpoint) { + httpEndpoints.add(httpEndpoint); + return this; + } + public DaprContainer withPlacementImage(String placementDockerImageName) { this.placementDockerImageName = placementDockerImageName; return this; @@ -161,7 +179,7 @@ public class DaprContainer extends GenericContainer { */ public DaprContainer withComponent(Path path) { try { - Map component = this.YAML_MAPPER.loadAs(Files.newInputStream(path), Map.class); + Map component = YAML_MAPPER.loadAs(Files.newInputStream(path), Map.class); String type = (String) component.get("type"); Map metadata = (Map) component.get("metadata"); @@ -221,12 +239,12 @@ public class DaprContainer extends GenericContainer { List cmds = new ArrayList<>(); cmds.add("./daprd"); - cmds.add("-app-id"); + cmds.add("--app-id"); cmds.add(appName); cmds.add("--dapr-listen-addresses=0.0.0.0"); cmds.add("--app-protocol"); cmds.add(DAPR_PROTOCOL.getName()); - cmds.add("-placement-host-address"); + cmds.add("--placement-host-address"); cmds.add(placementService + ":50005"); if (appChannelAddress != null && !appChannelAddress.isEmpty()) { @@ -239,6 +257,12 @@ public class DaprContainer extends GenericContainer { cmds.add(Integer.toString(appPort)); } + if (appHealthCheckPath != null && !appHealthCheckPath.isEmpty()) { + cmds.add("--enable-app-health-check"); + cmds.add("--app-health-check-path"); + cmds.add(appHealthCheckPath); + } + if (configuration != null) { cmds.add("--config"); cmds.add("/dapr-resources/" + configuration.getName() + ".yaml"); @@ -291,6 +315,15 @@ public class DaprContainer extends GenericContainer { withCopyToContainer(Transferable.of(subscriptionYaml), "/dapr-resources/" + subscription.getName() + ".yaml"); } + for (HttpEndpoint endpoint : httpEndpoints) { + String endpointYaml = HTTPENDPOINT_CONVERTER.convert(endpoint); + + LOGGER.info("> HTTPEndpoint YAML: \n"); + LOGGER.info("\t\n" + endpointYaml + "\n"); + + withCopyToContainer(Transferable.of(endpointYaml), "/dapr-resources/" + endpoint.getName() + ".yaml"); + } + dependsOn(placementContainer); } diff --git a/testcontainers-dapr/src/main/java/io/dapr/testcontainers/HttpEndpoint.java b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/HttpEndpoint.java new file mode 100644 index 000000000..482cac9a7 --- /dev/null +++ b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/HttpEndpoint.java @@ -0,0 +1,19 @@ +package io.dapr.testcontainers; + +public class HttpEndpoint { + private String name; + private String baseUrl; + + public HttpEndpoint(String name, String baseUrl) { + this.name = name; + this.baseUrl = baseUrl; + } + + public String getName() { + return name; + } + + public String getBaseUrl() { + return baseUrl; + } +} diff --git a/testcontainers-dapr/src/main/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverter.java b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverter.java new file mode 100644 index 000000000..db4a9cba4 --- /dev/null +++ b/testcontainers-dapr/src/main/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverter.java @@ -0,0 +1,32 @@ +package io.dapr.testcontainers.converter; + +import io.dapr.testcontainers.HttpEndpoint; +import org.yaml.snakeyaml.Yaml; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpEndpointYamlConverter implements YamlConverter { + private final Yaml mapper; + + public HttpEndpointYamlConverter(Yaml mapper) { + this.mapper = mapper; + } + + @Override + public String convert(HttpEndpoint endpoint) { + Map endpointProps = new LinkedHashMap<>(); + endpointProps.put("apiVersion", "dapr.io/v1alpha1"); + endpointProps.put("kind", "HTTPEndpoint"); + + Map endpointMetadata = new LinkedHashMap<>(); + endpointMetadata.put("name", endpoint.getName()); + endpointProps.put("metadata", endpointMetadata); + + Map endpointSpec = new LinkedHashMap<>(); + endpointSpec.put("baseUrl", endpoint.getBaseUrl()); + endpointProps.put("spec", endpointSpec); + + return mapper.dumpAsMap(endpointProps); + } +} diff --git a/testcontainers-dapr/src/test/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverterTest.java b/testcontainers-dapr/src/test/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverterTest.java new file mode 100644 index 000000000..ec2540fe9 --- /dev/null +++ b/testcontainers-dapr/src/test/java/io/dapr/testcontainers/converter/HttpEndpointYamlConverterTest.java @@ -0,0 +1,40 @@ +package io.dapr.testcontainers.converter; + +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.HttpEndpoint; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HttpEndpointYamlConverterTest { + private final Yaml MAPPER = YamlMapperFactory.create(); + + private final HttpEndpointYamlConverter converter = new HttpEndpointYamlConverter(MAPPER); + + @Test + void testHttpEndpointToYaml() { + DaprContainer dapr = new DaprContainer("daprio/daprd") + .withAppName("dapr-app") + .withAppPort(8081) + .withHttpEndpoint(new HttpEndpoint("my-endpoint", "http://localhost:8080")) + .withAppChannelAddress("host.testcontainers.internal"); + + Set endpoints = dapr.getHttpEndpoints(); + assertEquals(1, endpoints.size()); + + HttpEndpoint endpoint = endpoints.iterator().next(); + String endpointYaml = converter.convert(endpoint); + String expectedEndpointYaml = + "apiVersion: dapr.io/v1alpha1\n" + + "kind: HTTPEndpoint\n" + + "metadata:\n" + + " name: my-endpoint\n" + + "spec:\n" + + " baseUrl: http://localhost:8080\n"; + + assertEquals(expectedEndpointYaml, endpointYaml); + } +}