diff --git a/tutorials/workflow/java/history-propagation/README.md b/tutorials/workflow/java/history-propagation/README.md new file mode 100644 index 000000000..3400a3ea9 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/README.md @@ -0,0 +1,196 @@ +# Workflow History Propagation + +This tutorial demonstrates **workflow history propagation**, a Dapr 1.18+ feature that lets a parent workflow share its execution history with child workflows and activities. Downstream services can inspect the propagated history to make trust-aware decisions — without any external state store or custom messaging. See the [Workflow history propagation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-history-propagation/) for the feature reference. + +## Scenario: Patient intake / e-prescribing + +A `ComplianceAuditWorkflow` and a `DispenseMedicationActivity` refuse to act unless the propagated history proves the required upstream checks (insurance, allergies, drug interactions) actually ran. + +```mermaid +graph LR + SI((Start)) + PI[PatientIntakeWorkflow] + VI[VerifyInsurance] + PM[PrescribeMedicationWorkflow] + CA[CheckAllergies] + SD[ScreenDrugInteractions] + CO[ComplianceAuditWorkflow] + DM[DispenseMedication] + EW((End)) + SI --> PI + PI -->|no propagation| VI + PI -->|LINEAGE| PM + PM -->|no propagation| CA + PM -->|no propagation| SD + PM -->|LINEAGE| CO + PM -->|OWN_HISTORY| DM + CO --> DM + DM --> EW +``` + +- `PatientIntakeWorkflow` (root) calls `VerifyInsuranceActivity` (no propagation), then invokes `PrescribeMedicationWorkflow` with `propagateLineage()`. +- `PrescribeMedicationWorkflow` receives `scope=LINEAGE` with **1 ancestor** (PatientIntake). It runs allergy and interaction checks, then calls `ComplianceAuditWorkflow` with `propagateLineage()` and `DispenseMedicationActivity` with `propagateOwnHistory()`. +- `ComplianceAuditWorkflow` receives `scope=LINEAGE` with **2 ancestors** (PatientIntake + PrescribeMedication). It uses `getLastActivityByName(...)` to verify each required upstream activity completed, then approves. +- `DispenseMedicationActivity` receives `scope=OWN_HISTORY` with **1 ancestor** (PrescribeMedication only). The grandparent PatientIntake is intentionally not visible (trust boundary). + +## Java API surface + +```java +import io.dapr.durabletask.ActivityResult; +import io.dapr.durabletask.HistoryPropagationScope; +import io.dapr.durabletask.PropagatedHistory; +import io.dapr.durabletask.WorkflowResult; +import io.dapr.workflows.WorkflowTaskOptions; + +// Parent — propagate LINEAGE when calling a child workflow +AuditResult audit = ctx.callChildWorkflow( + ComplianceAuditWorkflow.class.getName(), + rec, + /* instanceId */ null, + WorkflowTaskOptions.propagateLineage(), + AuditResult.class).await(); + +// Parent — propagate OWN_HISTORY when calling an activity +DispenseResult dispense = ctx.callActivity( + DispenseMedicationActivity.class.getName(), + rec, + WorkflowTaskOptions.propagateOwnHistory(), + DispenseResult.class).await(); + +// Receiver (child workflow or activity) — read the propagated history +Optional historyOpt = ctx.getPropagatedHistory(); +historyOpt.ifPresent(history -> { + history.getScope(); // HistoryPropagationScope (LINEAGE | OWN_HISTORY) + history.getWorkflows(); // List — ancestor first, then own + + Optional intake = history.getLastWorkflowByName( + PatientIntakeWorkflow.class.getName()); + intake.flatMap(wf -> wf.getLastActivityByName(VerifyInsuranceActivity.class.getName())) + .map(ActivityResult::isCompleted); +}); +``` + +## Run the tutorial + +1. Use a terminal to navigate to the `tutorials/workflow/java/history-propagation` folder. + +2. Build and run the project using Maven. This spins up a Dapr sidecar via Testcontainers. + + + + ```bash + mvn spring-boot:test-run + ``` + + + +### Scenario 1 (happy path): lineage forwarded — pharmacy dispenses + +3. Run scenario 1 — the first POST request in the [`history-propagation.http`](./history-propagation.http) file, then poll the output endpoint: + + + + ```bash + curl -s -X POST http://localhost:8080/start \ + -H 'content-type: application/json' \ + -d '{"patientId":"P-1042","name":"Jane Doe","condition":"bacterial sinusitis","medication":"amoxicillin","dosage":500,"propagateHistory":true}' + echo "" + for i in $(seq 1 30); do + OUT=$(curl -s http://localhost:8080/output) + if echo "$OUT" | grep -q '"medication":"amoxicillin"'; then + echo "$OUT" + break + fi + sleep 2 + done + ``` + + + + The app logs show the propagation markers proving the feature works: + + ```text + i.d.s.e.h.PatientIntakeWorkflow : PROPAGATION-DEMO: root workflow received no propagated history (expected) + i.d.s.e.h.PrescribeMedicationWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=1 + i.d.s.e.h.ComplianceAuditWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=2 + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity VerifyInsurance: completed=true + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity CheckAllergies: completed=true + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity ScreenDrugInteractions: completed=true + i.d.s.e.h.ComplianceAuditWorkflow : APPROVED (risk=0.10, 2 workflow(s) verified) + i.d.s.e.h.a.DispenseMedicationActivity : PROPAGATION-DEMO: scope=OWN_HISTORY workflows=1 + i.d.s.e.h.a.DispenseMedicationActivity : DISPENSED: rx-P-1042-... (amoxicillin 500mg) for patient P-1042 + ``` + + The GET /output response is: + + ```json + {"dispensed":true,"dispenseId":"rx-P-1042-","patientId":"P-1042","medication":"amoxicillin"} + ``` + +### Scenario 2 (failure): lineage withheld — pharmacy refuses + +When `propagateHistory` is `false`, `PatientIntakeWorkflow` invokes `PrescribeMedicationWorkflow` **without** propagation options. `ComplianceAuditWorkflow` then receives no PatientIntake events in its propagated history, can't verify `VerifyInsurance` ran, and blocks the prescription. + +4. Run scenario 2 — the second POST request, polling the output endpoint until the failed result lands: + + + + ```bash + curl -s -X POST http://localhost:8080/start \ + -H 'content-type: application/json' \ + -d '{"patientId":"P-2087","name":"John Roe","condition":"strep throat","medication":"penicillin","dosage":500,"propagateHistory":false}' + echo "" + for i in $(seq 1 30); do + OUT=$(curl -s http://localhost:8080/output) + if echo "$OUT" | grep -q '"medication":"penicillin"'; then + echo "$OUT" + break + fi + sleep 2 + done + ``` + + + + The app logs show the audit failing because it cannot find the upstream `VerifyInsurance` activity in the propagated history: + + ```text + i.d.s.e.h.PatientIntakeWorkflow : Calling PrescribeMedicationWorkflow WITHOUT propagation (failure scenario) + i.d.s.e.h.PrescribeMedicationWorkflow : Starting prescription: penicillin 500mg for strep throat + i.d.s.e.h.ComplianceAuditWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=1 + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity VerifyInsurance: completed=false + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity CheckAllergies: completed=true + i.d.s.e.h.ComplianceAuditWorkflow : upstream activity ScreenDrugInteractions: completed=true + i.d.s.e.h.ComplianceAuditWorkflow : BLOCKED - missing upstream checks: insurance=false allergies=true interactions=true + i.d.s.e.h.PrescribeMedicationWorkflow : Audit blocked dispensing - aborting prescription + ``` + + The GET /output response is: + + ```json + {"dispensed":false,"dispenseId":null,"patientId":"P-2087","medication":"penicillin"} + ``` + +5. Stop the application by pressing `Ctrl+C`. diff --git a/tutorials/workflow/java/history-propagation/history-propagation.http b/tutorials/workflow/java/history-propagation/history-propagation.http new file mode 100644 index 000000000..449905d0f --- /dev/null +++ b/tutorials/workflow/java/history-propagation/history-propagation.http @@ -0,0 +1,30 @@ +@apphost=http://localhost:8080 + +### Scenario 1 (happy path): lineage forwarded - pharmacy dispenses +POST {{ apphost }}/start +Content-Type: application/json + +{ + "patientId": "P-1042", + "name": "Jane Doe", + "condition": "bacterial sinusitis", + "medication": "amoxicillin", + "dosage": 500, + "propagateHistory": true +} + +### Scenario 2 (failure): lineage withheld - ComplianceAudit refuses, dispensed=false +POST {{ apphost }}/start +Content-Type: application/json + +{ + "patientId": "P-2087", + "name": "John Roe", + "condition": "strep throat", + "medication": "penicillin", + "dosage": 500, + "propagateHistory": false +} + +### Get the workflow output (most recent /start) +GET {{ apphost }}/output diff --git a/tutorials/workflow/java/history-propagation/pom.xml b/tutorials/workflow/java/history-propagation/pom.xml new file mode 100644 index 000000000..61f32c69b --- /dev/null +++ b/tutorials/workflow/java/history-propagation/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + history-propagation + history-propagation + Workflow History Propagation Example + + + + + io.dapr.spring + dapr-spring-bom + 1.18.0-rc-2 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + + + io.dapr.spring + dapr-spring-boot-starter + + + io.dapr.spring + dapr-spring-boot-starter-test + test + + + io.rest-assured + rest-assured + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationApplication.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationApplication.java new file mode 100644 index 000000000..4b2a0c533 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationApplication.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 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; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class HistoryPropagationApplication { + + public static void main(String[] args) { + SpringApplication.run(HistoryPropagationApplication.class, args); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationConfiguration.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationConfiguration.java new file mode 100644 index 000000000..919958afa --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationConfiguration.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class HistoryPropagationConfiguration { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder().build(); + } + + @Bean + public ObjectMapper mapper() { + return new ObjectMapper(); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationRestController.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationRestController.java new file mode 100644 index 000000000..567c7e9e9 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/HistoryPropagationRestController.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 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; + +import io.dapr.spring.workflows.config.EnableDaprWorkflows; +import io.dapr.springboot.examples.historypropagation.PatientIntakeWorkflow; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +import io.dapr.springboot.examples.historypropagation.models.PrescriptionResult; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.dapr.workflows.client.WorkflowInstanceStatus; +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.concurrent.TimeoutException; + +@RestController +@EnableDaprWorkflows +public class HistoryPropagationRestController { + + @Autowired + private DaprWorkflowClient daprWorkflowClient; + + /* + * For example purposes only. In production, map workflowInstanceIds to your + * own business identifiers. + */ + private String instanceId; + + /** + * Start the PatientIntake workflow with the given patient record. + * + * @param record the patient record + * @return the workflow instance id + */ + @PostMapping("start") + public String start(@RequestBody PatientRecord record) { + instanceId = daprWorkflowClient.scheduleNewWorkflow(PatientIntakeWorkflow.class, record); + return instanceId; + } + + /** + * Get the output of the last started PatientIntake workflow. + * + * @return the prescription result, or a placeholder if not yet available + */ + @GetMapping("output") + public PrescriptionResult output() throws TimeoutException { + WorkflowInstanceStatus state = daprWorkflowClient.getInstanceState(instanceId, true); + if (state != null) { + return state.readOutputAs(PrescriptionResult.class); + } + return new PrescriptionResult(false, null, null, null); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/ComplianceAuditWorkflow.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/ComplianceAuditWorkflow.java new file mode 100644 index 000000000..30d8078bb --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/ComplianceAuditWorkflow.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 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.historypropagation; + +import io.dapr.durabletask.ActivityResult; +import io.dapr.durabletask.HistoryPropagationScope; +import io.dapr.durabletask.PropagatedHistory; +import io.dapr.durabletask.WorkflowResult; +import io.dapr.springboot.examples.historypropagation.activities.CheckAllergiesActivity; +import io.dapr.springboot.examples.historypropagation.activities.ScreenDrugInteractionsActivity; +import io.dapr.springboot.examples.historypropagation.activities.VerifyInsuranceActivity; +import io.dapr.springboot.examples.historypropagation.models.AuditResult; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import org.slf4j.Logger; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Grandchild workflow invoked by PrescribeMedicationWorkflow with + * {@code WorkflowTaskOptions.propagateLineage()}. Sees the full ancestor chain + * (PatientIntake + PrescribeMedication) and verifies that the required + * upstream activities (insurance, allergies, drug interactions) actually ran + * before approving the prescription. + */ +@Component +public class ComplianceAuditWorkflow implements Workflow { + @Override + public WorkflowStub create() { + return ctx -> { + Logger logger = ctx.getLogger(); + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Auditing prescription for patient {}", rec.getPatientId()); + + Optional historyOpt = ctx.getPropagatedHistory(); + if (historyOpt.isEmpty()) { + logger.warn("No propagated history received - cannot verify caller pipeline"); + ctx.complete(new AuditResult(false, 1.0, 0, + "no execution history provided - cannot verify caller pipeline")); + return; + } + + PropagatedHistory history = historyOpt.get(); + int reviewedWorkflows = history.getWorkflows().size(); + logger.info("PROPAGATION-DEMO: scope={} workflows={}", + history.getScope(), reviewedWorkflows); + for (WorkflowResult wf : history.getWorkflows()) { + logger.info(" ancestor workflow: name={} app={} instance={}", + wf.getName(), wf.getAppId(), wf.getInstanceId()); + } + + if (history.getScope() != HistoryPropagationScope.LINEAGE) { + logger.warn("Expected LINEAGE but got {}", history.getScope()); + } + + Optional intakeOpt = history.getLastWorkflowByName( + PatientIntakeWorkflow.class.getName()); + boolean insuranceVerified = intakeOpt + .flatMap(wf -> wf.getLastActivityByName(VerifyInsuranceActivity.class.getName())) + .map(ActivityResult::isCompleted) + .orElse(false); + + Optional parentOpt = history.getLastWorkflowByName( + PrescribeMedicationWorkflow.class.getName()); + boolean allergiesChecked = parentOpt + .flatMap(wf -> wf.getLastActivityByName(CheckAllergiesActivity.class.getName())) + .map(ActivityResult::isCompleted) + .orElse(false); + boolean interactionsScreened = parentOpt + .flatMap(wf -> wf.getLastActivityByName(ScreenDrugInteractionsActivity.class.getName())) + .map(ActivityResult::isCompleted) + .orElse(false); + + logger.info(" upstream activity VerifyInsurance: completed={}", insuranceVerified); + logger.info(" upstream activity CheckAllergies: completed={}", allergiesChecked); + logger.info(" upstream activity ScreenDrugInteractions: completed={}", interactionsScreened); + + boolean compliant = insuranceVerified && allergiesChecked && interactionsScreened; + if (compliant) { + logger.info("APPROVED (risk=0.10, {} workflow(s) verified)", reviewedWorkflows); + ctx.complete(new AuditResult(true, 0.10, reviewedWorkflows, + "all upstream checks verified")); + } else { + logger.warn("BLOCKED - missing upstream checks: insurance={} allergies={} interactions={}", + insuranceVerified, allergiesChecked, interactionsScreened); + ctx.complete(new AuditResult(false, 0.9, reviewedWorkflows, + "missing one or more required upstream checks")); + } + }; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PatientIntakeWorkflow.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PatientIntakeWorkflow.java new file mode 100644 index 000000000..b355f2e0d --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PatientIntakeWorkflow.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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.historypropagation; + +import io.dapr.springboot.examples.historypropagation.activities.VerifyInsuranceActivity; +import io.dapr.springboot.examples.historypropagation.models.InsuranceResult; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +import io.dapr.springboot.examples.historypropagation.models.PrescriptionResult; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.dapr.workflows.WorkflowTaskOptions; +import org.slf4j.Logger; +import org.springframework.stereotype.Component; + +/** + * Root workflow. Verifies insurance, then invokes PrescribeMedicationWorkflow + * with {@code WorkflowTaskOptions.propagateLineage()} so the child sees this + * workflow's events (and forwards them further as part of its own lineage + * propagation downstream). + */ +@Component +public class PatientIntakeWorkflow implements Workflow { + @Override + public WorkflowStub create() { + return ctx -> { + Logger logger = ctx.getLogger(); + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Starting intake for patient {}", rec.getPatientId()); + + ctx.getPropagatedHistory().ifPresentOrElse( + h -> logger.warn("Unexpected propagated history at root: scope={}", h.getScope()), + () -> logger.info("PROPAGATION-DEMO: root workflow received no propagated history (expected)")); + + InsuranceResult insurance = ctx.callActivity( + VerifyInsuranceActivity.class.getName(), rec, InsuranceResult.class).await(); + if (!insurance.isApproved()) { + logger.warn("Insurance verification failed for patient {}", rec.getPatientId()); + ctx.complete(new PrescriptionResult(false, null, rec.getPatientId(), rec.getMedication())); + return; + } + logger.info("Insurance verified: policy {}", insurance.getPolicyNumber()); + + // When propagateHistory is false, the child workflow receives no + // propagated history and ComplianceAudit cannot verify the upstream + // VerifyInsurance/CheckAllergies/ScreenDrugInteractions steps - the + // prescription is blocked and dispensed=false. + PrescriptionResult result; + if (rec.isPropagateHistory()) { + logger.info("Calling PrescribeMedicationWorkflow with LINEAGE propagation"); + result = ctx.callChildWorkflow( + PrescribeMedicationWorkflow.class.getName(), + rec, + null, + WorkflowTaskOptions.propagateLineage(), + PrescriptionResult.class).await(); + } else { + logger.info("Calling PrescribeMedicationWorkflow WITHOUT propagation (failure scenario)"); + result = ctx.callChildWorkflow( + PrescribeMedicationWorkflow.class.getName(), + rec, + PrescriptionResult.class).await(); + } + + logger.info("Prescription pipeline complete: dispensed={}", result.isDispensed()); + ctx.complete(result); + }; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PrescribeMedicationWorkflow.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PrescribeMedicationWorkflow.java new file mode 100644 index 000000000..7d8b53183 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/PrescribeMedicationWorkflow.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 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.historypropagation; + +import io.dapr.durabletask.HistoryPropagationScope; +import io.dapr.durabletask.PropagatedHistory; +import io.dapr.springboot.examples.historypropagation.activities.CheckAllergiesActivity; +import io.dapr.springboot.examples.historypropagation.activities.DispenseMedicationActivity; +import io.dapr.springboot.examples.historypropagation.activities.ScreenDrugInteractionsActivity; +import io.dapr.springboot.examples.historypropagation.models.AuditResult; +import io.dapr.springboot.examples.historypropagation.models.DispenseResult; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +import io.dapr.springboot.examples.historypropagation.models.PrescriptionResult; +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import io.dapr.workflows.WorkflowTaskOptions; +import org.slf4j.Logger; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Child workflow invoked by PatientIntakeWorkflow with + * {@code WorkflowTaskOptions.propagateLineage()}. Calls: + *
    + *
  • {@code ComplianceAuditWorkflow} with {@code propagateLineage()} - + * grandchild sees both PatientIntake and PrescribeMedication events.
  • + *
  • {@code DispenseMedicationActivity} with {@code propagateOwnHistory()} - + * activity sees ONLY PrescribeMedication events (trust boundary).
  • + *
+ */ +@Component +public class PrescribeMedicationWorkflow implements Workflow { + @Override + public WorkflowStub create() { + return ctx -> { + Logger logger = ctx.getLogger(); + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Starting prescription: {} {}mg for {}", + rec.getMedication(), rec.getDosage(), rec.getCondition()); + + Optional historyOpt = ctx.getPropagatedHistory(); + historyOpt.ifPresent(h -> { + logger.info("PROPAGATION-DEMO: scope={} workflows={}", + h.getScope(), h.getWorkflows().size()); + if (h.getScope() != HistoryPropagationScope.LINEAGE) { + logger.warn("Expected LINEAGE from parent but got {}", h.getScope()); + } + }); + + ctx.callActivity(CheckAllergiesActivity.class.getName(), rec, Boolean.class).await(); + ctx.callActivity(ScreenDrugInteractionsActivity.class.getName(), rec, Boolean.class).await(); + + AuditResult audit = ctx.callChildWorkflow( + ComplianceAuditWorkflow.class.getName(), + rec, + null, + WorkflowTaskOptions.propagateLineage(), + AuditResult.class).await(); + logger.info("Compliance audit returned: compliant={}", audit.isCompliant()); + + if (!audit.isCompliant()) { + logger.warn("Audit blocked dispensing - aborting prescription"); + ctx.complete(new PrescriptionResult(false, null, rec.getPatientId(), rec.getMedication())); + return; + } + + DispenseResult dispense = ctx.callActivity( + DispenseMedicationActivity.class.getName(), + rec, + WorkflowTaskOptions.propagateOwnHistory(), + DispenseResult.class).await(); + logger.info("Dispense returned: id={}", dispense.getDispenseId()); + + ctx.complete(new PrescriptionResult(true, dispense.getDispenseId(), + rec.getPatientId(), rec.getMedication())); + }; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/CheckAllergiesActivity.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/CheckAllergiesActivity.java new file mode 100644 index 000000000..78797f472 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/CheckAllergiesActivity.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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.historypropagation.activities; + +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +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 CheckAllergiesActivity implements WorkflowActivity { + private static final Logger logger = LoggerFactory.getLogger(CheckAllergiesActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Screening {} for {} allergies", rec.getPatientId(), rec.getMedication()); + return true; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/DispenseMedicationActivity.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/DispenseMedicationActivity.java new file mode 100644 index 000000000..ec8bb60f8 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/DispenseMedicationActivity.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 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.historypropagation.activities; + +import io.dapr.durabletask.ActivityResult; +import io.dapr.durabletask.HistoryPropagationScope; +import io.dapr.durabletask.PropagatedHistory; +import io.dapr.durabletask.WorkflowResult; +import io.dapr.springboot.examples.historypropagation.models.DispenseResult; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +import io.dapr.workflows.WorkflowActivity; +import io.dapr.workflows.WorkflowActivityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Pharmacy dispense step. Invoked by the parent workflow with + * {@code WorkflowTaskOptions.propagateOwnHistory()} - this activity sees only + * the direct caller's events, never the grandparent chain. + */ +@Component +public class DispenseMedicationActivity implements WorkflowActivity { + private static final Logger logger = LoggerFactory.getLogger(DispenseMedicationActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + PatientRecord rec = ctx.getInput(PatientRecord.class); + + Optional historyOpt = ctx.getPropagatedHistory(); + int reviewedWorkflows = 0; + if (historyOpt.isPresent()) { + PropagatedHistory history = historyOpt.get(); + reviewedWorkflows = history.getWorkflows().size(); + logger.info("PROPAGATION-DEMO: scope={} workflows={}", + history.getScope(), reviewedWorkflows); + for (WorkflowResult wf : history.getWorkflows()) { + logger.info(" ancestor workflow: name={} app={} instance={}", + wf.getName(), wf.getAppId(), wf.getInstanceId()); + Optional allergyCheck = wf.getLastActivityByName( + CheckAllergiesActivity.class.getName()); + allergyCheck.ifPresent(a -> logger.info( + " upstream activity CheckAllergiesActivity: completed={} failed={}", + a.isCompleted(), a.isFailed())); + } + if (history.getScope() != HistoryPropagationScope.OWN_HISTORY) { + logger.warn("Expected OWN_HISTORY but got {}", history.getScope()); + } + } else { + logger.info("No propagated history received - dispense activity invoked without propagation"); + } + + String dispenseId = "rx-" + rec.getPatientId() + "-" + System.currentTimeMillis(); + logger.info("DISPENSED: {} ({} {}mg) for patient {}", + dispenseId, rec.getMedication(), rec.getDosage(), rec.getPatientId()); + + return new DispenseResult(dispenseId, "dispensed", reviewedWorkflows); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/ScreenDrugInteractionsActivity.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/ScreenDrugInteractionsActivity.java new file mode 100644 index 000000000..f84cc2a77 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/ScreenDrugInteractionsActivity.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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.historypropagation.activities; + +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +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 ScreenDrugInteractionsActivity implements WorkflowActivity { + private static final Logger logger = LoggerFactory.getLogger(ScreenDrugInteractionsActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Screening drug interactions for {} {}mg in patient {}", + rec.getMedication(), rec.getDosage(), rec.getPatientId()); + return true; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/VerifyInsuranceActivity.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/VerifyInsuranceActivity.java new file mode 100644 index 000000000..30cf8c389 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/activities/VerifyInsuranceActivity.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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.historypropagation.activities; + +import io.dapr.springboot.examples.historypropagation.models.InsuranceResult; +import io.dapr.springboot.examples.historypropagation.models.PatientRecord; +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 VerifyInsuranceActivity implements WorkflowActivity { + private static final Logger logger = LoggerFactory.getLogger(VerifyInsuranceActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + PatientRecord rec = ctx.getInput(PatientRecord.class); + logger.info("Checking insurance coverage for patient {}", rec.getPatientId()); + return new InsuranceResult(true, "POL-" + rec.getPatientId()); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/AuditResult.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/AuditResult.java new file mode 100644 index 000000000..11b212220 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/AuditResult.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 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.historypropagation.models; + +public class AuditResult { + + private boolean compliant; + private double riskScore; + private int reviewedWorkflows; + private String notes; + + public AuditResult() { + } + + public AuditResult(boolean compliant, double riskScore, int reviewedWorkflows, String notes) { + this.compliant = compliant; + this.riskScore = riskScore; + this.reviewedWorkflows = reviewedWorkflows; + this.notes = notes; + } + + public boolean isCompliant() { + return compliant; + } + + public void setCompliant(boolean compliant) { + this.compliant = compliant; + } + + public double getRiskScore() { + return riskScore; + } + + public void setRiskScore(double riskScore) { + this.riskScore = riskScore; + } + + public int getReviewedWorkflows() { + return reviewedWorkflows; + } + + public void setReviewedWorkflows(int reviewedWorkflows) { + this.reviewedWorkflows = reviewedWorkflows; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/DispenseResult.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/DispenseResult.java new file mode 100644 index 000000000..5845fc395 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/DispenseResult.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.historypropagation.models; + +public class DispenseResult { + + private String dispenseId; + private String status; + private int reviewedWorkflows; + + public DispenseResult() { + } + + public DispenseResult(String dispenseId, String status, int reviewedWorkflows) { + this.dispenseId = dispenseId; + this.status = status; + this.reviewedWorkflows = reviewedWorkflows; + } + + public String getDispenseId() { + return dispenseId; + } + + public void setDispenseId(String dispenseId) { + this.dispenseId = dispenseId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getReviewedWorkflows() { + return reviewedWorkflows; + } + + public void setReviewedWorkflows(int reviewedWorkflows) { + this.reviewedWorkflows = reviewedWorkflows; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/InsuranceResult.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/InsuranceResult.java new file mode 100644 index 000000000..b3ae9ab8c --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/InsuranceResult.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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.historypropagation.models; + +public class InsuranceResult { + + private boolean approved; + private String policyNumber; + + public InsuranceResult() { + } + + public InsuranceResult(boolean approved, String policyNumber) { + this.approved = approved; + this.policyNumber = policyNumber; + } + + public boolean isApproved() { + return approved; + } + + public void setApproved(boolean approved) { + this.approved = approved; + } + + public String getPolicyNumber() { + return policyNumber; + } + + public void setPolicyNumber(String policyNumber) { + this.policyNumber = policyNumber; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PatientRecord.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PatientRecord.java new file mode 100644 index 000000000..c383904a7 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PatientRecord.java @@ -0,0 +1,102 @@ +/* + * Copyright 2026 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.historypropagation.models; + +public class PatientRecord { + + private String patientId; + private String name; + private String condition; + private String medication; + private int dosage; + /** + * When true (default), PatientIntake forwards its execution history to the + * child PrescribeMedication workflow via {@code propagateLineage()}. When + * false, no propagation options are passed - downstream consumers cannot + * verify the upstream pipeline and the prescription is blocked. + */ + private boolean propagateHistory = true; + + public PatientRecord() { + } + + public PatientRecord(String patientId, String name, String condition, String medication, int dosage) { + this(patientId, name, condition, medication, dosage, true); + } + + public PatientRecord(String patientId, String name, String condition, String medication, + int dosage, boolean propagateHistory) { + this.patientId = patientId; + this.name = name; + this.condition = condition; + this.medication = medication; + this.dosage = dosage; + this.propagateHistory = propagateHistory; + } + + public String getPatientId() { + return patientId; + } + + public void setPatientId(String patientId) { + this.patientId = patientId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCondition() { + return condition; + } + + public void setCondition(String condition) { + this.condition = condition; + } + + public String getMedication() { + return medication; + } + + public void setMedication(String medication) { + this.medication = medication; + } + + public int getDosage() { + return dosage; + } + + public void setDosage(int dosage) { + this.dosage = dosage; + } + + public boolean isPropagateHistory() { + return propagateHistory; + } + + public void setPropagateHistory(boolean propagateHistory) { + this.propagateHistory = propagateHistory; + } + + @Override + public String toString() { + return "PatientRecord [patientId=" + patientId + ", name=" + name + + ", condition=" + condition + ", medication=" + medication + + ", dosage=" + dosage + "mg, propagateHistory=" + propagateHistory + "]"; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PrescriptionResult.java b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PrescriptionResult.java new file mode 100644 index 000000000..d124073fa --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/java/io/dapr/springboot/examples/historypropagation/models/PrescriptionResult.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 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.historypropagation.models; + +public class PrescriptionResult { + + private boolean dispensed; + private String dispenseId; + private String patientId; + private String medication; + + public PrescriptionResult() { + } + + public PrescriptionResult(boolean dispensed, String dispenseId, String patientId, String medication) { + this.dispensed = dispensed; + this.dispenseId = dispenseId; + this.patientId = patientId; + this.medication = medication; + } + + public boolean isDispensed() { + return dispensed; + } + + public void setDispensed(boolean dispensed) { + this.dispensed = dispensed; + } + + public String getDispenseId() { + return dispenseId; + } + + public void setDispenseId(String dispenseId) { + this.dispenseId = dispenseId; + } + + public String getPatientId() { + return patientId; + } + + public void setPatientId(String patientId) { + this.patientId = patientId; + } + + public String getMedication() { + return medication; + } + + public void setMedication(String medication) { + this.medication = medication; + } +} diff --git a/tutorials/workflow/java/history-propagation/src/main/resources/application.properties b/tutorials/workflow/java/history-propagation/src/main/resources/application.properties new file mode 100644 index 000000000..ce8d62d4b --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=history-propagation diff --git a/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/DaprTestContainersConfig.java b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/DaprTestContainersConfig.java new file mode 100644 index 000000000..31122a18f --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/DaprTestContainersConfig.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 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; + +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprLogLevel; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; + +import java.util.Collections; + +import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG; + +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + @Bean + @ServiceConnection + public DaprContainer daprContainer() { + return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName("history-propagation-app") + .withComponent(new Component("kvstore", "state.in-memory", "v1", + Collections.singletonMap("actorStateStore", String.valueOf(true)))) + .withAppPort(8080) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal") + .withDaprLogLevel(DaprLogLevel.INFO) + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/HistoryPropagationAppTests.java b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/HistoryPropagationAppTests.java new file mode 100644 index 000000000..ddfaa6ef8 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/HistoryPropagationAppTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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; + +import io.dapr.springboot.DaprAutoConfiguration; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.awaitility.Awaitility; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.Duration; + +import static io.dapr.springboot.examples.HistoryPropagationAppTests.StringMatchesUUIDPattern.matchesThePatternOfAUUID; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; + +@SpringBootTest(classes = {TestHistoryPropagationApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class, }, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class HistoryPropagationAppTests { + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + 8080; + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + + @Test + void testHistoryPropagation() { + given().contentType(ContentType.JSON) + .body("{\"patientId\":\"P-1042\",\"name\":\"Jane Doe\"," + + "\"condition\":\"bacterial sinusitis\"," + + "\"medication\":\"amoxicillin\",\"dosage\":500}") + .when() + .post("/start") + .then() + .statusCode(200) + .body(matchesThePatternOfAUUID()); + + // Workflow completes asynchronously; poll /output until dispensed=true. + Awaitility.await() + .atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(2)) + .untilAsserted(() -> given().contentType(ContentType.JSON) + .when() + .get("/output") + .then() + .statusCode(200) + .body(containsString("\"dispensed\":true"))); + } + + static class StringMatchesUUIDPattern extends TypeSafeMatcher { + private static final String UUID_REGEX = + "[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"; + + @Override + protected boolean matchesSafely(String s) { + return s.matches(UUID_REGEX); + } + + @Override + public void describeTo(Description description) { + description.appendText("a string matching the pattern of a UUID"); + } + + public static Matcher matchesThePatternOfAUUID() { + return new StringMatchesUUIDPattern(); + } + } +} diff --git a/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/TestHistoryPropagationApplication.java b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/TestHistoryPropagationApplication.java new file mode 100644 index 000000000..7415a4e46 --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/test/java/io/dapr/springboot/examples/TestHistoryPropagationApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 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; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestHistoryPropagationApplication { + + public static void main(String[] args) { + SpringApplication.from(HistoryPropagationApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } +} diff --git a/tutorials/workflow/java/history-propagation/src/test/resources/application.properties b/tutorials/workflow/java/history-propagation/src/test/resources/application.properties new file mode 100644 index 000000000..ce8d62d4b --- /dev/null +++ b/tutorials/workflow/java/history-propagation/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=history-propagation